commit 5296d0e97ce04781e71335d8d95b59ba9d59a8aa Author: wehub-resource-sync Date: Mon Jul 13 12:35:44 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7da5761 --- /dev/null +++ b/.env.example @@ -0,0 +1,256 @@ +# NVIDIA NIM Config +NVIDIA_NIM_API_KEY="" + + +# OpenRouter Config +OPENROUTER_API_KEY="" + + +# Mistral La Plateforme Config (Experiment plan free tier – rate limits; OpenAI-compatible at api.mistral.ai/v1) +MISTRAL_API_KEY="" + + +# Mistral Codestral (separate key from La Plateforme; OpenAI-compatible at codestral.mistral.ai/v1) +CODESTRAL_API_KEY="" + + +# DeepSeek Config (OpenAI-compatible Chat Completions at api.deepseek.com) +DEEPSEEK_API_KEY="" + + +# Kimi Config (OpenAI-compatible Chat Completions at api.moonshot.ai/v1) +KIMI_API_KEY="" + + +# Wafer Config (OpenAI-compatible Chat Completions at pass.wafer.ai/v1) +WAFER_API_KEY="" + + +# MiniMax Config (OpenAI-compatible Chat Completions at api.minimax.io/v1) +MINIMAX_API_KEY="" + + +# OpenCode Zen (opencode.ai/zen/v1) and OpenCode Go (opencode.ai/zen/go/v1) share OPENCODE_API_KEY +OPENCODE_API_KEY="" + + +# Vercel AI Gateway Config (OpenAI-compatible Chat Completions at ai-gateway.vercel.sh/v1) +AI_GATEWAY_API_KEY="" + + +# Hugging Face Inference Providers Config (OpenAI-compatible Chat Completions at router.huggingface.co/v1) +HUGGINGFACE_API_KEY="" + + +# Cohere Config (OpenAI-compatible Chat Completions at api.cohere.ai/compatibility/v1) +COHERE_API_KEY="" + + +# GitHub Models Config (OpenAI-compatible Chat Completions at models.github.ai/inference) +GITHUB_MODELS_TOKEN="" + + +# Z.ai Config (GLM Coding Plan OpenAI-compatible Chat Completions at api.z.ai/api/coding/paas/v4) +ZAI_API_KEY="" + + +# Fireworks AI Config (OpenAI-compatible Chat Completions at api.fireworks.ai/inference/v1) +FIREWORKS_API_KEY="" + + +# Cloudflare Workers AI Config (OpenAI-compatible Chat Completions at api.cloudflare.com/client/v4/accounts//ai/v1) +CLOUDFLARE_API_TOKEN="" +CLOUDFLARE_ACCOUNT_ID="" + + +# Gemini / Google AI Studio (OpenAI-compatible Chat Completions; see https://ai.google.dev/gemini-api/docs/openai) +GEMINI_API_KEY="" + + +# Groq Cloud (OpenAI-compatible Chat Completions; see https://console.groq.com/docs/openai) +GROQ_API_KEY="" + + +# SambaNova Cloud (OpenAI-compatible Chat Completions at api.sambanova.ai/v1) +SAMBANOVA_API_KEY="" + + +# Cerebras Inference (OpenAI-compatible Chat Completions; see https://inference-docs.cerebras.ai/resources/openai) +CEREBRAS_API_KEY="" + + +# LM Studio Config (local provider, no API key required) +LM_STUDIO_BASE_URL="http://localhost:1234/v1" + + +# Llama.cpp Config (local provider, no API key required) +LLAMACPP_BASE_URL="http://localhost:8080/v1" + + +# Ollama Config (local provider, no API key required) +OLLAMA_BASE_URL="http://localhost:11434" + + +# All Claude model requests are mapped to these models, plain model is fallback +# Format: provider_type/model/name +# Valid providers: "nvidia_nim" | "open_router" | "gemini" | "deepseek" | "mistral" | "mistral_codestral" | "opencode" | "opencode_go" | "vercel" | "huggingface" | "cohere" | "github_models" | "wafer" | "kimi" | "minimax" | "cerebras" | "groq" | "sambanova" | "fireworks" | "cloudflare" | "zai" | "lmstudio" | "llamacpp" | "ollama" +MODEL_OPUS= +MODEL_SONNET= +MODEL_HAIKU= +MODEL="nvidia_nim/nvidia/nemotron-3-super-120b-a12b" + + +# Optional live smoke model overrides. Provider smoke runs once per configured +# provider even when MODEL/MODEL_* route to a different provider. +FCC_SMOKE_MODEL_NVIDIA_NIM= +FCC_SMOKE_MODEL_OPEN_ROUTER= +FCC_SMOKE_MODEL_MISTRAL= +FCC_SMOKE_MODEL_MISTRAL_REASONING= +FCC_SMOKE_MODEL_MISTRAL_CODESTRAL= +FCC_SMOKE_MODEL_DEEPSEEK= +FCC_SMOKE_MODEL_LMSTUDIO= +FCC_SMOKE_MODEL_LLAMACPP= +FCC_SMOKE_MODEL_OLLAMA= +FCC_SMOKE_MODEL_KIMI= +FCC_SMOKE_MODEL_WAFER= +FCC_SMOKE_MODEL_MINIMAX= +FCC_SMOKE_MODEL_OPENCODE= +FCC_SMOKE_MODEL_OPENCODE_GO= +FCC_SMOKE_MODEL_VERCEL= +FCC_SMOKE_MODEL_HUGGINGFACE= +FCC_SMOKE_MODEL_COHERE= +FCC_SMOKE_MODEL_GITHUB_MODELS= +FCC_SMOKE_MODEL_ZAI= +FCC_SMOKE_MODEL_FIREWORKS= +FCC_SMOKE_MODEL_CLOUDFLARE= +FCC_SMOKE_MODEL_GEMINI= +FCC_SMOKE_MODEL_GROQ= +FCC_SMOKE_MODEL_SAMBANOVA= +FCC_SMOKE_MODEL_CEREBRAS= +FCC_SMOKE_NIM_MODELS= +FCC_SMOKE_NIM_EXTRA_MODELS= +FCC_SMOKE_OPENROUTER_FREE_MODELS= +FCC_SMOKE_OPENROUTER_FREE_EXTRA_MODELS= + + +# Thinking output +# Per-Claude-model switches for provider reasoning requests and Claude thinking blocks. +# Blank per-model switches inherit ENABLE_MODEL_THINKING. +ENABLE_OPUS_THINKING= +ENABLE_SONNET_THINKING= +ENABLE_HAIKU_THINKING= +ENABLE_MODEL_THINKING=true + + +# Provider config +# Per-provider proxy support: http and socks5, example: "http://username:password@host:port" +NVIDIA_NIM_PROXY="" +OPENROUTER_PROXY="" +MISTRAL_PROXY="" +CODESTRAL_PROXY="" +LMSTUDIO_PROXY="" +LLAMACPP_PROXY="" +KIMI_PROXY="" +WAFER_PROXY="" +MINIMAX_PROXY="" +OPENCODE_PROXY="" +OPENCODE_GO_PROXY="" +VERCEL_AI_GATEWAY_PROXY="" +HUGGINGFACE_PROXY="" +COHERE_PROXY="" +GITHUB_MODELS_PROXY="" +ZAI_PROXY="" +FIREWORKS_PROXY="" +CLOUDFLARE_PROXY="" +GEMINI_PROXY="" +GROQ_PROXY="" +SAMBANOVA_PROXY="" +CEREBRAS_PROXY="" + +PROVIDER_RATE_LIMIT=1 +PROVIDER_RATE_WINDOW=3 +PROVIDER_MAX_CONCURRENCY=5 + + +# HTTP client timeouts (seconds) for provider API requests +HTTP_READ_TIMEOUT=300 +HTTP_WRITE_TIMEOUT=60 +HTTP_CONNECT_TIMEOUT=60 + + +# Optional server API key (Anthropic-style) +ANTHROPIC_AUTH_TOKEN="freecc" + + +# Open /admin in the default browser when fcc-server becomes healthy (set 0/false/no to disable) +FCC_OPEN_BROWSER=true + + +# Messaging Platform: "telegram" | "discord" | "none" +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) +# (Independent of MODEL=nvidia_nim/...: that selects the *chat* provider; this selects voice STT only.) +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" + + +# Telegram Config +TELEGRAM_BOT_TOKEN="" +ALLOWED_TELEGRAM_USER_ID="" +# Optional Telegram-only proxy. +# Supported schemes: http, https, socks4, socks5, socks5h. +# Example: "socks5://127.0.0.1:1080" or "https://user:password@host:port" +TELEGRAM_PROXY_URL="" + + +# Discord Config +DISCORD_BOT_TOKEN="" +ALLOWED_DISCORD_CHANNELS="" + + +# Agent Config +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 + + +# Local Anthropic web_search / web_fetch handling (performs outbound HTTP; on by default) +ENABLE_WEB_SERVER_TOOLS=true +WEB_FETCH_ALLOWED_SCHEMES=http,https +WEB_FETCH_ALLOW_PRIVATE_NETWORKS=false + + +# Structured TRACE logs: lines with `"trace": true` merge ingress/routing/cli/provider/egress +# stages. Conversation text is logged in those payloads (verbatim). Values under keys named +# like ``api_key`` / ``authorization`` are redacted. Raw transport payloads still require +# the LOG_RAW_* toggles below. +# +# Verbose diagnostics (avoid logging raw prompts / SSE bodies in production) +DEBUG_PLATFORM_EDITS=false +DEBUG_SUBAGENT_STACK=false +# When true, also allows DEBUG-level httpx/httpcore/telegram log noise (not just payload logging). +LOG_RAW_API_PAYLOADS=false +LOG_RAW_SSE_EVENTS=false +# When true, log full exception text and tracebacks for unhandled errors (may leak request-derived data). +LOG_API_ERROR_TRACEBACKS=false +# When true, log message/transcription text previews in messaging adapters only (handler ingress always TRACEs verbatim text separately). +LOG_RAW_MESSAGING_CONTENT=false +# When true, log full Claude CLI stderr, non-JSON stdout lines, and parser error text. +LOG_RAW_CLI_DIAGNOSTICS=false +# When true, log full exception and CLI error message strings in messaging (may leak user content). +LOG_MESSAGING_ERROR_DETAILS=false diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..776459c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: "uv" + directory: "/" + schedule: + interval: "weekly" + groups: + minor-and-patch: + update-types: ["minor", "patch"] + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..4660574 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,74 @@ +# Branch protection: require every status check this workflow reports, for example: +# Ban suppressions and legacy annotations +# ruff-format, ruff-check, ty, pytest +# GitHub may prefix with the workflow name (e.g. "CI / ruff-format"); use the names the branch protection UI offers after a run. + +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + merge_group: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + no-suppressions-or-legacy-annotations: + name: Ban suppressions and legacy annotations + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + ref: ${{ github.event.pull_request.head.sha || github.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + fetch-depth: 1 + + - name: "Fail on type ignores and legacy future annotations" + run: | + if grep -rE '# type: ignore|# ty: ignore|from __future__ import annotations' --include='*.py' . --exclude-dir=.venv --exclude-dir=.git; then + echo "::error::type: ignore / ty: ignore comments and legacy future annotations are not allowed. Fix the underlying type/import issue instead." + exit 1 + fi + exit 0 + + quality: + name: ${{ matrix.id }} + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - id: ruff-format + run: uv run ruff format --check + - id: ruff-check + run: uv run ruff check + - id: ty + run: uv run ty check + - id: pytest + run: uv run pytest -v --tb=short + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + ref: ${{ github.event.pull_request.head.sha || github.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + fetch-depth: 1 + + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 + with: + version: "0.11.15" + enable-cache: true + cache-python: true + + - name: Run + run: ${{ matrix.run }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18f87d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +__pycache__ +.claude +.cursor +.pytest_cache +.ruff_cache +.serena +.venv +agent_workspace +.env +/logs/ +server.log +/server.*.log +debug-*.log +.coverage +llama_cache +.smoke-results +.vscode \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..12566ed --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14.0 \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..164e80d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,105 @@ +# AGENTIC DIRECTIVE + +> Keep AGENTS.md and CLAUDE.md identical. + +## 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.0 stable using `uv python install 3.14.0` if not already installed (requires uv >=0.9; see `[tool.uv] required-version` in `pyproject.toml`) +- 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). +- Before pushing, prefer `./scripts/ci.sh` (macOS/Linux) or `.\scripts\ci.ps1` (Windows) to run the local CI sequence; requires `uv` on PATH. The local scripts run Ruff in repair mode (`ruff format`, then `ruff check --fix`) before type checking and tests. +- Use `--only` / `--skip` (PowerShell: `-Only` / `-Skip`) to run a subset when iterating; use `--dry-run` to print commands without running them. +- GitHub CI remains check-only for Ruff (`ruff format --check`, `ruff check`) so branch protection verifies committed code. +- Fall back to individual repair commands when debugging local failures: `uv run ruff format`, `uv run ruff check --fix`, `uv run ty check`, `uv run pytest -v --tb=short`. Use GitHub-style checks only when verifying enforcement locally: `uv run ruff format --check`, `uv run ruff check`. +- Do not add `# type: ignore` or `# ty: ignore`; fix the underlying type issue. +- Do not add `from __future__ import annotations`; Python 3.14 native lazy annotations are the project standard. +- All 5 check IDs are represented in `scripts/ci.sh` / `scripts/ci.ps1` and enforced in `tests.yml` on push/merge (parallel jobs: suppression grep, ruff-format, ruff-check, ty, pytest). +- GitHub CI runs on `push`, `pull_request`, and `merge_group` so required checks validate merge queue candidates before they land. +- Repository protection should use rulesets: a non-bypassable main integrity ruleset requires pull requests, merge queue, required checks, and blocks direct/force pushes to `main`; a separate review ruleset may allow `Alishahryar1`/admins to bypass review only. +- Required status checks: set **required status checks** to **all** of those statuses (e.g. **Ban suppressions and legacy annotations**, **ruff-format**, **ruff-check**, **ty**, **pytest**—use the exact labels GitHub shows, which may be prefixed with **CI /**). Remove **ci** from required checks if it was previously added for the old gate job. + +## 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 + +- **Shared utilities**: Put shared Anthropic protocol logic in neutral `src/free_claude_code/core/anthropic/` modules. Do not have one provider import from another provider's utils. +- **Failure ownership**: Keep canonical failure semantics and redaction SDK-free in `core/`; providers alone classify SDK/HTTP failures and own retries; protocol/API adapters alone choose wire error types and commit-boundary serialization. +- **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. +- **Python 3.14 annotations**: Do not use `from __future__ import annotations`; rely on native lazy annotations and fix circular import boundaries instead of hiding them with annotation stringization. +- **Imports**: Prefer top-level imports. Avoid `TYPE_CHECKING` and local imports for first-party or required dependencies; if a top-level import creates a cycle, move shared types/protocols to a neutral owner. +- **Complete migrations**: When moving modules, update imports to the new owner and remove old compatibility shims in the same change unless preserving a published interface is explicitly required. +- **Maximum Test Coverage**: There should be maximum test coverage for everything, preferably live smoke test coverage to catch bugs early + +## 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 `./scripts/ci.sh` or `.\scripts\ci.ps1`, plus relevant smoke tests when needed. 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. +7. **VERSION**: If the commit touches production files on `main`, bump semver in the same commit (see [Versioning](#versioning-main)). + +## VERSIONING (MAIN) + +Every commit on `main` that changes a **production file** must include a semver bump in **`pyproject.toml`** in the **same commit**. Do not merge or push prod changes without updating the version. + +### Production files + +These paths count as production (runtime, packaging, or install surface): + +- `src/free_claude_code/api/`, `src/free_claude_code/cli/`, `src/free_claude_code/config/`, `src/free_claude_code/core/`, `src/free_claude_code/messaging/`, `src/free_claude_code/providers/` +- `src/free_claude_code/application/` +- `.env.example` +- `pyproject.toml` (dependencies, scripts, packaging) +- `scripts/install.sh`, `scripts/install.ps1`, `scripts/uninstall.sh`, `scripts/uninstall.ps1`, `scripts/ci.sh`, `scripts/ci.ps1` + +These do **not** require a version bump on their own: + +- `tests/`, `smoke/` +- Docs and assets: `README.md`, `assets/`, `AGENTS.md`, `CLAUDE.md` +- CI and repo config: `.github/`, `.gitignore` + +If a single commit mixes production and non-production edits, still bump the version. + +### Semver rules + +Use `[project].version` as `MAJOR.MINOR.PATCH`: + +- **PATCH** (`x.y.Z+1`): bug fixes, refactors with no user-visible behavior change, dependency updates, packaging/install fixes. +- **MINOR** (`x.Y+1.0`): backward-compatible features—new providers, admin fields, CLI commands, config options, or behavior additions. +- **MAJOR** (`X+1.0.0`): breaking changes—removed or renamed env vars, incompatible API/CLI/default changes, or migrations users must act on. + +When unsure between PATCH and MINOR, prefer PATCH for fixes and MINOR for new capability. + +### Required steps + +1. Classify the change and choose the bump level. +2. Update `version` in `pyproject.toml`. +3. Run `uv lock` so `uv.lock` reflects the new package version. +4. Include the version and lockfile updates in the same commit as the production change. + +Example commit on `main` after a packaging fix: bump `1.2.38` → `1.2.39`, run `uv lock`, commit together with the fix. + +## 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/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..fd228e4 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,1294 @@ +# Architecture + +This document is a maintainer-oriented map of Free Claude Code. It explains the +runtime boundaries, request flows, provider abstraction, configuration model, +optional messaging bridge, and verification strategy. + +For installation, provider setup, and user-facing usage, see +[README.md](README.md). This file focuses on where behavior lives in the codebase +and how contributors should extend it. + +## System Overview + +Free Claude Code is a local proxy for agent clients. It accepts Anthropic +Messages traffic from Claude Code and Pi clients and OpenAI Responses traffic +from Codex clients, routes the request to a configured upstream provider, and +preserves the wire protocol expected by the caller. + +There are three runtime surfaces: + +- HTTP proxy: FastAPI routes expose Anthropic-compatible, Responses-compatible, + health, model-listing, stop, and admin endpoints. +- CLI launchers: wrapper entrypoints prepare Claude Code, Codex, and Pi sessions + so they target the local proxy. +- Messaging bridge: optional Discord or Telegram adapters turn chat messages + into managed client CLI sessions. + +```mermaid +flowchart LR + ClaudeCode[Claude Code CLI and Extensions] --> ProxyAPI[FastAPI Proxy] + Codex[Codex CLI and Extensions] --> ProxyAPI + Pi[Pi Coding Agent] --> ProxyAPI + AdminUI[Local Admin UI] --> ProxyAPI + Bots[Discord or Telegram Bots] --> Messaging[Messaging Bridge] + Messaging --> ClientCLI[Managed Client CLI Sessions] + ClientCLI --> ProxyAPI + ProxyAPI --> Handlers[API Product Handlers] + Handlers --> Router[application ModelRouter] + Handlers --> Executor[application ProviderExecutor] + Executor --> Lease[Provider Generation Lease] + Lease --> Providers[ProviderRuntime] + Providers --> OpenAIChat[OpenAI Chat Provider Profiles And Specialized Adapters] +``` + +## Package Boundaries + +The installable wheel packages are declared in [pyproject.toml](pyproject.toml): + +- [src/free_claude_code/application/](src/free_claude_code/application/) is the dependency-leaf application boundary. It + owns immutable routing/model-metadata values, model routing, shared provider + execution, the consumer-facing `ProviderPort`, request-runtime lease ports, + task control, and deterministic request/readiness errors. It depends only on + configuration and core protocol-neutral logic. +- [src/free_claude_code/api/](src/free_claude_code/api/) is the HTTP adapter. It owns the FastAPI app, routes, API product + handlers, local optimizations, model-catalog responses, HTTP error mapping, + response commit timing, and Admin-specific ports. It consumes application and + protocol types instead of defining use cases or wire schemas. +- [src/free_claude_code/cli/](src/free_claude_code/cli/) owns console entrypoints, client CLI launchers, process/session + management, and client adapter contracts. +- [src/free_claude_code/config/](src/free_claude_code/config/) owns settings, provider metadata, filesystem paths, + logging setup, constants, and provider ID catalogs. +- [src/free_claude_code/core/](src/free_claude_code/core/) owns provider-neutral protocol logic: wire request and response + models, Anthropic conversion, SSE construction, OpenAI Responses conversion, + canonical execution-failure semantics, credential-safe diagnostics, token + counting, and structured trace helpers. It never classifies provider SDK or + HTTP client exceptions. +- [src/free_claude_code/messaging/](src/free_claude_code/messaging/) owns optional platform adapters, incoming message + handling, tree queues, transcript rendering, persistence, commands, and voice + support. +- [src/free_claude_code/providers/](src/free_claude_code/providers/) owns provider construction, the shared OpenAI-chat + provider, specialized adapters, SDK/HTTP failure classification, retry and + recovery policy, rate limiting, model listing, and concrete provider adapters. +- [src/free_claude_code/runtime/](src/free_claude_code/runtime/) is the process composition root. It owns application + startup and shutdown, provider generations, Admin runtime operations, and the + concrete wiring between API, providers, messaging, and managed CLI sessions. + +[tests/](tests/) contains deterministic unit and contract coverage. +[smoke/](smoke/) contains local and live product smoke tests that can launch +subprocesses or touch real services. + +Production package imports follow one least-privilege dependency policy. Every +listed edge is exercised by the current code; removing the last use of an edge +also removes that permission: + +| Package | Exact allowed direct dependencies | +| --- | --- | +| `config` | none | +| `core` | none | +| `application` | `config`, `core` | +| `messaging` | `core` | +| `providers` | `application`, `config`, `core` | +| `api` | `application`, `config`, `core` | +| `cli` | `config`, `core` | +| `runtime` | `api`, `application`, `cli`, `config`, `core`, `messaging`, `providers` | + +There is one exact exception: +`free_claude_code.cli.entrypoints` imports +`free_claude_code.runtime.bootstrap` because the installed server executable +delegates construction to the process composition root. The exception does not +permit any broader dependency from `cli` to `runtime`. Every new top-level +package or cross-package edge must be added to the policy deliberately. + +Internal modules do not import an ancestor package facade; package initializers +may import dependency leaves to publish supported exports. Code outside +`core.openai_responses` and `messaging.trees` consumes those owners through their +package facades. The supported top-level messaging extension surface is +`IncomingMessage`, `MessageScope`, `ManagedClaudeSessionProtocol`, +`ManagedClaudeSessionManagerProtocol`, and `OutboundMessenger`; workflow, +persistence, parsing, and mutable tree implementations remain internal. + +Optional voice dependencies also have exact lazy owners: + +| Dependency | Owner | +| --- | --- | +| `torch`, `transformers`, `librosa` | `messaging.transcription` | +| `riva.client` | `providers.nvidia_nim.voice` | + +They must be imported below a function boundary so importing the application or +server does not require an optional extra. Static AST enforcement cannot observe +dynamic imports. Deliberate provider factory loading is instead protected by the +provider catalog, supported-ID, and factory synchronization contract. + +[core/version.py](src/free_claude_code/core/version.py) is the sole runtime owner +of the FCC release version. It reads installed distribution metadata for +FastAPI/OpenAPI, FCC-owned CLI `--version` output, and the outbound web-tools +user agent. A source-only checkout without installed metadata reports the +explicit `0+unknown` fallback; runtime code never parses `pyproject.toml` or +duplicates a release literal. Client launcher arguments remain transparent to +their wrapped clients except for FCC-owned ephemeral provider configuration. + +The main ownership rule is that Anthropic and Responses protocol schemas and +shared protocol behavior belong in [src/free_claude_code/core/](src/free_claude_code/core/), while request routing and +provider execution belong in [src/free_claude_code/application/](src/free_claude_code/application/). Routes use core schemas +directly for wire validation and call application use cases. Provider modules use +the same concrete request types and neutral helpers instead of importing the API +adapter or another provider. +Protocol consumers use the public `core.anthropic` and +`core.openai_responses` facades. Low-level Anthropic core and provider modules +may import the dependency-leaf Anthropic `models.py` module directly so their +type dependency is explicit; Responses consumers outside its owner remain +facade-only. Package initialization and those leaves must remain import-order safe. +The model-list schema stays beside its API-owned construction policy in +`api/model_catalog.py`; there is no generic API model package. + +## Customer-Facing Contract + +FCC optimizes for installed user workflows, not internal compatibility. The +behavior that must be preserved is that these user-facing surfaces run correctly +for real prompts against supported providers: + +- `fcc-server` and the local Admin UI for configuring supported providers, + model routing, auth, server tools, messaging, and diagnostics. +- `fcc-claude`, Claude Code, and the Anthropic-compatible proxy behavior Claude + Code relies on, including streaming text, native/interleaved thinking, tool + use/results, model discovery, token counting, retries/recovery, and supported + local server-tool behavior. +- `fcc-codex`, Codex CLI/extensions, and the streaming OpenAI Responses behavior + Codex relies on, including native/interleaved reasoning, function and custom + tool calls, generated `/model` catalog support, Responses stream lifecycle + events, and Responses-to-Anthropic conversion at the adapter boundary. +- `fcc-pi`, Pi, and the Anthropic-compatible proxy behavior Pi relies on, + including an FCC-scoped model catalog, streaming text and reasoning, and tool + use/results. +- Configured Discord and Telegram messaging bridges, including command handling, + reply-based conversation branches, status updates, transcript rendering, + managed Claude/Codex task execution where configured, task stop/clear flows, + persistence, and optional voice-note transcription. +- Installation, update, init, and uninstall scripts insofar as they make the + above workflows available on a user's machine. + +Internal modules, class designs, helper APIs, route implementations, and tests +are not stable contracts. Refactors may replace or remove them when doing so +simplifies the system, improves correctness, or better matches these +architecture boundaries. When tests primarily encode an obsolete internal shape, +update the tests to assert the customer-facing behavior instead. Features, +compatibility shims, endpoints, or helper paths that do not serve one of the +surfaces above are not product requirements and should be removed rather than +preserved. + +The supported messaging extension surface consists of transport ingress values, +platform ports, and managed-session protocols. Tree aggregates, processors, +repositories, transition values, and package-level re-exports of those +implementation types are internal; they are not a versioned Python SDK surface. + +## Design Pressure And Refactor Targets + +The current package boundaries are intentional, but several modules still carry +large orchestration responsibilities. Treat these as refactor targets, not as +new places to add unrelated behavior: + +- [api/handlers/](src/free_claude_code/api/handlers/) owns customer-facing API product flows: + Claude Messages, OpenAI Responses, and token counting. Keep route handlers + thin, keep Claude-only behavior in the Messages handler, and use + [application/execution.py](src/free_claude_code/application/execution.py) only for shared + provider resolution, preflight, tracing, token counting, and streaming. +- [providers/openai_chat/](src/free_claude_code/providers/openai_chat/) owns the common upstream provider + behavior. It separates immutable vendor profiles from per-request stream + execution, recovery, request policy, and tool-call assembly. Shared + protocol rules belong in [src/free_claude_code/core/](src/free_claude_code/core/). +- [messaging/workflow.py](src/free_claude_code/messaging/workflow.py) coordinates messaging runtime + dependencies. Inbound turn intake, queued node execution, slash command + dependencies, and tree queue internals live in separate modules so new + behavior has one owner instead of growing the workflow object. +- [config/admin/](src/free_claude_code/config/admin/) owns Admin UI config behavior. Keep + provider fields catalog-driven, and keep manifest, source loading, validation, + env rendering, value presentation, and status metadata in their package owners. + +## Runtime Startup And Lifecycle + +Console scripts are registered in [pyproject.toml](pyproject.toml): + +- `fcc-server` and `free-claude-code` call `free_claude_code.cli.entrypoints:serve`. +- `fcc-init` calls `free_claude_code.cli.entrypoints:init`. +- `fcc-claude` calls `free_claude_code.cli.launchers.claude:launch`. +- `fcc-codex` calls `free_claude_code.cli.launchers.codex:launch`. +- `fcc-pi` calls `free_claude_code.cli.launchers.pi:launch`. + +[scripts/install.sh](scripts/install.sh) and [scripts/install.ps1](scripts/install.ps1) +install or update the uv tool plus optional voice extras. [scripts/uninstall.sh](scripts/uninstall.sh) +and [scripts/uninstall.ps1](scripts/uninstall.ps1) remove only the FCC uv tool and always +delete the managed `~/.fcc/` tree from [config/paths.py](src/free_claude_code/config/paths.py); they do not remove +uv, Claude Code, Codex, Pi, or uv-managed Python runtimes. [scripts/ci.sh](scripts/ci.sh) and +[scripts/ci.ps1](scripts/ci.ps1) mirror [.github/workflows/tests.yml](.github/workflows/tests.yml) +for local pre-push verification. + +[cli/entrypoints.py](src/free_claude_code/cli/entrypoints.py) starts the FastAPI server with Uvicorn. +`serve()` migrates legacy env files when needed, loads cached settings, runs a +supervised server instance, and can restart the server after admin config changes. +An Admin restart constructs the next instance only when the prior +`ApplicationRuntime` reports that its complete ownership graph closed. An +incomplete ASGI shutdown therefore exits the supervisor instead of overlapping +old and replacement graphs. On final shutdown it best-effort kills registered +child processes. + +[runtime/bootstrap.py](src/free_claude_code/runtime/bootstrap.py) is the single production composition function. The CLI +supervisor supplies one settings snapshot and its restart callback; bootstrap +configures logging, constructs the runtime owners and the configured voice + transcriber, constructs the explicit `ApiServices` composition value, and + returns the ASGI application. Provider request leases and task control satisfy + the consumer-owned ports in [application/ports.py](src/free_claude_code/application/ports.py); Admin operations retain + their inbound-adapter port in [api/ports.py](src/free_claude_code/api/ports.py). + +[api/app.py](src/free_claude_code/api/app.py) registers routers and exception +handlers around an explicit `ApiServices` value, then wraps the application in a +pure ASGI correlation boundary. The boundary surrounds the complete wire send; +it does not proxy streaming responses through `BaseHTTPMiddleware`. The API does +not read global settings or construct runtime resources. +`app.state.services` is the only runtime state published to FastAPI. + +[runtime/application.py](src/free_claude_code/runtime/application.py) owns process startup and shutdown, optional messaging, +the selected transcriber, the managed CLI session manager, Admin pending state, +and the injected restart callback. Shutdown is serialized and ordered: quiesce +messaging ingress, cancel and drain workflow/CLI work, flush persistence, close +delivery, close transcription, then close providers. An owner reference is +released only after its cleanup succeeds; cancellation or failure leaves the +incomplete graph retryable. Teardown stops at a failed dependency gate rather +than closing resources that still-live upstream work may need, and the ASGI +adapter reports that incomplete graph as lifespan shutdown failure. Cleanup is +completion-driven: generic timeouts do not cancel half-closed external resources; +the process supervisor owns any force-termination deadline. Optional messaging +startup remains nonfatal only when every partially constructed messaging owner +was successfully cleaned; incomplete startup cleanup fails the application +startup and retains the graph for the next close attempt. +[runtime/asgi.py](src/free_claude_code/runtime/asgi.py) drives that owner from ASGI lifespan messages and preserves +the concise startup-failure contract. + +[runtime/provider_manager.py](src/free_claude_code/runtime/provider_manager.py) is the only owner that constructs, publishes, +retires, and closes provider generations. Each request acquires a generation +lease before routing. Non-streaming responses release it after aggregation; +streaming responses bind it to FCC's response owner, which first closes the +entire body chain and then releases the lease on completion, failure, +cancellation, disconnect, or a response-start send failure. A provider-only +Admin Apply prepares a candidate and commits configuration before publication. +New requests then use the candidate while old streams finish on the retired +generation; its last lease closes it exactly once. Final shutdown rejects new +acquisition and replacement, waits every lease, and awaits the same +manager-owned cleanup task even if the initiating request or lease release is +cancelled. Failed generation or unpublished-candidate cleanup remains owned and +retryable; the manager does not become terminal or clear its model catalog until +every owned runtime closes. + +The manager also owns one application-lifetime provider model catalog and its +single best-effort discovery task. The catalog survives provider replacement. +This keeps the server model inventory stable without extra synchronization; +Claude clients may independently retain the list they fetched at startup. + +## Configuration Model + +[config/settings.py](src/free_claude_code/config/settings.py) owns the flat Pydantic Settings schema: +raw env fields, validation, and `get_settings()`. It should not own routing, +model-ref parsing, launcher defaults, or web-tool policy. Dotenv discovery lives +in [config/env_files.py](src/free_claude_code/config/env_files.py) and uses this order: + +1. repo-local `.env`; +2. managed `~/.fcc/.env`; +3. optional `FCC_ENV_FILE`, appended when present. + +Later dotenv files override earlier dotenv files. Process environment variables +also participate through Pydantic settings resolution. `ANTHROPIC_AUTH_TOKEN` +has an extra guard after settings are built: if any configured dotenv file +defines it, that dotenv value replaces a stale inherited shell token. Auth-token +source detection for startup warnings also belongs to `src/free_claude_code/config/env_files.py`. + +[config/paths.py](src/free_claude_code/config/paths.py) defines managed paths: + +- config directory: `~/.fcc`; +- managed env file: `~/.fcc/.env`; +- generated Codex model catalog: `~/.fcc/codex-model-catalog.json`; +- messaging state directory: `~/.fcc/agent_workspace`; +- server log: `~/.fcc/logs/server.log`. + +Model routing configuration is tiered: + +- `MODEL` is the fallback provider-prefixed model ref. +- `MODEL_OPUS`, `MODEL_SONNET`, and `MODEL_HAIKU` override Claude model tiers. +- `ENABLE_MODEL_THINKING` is the global thinking switch. +- `ENABLE_OPUS_THINKING`, `ENABLE_SONNET_THINKING`, and + `ENABLE_HAIKU_THINKING` optionally override thinking by tier. + +[config/model_refs.py](src/free_claude_code/config/model_refs.py) owns provider-prefixed model ref +parsing and configured `MODEL*` inventory. API routing and provider validation +depend on those helpers instead of adding behavior methods to Settings. + +[config/admin/](src/free_claude_code/config/admin/) owns the Admin UI config manifest and +managed env writes. Provider credential, local URL, proxy, and display-name +metadata is generated from [config/provider_catalog.py](src/free_claude_code/config/provider_catalog.py); +admin-only help text stays beside the admin manifest. The package splits source +loading, value presentation, validation, persistence, and provider status into +separate modules. [api/admin_routes.py](src/free_claude_code/api/admin_routes.py) exposes local-only +admin endpoints that load and validate config, then delegate runtime operations +through `AdminRuntimePort`. Provider-only Apply prepares prospective settings, +atomically commits the managed env, and publishes a new provider generation. +Restart-required changes preserve the existing supervisor restart flow and do +not publish an in-process generation first. + +[.env.example](.env.example) is the single install/init/admin template source. +It is packaged as a [src/free_claude_code/config/](src/free_claude_code/config/) resource for `fcc-init` and Admin UI +template defaults; runtime settings do not read it as a live config file. + +Admin routes call `require_loopback_admin()`, which rejects non-loopback clients +and non-local origins. + +## HTTP Request Flow + +[api/routes.py](src/free_claude_code/api/routes.py) exposes the public proxy routes: + +- `POST /v1/messages`: Anthropic Messages-compatible streaming requests. +- `POST /v1/responses`: OpenAI Responses-compatible requests. +- `POST /v1/messages/count_tokens`: Anthropic token counting. +- `GET /v1/models`: gateway and Claude-compatible model listing. +- `GET /health`: health check. +- `POST /stop`: stop CLI sessions and pending tasks. +- `HEAD` and `OPTIONS` probes for compatibility on supported endpoints. + +Admin routes live beside these in [api/admin_routes.py](src/free_claude_code/api/admin_routes.py). + +Authentication is handled by `require_api_key()` in +[api/dependencies.py](src/free_claude_code/api/dependencies.py). If `ANTHROPIC_AUTH_TOKEN` is blank, +proxy auth is disabled. Otherwise the token may be supplied through `x-api-key`, +`Authorization: Bearer ...`, or `anthropic-auth-token`. Comparisons use +constant-time matching. + +HTTP request correlation is owned at ingress. A pure ASGI boundary creates one +opaque FCC request ID before routing, places it in log context and request state, +and adds `request-id` while forwarding the actual `http.response.start` message. +OpenAI-compatible Responses and the shared model catalog also expose the same +value as `x-request-id`. Provider execution and trace events receive that +existing ID; they do not create a second identifier. Keeping the context around +the complete inner ASGI call preserves correlation during streaming and leaves +response lifetime finalization under the concrete response owner. Starlette's +outer server-error boundary bypasses user middleware for its catch-all 500, so +that one handler explicitly attaches the same ingress-owned headers. + +[api/handlers/](src/free_claude_code/api/handlers/) owns the public API product flows. +`MessagesHandler` validates non-empty messages, resolves models, applies +Claude-only safety-classifier and local optimization policy, handles local web +server tools, then streams Anthropic SSE. `ResponsesHandler` owns streaming-only +OpenAI Responses validation and conversion for Codex clients. `TokenCountHandler` +owns Anthropic token counting. Shared provider execution lives in +[application/execution.py](src/free_claude_code/application/execution.py). `ProviderExecutor` resolves the narrow +consumer-owned `ProviderPort`, synchronously preflights the upstream request, +emits trace events, counts input tokens, and returns an Anthropic SSE iterator. +It receives only a provider resolver and the few scalar collaborators it needs; +it does not depend on FastAPI, provider implementations, or the full settings +object. +[api/response_streams.py](src/free_claude_code/api/response_streams.py) owns public streaming egress +commit timing. It waits for the first protocol chunk before returning a +successful FCC-owned `StreamingResponse`. Its explicit replay iterator owns the +prefetched stream even before replay begins. The response itself owns one +idempotent finalization task: close the body transitively, then release the +provider-generation lease. This finalizer surrounds the real ASGI send and runs +to completion even when sending headers or the first body frame fails. A provider +execution failure before that commit boundary remains a real typed non-2xx JSON +response. Once FCC has finalized the failure, the response includes +`x-should-retry: false` so FCC retains ownership of upstream retry/recovery +without causing a second client retry loop. After the first chunk has escaped, +HTTP status is committed; Messages emits an Anthropic `event: error` and closes +without a synthetic `message_stop`; Responses emits `response.failed` with the +original response ID. Non-streaming Messages aggregate internally and return +non-2xx JSON for any terminal stream error, discarding incomplete content rather +than presenting a partial success. + +The public response chain follows a transitive close-ownership rule. A response +owns its replay iterator; replay owns the active protocol adapter; each protocol +adapter owns its direct input; tracing owns the executor body; the executor body +owns the provider iterator; and the provider runner owns its upstream stream. +Each of these response-chain owners closes its direct input on normal completion, +failure, cancellation, and early consumer close. Failures from those explicit +cleanup calls are trace metadata and cannot replace an established wire outcome; +a generation lease is released only after the body chain has finished closing. + +Ingress authentication, request validation, model routing, and deterministic +preflight failures remain ordinary HTTP errors and do not receive the terminal +provider-execution retry header. Missing provider configuration and a shutting +down request runtime are application-readiness errors: Messages serializes them +as Anthropic JSON, Responses serializes them as OpenAI JSON, and neither is +misclassified as an already-finalized provider execution failure. + +```mermaid +sequenceDiagram + participant Client + participant Route as FastAPIRoute + participant Handler as ProductHandler + participant Router as ModelRouter + participant Exec as ProviderExecutor + participant Manager as ProviderRuntimeManager + participant Lease as ProviderGenerationLease + participant Runtime as ProviderRuntimeGeneration + participant Provider + + Client->>Route: POST /v1/messages + Route->>Route: require_api_key + Route->>Manager: acquire current generation + Manager-->>Route: Lease(settings, provider resolver) + Route->>Handler: create message + Handler->>Router: resolve model and thinking + Handler->>Handler: server tools or optimizations + Handler->>Exec: stream routed request + Exec->>Lease: resolve provider + Lease->>Runtime: cached or new provider + Runtime->>Provider: cached or new provider + Exec->>Provider: preflight_stream + Exec->>Provider: stream_response + Provider-->>Client: Anthropic SSE events + Route->>Lease: release after complete body +``` + +OpenAI Responses uses the same provider execution primitive without importing +Claude-only message intercepts. `ResponsesHandler` delegates protocol work to +the `OpenAIResponsesAdapter` in +[src/free_claude_code/core/openai_responses/adapter.py](src/free_claude_code/core/openai_responses/adapter.py). The adapter +converts the Responses payload into an Anthropic Messages payload before +provider execution, then converts Anthropic SSE back to Responses SSE. + +## Model Routing + +[application/routing.py](src/free_claude_code/application/routing.py) resolves incoming client model names. +It supports two forms: + +- Direct provider model refs such as `nvidia_nim/nvidia/model-name`. +- Gateway model IDs decoded by [core/gateway_model_ids.py](src/free_claude_code/core/gateway_model_ids.py). + +If the incoming model is not direct, `ModelRouter` maps it by Claude tier. Names +containing `opus`, `sonnet`, or `haiku` use the matching tier override when set, +otherwise they fall back to `MODEL`. + +The router also resolves thinking. Gateway model IDs can force thinking on or +off; otherwise `ModelRouter` applies tier-specific thinking overrides or the +global setting. `ResolvedModel` carries only the selected route and thinking +decision; provider catalog metadata does not cross the application boundary. + +`GET /v1/models` advertises: + +- configured provider model refs; +- cached provider-discovered models; +- no-thinking variants when appropriate; +- built-in Claude model IDs for compatibility with Claude clients. + +Provider model discovery and optional thinking metadata live in the +application-level catalog owned by `ProviderRuntimeManager`. +`ProviderModelInfo.supports_thinking` alone owns discovered per-model thinking +support; provider-wide capabilities do not model thinking. The catalog is not +part of an individual provider generation, so a hot replacement does not erase +the last useful model list. Discovery failures retain prior entries. + +Codex-specific model picker shaping stays out of this route. `fcc-codex` fetches +the same `/v1/models` response at launch, converts FCC gateway IDs into +provider-selectable Codex slugs, writes `~/.fcc/codex-model-catalog.json`, and +passes it as `model_catalog_json`. Codex users open the native picker with +`/model`; FCC does not implement a proxy-level `/models` alias. + +## Provider Architecture + +Provider metadata is neutral and centralized in +[config/provider_catalog.py](src/free_claude_code/config/provider_catalog.py). Each +`ProviderDescriptor` declares provider ID, display name, locality, credential env +var, default base URL, settings attribute names, and proxy support. It does not +select a concrete adapter. + +[providers/runtime/](src/free_claude_code/providers/runtime/) owns construction details for one +closable provider generation: construction policy, resolved provider +configuration, lazy provider instances, provider-owned rate limiters, and +cleanup. [providers/runtime/factory.py](src/free_claude_code/providers/runtime/factory.py) +constructs ordinary provider IDs from `OPENAI_CHAT_PROFILES` and keeps a sparse +factory mapping only for adapters with real state or algorithms. The union of +those two construction owners must exactly equal the neutral provider catalog. +`ProviderRuntime` directly guarantees one provider and limiter per provider ID +within a generation; there is no pass-through cache object, process singleton, +or second limiter registry. Provider admission combines a strict proactive window with +one reactive backoff deadline. Positive backoffs can only extend that deadline, +and admission loops until proactive capacity and the final reactive check are +simultaneously available. The proactive timestamp is recorded only when that +check succeeds, so a concurrent 429/5xx cannot be missed, shortened, consume +unused quota, or release queued requests as an expiry burst. Retired generations +retain their own synchronization state until request leases drain, while new +generations and separate server instances never reuse it. Hot replacement +therefore begins with fresh quota state; an old and new generation enforce +independent budgets while old request leases drain. Application-level generation +publication, request leases, model metadata, discovery orchestration, and +configured-model validation belong to `ProviderRuntimeManager` in the runtime +package. This separates a single generation's resources from process-lifetime +state. + +[application/model_metadata.py](src/free_claude_code/application/model_metadata.py) owns the immutable +`ProviderModelInfo` value consumed by the application catalog. Provider-specific +model-list modules retain response parsing and construct that value directly; +there is no provider-layer alias for the former owner. + +[application/ports.py](src/free_claude_code/application/ports.py) defines the two provider operations consumed by request +execution: synchronous `preflight_stream()` and lazy `stream_response()`. API +handlers and application execution depend on that structural port, never on a +provider base class. Provider adapters implement it without registration or a +compatibility layer. + +[providers/base.py](src/free_claude_code/providers/base.py) defines provider-internal construction and lifecycle contracts: + +- `ProviderConfig`: shared provider settings such as API key, base URL, rate + limits, timeouts, proxy, thinking, and logging flags. It is a frozen internal + value whose base URL has already been resolved from the catalog. +- `BaseProvider`: the abstract implementation base for cleanup, model listing, + explicit preflight, and `stream_response()`. + +There is one upstream provider family: +[providers/openai_chat/](src/free_claude_code/providers/openai_chat/) implements the concrete +`OpenAIChatProvider` used by every OpenAI-compatible `/chat/completions` +upstream. `OpenAIChatProfile` contains immutable request policy, +postprocessors, and base-URL normalization for ordinary vendors. Configuration +differences therefore remain data rather than empty subclasses. The package also +owns the exactly typed private per-request runner, recovery operations, tool-call +assembly, and streamed usage handling. No obsolete generic transport namespace +or untyped provider backchannel remains. + +`OpenAIChatProvider` explicitly implements preflight by constructing the same +upstream request body it will later stream. `BaseProvider` makes that operation +abstract, so a new provider cannot silently omit the commit-boundary validation. +LM Studio composes the OpenAI-chat conversion first and its context-budget probe +second; conversion failure therefore cannot open a stream or run the probe. + +Providers call the OpenAI request policy for Anthropic-to-OpenAI conversion, +thinking replay selection, `extra_body`, and chat-completion field normalization. +Specialized provider packages remain only for true upstream quirks such as +Gemini thought signatures, NIM tool-schema aliases, retry downgrades, and NVCF +deployment-failure classification, or DeepSeek attachment/tool/thinking +compatibility. Ollama, llama.cpp, and LM Studio use their OpenAI-compatible Chat +Completions endpoints like the remote providers. DeepSeek intentionally uses its +OpenAI-compatible Chat Completions endpoint because that is the endpoint that +reports prompt-cache hit/miss counters; the provider maps those counters back +into Anthropic usage fields for Claude-compatible clients. Cloudflare uses its +account-scoped Workers AI OpenAI-compatible Chat Completions endpoint for +`@cf/...` model IDs, while account ID composition, model search, and +Cloudflare-specific reasoning deltas stay in the Cloudflare provider client. +OpenRouter remains specialized for model filtering and reasoning-detail stream +events. Wafer, Kimi, MiniMax, Fireworks, and Z.ai use ordinary declarative +profiles for their thinking, token, and `extra_body` policy. Z.ai is treated as +the GLM Coding Plan provider and uses Z.ai's Coding Plan OpenAI base. +Mistral La Plateforme keeps its native `reasoning_effort` and thinking-chunk +request/stream mapping inside +[providers/mistral/reasoning.py](src/free_claude_code/providers/mistral/reasoning.py), including its +fallback retry when a selected Mistral model rejects reasoning fields. +NIM reasoning budget control is also treated as a provider-owned best-effort +downgrade: if an upstream NIM deployment rejects explicit budget control, FCC +retries without the budget while preserving thinking enablement. + +Shared provider responsibilities include upstream rate limiting, model listing, +SDK/HTTP failure classification, safe diagnostic construction, HTTP resource +cleanup, thinking/tool handling, retry or recovery where supported, and +returning successful Anthropic SSE strings to the service layer. Final failures +cross that boundary as `ExecutionFailure`, not as provider-authored wire events. +Every provider receives the same concrete +`MessagesRequest` owned by the Anthropic protocol package. Known wire fields are +accessed through that model; `Any` and dynamic attribute lookup are reserved for +SDK response objects and genuinely open-ended nested extension payloads. +Provider-specific inputs that do not apply to other upstreams, such as +Cloudflare's account ID, stay in that provider's factory/client instead of being +added to shared `ProviderConfig`. +Gateway providers such as Vercel AI Gateway, Hugging Face, and Cohere are +profiles because their documented behavior is expressible as request policy. +GitHub Models remains specialized because it owns API headers, a separate model +catalog client, and capability filtering. The OpenAI-chat provider owns standard +streamed usage handling: it requests +`stream_options.include_usage`, consumes provider `prompt_tokens` and +`completion_tokens` when present, and falls back to local estimates when +providers omit or reject optional usage metadata. Provider modules only own true +usage quirks such as DeepSeek prompt-cache counters. + +### Adding A Provider + +1. Add provider metadata to [config/provider_catalog.py](src/free_claude_code/config/provider_catalog.py). +2. Add credentials and related settings to [config/settings.py](src/free_claude_code/config/settings.py) + and [.env.example](.env.example) when user configurable. +3. Let Admin UI provider credential, local URL, and proxy fields come from the + catalog. Add admin-only help text or provider-specific fields under + [config/admin/](src/free_claude_code/config/admin/) only when the generated manifest is + insufficient. +4. Add an `OpenAIChatProfile` under [providers/openai_chat/](src/free_claude_code/providers/openai_chat/) when + request policy fully describes the upstream. +5. Add a specialized provider package and sparse factory entry only when the + upstream owns state, model-list behavior, stream events, or retry algorithms + that a profile cannot express. +6. Add deterministic tests under [tests/providers/](tests/providers/) and any + relevant contract tests. +7. Add smoke coverage or smoke config in [smoke/](smoke/) when the provider can + be exercised live. +8. Update user-facing provider docs in [README.md](README.md) when users need new + setup instructions. + +## Protocol Conversion And Streaming Contracts + +[src/free_claude_code/core/anthropic/](src/free_claude_code/core/anthropic/) owns Anthropic-side protocol behavior: + +- `models.py` defines the permissive Messages and token-count wire requests, + content/tool/thinking blocks, and Anthropic response envelopes; +- trace-safe request snapshots stay beside those models so the generic trace + module remains protocol-independent and import-order safe; +- content and message conversion for OpenAI-compatible upstreams; +- request serialization primitives shared by provider request policies; +- tool schema and tool-result handling; +- thinking block handling; +- stream lifecycle through `src/free_claude_code/core/anthropic/streaming`, including the neutral + stream ledger, Anthropic SSE emitter, continuation-body construction, and tool repair; +- token counting and Anthropic-owned failure-kind-to-wire mapping. + +Shared stream behavior lives under +[src/free_claude_code/core/anthropic/streaming/](src/free_claude_code/core/anthropic/streaming/). The shared layer owns the +Anthropic content-block ledger, SSE serialization, continuation request +transformations, and tool JSON repair. It does not import `httpx` or the OpenAI +SDK and does not decide whether an upstream failure is retryable. + +[core/failures.py](src/free_claude_code/core/failures.py) defines the immutable, +protocol-neutral `FailureKind` and `ExecutionFailure`. The exception is the +value propagated through async iterators; its semantic fields are immutable, +while Python remains free to attach traceback/cause metadata during unwinding. +[core/diagnostics.py](src/free_claude_code/core/diagnostics.py) owns bounded error +body/cause extraction, credential redaction, safe traceback formatting, and +copyable request-ID diagnostics. Anthropic and Responses packages independently +map the canonical kind and status to their wire error types. + +[providers/failure_policy.py](src/free_claude_code/providers/failure_policy.py) +owns generic raw OpenAI SDK and `httpx` exception classification, +transient status/body inference, stable provider wording, and final diagnostic +construction for those failures. +Concrete adapters may supply one narrow semantic override for an upstream quirk +that the shared SDK cannot express correctly. The concrete adapter owns the +exact upstream marker, while the shared failure policy owns its canonical +meaning and wording. The limiter uses that meaning for retry qualification and +its existing provider-wide reactive backoff while retaining the raw exception, +so exhausted retries still receive the original HTTP status/body through the +shared redaction and diagnostic path. For NVCF's function-scoped failure this +deliberately keeps the simple one-limiter-per-provider policy; a degraded NIM +function can therefore briefly delay other NIM models during backoff. No +provider-specific marker enters `core/`, another provider, or an API adapter. +[providers/stream_recovery.py](src/free_claude_code/providers/stream_recovery.py) +owns the 0.75-second/65,536-byte holdback, four transparent early retries after +the first attempt, and five midstream recovery attempts. Provider opening keeps +its existing five-attempt exponential-backoff budget. `ExecutionFailure.retryable` +records provider-policy eligibility; it never tells the client to retry after FCC +has finalized the failure. + +The OpenAI-chat provider remains an upstream adapter: it converts OpenAI chat +chunks into ledger operations. After retry, continuation, and tool salvage are +exhausted, it discards uncommitted output or flushes committed output, closes +open content blocks, and raises `ExecutionFailure`. It never synthesizes a +terminal Anthropic error event. + +The public HTTP commit boundary solely decides whether a final failure can use +non-2xx JSON or must use a terminal protocol event; the protocol packages own +envelope and event serialization. Before the first public frame the boundary +returns typed non-2xx JSON with `x-should-retry: false`; after the first frame +Messages appends one Anthropic `event: error`, while Responses emits +`response.failed` with the original response ID. Non-streaming Messages catches +the same failure and discards its partial aggregate. Unexpected failures use the +same commit-state split but do not acquire provider retry semantics. + +[src/free_claude_code/core/openai_responses/](src/free_claude_code/core/openai_responses/) owns OpenAI Responses support: + +- the permissive `OpenAIResponsesRequest` ingress model used directly by the + FastAPI route and the protocol adapter; +- the `OpenAIResponsesAdapter` facade used by the API layer; +- streaming-only `/v1/responses` support for Codex/FCC workflows; +- Responses request conversion into Anthropic Messages payloads; +- Anthropic SSE conversion into Responses SSE; +- OpenAI-compatible error envelopes. + +The package intentionally does not implement the full OpenAI Responses surface. +FCC accepts omitted `stream` or `stream: true`; `stream: false` is rejected with +an OpenAI-shaped client error because installed FCC/Codex workflows only need +streaming. Request conversion, stream transformation, Anthropic SSE parsing, +Responses SSE event formatting, output item construction, tool identity mapping, +reasoning mapping, ID generation, and error envelope construction each live +behind the adapter boundary. The concrete request object crosses that boundary +unchanged; nested Responses input and tool data stays permissive and is +interpreted by the conversion functions. `stream.py` is the public streaming +entrypoint; +[src/free_claude_code/core/openai_responses/streaming/](src/free_claude_code/core/openai_responses/streaming/) owns the +block-indexed Responses stream assembler. The package separates Anthropic SSE +dispatch, block state, output ledger ordering, block completion, SSE event +builders, and error mapping. API code should depend on the adapter, not on +those internal module owners directly. Responses output payloads stay +OpenAI-shaped. Canonical execution failures enter the assembler directly, so +Responses does not infer provider failure semantics by parsing an Anthropic +terminal error. +Post-start Responses failures are assembler-owned: the active +`ResponsesStreamAssembler` emits `response.failed` so the terminal event keeps +the same `response.id`, output ledger, and usage state as the earlier +`response.created`. + +Responses custom tools are also boundary-owned. The adapter accepts native +Responses `custom` tool declarations, represents them internally as Anthropic +tools with a single string `input` field, and restores `custom_tool_call`, +`custom_tool_call_output`, and `response.custom_tool_call_input.*` shapes at the +Responses edge. Text or grammar format metadata is preserved as model guidance; +FCC does not validate custom-tool grammars. + +Responses reasoning is handled as protocol conversion, not provider policy. +`reasoning.effort = "none"` converts to a disabled Anthropic `thinking` +request; any other explicit Responses reasoning request enables Anthropic +thinking without translating OpenAI effort names into Anthropic token budgets. +Prior Responses `reasoning` input items replay plaintext `reasoning_text`, or +fallback `summary_text`, into assistant `reasoning_content`. Encrypted reasoning +input is ignored because the proxy cannot decrypt it. + +Provider thinking output maps back to Responses reasoning in the same block +order the upstream Anthropic stream produced. Anthropic `thinking` blocks become +Responses `reasoning` output items and `response.reasoning_text.*` stream +events. Anthropic `redacted_thinking` becomes a Responses `reasoning` item with +`encrypted_content`; the opaque value is not exposed as visible text and FCC +does not synthesize reasoning summaries. + +Provider code should delegate protocol details to these modules. Avoid copying +conversion code into individual providers, and avoid provider-to-provider imports +for shared Anthropic behavior. + +## Local Optimizations And Server Tools + +[api/optimization_handlers.py](src/free_claude_code/api/optimization_handlers.py) short-circuits +common low-value client requests before they reach a provider: + +- quota probes; +- command prefix detection; +- title generation; +- suggestion mode; +- filepath extraction. + +The Messages handler runs these only after model routing and after local server-tool +handling. Each optimization is controlled by settings flags. + +Claude Code auto-mode safety-classifier requests are a message-only routing +policy, not a short-circuit response. After routing, the Messages handler detects the +narrow classifier prompt shape and forces thinking off before provider execution +so Claude Code receives a parser-readable `yes` or +`no` verdict. + +Local `web_search` and `web_fetch` handling lives under +[api/web_tools/](src/free_claude_code/api/web_tools/). When `ENABLE_WEB_SERVER_TOOLS` is true, the +Messages handler can stream local Anthropic server-tool responses without sending the +request upstream. [api/web_tools/egress.py](src/free_claude_code/api/web_tools/egress.py) enforces URL +scheme and private-network restrictions for `web_fetch`. + +Anthropic server-tool definitions are never passed to upstream OpenAI Chat +providers because that conversion would be lossy. Forced `web_search` or +`web_fetch` requests are handled locally when `ENABLE_WEB_SERVER_TOOLS` is true; +otherwise the Messages handler rejects them before provider execution. + +## CLI Launchers And Managed Claude + +[cli/proxy_auth.py](src/free_claude_code/cli/proxy_auth.py) owns the neutral +proxy-auth token policy shared by client launchers. A blank configured token +becomes the local-only `fcc-no-auth` sentinel so clients cross their login gates +while FCC continues to run without API authentication. + +[cli/claude_env.py](src/free_claude_code/cli/claude_env.py) owns the canonical +Claude Code proxy environment used by every FCC-launched Claude process. It +strips inherited `ANTHROPIC_*` variables, sets `ANTHROPIC_BASE_URL`, enables +gateway model discovery, configures the auto-compact window, disables +nonessential Anthropic traffic, and always sets `ANTHROPIC_AUTH_TOKEN`. Blank +proxy auth uses the shared local-only sentinel so Claude Code reaches the proxy +instead of stopping at its login gate. + +[cli/launchers/claude.py](src/free_claude_code/cli/launchers/claude.py) owns the installed +`fcc-claude` launcher: + +- `fcc-claude` applies the shared proxy environment without changing the user's + Claude command arguments. + +[cli/launchers/codex.py](src/free_claude_code/cli/launchers/codex.py) owns the installed +`fcc-codex` launcher: + +- `fcc-codex` strips official OpenAI and Codex credential variables. +- It strips parent-only Codex thread, shell, permission, and origin context so + each launched client owns an independent runtime identity. +- It creates an ephemeral `fcc` model provider with `wire_api = "responses"` and + a base URL pointing at the local proxy `/v1` path. +- After proxy health succeeds, it fetches `/v1/models`, writes a generated Codex + `model_catalog_json` file under `~/.fcc/`, and injects that path so Codex's + native `/model` picker lists FCC provider slugs. Catalog generation is + fail-open: launch continues with a warning if the catalog cannot be prepared. +- It stores the proxy auth token in `FCC_CODEX_API_KEY` for Codex to read. + +[cli/launchers/pi.py](src/free_claude_code/cli/launchers/pi.py) owns the installed +`fcc-pi` launcher and [cli/launchers/pi_extension.ts](src/free_claude_code/cli/launchers/pi_extension.ts) +is its bundled Pi adapter: + +- Session commands load the extension from its absolute installed path and + scope Pi to the ephemeral `free-claude-code/**` provider, whose model IDs + retain FCC's nested `provider/model` routing reference. +- The extension fetches FCC's `/v1/models` catalog before registration, projects + only routable provider-model IDs, and registers an `anthropic-messages` + provider targeting the local proxy. Catalog failure is fail-closed so Pi never + silently falls back to a different provider. +- FCC connection values live only in child-process `FCC_PI_*` variables. Native + Pi credentials and persistent configuration remain untouched. +- Pi package-management, configuration, help, and version commands pass through + unchanged because they do not create an FCC-backed session. + +[cli/managed/](src/free_claude_code/cli/managed/) owns managed Claude Code subprocesses used by +Discord and Telegram messaging. Managed task invocations extend the same proxy +environment only with non-interactive terminal settings, optional `--resume`, +optional `--fork-session`, `--model opus`, and `--output-format stream-json`. +Messaging pins this Claude tier alias so phone sessions route through +`MODEL_OPUS` or the `MODEL` fallback instead of inheriting a user's interactive +`/model` picker state. Managed execution does not override Claude's +`plansDirectory`; plan files use Claude's native user-level location so the +project workspace may reside on any filesystem volume. The managed session +parser extracts persistent Claude session IDs and yields Claude stream-json +events to the messaging event parser. Managed Claude +also owns subprocess stderr diagnostic classification so known benign Claude +Code notices do not become messaging task errors, while unknown stderr remains +fatal. Before subprocess stop, the manager marks the session closing so new +lookups and aliases cannot borrow it; the session also marks itself terminal so +an already-issued reference cannot launch again. One lifecycle lock linearizes +that terminal transition with subprocess publication. Aliases plus PID +registration remain owned until exit is confirmed. Aggregate shutdown attempts +every distinct mapped or closing session, removes only confirmed successes, +reports a count-only failure, and leaves failures available for the next cleanup +attempt. Real-session registration is collision-safe and becomes durable tree +state only after the manager accepts it. + +Codex and Pi are supported through their installed launchers. FCC does not keep +internal managed session runners for them because no user-facing messaging +setting selects either client for Discord or Telegram. + +## Messaging Architecture + +Messaging is optional. [runtime/application.py](src/free_claude_code/runtime/application.py) calls +`create_messaging_components()` from +[messaging/platforms/factory.py](src/free_claude_code/messaging/platforms/factory.py) during startup. +If `MESSAGING_PLATFORM` is `none`, or if the selected platform token is missing, +the messaging bridge is skipped. + +`ApplicationRuntime` privately owns the selected platform runtime, the +`MessagingWorkflow`, configured `Transcriber`, and managed CLI session manager. +The workflow owns conversation snapshot restoration and terminal close: cancel +work, stop managed CLI sessions, await every processor-owned claim and recovery +task, then flush persistence. Interactive `/stop` keeps its bounded task-drain +behavior; only terminal close waits for full completion. +The API sees only the application-owned `TaskController` used to preserve +`/stop` behavior. + +The platform factory returns a `MessagingPlatformComponents` bundle from +[messaging/platforms/ports.py](src/free_claude_code/messaging/platforms/ports.py): a +`MessagingRuntime` with separate `quiesce()` and `close()` phases, an +`OutboundMessenger` for queued sends/edits/deletes, an optional +`VoiceCancellation` port for scoped and bulk voice cancellation during `/stop` +and `/clear`, and an optional immutable startup-notice intent. Workflow code +depends on these ports and values, not on Telegram or Discord SDK objects. + +Runtime adapters in +[messaging/platforms/telegram.py](src/free_claude_code/messaging/platforms/telegram.py) and +[messaging/platforms/discord.py](src/free_claude_code/messaging/platforms/discord.py) own SDK client +lifecycle, event subscription, inbound handoff, voice-note handoff, and one +injected `MessagingRateLimiter`. The platform factory creates a fresh limiter +for the selected runtime. `quiesce()` stops new SDK ingress and drains active +handlers while delivery remains available; after workflow tasks settle, +`close()` drains the outbox and limiter. Discord additionally retains, observes, +and drains its long-lived client task and inbound-handler tasks, so an SDK exit +after initial readiness immediately withdraws the runtime's connected state. +Telegram retries initialization and polling as separate repeatable steps; it +never restarts an already-running SDK application after polling bootstrap fails. +Separate application runtimes cannot share or stop each other's queue. Inbound +normalization lives in +[messaging/platforms/telegram_inbound.py](src/free_claude_code/messaging/platforms/telegram_inbound.py) +and [messaging/platforms/discord_inbound.py](src/free_claude_code/messaging/platforms/discord_inbound.py). +Outbound SDK calls live in +[messaging/platforms/telegram_io.py](src/free_claude_code/messaging/platforms/telegram_io.py) and +[messaging/platforms/discord_io.py](src/free_claude_code/messaging/platforms/discord_io.py). Shared +delivery policy lives in [messaging/platforms/outbox.py](src/free_claude_code/messaging/platforms/outbox.py), +which requires that limiter directly and owns queued send/edit/list-based delete, +dedup keys, and retained fire-and-forget tasks. Shutdown cancels and awaits both +queued limiter work and arbitrary outbox work; there is no optional unthrottled +fallback, and both owners reject admission once close begins. Workflow and command code request deletion of +message ID lists; platform IO decides whether to use native batch deletion +(Telegram) or internal per-message deletion (Discord). +Shared voice-note orchestration lives in +[messaging/platforms/voice_flow.py](src/free_claude_code/messaging/platforms/voice_flow.py), which owns +file-size validation, temp-file cleanup, transcription, error replies, and the +handoff to `IncomingMessage`. Before status delivery it reserves an opaque claim +in the `PendingVoiceRegistry` owned by [messaging/voice.py](src/free_claude_code/messaging/voice.py). +That registry atomically owns optional status binding, cancellation by either +message ID, and one child task that retains the exclusive handoff lease through +the complete workflow callback. An explicit stop or clear atomically removes +the exact claim and assumes ownership under the registry lock, then cancels and +joins its published child without holding that lock. Caller cancellation instead +keeps both aliases published while it cancels and drains the child, then removes +only that exact generation. Repeated cancellation cannot abandon either join or +pre-handoff cleanup, and fatal callback failures release the aliases before they +propagate. A cancellation that wins turns late status, transcription, callback +completion, or ordinary callback failure into cleanup-only work. Bulk +cancellation deduplicates the voice/status aliases and excludes the exact +current handoff child plus claims participating in a nested cancellation, so a +voice-transcribed `/stop` or `/clear` cannot cancel itself or form a recursive +join cycle. A stale flow cannot bind or remove a newer generation reusing the +same ID. Pending voice identities use the same +`(platform, chat_id)` `MessageScope` as tree references, so raw IDs from different +transports cannot share cancellation ownership. The flow depends only on the +consumer-owned `Transcriber` protocol. Bootstrap selects either the +instance-owned local Whisper `TranscriptionService` or the provider-owned +`NvidiaNimTranscriber`. Messaging no longer imports a provider adapter, and the +local service retains only one lazy pipeline for its immutable runtime settings; +caller cancellation waits for thread-backed transcription to actually exit +before temporary files, pipelines, or credentials are released. The NIM adapter +closes its per-call authenticated gRPC channel before that worker exits. Changing the +credential used by an active voice backend through Admin is therefore +restart-required, while the same provider credential remains hot-replaceable +when voice does not use it. + +[messaging/workflow.py](src/free_claude_code/messaging/workflow.py) contains `MessagingWorkflow`, the +platform-agnostic coordinator. It owns dependencies, render settings, the +state-transaction lock, global stop generation, per-chat clear generations, +stop/clear side effects, and shutdown-visible state. Each inbound turn snapshots +both applicable generations before external status I/O and rechecks them while +committing admission. Global `/stop` invalidates every older provisional turn; +standalone `/clear` invalidates only the invoking `MessageScope`. Before taking +the workflow lock, those commands cancel and join their applicable older voice +handoffs; they then cancel any matching tree that won admission during the join. +Reply-scoped commands first join the matching voice claim and then apply an exact +reference transition, so either the voice cancellation or admitted-tree +transition wins without double-counting. Stop operations return one typed +outcome after assigning every terminal status owner. The outcome records which message scopes own terminal +status feedback. Existing task statuses are the sole success UI when every +affected status is in the invoking scope; the command adapter sends a message +for a no-op, any cross-scope work, or the rare voice cancellation that wins +before a status ID is bound. Generation validation, tree admission, processor +publication, and persistence of the detached snapshot complete as one +workflow-owned operation; caller cancellation is restored only after that +transaction finishes. Stop and clear use the same completion-driven boundary, +so caller cancellation cannot leave a committed state transition without its +remaining cancellation and persistence cleanup. At startup it restores and normalizes +persisted state before ingress begins, then repairs interrupted platform +statuses after outbound delivery starts. Diagnostic detail policy is captured +at construction and passed into the processor; messaging does not read global +settings while executing callbacks or failures. + +Clearable lifecycle notices are workflow-owned rather than SDK-runtime side +effects. After transport readiness and restored-status repair, +`ApplicationRuntime` hands the platform's semantic startup-notice intent to the +workflow. The workflow owns platform rendering and snapshots the notice chat's +clear generation before sending outside its state lock. Once delivery returns a +message ID, a cancellation-safe finalizer briefly reacquires the lock: it records +the ID only if no standalone clear in that chat or startup cancellation crossed +the reservation; +otherwise it releases the lock and deletes the notice. Failed compensation +attempts to restore the ID to the current managed-message log so a later `/clear` can +retry. No platform I/O runs under the workflow lock. Ordinary notice-send +failure is privacy-safe and nonfatal, while cancellation before a delivery +receipt remains immediate and cannot create a phantom message ID. + +[messaging/turn_intake.py](src/free_claude_code/messaging/turn_intake.py) owns slash command dispatch, +status-echo filtering, initial status messages, and rendering detached frozen +admission/queue effects. The workflow records each accepted inbound prompt, +voice note, or command before intake performs external status I/O. Intake asks +the workflow to resolve and admit turns rather than receiving a mutable tree. +Reply lookup is always scoped by platform and chat; an unknown or cross-chat +reference starts an independent root. A previously resolved exact parent that a +concurrent clear removes is instead rejected as `PARENT_REMOVED`; intake then +best-effort deletes both the stale child prompt and its provisional status. +Duplicate delivery deletes only its provisional status. + +[messaging/node_runner.py](src/free_claude_code/messaging/node_runner.py) owns managed CLI session +lifecycle for queued nodes: parent-session fork/resume, session registration, +CLI event parsing, transcript/status updates, cancellation, error propagation, +and session cleanup. It executes an immutable `NodeClaim`; session, completion, +and failure writes return through `TreeQueueManager` with that claim identity. A +non-exit CLI error may render an error immediately, but only a terminal failure +propagates to queued descendants; a later successful exit is authoritative for +the same live, non-cancelled claim. A stale runner receives no snapshot and +cannot restore a branch removed by `/clear`. + +[messaging/event_parser.py](src/free_claude_code/messaging/event_parser.py) normalizes managed Claude +JSON events into low-level transcript events. +[messaging/transcript/](src/free_claude_code/messaging/transcript/) owns transcript assembly and +rendering: open content-block tracking, Task/subagent display state, segment +models, render context, and truncation. Platform markdown details stay in +[messaging/rendering/](src/free_claude_code/messaging/rendering/). + +[messaging/command_context.py](src/free_claude_code/messaging/command_context.py) defines the typed +dependency surface for `/stop`, `/clear`, and `/stats`; commands should not +depend on the concrete workflow object or on platform SDK runtimes. + +[messaging/trees/runtime.py](src/free_claude_code/messaging/trees/runtime.py) contains the +`MessageTree` aggregate. Its lock is private, and complete operations own every +graph/queue/claim invariant: add-and-admit, enqueue-or-claim, +finish-and-claim-next, semantic state writes, cancellation, and atomic branch +removal. Logical `parent_id` owns execution/session ancestry, while +`parent_reference_id` records the exact prompt or FCC status that received the +platform reply. The aggregate derives literal reference adjacency from those +canonical fields instead of maintaining a second graph. Removing a prompt +therefore removes its status and every literal descendant; removing a status +preserves its prompt and prompt-level siblings while invalidating that prompt's +session. `TreeIdentity` is `(platform, chat_id, root_message_id)`, because +platform message IDs are not globally unique. Every execution receives a fresh +opaque claim ID, so a task from an older runtime generation cannot mutate or +collide with a re-admitted tree. Active execution ownership is separate from the +node's UI state: cancellation can still reach a task that already rendered +complete/error but is cleaning up, while a cancellation tombstone prevents late +success from reviving a stopped node. Only the matching finish transition may +select the FIFO successor. Duplicate node/status admission and terminal-node +re-admission are rejected without changing active state. + +[messaging/trees/transitions.py](src/free_claude_code/messaging/trees/transitions.py) owns frozen, +slotted claims, queue entries, read views, and cancellation/removal effects. +These values copy the UI and execution facts callers need and never contain a +mutable `MessageNode`, lock, or `asyncio.Task`. + +[messaging/trees/manager.py](src/free_claude_code/messaging/trees/manager.py) is the only external +tree facade. It keeps one structural lock across aggregate membership changes +and repository index publication/removal, registers node and status references +together under `MessageScope`, coordinates cross-tree requests, and returns +transition-owned snapshots. Claim completion re-enters that same lock: the +manager verifies the exact aggregate is still published and publishes any +successor task slot before a competing detach can commit. Cancellation and +removal entrypoints finish their exact transition despite caller cancellation, +so a committed detach cannot lose its persistence result. Reply `/clear` is one +exact-reference cancel-and-detach transition before platform I/O; standalone +clear atomically detaches every aggregate in the invoking scope before task +draining. Reply `/stop` cancels exactly one request; its matching finisher +releases execution ownership and advances the next eligible queued request. +Global `/stop` drains every queue instead, and reply `/clear` removes the selected +literal message subtree before any survivor can advance. Separate scopes and +trees still progress independently. Subtree transitions return exact reference +IDs for both repository unindexing and authorized platform deletion, including +user-authored messages selected by the explicit command. +[messaging/trees/repository.py](src/free_claude_code/messaging/trees/repository.py) +is manager-private and owns only aggregate/reference indexes. +[messaging/trees/processor.py](src/free_claude_code/messaging/trees/processor.py) owns every +`asyncio.Task`, keyed by globally unique claim ID. It publishes a task slot +before task creation, which is safe under Python's eager task factory, then +launches claims returned by the aggregate, cancels the exact matching task, +drains cleanup outside tree locks, and feeds matching completion back to the +aggregate. Cancellation before a task body starts has an explicit recovery path; +the cancellation flag is rechecked after callbacks, and best-effort UI callback +failure cannot prevent successor launch. If a node processor unexpectedly +escapes, the processor routes failure through the manager-owned aggregate +transition; the workflow persists its snapshot and schedules its UI effect as +normal queue advancement continues. The processor's completion event covers the +published slot from launch through normal completion, successor publication, +and pre-run recovery, so terminal workflow close cannot release delivery while +cleanup is still active. A failed aggregate-completion callback releases its +finished task slot, records the failure, and hands it to the terminal waiter +exactly once; a failed close therefore retains the workflow for reconciliation +instead of hanging on ownership that no longer exists. +[messaging/trees/node.py](src/free_claude_code/messaging/trees/node.py) owns +`MessageNode` and `MessageState`; each node keeps only the copied scope and +prompt needed by the aggregate rather than retaining a mutable ingress value, +[messaging/trees/graph.py](src/free_claude_code/messaging/trees/graph.py) owns parent/child and +status-message lookup state, and +[messaging/trees/snapshot.py](src/free_claude_code/messaging/trees/snapshot.py) owns typed persisted +conversation snapshots. New snapshots serialize scoped trees as a list, while +loading derives scope from existing pre-scope `sessions.json` tree roots. Nodes +persist logical and exact-reference parent relations; runtime child indexes are +rebuilt on restore, and transport ingress payloads do not leak into aggregate +storage. Old snapshots without an exact parent reference attach conservatively +to the logical parent prompt. A cleared optional status is valid only for an +inert node; runnable restored nodes must still have a status. +A malformed tree carrying neither current scope nor legacy root ingress is +reported and skipped because assigning it to an inferred chat would violate the +same ownership boundary. + +[messaging/session/](src/free_claude_code/messaging/session/) persists typed conversation snapshots +and message IDs to a JSON file under the managed messaging state directory. +`SessionStore` reads existing `sessions.json` files but exposes typed snapshot +APIs to runtime code and deep-copies snapshot ingress and egress so no caller +shares mutable persisted state. Debounced atomic writes live in +[messaging/session/persistence.py](src/free_claude_code/messaging/session/persistence.py). One writer +lock serializes physical replaces, and a generation check under that lock +prevents an older timer snapshot from landing after a newer flush or clear. +Timer-triggered saves are best effort and leave the store dirty on failure; +explicit flushes and authoritative writes propagate failure while preserving +that dirty state for retry. Successful retry writes the current in-memory +snapshot and is the only operation that marks it clean. +Standalone `/clear` detaches and drains only the invoking scope, then writes an +authoritative scoped removal while other chats remain intact. Per-chat deletion +ownership lives in +[messaging/session/managed_message_log.py](src/free_claude_code/messaging/session/managed_message_log.py). +The registry accepts managed inbound prompts, voice notes, and commands as well +as FCC output. It migrates legacy `message_log` entries and persists the final +shape as `managed_messages`. Startup notices use the same registry. An incoming +standalone `/clear` defers insertion because the command handler already owns its +ID on success; this prevents the command from evicting an older deletion target +when an explicit cap is configured. Failed or cancelled clear attempts record +the command before propagating so a later clear can discover it. + +`/clear` commits FCC state cleanup first and then best-effort deletes the exact +authorized message-ID set through the list-based outbound port. Standalone clear +deletes every tracked user and FCC message in its chat; reply clear deletes only +the selected literal reply subtree plus its command. Discord/Telegram can still +reject individual deletions for platform reasons such as permissions, age, or +missing messages; such failures never restore cleared FCC state. + +```mermaid +sequenceDiagram + participant Runtime as DiscordOrTelegramRuntime + participant Outbound as OutboundMessenger + participant Workflow as MessagingWorkflow + participant Intake as MessagingTurnIntake + participant Queue as TreeQueueManager + participant Runner as MessagingNodeRunner + participant Manager as ManagedClaudeSessionManager + participant CLI as ClaudeCode + participant Proxy as LocalProxy + + Runtime->>Workflow: IncomingMessage + Workflow->>Intake: handle inbound turn + Intake->>Queue: create or extend message tree + Queue->>Runner: process node in order + Runner->>Manager: get_or_create_session + Manager->>CLI: launch JSON stream task + CLI->>Proxy: provider-backed API calls + CLI-->>Runner: parsed stdout events + Runner-->>Outbound: status and transcript updates +``` + +## Observability, Diagnostics, And Safety + +[core/trace.py](src/free_claude_code/core/trace.py) emits structured trace events across stages such +as ingress, routing, provider, egress, messaging, and client CLI execution. Trace +payloads are intended to connect API, provider, CLI, and messaging activity +without requiring raw transport logs by default. + +Logging defaults are conservative: + +- API payloads and SSE events are not logged raw unless explicitly enabled. +- Provider and application errors log metadata by default; verbose traceback and + message logging are opt-in. +- Messaging text, transcription previews, CLI diagnostics, and detailed + messaging exception strings are controlled by separate diagnostic flags. +- Process logging, server/managed-CLI authentication, and messaging diagnostics + are captured by their lifecycle owners at construction. Admin marks those + settings restart-required so an Apply cannot report success while an existing + runtime continues using stale security or privacy policy. +- Values under keys that look like API keys, authorization, tokens, or secrets + are redacted by trace helpers where structured traces are emitted. + +Important safety boundaries: + +- Admin UI and admin APIs are loopback-only. +- Proxy API auth is controlled by `ANTHROPIC_AUTH_TOKEN`. +- `web_fetch` egress defaults to configured URL schemes and blocks private + network targets unless explicitly allowed. +- Local provider URLs are user-configurable, but local-provider status checks are + exposed only through the local admin API. + +## Testing And CI Strategy + +Deterministic tests live under [tests/](tests/). They cover API routes, config, +provider conversion, upstream adapters, streaming contracts, messaging, CLI +adapters, import boundaries, provider catalog contracts, and other invariants. +The import-boundary contract derives every static production edge with one AST +scanner and checks the package matrix, exact exceptions, facade ownership, and +lazy optional imports. The resulting first-party module graph must remain +acyclic. The same contract rejects untyped provider collaborators and private +provider access from helper modules. These tests protect current architectural +properties rather than preserving deleted modules or an exact internal file +layout. + +Live and local product tests live under [smoke/](smoke/). See +[smoke/README.md](smoke/README.md) for target taxonomy, environment variables, +failure classes, and examples. Smoke tests can launch subprocesses, call real +providers, touch local model servers, and optionally send bot messages. + +CI is defined in [.github/workflows/tests.yml](.github/workflows/tests.yml). It +enforces: + +- `Ban type ignore suppressions`; +- `ruff-format`; +- `ruff-check`; +- `ty`; +- `pytest`. + +Contributor verification commands: + +```powershell +uv run ruff format +uv run ruff check +uv run ty check +uv run pytest +``` + +For docs-only architecture changes, a source-link and accuracy review is usually +sufficient. Full CI can still be run when the doc accompanies runtime changes or +when maintainers want branch-level assurance. + +## Extension Checklists + +### Add An Admin Setting + +1. Add or expose the setting in [config/settings.py](src/free_claude_code/config/settings.py). +2. Add the template key to [.env.example](.env.example) if users configure it. +3. Add a `ConfigFieldSpec` under [config/admin/](src/free_claude_code/config/admin/), or add + provider catalog metadata when the setting is provider credential, local URL, + proxy, or display-name metadata. +4. Mark `restart_required` or `session_sensitive` when runtime state cannot be + updated in place. +5. Add tests under [tests/api/](tests/api/) or [tests/config/](tests/config/). + +### Add Or Change A Client Surface + +1. For an installed wrapper, add or update a launcher under + [cli/launchers/](src/free_claude_code/cli/launchers/) and keep credential stripping local to that + client. +2. For messaging-managed execution, update [cli/managed/](src/free_claude_code/cli/managed/) only + when Discord or Telegram should actually run a different managed client. +3. Ensure managed task parsing emits the event shapes expected by + [messaging/event_parser.py](src/free_claude_code/messaging/event_parser.py) and + [messaging/node_event_pipeline.py](src/free_claude_code/messaging/node_event_pipeline.py). +4. Add launcher, managed-session, and customer-flow tests under + [tests/cli/](tests/cli/) and [tests/messaging/](tests/messaging/). + +### Add A Messaging Platform + +1. Implement a `MessagingRuntime`, `OutboundMessenger`, and inbound normalizer + under [messaging/platforms/](src/free_claude_code/messaging/platforms/). +2. Reuse [messaging/platforms/outbox.py](src/free_claude_code/messaging/platforms/outbox.py) for + queued outbound delivery and + [messaging/platforms/voice_flow.py](src/free_claude_code/messaging/platforms/voice_flow.py) for + voice-note handoff when the platform supports audio. +3. Add construction logic to + [messaging/platforms/factory.py](src/free_claude_code/messaging/platforms/factory.py). +4. Add settings and admin fields for tokens, allowlists, and platform-specific + runtime options. +5. Add rendering profile support in + [messaging/rendering/profiles.py](src/free_claude_code/messaging/rendering/profiles.py) if needed. +6. Add deterministic runtime/outbound/workflow tests and optional live smoke + targets. + +### Add Protocol Behavior + +1. Put shared Anthropic behavior under [src/free_claude_code/core/anthropic/](src/free_claude_code/core/anthropic/). +2. Put OpenAI Responses behavior under + [src/free_claude_code/core/openai_responses/](src/free_claude_code/core/openai_responses/). +3. Keep provider-specific request quirks inside the provider profile or specialized + provider subclass. +4. Add stream contract tests under [tests/contracts/](tests/contracts/) or + [tests/core/](tests/core/) when event shape or ordering changes. +5. Add provider tests when the behavior changes upstream request or response + handling. + +## Maintenance Rules For This Document + +Update this file when a change adds or meaningfully changes: + +- a top-level package or installable runtime boundary; +- a public route or wire protocol; +- startup, shutdown, or resource ownership; +- configuration precedence or managed config behavior; +- provider runtime, catalog, or upstream-adapter architecture; +- model routing or thinking behavior; +- CLI adapter behavior; +- messaging platform behavior; +- protocol conversion or streaming contracts; +- CI, smoke, or verification strategy. + +Docs-only changes to this file do not require a semver bump. Production code +changes still follow the versioning rules in [AGENTS.md](AGENTS.md) and +[CLAUDE.md](CLAUDE.md). + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..164e80d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,105 @@ +# AGENTIC DIRECTIVE + +> Keep AGENTS.md and CLAUDE.md identical. + +## 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.0 stable using `uv python install 3.14.0` if not already installed (requires uv >=0.9; see `[tool.uv] required-version` in `pyproject.toml`) +- 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). +- Before pushing, prefer `./scripts/ci.sh` (macOS/Linux) or `.\scripts\ci.ps1` (Windows) to run the local CI sequence; requires `uv` on PATH. The local scripts run Ruff in repair mode (`ruff format`, then `ruff check --fix`) before type checking and tests. +- Use `--only` / `--skip` (PowerShell: `-Only` / `-Skip`) to run a subset when iterating; use `--dry-run` to print commands without running them. +- GitHub CI remains check-only for Ruff (`ruff format --check`, `ruff check`) so branch protection verifies committed code. +- Fall back to individual repair commands when debugging local failures: `uv run ruff format`, `uv run ruff check --fix`, `uv run ty check`, `uv run pytest -v --tb=short`. Use GitHub-style checks only when verifying enforcement locally: `uv run ruff format --check`, `uv run ruff check`. +- Do not add `# type: ignore` or `# ty: ignore`; fix the underlying type issue. +- Do not add `from __future__ import annotations`; Python 3.14 native lazy annotations are the project standard. +- All 5 check IDs are represented in `scripts/ci.sh` / `scripts/ci.ps1` and enforced in `tests.yml` on push/merge (parallel jobs: suppression grep, ruff-format, ruff-check, ty, pytest). +- GitHub CI runs on `push`, `pull_request`, and `merge_group` so required checks validate merge queue candidates before they land. +- Repository protection should use rulesets: a non-bypassable main integrity ruleset requires pull requests, merge queue, required checks, and blocks direct/force pushes to `main`; a separate review ruleset may allow `Alishahryar1`/admins to bypass review only. +- Required status checks: set **required status checks** to **all** of those statuses (e.g. **Ban suppressions and legacy annotations**, **ruff-format**, **ruff-check**, **ty**, **pytest**—use the exact labels GitHub shows, which may be prefixed with **CI /**). Remove **ci** from required checks if it was previously added for the old gate job. + +## 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 + +- **Shared utilities**: Put shared Anthropic protocol logic in neutral `src/free_claude_code/core/anthropic/` modules. Do not have one provider import from another provider's utils. +- **Failure ownership**: Keep canonical failure semantics and redaction SDK-free in `core/`; providers alone classify SDK/HTTP failures and own retries; protocol/API adapters alone choose wire error types and commit-boundary serialization. +- **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. +- **Python 3.14 annotations**: Do not use `from __future__ import annotations`; rely on native lazy annotations and fix circular import boundaries instead of hiding them with annotation stringization. +- **Imports**: Prefer top-level imports. Avoid `TYPE_CHECKING` and local imports for first-party or required dependencies; if a top-level import creates a cycle, move shared types/protocols to a neutral owner. +- **Complete migrations**: When moving modules, update imports to the new owner and remove old compatibility shims in the same change unless preserving a published interface is explicitly required. +- **Maximum Test Coverage**: There should be maximum test coverage for everything, preferably live smoke test coverage to catch bugs early + +## 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 `./scripts/ci.sh` or `.\scripts\ci.ps1`, plus relevant smoke tests when needed. 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. +7. **VERSION**: If the commit touches production files on `main`, bump semver in the same commit (see [Versioning](#versioning-main)). + +## VERSIONING (MAIN) + +Every commit on `main` that changes a **production file** must include a semver bump in **`pyproject.toml`** in the **same commit**. Do not merge or push prod changes without updating the version. + +### Production files + +These paths count as production (runtime, packaging, or install surface): + +- `src/free_claude_code/api/`, `src/free_claude_code/cli/`, `src/free_claude_code/config/`, `src/free_claude_code/core/`, `src/free_claude_code/messaging/`, `src/free_claude_code/providers/` +- `src/free_claude_code/application/` +- `.env.example` +- `pyproject.toml` (dependencies, scripts, packaging) +- `scripts/install.sh`, `scripts/install.ps1`, `scripts/uninstall.sh`, `scripts/uninstall.ps1`, `scripts/ci.sh`, `scripts/ci.ps1` + +These do **not** require a version bump on their own: + +- `tests/`, `smoke/` +- Docs and assets: `README.md`, `assets/`, `AGENTS.md`, `CLAUDE.md` +- CI and repo config: `.github/`, `.gitignore` + +If a single commit mixes production and non-production edits, still bump the version. + +### Semver rules + +Use `[project].version` as `MAJOR.MINOR.PATCH`: + +- **PATCH** (`x.y.Z+1`): bug fixes, refactors with no user-visible behavior change, dependency updates, packaging/install fixes. +- **MINOR** (`x.Y+1.0`): backward-compatible features—new providers, admin fields, CLI commands, config options, or behavior additions. +- **MAJOR** (`X+1.0.0`): breaking changes—removed or renamed env vars, incompatible API/CLI/default changes, or migrations users must act on. + +When unsure between PATCH and MINOR, prefer PATCH for fixes and MINOR for new capability. + +### Required steps + +1. Classify the change and choose the bump level. +2. Update `version` in `pyproject.toml`. +3. Run `uv lock` so `uv.lock` reflects the new package version. +4. Include the version and lockfile updates in the same commit as the production change. + +Example commit on `main` after a packaging fix: bump `1.2.38` → `1.2.39`, run `uv lock`, commit together with the fix. + +## 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/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9f9b557 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,63 @@ +# Contributing + +Thanks for helping improve Free Claude Code. Keep changes focused, test the behavior you change, and preserve the public Claude Code and Codex workflows. + +## Before Opening A Pull Request + +- Open an issue before proposing README changes. +- Do not open Docker integration pull requests. +- For bugs, include every model mapping, the active model when the failure occurred, the complete error, and reproducible steps. +- Add focused tests for behavior changes and relevant edge cases. +- Read [ARCHITECTURE.md](ARCHITECTURE.md) before changing package boundaries, providers, protocol conversion, launchers, or messaging. + +## Development Setup + +Install [uv](https://docs.astral.sh/uv/) and Python 3.14, then run directly from the checkout: + +```bash +git clone https://github.com/Alishahryar1/free-claude-code.git +cd free-claude-code +uv python install 3.14.0 +uv run fcc-server +``` + +Use `uv run` for Python commands. Do not run the project with a global Python interpreter. + +## Quality Checks + +Run the complete local CI sequence before opening a pull request: + +```bash +./scripts/ci.sh +``` + +```powershell +.\scripts\ci.ps1 +``` + +Useful iteration flags are `--only`, `--skip`, and `--dry-run` on macOS/Linux, or `-Only`, `-Skip`, and `-DryRun` in PowerShell. + +Individual repair and test commands: + +```bash +uv run ruff format +uv run ruff check --fix +uv run ty check +uv run pytest -v --tb=short +``` + +GitHub CI runs Ruff in check-only mode and also bans `# type: ignore`, `# ty: ignore`, and legacy annotation workarounds. Fix underlying typing and import-boundary problems instead of suppressing them. + +## Project Standards + +- Target Python 3.14 and rely on native lazy annotations; do not add `from __future__ import annotations`. +- Python 3.14 supports multiple exception types without parentheses, such as `except TypeError, ValueError:`. +- Keep shared Anthropic protocol behavior under `src/free_claude_code/core/anthropic/` rather than importing utilities from another provider. +- Keep provider-specific configuration in the provider that owns it. +- Remove dead compatibility code when completing migrations unless preserving a published interface is explicitly required. + +## Versioning + +Changes to runtime code, packaging, dependencies, or install/CI scripts require a semantic version bump in `pyproject.toml` and a matching `uv lock` update in the same commit. Documentation, tests, smoke coverage, and repository configuration do not require a version bump by themselves. + +See [ARCHITECTURE.md](ARCHITECTURE.md) for extension checklists and the full system design. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e174983 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ali Khokhar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..16b05b4 --- /dev/null +++ b/README.md @@ -0,0 +1,431 @@ +
+ +# 🤖 Free Claude Code + +Use Claude Code, Codex, Pi, editor extensions, or chat bots through your own provider-backed proxy. + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT) +[![Python 3.14](https://img.shields.io/badge/python-3.14-3776ab.svg?style=for-the-badge&logo=python&logoColor=white)](https://www.python.org/downloads/) +[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json&style=for-the-badge)](https://github.com/astral-sh/uv) +[![Tested with Pytest](https://img.shields.io/badge/testing-Pytest-00c0ff.svg?style=for-the-badge)](https://github.com/Alishahryar1/free-claude-code/actions/workflows/tests.yml) +[![Type checking: Ty](https://img.shields.io/badge/type%20checking-ty-ffcc00.svg?style=for-the-badge)](https://pypi.org/project/ty/) +[![Code style: Ruff](https://img.shields.io/badge/code%20formatting-ruff-f5a623.svg?style=for-the-badge)](https://github.com/astral-sh/ruff) +[![Logging: Loguru](https://img.shields.io/badge/logging-loguru-4ecdc4.svg?style=for-the-badge)](https://github.com/Delgan/loguru) + +Run your coding agents with free, paid, or local models. Choose and validate providers from one local Admin UI. + +[Quick Start](#quick-start) · [Providers](#choose-a-provider) · [Clients](#connect-your-client) · [Integrations](#optional-integrations) · [Manage](#manage-your-installation) + +
+ +
+ Free Claude Code in action +

Claude Code running through the Free Claude Code proxy.

+
+ +
+ Codex CLI in action through Free Claude Code +

Codex CLI using the local FCC Responses provider.

+
+ + + +
+ Claude Code model picker showing gateway models +

Claude Code native /model picker with FCC gateway models.

+
+ +
+ Codex model picker showing generated FCC model catalog +

Codex native /model picker with the generated FCC catalog.

+
+ +## Star History + +
+ + + + + Star History Chart + + +
+ +## What You Get + +- Launch Claude Code with `fcc-claude`, Codex with `fcc-codex`, or Pi with `fcc-pi`. +- Switch among 24 cloud and local providers from the Admin UI. +- Use each coding agent's native model picker. +- Route Opus, Sonnet, Haiku, and fallback traffic to different models. +- Keep streaming, tool use, and reasoning support across compatible models. +- Connect Claude Code and Codex in VS Code or Claude Code through JetBrains ACP. +- Optionally run Claude Code sessions through Discord or Telegram with voice-note transcription. +- Protect the local proxy with optional token authentication. + +## Quick Start + + + +### 1. Install Or Update + +macOS/Linux: + +```bash +curl -fsSL "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.sh" | sh +``` + +Windows PowerShell: + +```powershell +& ([scriptblock]::Create((irm "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.ps1"))) +``` + +Re-run the same command whenever you want to update. You can review the installers before running them: [install.sh](scripts/install.sh) and [install.ps1](scripts/install.ps1). + +### 2. Start The Server + +```bash +fcc-server +``` + +To print the installed Free Claude Code version without starting the server, +run `fcc-server --version`. + +Keep this process running. The startup log shows the Admin UI address: + +```text +INFO: Admin UI: http://127.0.0.1:8082/admin (local-only) +``` + +Use the port shown in your terminal if it differs from `8082`. + + + +### 3. Configure NVIDIA NIM + +1. Create an API key at [build.nvidia.com/settings/api-keys](https://build.nvidia.com/settings/api-keys). +2. Open the Admin UI URL from the server log. +3. Paste the key into `NVIDIA_NIM_API_KEY`. +4. Leave `MODEL` on the default `nvidia_nim/nvidia/nemotron-3-super-120b-a12b`, or select another model. +5. Click **Validate**, then **Apply**. + +
+ Local admin UI for proxy settings +
+ +### 4. Run Your Coding Agent + +Claude Code: + +```bash +fcc-claude +``` + +Codex: + +```bash +fcc-codex +``` + +Pi: + +```bash +fcc-pi +``` + +All three launchers use the current Admin UI settings. Use the agent's model picker to choose from the models FCC exposes. Normal CLI arguments still work, for example: + +```bash +fcc-codex exec "hello" +``` + +`fcc-pi` registers FCC only for that Pi process; your existing Pi settings, sessions, credentials, and extensions remain unchanged. + +## Choose A Provider + +Enter the listed setting in the Admin UI, set `MODEL` to a provider-prefixed model ID, then click **Validate** and **Apply**. Provider names link to their key, model, or setup pages. + +| Provider | Admin UI setting | Example `MODEL` | +| --- | --- | --- | +| [NVIDIA NIM](https://build.nvidia.com/settings/api-keys) | `NVIDIA_NIM_API_KEY` | `nvidia_nim/nvidia/nemotron-3-super-120b-a12b` | +| [OpenRouter](https://openrouter.ai/keys) | `OPENROUTER_API_KEY` | `open_router/openrouter/free` | +| [Google AI Studio (Gemini)](https://aistudio.google.com/apikey) | `GEMINI_API_KEY` | `gemini/models/gemini-3.1-flash-lite` | +| [DeepSeek](https://platform.deepseek.com/api_keys) | `DEEPSEEK_API_KEY` | `deepseek/deepseek-chat` | +| [Mistral La Plateforme](https://console.mistral.ai/) | `MISTRAL_API_KEY` | `mistral/devstral-small-latest` | +| [Mistral Codestral](https://console.mistral.ai/) | `CODESTRAL_API_KEY` | `mistral_codestral/codestral-latest` | +| [OpenCode Zen](https://opencode.ai/auth) | `OPENCODE_API_KEY` | `opencode/gpt-5.3-codex` | +| [OpenCode Go](https://opencode.ai/auth) | `OPENCODE_API_KEY` | `opencode_go/minimax-m2.7` | +| [Vercel AI Gateway](https://vercel.com/docs/ai-gateway/models-and-providers) | `AI_GATEWAY_API_KEY` | `vercel/openai/gpt-5.5` | +| [Hugging Face Inference Providers](https://huggingface.co/settings/tokens) | `HUGGINGFACE_API_KEY` | `huggingface/Qwen/Qwen3-Coder-480B-A35B-Instruct:fastest` | +| [Cohere](https://dashboard.cohere.com/api-keys) | `COHERE_API_KEY` | `cohere/command-a-plus-05-2026` | +| [GitHub Models](https://github.com/marketplace?type=models) | `GITHUB_MODELS_TOKEN` | `github_models/openai/gpt-4.1` | +| [Wafer](https://wafer.ai/) | `WAFER_API_KEY` | `wafer/DeepSeek-V4-Pro` | +| [Kimi](https://platform.moonshot.ai/console/api-keys) | `KIMI_API_KEY` | `kimi/kimi-k2.5` | +| [MiniMax](https://platform.minimax.io/user-center/basic-information/interface-key) | `MINIMAX_API_KEY` | `minimax/MiniMax-M3` | +| [Cerebras Inference](https://cloud.cerebras.ai/) | `CEREBRAS_API_KEY` | `cerebras/gpt-oss-120b` | +| [Groq](https://console.groq.com/keys) | `GROQ_API_KEY` | `groq/llama-3.3-70b-versatile` | +| [SambaNova](https://cloud.sambanova.ai/apis) | `SAMBANOVA_API_KEY` | `sambanova/Meta-Llama-3.3-70B-Instruct` | +| [Fireworks AI](https://fireworks.ai/account/api-keys) | `FIREWORKS_API_KEY` | `fireworks/accounts/fireworks/models/llama-v3p3-70b-instruct` | +| [Cloudflare Workers AI](https://developers.cloudflare.com/workers-ai/) | `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` | `cloudflare/@cf/moonshotai/kimi-k2.6` | +| [Z.ai](https://z.ai/manage-apikey/apikey-list) | `ZAI_API_KEY` | `zai/glm-5.2` | +| [LM Studio](https://lmstudio.ai/) | `LM_STUDIO_BASE_URL` | `lmstudio/` | +| [llama.cpp](https://github.com/ggml-org/llama.cpp) | `LLAMACPP_BASE_URL` | `llamacpp/` | +| [Ollama](https://ollama.com/) | `OLLAMA_BASE_URL` | `ollama/` | + +Important provider notes: + +- Mistral Codestral uses a separate key from Mistral La Plateforme. +- OpenCode Zen and OpenCode Go share `OPENCODE_API_KEY` but use different model prefixes. +- Cloudflare requires both its API token and account ID. +- Prefer tool-capable models for coding agents. Local models also need enough context for the agent's system prompt and tool definitions. + +
+Local provider setup + +### LM Studio + +Start LM Studio's local server, load a tool-capable model, and use the model identifier shown by LM Studio with the `lmstudio/` prefix. The default URL is `http://localhost:1234/v1`. + +### llama.cpp + +Start `llama-server` with its OpenAI-compatible Chat Completions API and enough context for the model. Use the local model ID with the `llamacpp/` prefix. `LLAMACPP_BASE_URL` defaults to `http://localhost:8080/v1`; FCC accepts either the server root or an explicit `/v1` suffix. + +### Ollama + +```bash +ollama pull llama3.1 +ollama serve +``` + +Use the tag shown by `ollama list` with the `ollama/` prefix. `OLLAMA_BASE_URL` defaults to `http://localhost:11434`; FCC accepts either the root URL or an explicit `/v1` suffix. + +
+ +### Optional Model-Tier Routing + +`MODEL` is the fallback for every request. Set `MODEL_OPUS`, `MODEL_SONNET`, or `MODEL_HAIKU` to override individual Claude Code tiers; leave a tier blank to inherit `MODEL`. + +For example, route Opus to `nvidia_nim/moonshotai/kimi-k2.6`, Sonnet to `open_router/openrouter/free`, Haiku to `lmstudio/qwen3.5-coder`, and keep `MODEL` on `zai/glm-5.2`. + + + +## Connect Your Client + +For terminal use, start `fcc-server`, then run `fcc-claude`, `fcc-codex`, or `fcc-pi`. Use the guides below for editor integrations. + +
+Claude Code in VS Code + +Install the [Claude Code extension](https://marketplace.visualstudio.com/items?itemName=anthropic.claude-code). Open VS Code's user settings as JSON and add: + +```json +"claudeCode.disableLoginPrompt": true, +"claudeCode.environmentVariables": [ + { "name": "ANTHROPIC_BASE_URL", "value": "http://localhost:8082" }, + { "name": "ANTHROPIC_AUTH_TOKEN", "value": "freecc" }, + { "name": "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", "value": "1" }, + { "name": "CLAUDE_CODE_AUTO_COMPACT_WINDOW", "value": "190000" }, + { "name": "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "value": "1" } +] +``` + +Match the port and authentication token to the Admin UI, then reload the extension. + +
+ +
+Codex in VS Code + +Install the [Codex extension](https://marketplace.visualstudio.com/items?itemName=openai.chatgpt). Create or edit `~/.codex/config.toml` (`%USERPROFILE%\.codex\config.toml` on Windows): + +```toml +model_provider = "fcc" +model = "nvidia_nim/nvidia/nemotron-3-super-120b-a12b" + +[model_providers.fcc] +name = "Free Claude Code" +base_url = "http://127.0.0.1:8082/v1" +env_key = "FCC_CODEX_API_KEY" +wire_api = "responses" +``` + +Store the Admin UI authentication token in `~/.codex/auth.json` or its Windows equivalent: + +```json +{ + "FCC_CODEX_API_KEY": "freecc" +} +``` + +Match `model`, the port, and the token to the Admin UI, then restart VS Code. For WSL-backed Codex, edit the files inside WSL. + +
+ +
+Claude Code in JetBrains ACP + +Edit the installed Claude ACP configuration: + +- Windows: `C:\Users\%USERNAME%\AppData\Roaming\JetBrains\acp-agents\installed.json` +- Linux/macOS: `~/.jetbrains/acp.json` + +Set the environment for `acp.registry.claude-acp`: + +```json +"env": { + "ANTHROPIC_BASE_URL": "http://localhost:8082", + "ANTHROPIC_AUTH_TOKEN": "freecc", + "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "1", + "CLAUDE_CODE_AUTO_COMPACT_WINDOW": "190000", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1" +} +``` + +Match the port and token to the Admin UI, then restart the IDE. + +
+ +
+Claude Code still asks you to log in + +If Claude Code asks you to log in after you configure the FCC URL and token, open its state file: + +- Windows: `%USERPROFILE%\.claude.json` +- macOS/Linux/WSL: `~/.claude.json` + +Merge this property into the existing JSON without removing its other fields: + +```json +"hasCompletedOnboarding": true +``` + +If the file does not exist, create it with a complete JSON object: + +```json +{ + "hasCompletedOnboarding": true +} +``` + +Restart Claude Code or the IDE after saving the file. + +
+ + + +## Optional Integrations + +Configure integrations from **Admin UI → Messaging**, then click **Validate** and **Apply**. + +
+ Admin UI Messaging view with bot and voice settings +
+ +
+Discord bot + +1. Create a bot in the [Discord Developer Portal](https://discord.com/developers/applications). +2. Enable **Message Content Intent** and invite it with read, send, + message-history, and **Manage Messages** permissions so `/clear` can remove + user prompts. +3. Set **Messaging Platform** to **discord**. +4. Enter **Discord Bot Token**, **Allowed Discord Channels**, and an absolute **Allowed Directory**. +5. Apply the settings and restart the server if requested. + +
+ +
+Telegram bot + +1. Create a bot with [@BotFather](https://t.me/BotFather). +2. Get your numeric user ID from [@userinfobot](https://t.me/userinfobot). + In groups, grant the bot permission to delete messages. +3. Set **Messaging Platform** to **telegram**. +4. Enter **Telegram Bot Token**, **Allowed Telegram User ID**, and an absolute **Allowed Directory**. +5. Apply the settings and restart the server if requested. + +
+ +### Messaging commands + +| Usage | Behavior | +| --- | --- | +| `/stats` | Show session state. | +| Standalone `/stop` | Cancel all work. | +| Reply with `/stop` | Cancel only the selected request while other queued requests continue. | +| Standalone `/clear` | Reset all FCC state and remove every tracked message in that chat, including user prompts, voice notes, FCC replies, Telegram's online notice, and the clear command itself. | +| Reply with `/clear` | Delete the selected message and its literal platform reply subtree while preserving its ancestors and siblings. | + +
+Voice notes + +Re-run the installer with the voice backend you need. + +macOS/Linux: + +```bash +# NVIDIA NIM transcription +curl -fsSL "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.sh" | sh -s -- --voice-nim + +# Local Whisper on CPU or CUDA +curl -fsSL "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.sh" | sh -s -- --voice-local + +# Both backends +curl -fsSL "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.sh" | sh -s -- --voice-all + +# Local Whisper with the CUDA 13.0 PyTorch backend +curl -fsSL "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.sh" | sh -s -- --voice-local --torch-backend cu130 +``` + +Windows PowerShell: + +```powershell +# NVIDIA NIM transcription +& ([scriptblock]::Create((irm "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.ps1"))) -VoiceNim + +# Local Whisper on CPU or CUDA +& ([scriptblock]::Create((irm "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.ps1"))) -VoiceLocal + +# Both backends +& ([scriptblock]::Create((irm "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.ps1"))) -VoiceAll + +# Local Whisper with the CUDA 13.0 PyTorch backend +& ([scriptblock]::Create((irm "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.ps1"))) -VoiceLocal -TorchBackend cu130 +``` + +Restart `fcc-server`. In **Admin UI → Messaging → Voice**, enable voice notes, select `cpu`, `cuda`, or `nvidia_nim`, and choose the Whisper model. Local gated models need `HUGGINGFACE_API_KEY`; NVIDIA NIM transcription needs `NVIDIA_NIM_API_KEY`. + +
+ +## Manage Your Installation + +### Update + +Re-run the matching command from [Install Or Update](#install). + +### Uninstall + +Stop every running FCC command first. The uninstaller removes the FCC uv tool, verifies every FCC command is gone, and then deletes `~/.fcc/`. It leaves uv, Python, Claude Code, Codex, Pi, and shared PATH entries intact. + +macOS/Linux: + +```bash +curl -fsSL "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/uninstall.sh" | sh +``` + +Windows PowerShell: + +```powershell +& ([scriptblock]::Create((irm "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/uninstall.ps1"))) +``` + +## Project Links + +- [Report bugs or request features](https://github.com/Alishahryar1/free-claude-code/issues) +- [Architecture and extension guide](ARCHITECTURE.md) +- [Contributing guide](CONTRIBUTING.md) + +## License + +MIT License. See [LICENSE](LICENSE) for details. diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..205924a --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`Alishahryar1/free-claude-code` +- 原始仓库:https://github.com/Alishahryar1/free-claude-code +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/assets/admin-messaging.png b/assets/admin-messaging.png new file mode 100644 index 0000000..5bcc561 Binary files /dev/null and b/assets/admin-messaging.png differ diff --git a/assets/admin-page.png b/assets/admin-page.png new file mode 100644 index 0000000..5c53b99 Binary files /dev/null and b/assets/admin-page.png differ diff --git a/assets/cc-model-picker.png b/assets/cc-model-picker.png new file mode 100644 index 0000000..b5ad7aa Binary files /dev/null and b/assets/cc-model-picker.png differ diff --git a/assets/codex-model-picker.png b/assets/codex-model-picker.png new file mode 100644 index 0000000..dca8ec9 Binary files /dev/null and b/assets/codex-model-picker.png differ diff --git a/assets/codex.png b/assets/codex.png new file mode 100644 index 0000000..5fa12fa Binary files /dev/null and b/assets/codex.png differ diff --git a/assets/how-it-works.mmd b/assets/how-it-works.mmd new file mode 100644 index 0000000..c3572c1 --- /dev/null +++ b/assets/how-it-works.mmd @@ -0,0 +1,57 @@ +%%{init: {"theme": "base", "themeVariables": {"fontFamily": "Inter, ui-sans-serif, system-ui, Segoe UI, Arial", "background": "transparent", "primaryTextColor": "#172033", "lineColor": "#718096"}}}%% +flowchart LR + claudeClient(["Claude Code
CLI / VS Code / JetBrains / Bots"]) + codexClient(["Codex CLI
VS Code extension"]) + messagesApi["Anthropic API
/v1/messages, /v1/models"] + responsesApi["OpenAI Responses API
/v1/responses"] + + subgraph proxy["Free Claude Code Proxy :8082"] + admin["Local Admin UI
keys, models, status"] + config[("Managed Config
~/.fcc/.env")] + convert["Responses Converter
OpenAI to Anthropic"] + router{"Model Router
Opus / Sonnet / Haiku"} + optimize["Request Optimizations
cheap local probes"] + normalize["Protocol Normalizer
streaming, tools, thinking"] + adapters["Provider Adapters
Anthropic + OpenAI-compatible"] + end + + subgraph hosted["Hosted Providers"] + nim(["NVIDIA NIM"]) + kimi(["Kimi"]) + wafer(["Wafer"]) + openrouter(["OpenRouter"]) + deepseek(["DeepSeek"]) + end + + subgraph local["Local Providers"] + lmstudio(["LM Studio"]) + llamacpp(["llama.cpp"]) + ollama(["Ollama"]) + end + + claudeClient -->|"Anthropic Messages"| messagesApi + codexClient -->|"OpenAI Responses"| responsesApi + messagesApi --> router + responsesApi --> convert + convert --> router + admin --> config + config --> router + router --> optimize + optimize --> normalize + normalize --> adapters + adapters --> hosted + adapters --> local + + classDef client fill:#efe7ff,stroke:#7c3aed,stroke-width:2px,color:#2e1065; + classDef api fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#172554; + classDef proxy fill:#ecfeff,stroke:#0891b2,stroke-width:2px,color:#164e63; + classDef config fill:#fef3c7,stroke:#d97706,stroke-width:2px,color:#78350f; + classDef transform fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#14532d; + classDef provider fill:#fff7ed,stroke:#ea580c,stroke-width:2px,color:#7c2d12; + + class claudeClient,codexClient client; + class messagesApi,responsesApi api; + class admin,router proxy; + class config config; + class convert,optimize,normalize,adapters transform; + class nim,kimi,wafer,openrouter,deepseek,lmstudio,llamacpp,ollama provider; diff --git a/assets/how-it-works.svg b/assets/how-it-works.svg new file mode 100644 index 0000000..6e2a940 --- /dev/null +++ b/assets/how-it-works.svg @@ -0,0 +1 @@ +

Free Claude Code Proxy :8082

Anthropic Messages

OpenAI Responses

Local Providers

LM Studio

llama.cpp

Ollama

Hosted Providers

NVIDIA NIM

Kimi

Wafer

OpenRouter

DeepSeek

Claude Code
CLI / VS Code / JetBrains / Bots

Codex CLI
VS Code extension

Anthropic API
/v1/messages, /v1/models

OpenAI Responses API
/v1/responses

Local Admin UI
keys, models, status

Managed Config
~/.fcc/.env

Responses Converter
OpenAI to Anthropic

Model Router
Opus / Sonnet / Haiku

Request Optimizations
cheap local probes

Protocol Normalizer
streaming, tools, thinking

Provider Adapters
Anthropic + OpenAI-compatible

\ No newline at end of file diff --git a/assets/pic.png b/assets/pic.png new file mode 100644 index 0000000..52bef08 Binary files /dev/null and b/assets/pic.png differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4eadc12 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,131 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "free-claude-code" +version = "4.0.0" +description = "Local proxy connecting coding agents to OpenAI-compatible AI providers" +readme = "README.md" +requires-python = ">=3.14.0" +dependencies = [ + "fastapi[standard]>=0.139.0", + "uvicorn>=0.50.0", + "httpx[socks]>=0.28.1", + "markdown-it-py>=4.2.0", + "pydantic>=2.13.4", + "python-dotenv>=1.2.2", + "tiktoken>=0.13.0", + "python-telegram-bot>=22.8", + "discord.py>=2.7.1", + "pydantic-settings>=2.14.2", + "openai>=2.44.0", + "loguru>=0.7.0", + "aiohttp>=3.14.1", + "jsonschema>=4.25.0", +] + +[project.scripts] +fcc-server = "free_claude_code.cli.entrypoints:serve" +free-claude-code = "free_claude_code.cli.entrypoints:serve" +fcc-init = "free_claude_code.cli.entrypoints:init" +fcc-claude = "free_claude_code.cli.launchers.claude:launch" +fcc-codex = "free_claude_code.cli.launchers.codex:launch" +fcc-pi = "free_claude_code.cli.launchers.pi:launch" + +[project.optional-dependencies] +voice = [ + "grpcio>=1.81.1", + "grpcio-tools>=1.81.1", + "nvidia-riva-client>=2.26.0", +] +voice_local = [ + "torch>=2.12.1", + "transformers>=5.13.0", + "accelerate>=1.14.0", + "librosa>=0.10.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/free_claude_code"] + +[tool.hatch.build.targets.wheel.force-include] +".env.example" = "free_claude_code/config/env.example" + +[tool.uv] +required-version = ">=0.11.0" + +[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.1.1", + "pytest-asyncio>=1.4.0", + "pytest-cov>=7.1.0", + "ty>=0.0.56", + "ruff>=0.15.20", + "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 = ["free_claude_code", "smoke"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +line-ending = "auto" +skip-magic-trailing-comma = false + +[tool.pytest.ini_options] +pythonpath = ["src"] +addopts = "-n auto" +testpaths = ["tests"] +markers = [ + "live: opt-in local smoke tests that can touch real services", + "interactive: smoke tests requiring manual user interaction", + "provider: live provider checks", + "messaging: live messaging platform checks", + "cli: CLI integration checks", + "clients: client compatibility checks", + "voice: voice transcription checks", + "contract: deterministic feature contract checks", + "smoke_target(name): route a smoke test behind FCC_SMOKE_TARGETS", + "xdist_group(name): group smoke provider items under pytest-xdist --dist=loadgroup", +] + +[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/scripts/ci.ps1 b/scripts/ci.ps1 new file mode 100644 index 0000000..f7412b8 --- /dev/null +++ b/scripts/ci.ps1 @@ -0,0 +1,210 @@ +param( + [string[]] $Only = @(), + [string[]] $Skip = @(), + [switch] $DryRun, + [switch] $Help, + [Parameter(ValueFromRemainingArguments = $true)] + [object[]] $RemainingArgs = @() +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$CheckOrder = @( + "suppressions", + "ruff-format", + "ruff-check", + "ty", + "pytest" +) + +function Show-Usage { + @" +Usage: ci.ps1 [options] + +Runs the local sequence for the same check IDs enforced by GitHub CI. +Requires uv on PATH when running ruff, ty, or pytest checks. +Local ruff checks repair formatting and autofixable lint before later checks. + +Checks (in order): + suppressions Ban type ignores and legacy future annotations + ruff-format uv run ruff format + ruff-check uv run ruff check --fix + ty uv run ty check + pytest uv run pytest -v --tb=short + +Options: + -Only ID Run only the given check (repeatable) + -Skip ID Skip the given check (repeatable) + -DryRun Print commands without running them. + -Help Show this help text. +"@ +} + +function Write-Step { + param([string] $Message) + + Write-Host "" + Write-Host "==> $Message" +} + +function Format-Argument { + param([string] $Value) + + if ($Value -match '^[A-Za-z0-9_./:@%+=,\[\]-]+$') { + return $Value + } + + return "'" + ($Value -replace "'", "''") + "'" +} + +function Invoke-CiCommand { + param( + [string] $FilePath, + [string[]] $Arguments = @() + ) + + $parts = @($FilePath) + $Arguments + $commandText = ($parts | ForEach-Object { Format-Argument ([string] $_) }) -join " " + Write-Host "+ $commandText" + + if (-not $DryRun) { + & $FilePath @Arguments + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code ${LASTEXITCODE}: $commandText" + } + } +} + +function Test-ValidCheckId { + param([string] $CheckId) + + return $CheckOrder -contains $CheckId +} + +function Assert-ValidCheckId { + param([string] $CheckId) + + if (-not (Test-ValidCheckId $CheckId)) { + throw "unknown check id: $CheckId (expected one of: $($CheckOrder -join ', '))" + } +} + +function Test-ShouldRunCheck { + param([string] $CheckId) + + if ($Only.Count -gt 0 -and ($Only -notcontains $CheckId)) { + return $false + } + + if ($Skip -contains $CheckId) { + return $false + } + + return $true +} + +function Assert-UvAvailable { + if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { + throw "uv is required but was not found on PATH. Install uv first (see README or scripts/install.ps1)." + } +} + +function Test-SelectedChecksNeedUv { + if ($DryRun) { + return $false + } + + foreach ($checkId in $CheckOrder) { + if ((Test-ShouldRunCheck $checkId) -and $checkId -ne "suppressions") { + return $true + } + } + + return $false +} + +function Invoke-SuppressionsCheck { + Write-Step "Ban suppressions and legacy annotations" + $pattern = '# type: ignore|# ty: ignore|from __future__ import annotations' + Write-Host "+ Get-ChildItem -Recurse -Filter *.py (excluding .venv, .git) | Select-String '$pattern'" + + if (-not $DryRun) { + $matches = Get-ChildItem -Path . -Recurse -Filter *.py -File | + Where-Object { + $full = $_.FullName + $full -notmatch '[\\/]\.venv[\\/]' -and + $full -notmatch '[\\/]\.git[\\/]' + } | + Select-String -Pattern $pattern + + if ($matches) { + $matches | ForEach-Object { Write-Host $_.Line } + throw "type: ignore / ty: ignore comments and legacy future annotations are not allowed. Fix the underlying type/import issue instead." + } + } +} + +function Invoke-RuffFormatCheck { + Write-Step "ruff format" + Invoke-CiCommand -FilePath "uv" -Arguments @("run", "ruff", "format") +} + +function Invoke-RuffLintCheck { + Write-Step "ruff check --fix" + Invoke-CiCommand -FilePath "uv" -Arguments @("run", "ruff", "check", "--fix") +} + +function Invoke-TyCheck { + Write-Step "ty check" + Invoke-CiCommand -FilePath "uv" -Arguments @("run", "ty", "check") +} + +function Invoke-PytestCheck { + Write-Step "pytest" + Invoke-CiCommand -FilePath "uv" -Arguments @("run", "pytest", "-v", "--tb=short") +} + +function Invoke-Check { + param([string] $CheckId) + + switch ($CheckId) { + "suppressions" { Invoke-SuppressionsCheck } + "ruff-format" { Invoke-RuffFormatCheck } + "ruff-check" { Invoke-RuffLintCheck } + "ty" { Invoke-TyCheck } + "pytest" { Invoke-PytestCheck } + default { throw "unknown check id: $CheckId" } + } +} + +if ($Help) { + Show-Usage + return +} + +if ($RemainingArgs.Count -gt 0) { + Show-Usage + throw "Unknown option: $($RemainingArgs -join ' ')" +} + +foreach ($checkId in $Only) { + Assert-ValidCheckId $checkId +} + +foreach ($checkId in $Skip) { + Assert-ValidCheckId $checkId +} + +if (Test-SelectedChecksNeedUv) { + Assert-UvAvailable +} + +foreach ($checkId in $CheckOrder) { + if (Test-ShouldRunCheck $checkId) { + Invoke-Check $checkId + } +} + +Write-Host "" +Write-Host "All selected CI checks passed." diff --git a/scripts/ci.sh b/scripts/ci.sh new file mode 100755 index 0000000..81021e0 --- /dev/null +++ b/scripts/ci.sh @@ -0,0 +1,212 @@ +#!/bin/sh +set -eu + +CHECK_ORDER="suppressions ruff-format ruff-check ty pytest" + +dry_run=0 +only_checks="" +skip_checks="" + +show_usage() { + cat <<'USAGE' +Usage: ci.sh [options] + +Runs the local sequence for the same check IDs enforced by GitHub CI. +Requires uv on PATH when running ruff, ty, or pytest checks. +Local ruff checks repair formatting and autofixable lint before later checks. + +Checks (in order): + suppressions Ban type ignores and legacy future annotations + ruff-format uv run ruff format + ruff-check uv run ruff check --fix + ty uv run ty check + pytest uv run pytest -v --tb=short + +Options: + --only ID Run only the given check (repeatable) + --skip ID Skip the given check (repeatable) + --dry-run Print commands without running them. + --help Show this help text. +USAGE +} + +fail() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +step() { + printf '\n==> %s\n' "$1" +} + +quote_arg() { + case "$1" in + *[!A-Za-z0-9_./:@%+=,-]*|"") + escaped=$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g') + printf '"%s"' "$escaped" + ;; + *) + printf '%s' "$1" + ;; + esac +} + +print_command() { + printf '+' + for arg in "$@"; do + printf ' ' + quote_arg "$arg" + done + printf '\n' +} + +run() { + print_command "$@" + if [ "$dry_run" -eq 0 ]; then + "$@" + fi +} + +valid_check_id() { + case "$1" in + suppressions | ruff-format | ruff-check | ty | pytest) return 0 ;; + *) return 1 ;; + esac +} + +validate_check_id() { + if ! valid_check_id "$1"; then + fail "unknown check id: $1 (expected one of: $CHECK_ORDER)" + fi +} + +contains_check_id() { + list=$1 + id=$2 + case " $list " in + *" $id "*) return 0 ;; + *) return 1 ;; + esac +} + +should_run_check() { + check_id=$1 + + if [ -n "$only_checks" ] && ! contains_check_id "$only_checks" "$check_id"; then + return 1 + fi + + if contains_check_id "$skip_checks" "$check_id"; then + return 1 + fi + + return 0 +} + +assert_uv_available() { + if ! command -v uv >/dev/null 2>&1; then + fail "uv is required but was not found on PATH. Install uv first (see README or scripts/install.sh)." + fi +} + +selected_checks_need_uv() { + if [ "$dry_run" -ne 0 ]; then + return 1 + fi + + for check_id in $CHECK_ORDER; do + if should_run_check "$check_id" && [ "$check_id" != "suppressions" ]; then + return 0 + fi + done + + return 1 +} + +run_suppressions() { + step "Ban suppressions and legacy annotations" + pattern='# type: ignore|# ty: ignore|from __future__ import annotations' + print_command grep -rE "$pattern" --include='*.py' . \ + --exclude-dir=.venv --exclude-dir=.git + if [ "$dry_run" -eq 0 ]; then + if grep -rE "$pattern" --include='*.py' . \ + --exclude-dir=.venv --exclude-dir=.git; then + fail "type: ignore / ty: ignore comments and legacy future annotations are not allowed. Fix the underlying type/import issue instead." + fi + fi +} + +run_ruff_format() { + step "ruff format" + run uv run ruff format +} + +run_ruff_check() { + step "ruff check --fix" + run uv run ruff check --fix +} + +run_ty() { + step "ty check" + run uv run ty check +} + +run_pytest() { + step "pytest" + run uv run pytest -v --tb=short +} + +run_check() { + case "$1" in + suppressions) run_suppressions ;; + ruff-format) run_ruff_format ;; + ruff-check) run_ruff_check ;; + ty) run_ty ;; + pytest) run_pytest ;; + *) fail "unknown check id: $1" ;; + esac +} + +parse_args() { + while [ "$#" -gt 0 ]; do + case "$1" in + --only) + shift + [ "$#" -gt 0 ] || fail "--only requires a check id." + validate_check_id "$1" + only_checks="${only_checks} $1" + ;; + --skip) + shift + [ "$#" -gt 0 ] || fail "--skip requires a check id." + validate_check_id "$1" + skip_checks="${skip_checks} $1" + ;; + --dry-run) + dry_run=1 + ;; + --help|-h) + show_usage + exit 0 + ;; + *) + show_usage >&2 + fail "unknown option: $1" + ;; + esac + shift + done +} + +parse_args "$@" +if selected_checks_need_uv; then + assert_uv_available +fi + +for check_id in $CHECK_ORDER; do + if should_run_check "$check_id"; then + run_check "$check_id" + fi +done + +printf '\nAll selected CI checks passed.\n' diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..6f73b1b --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,557 @@ +param( + [switch] $VoiceNim, + [switch] $VoiceLocal, + [switch] $VoiceAll, + [string] $TorchBackend = "", + [switch] $DryRun, + [switch] $Help, + [Parameter(ValueFromRemainingArguments = $true)] + [object[]] $RemainingArgs = @() +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +$RepoArchiveUrl = "https://github.com/Alishahryar1/free-claude-code/archive/refs/heads/main.zip" +$PythonVersion = "3.14.0" +$MinUvVersion = "0.11.0" +$ClaudeInstallUrl = "https://claude.ai/install.ps1" +$CodexInstallUrl = "https://chatgpt.com/codex/install.ps1" +$PiInstallUrl = "https://pi.dev/install.ps1" +$UvInstallUrl = "https://astral.sh/uv/install.ps1" + +function Show-Usage { + @" +Usage: install.ps1 [options] + +Installs Claude Code, Codex, and Pi if missing, ensures a compatible uv, and installs or updates Free Claude Code. + +Options: + -VoiceNim Install NVIDIA NIM voice transcription support. + -VoiceLocal Install local Whisper voice transcription support. + -VoiceAll Install all voice transcription backends. + -TorchBackend VALUE Use a uv PyTorch backend, such as cu130. Requires local voice. + -DryRun Print commands without running them. + -Help Show this help text. +"@ +} + +function Write-Step { + param([string] $Message) + + Write-Host "" + Write-Host "==> $Message" +} + +function Format-Argument { + param([string] $Value) + + if ($Value -match '^[A-Za-z0-9_./:@%+=,\[\]\\-]+$') { + return $Value + } + + return "'" + ($Value -replace "'", "''") + "'" +} + +function Format-Command { + param( + [string] $FilePath, + [string[]] $Arguments = @() + ) + + $parts = @($FilePath) + $Arguments + return ($parts | ForEach-Object { Format-Argument ([string] $_) }) -join " " +} + +function Invoke-NativeCommand { + param( + [string] $FilePath, + [string[]] $Arguments = @() + ) + + $commandText = Format-Command -FilePath $FilePath -Arguments $Arguments + Write-Host "+ $commandText" + if ($DryRun) { + return + } + + $global:LASTEXITCODE = 0 + & $FilePath @Arguments + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + throw "Command failed with exit code ${exitCode}: $commandText" + } +} + +function Invoke-NativeCapture { + param( + [string] $FilePath, + [string[]] $Arguments = @() + ) + + $commandText = Format-Command -FilePath $FilePath -Arguments $Arguments + Write-Host "+ $commandText" + $global:LASTEXITCODE = 0 + $output = & $FilePath @Arguments + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + throw "Command failed with exit code ${exitCode}: $commandText" + } + + return ($output | Out-String).Trim() +} + +function Get-ApplicationCommand { + param([string] $Name) + + $commands = @(Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue) + if ($commands.Count -eq 0) { + return $null + } + + return $commands[0] +} + +function Get-PowerShellExecutable { + param([string] $PowerShellHome = $PSHOME) + + $executableName = if ($PSVersionTable.PSEdition -eq "Core") { + "pwsh.exe" + } + else { + "powershell.exe" + } + $bundledExecutable = Join-Path $PowerShellHome $executableName + if (Test-Path -LiteralPath $bundledExecutable -PathType Leaf) { + return $bundledExecutable + } + + $pathCommand = Get-ApplicationCommand ([IO.Path]::GetFileNameWithoutExtension($executableName)) + if ($pathCommand) { + return $pathCommand.Source + } + + throw "Unable to locate a PowerShell executable for the downloaded installer." +} + +function Add-PathEntry { + param([string] $PathEntry) + + if ([string]::IsNullOrWhiteSpace($PathEntry)) { + return + } + + $separator = [IO.Path]::PathSeparator + $entries = @() + if (-not [string]::IsNullOrEmpty($env:Path)) { + $entries = $env:Path -split [regex]::Escape([string] $separator) + } + + if ($entries -notcontains $PathEntry) { + $env:Path = "$PathEntry$separator$env:Path" + } +} + +function Add-KnownBinDirectories { + if (-not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) { + Add-PathEntry (Join-Path $env:USERPROFILE ".local\bin") + } + if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + Add-PathEntry (Join-Path $env:LOCALAPPDATA "Programs\OpenAI\Codex\bin") + Add-PathEntry (Join-Path $env:LOCALAPPDATA "pi-node\current") + } + if (-not [string]::IsNullOrWhiteSpace($env:APPDATA)) { + Add-PathEntry (Join-Path $env:APPDATA "npm") + } +} + +function Add-PiBinDirectories { + if ($DryRun) { + return + } + + Add-KnownBinDirectories + $npm = Get-ApplicationCommand "npm" + if (-not $npm) { + return + } + + $prefix = (& $npm.Source prefix -g 2>$null | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($prefix)) { + $prefix = (& $npm.Source config get prefix 2>$null | Out-String).Trim() + } + if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($prefix)) { + Add-PathEntry $prefix + } +} + +function Invoke-DownloadedPowerShellInstaller { + param( + [string] $Url, + [string] $Name, + [switch] $NonInteractive + ) + + if ($DryRun) { + Write-Host "+ irm $Url -OutFile " + $prefix = if ($NonInteractive) { "CODEX_NON_INTERACTIVE=1 " } else { "" } + Write-Host "+ ${prefix}powershell -NoProfile -ExecutionPolicy Bypass -File " + return + } + + $temporaryScript = Join-Path ([IO.Path]::GetTempPath()) ("fcc-install-" + [guid]::NewGuid().ToString("N") + ".ps1") + try { + Write-Host "+ irm $Url -OutFile $(Format-Argument $temporaryScript)" + Invoke-RestMethod -Uri $Url -OutFile $temporaryScript -ErrorAction Stop + if ((-not (Test-Path -LiteralPath $temporaryScript)) -or ((Get-Item -LiteralPath $temporaryScript).Length -eq 0)) { + throw "The downloaded $Name installer was empty." + } + + $powerShellPath = Get-PowerShellExecutable + + $hadNonInteractive = Test-Path Env:CODEX_NON_INTERACTIVE + $previousNonInteractive = $env:CODEX_NON_INTERACTIVE + try { + if ($NonInteractive) { + $env:CODEX_NON_INTERACTIVE = "1" + } + Invoke-NativeCommand -FilePath $powerShellPath -Arguments @( + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + $temporaryScript + ) + } + finally { + if ($hadNonInteractive) { + $env:CODEX_NON_INTERACTIVE = $previousNonInteractive + } + else { + Remove-Item Env:CODEX_NON_INTERACTIVE -ErrorAction SilentlyContinue + } + } + } + finally { + Remove-Item -LiteralPath $temporaryScript -Force -ErrorAction SilentlyContinue + } +} + +function Confirm-Application { + param( + [string] $CommandName, + [string] $DisplayName + ) + + if ($DryRun) { + Write-Host "+ $CommandName --version" + return + } + + $command = Get-ApplicationCommand $CommandName + if (-not $command) { + throw "$DisplayName was installed, but '$CommandName' is not available on PATH." + } + Invoke-NativeCommand -FilePath $command.Source -Arguments @("--version") +} + +function Test-PiApplication { + param($Command) + + try { + $helpOutput = (& $Command.Source --help 2>$null | Out-String) + } + catch { + return $false + } + return ( + $LASTEXITCODE -eq 0 -and + $helpOutput.Contains("--extension") -and + $helpOutput.Contains("--models") + ) +} + +function Confirm-PiApplication { + if ($DryRun) { + Write-Host "+ pi --help (verify --extension and --models support)" + Write-Host "+ pi --version" + return + } + + $command = Get-ApplicationCommand "pi" + if (-not $command) { + throw "Pi was installed, but 'pi' is not available on PATH." + } + if (-not (Test-PiApplication $command)) { + throw "The 'pi' command at '$($command.Source)' is not a compatible Pi Coding Agent." + } + Invoke-NativeCommand -FilePath $command.Source -Arguments @("--version") +} + +function Ensure-ClaudeCode { + if (Get-ApplicationCommand "claude") { + Write-Host "Claude Code already found on PATH; verifying it." + } + else { + Invoke-DownloadedPowerShellInstaller -Url $ClaudeInstallUrl -Name "Claude Code" + Add-KnownBinDirectories + } + + Confirm-Application -CommandName "claude" -DisplayName "Claude Code" +} + +function Ensure-Codex { + if (Get-ApplicationCommand "codex") { + Write-Host "Codex already found on PATH; verifying it." + } + else { + Invoke-DownloadedPowerShellInstaller -Url $CodexInstallUrl -Name "Codex" -NonInteractive + Add-KnownBinDirectories + } + + Confirm-Application -CommandName "codex" -DisplayName "Codex" +} + +function Ensure-Pi { + $existingPi = Get-ApplicationCommand "pi" + if ($existingPi -and ($DryRun -or (Test-PiApplication $existingPi))) { + Write-Host "Pi already found on PATH; verifying it." + } + else { + if ($existingPi) { + Write-Host "The existing 'pi' command at '$($existingPi.Source)' is not Pi Coding Agent; installing Pi." + } + Invoke-DownloadedPowerShellInstaller -Url $PiInstallUrl -Name "Pi" + Add-PiBinDirectories + } + + Confirm-PiApplication +} + +function Convert-UvVersionOutput { + param([string] $Output) + + if ([string]::IsNullOrWhiteSpace($Output)) { + return "" + } + + if ($Output -match '(?m)(?:^|\s)(?:uv\s+)?(?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z][0-9A-Za-z.-]*)?)\b') { + return $Matches["version"] + } + + return "" +} + +function Get-UvVersion { + param([string] $UvPath) + + $output = Invoke-NativeCapture -FilePath $UvPath -Arguments @("--version") + $version = Convert-UvVersionOutput $output + if ([string]::IsNullOrWhiteSpace($version)) { + throw "uv is present, but 'uv --version' did not return a valid version." + } + + return $version +} + +function Test-UvVersionAtLeast { + param( + [string] $Version, + [string] $Minimum + ) + + $normalizedVersion = (Convert-UvVersionOutput $Version) -replace '[-+].*$', '' + $normalizedMinimum = (Convert-UvVersionOutput $Minimum) -replace '[-+].*$', '' + if ([string]::IsNullOrWhiteSpace($normalizedVersion) -or [string]::IsNullOrWhiteSpace($normalizedMinimum)) { + throw "Unable to compare uv versions." + } + + return ([version] $normalizedVersion) -ge ([version] $normalizedMinimum) +} + +function Confirm-Uv { + if ($DryRun) { + Write-Host "+ uv --version" + return + } + + $uvCommand = Get-ApplicationCommand "uv" + if (-not $uvCommand) { + throw "uv was installed, but it is not available on PATH." + } + + $version = Get-UvVersion $uvCommand.Source + if (-not (Test-UvVersionAtLeast -Version $version -Minimum $MinUvVersion)) { + throw "uv $MinUvVersion or newer is required; found uv $version after installation." + } + Write-Host "Verified uv $version." +} + +function Ensure-Uv { + if ($DryRun) { + if (Get-ApplicationCommand "uv") { + Write-Host "+ uv --version" + Write-Host "A compatible existing uv will be left unchanged; an obsolete one will be replaced by the standalone installer." + } + else { + Write-Host "uv is not installed; the current standalone uv would be installed." + Invoke-DownloadedPowerShellInstaller -Url $UvInstallUrl -Name "uv" + Confirm-Uv + } + return + } + + $uvCommand = Get-ApplicationCommand "uv" + if ($uvCommand) { + $version = Get-UvVersion $uvCommand.Source + if (Test-UvVersionAtLeast -Version $version -Minimum $MinUvVersion) { + Write-Host "uv $version already satisfies >=$MinUvVersion; leaving it unchanged." + return + } + Write-Host "uv $version is below $MinUvVersion; installing the current standalone uv." + } + else { + Write-Host "uv is not installed; installing the current standalone uv." + } + + Invoke-DownloadedPowerShellInstaller -Url $UvInstallUrl -Name "uv" + Add-KnownBinDirectories + Confirm-Uv +} + +function Get-PackageSpec { + $includeNim = $VoiceNim + $includeLocal = $VoiceLocal + + if ($VoiceAll) { + $includeNim = $true + $includeLocal = $true + } + + if ($includeNim -and $includeLocal) { + return "free-claude-code[voice,voice_local] @ $RepoArchiveUrl" + } + if ($includeNim) { + return "free-claude-code[voice] @ $RepoArchiveUrl" + } + if ($includeLocal) { + return "free-claude-code[voice_local] @ $RepoArchiveUrl" + } + return "free-claude-code @ $RepoArchiveUrl" +} + +function Install-FreeClaudeCode { + $packageSpec = Get-PackageSpec + $arguments = @( + "tool", + "install", + "--force", + "--refresh-package", + "free-claude-code", + "--python", + $PythonVersion + ) + if (-not [string]::IsNullOrWhiteSpace($TorchBackend)) { + $arguments += @("--torch-backend", $TorchBackend) + } + $arguments += $packageSpec + + $uvPath = "uv" + if (-not $DryRun) { + $uvCommand = Get-ApplicationCommand "uv" + if (-not $uvCommand) { + throw "uv is not available for the Free Claude Code installation." + } + $uvPath = $uvCommand.Source + } + Invoke-NativeCommand -FilePath $uvPath -Arguments $arguments +} + +function Configure-AndConfirmFreeClaudeCode { + if ($DryRun) { + Write-Host "+ uv tool update-shell" + Write-Host "+ uv tool dir --bin" + Write-Host "+ verify fcc-server, fcc-claude, fcc-codex, and fcc-pi in the uv tool bin directory" + Write-Host "+ fcc-server --version" + return + } + + $uvCommand = Get-ApplicationCommand "uv" + if (-not $uvCommand) { + throw "uv is not available for PATH configuration." + } + Invoke-NativeCommand -FilePath $uvCommand.Source -Arguments @("tool", "update-shell") + $toolBin = Invoke-NativeCapture -FilePath $uvCommand.Source -Arguments @("tool", "dir", "--bin") + if ([string]::IsNullOrWhiteSpace($toolBin)) { + throw "uv returned an empty tool bin directory." + } + + Add-PathEntry $toolBin + $toolBinPath = ([IO.Path]::GetFullPath($toolBin)).TrimEnd( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar + ) + $installedCommands = @{} + foreach ($commandName in @("fcc-server", "fcc-claude", "fcc-codex", "fcc-pi")) { + $command = Get-ApplicationCommand $commandName + if (-not $command) { + throw "Free Claude Code installation did not create '$commandName'." + } + $commandDirectory = ([IO.Path]::GetFullPath((Split-Path -Parent $command.Source))).TrimEnd( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar + ) + if (-not $commandDirectory.Equals($toolBinPath, [StringComparison]::OrdinalIgnoreCase)) { + throw "'$commandName' resolved outside the uv tool bin directory: $($command.Source)" + } + $installedCommands[$commandName] = $command.Source + } + + Invoke-NativeCommand -FilePath $installedCommands["fcc-server"] -Arguments @("--version") +} + +if ($Help) { + Show-Usage + return +} + +if ($RemainingArgs.Count -gt 0) { + Show-Usage + throw "Unknown option: $($RemainingArgs -join ' ')" +} + +if ((-not [string]::IsNullOrWhiteSpace($TorchBackend)) -and (-not ($VoiceLocal -or $VoiceAll))) { + throw "-TorchBackend requires -VoiceLocal or -VoiceAll." +} + +Add-KnownBinDirectories + +Write-Step "Ensuring Claude Code is installed" +Ensure-ClaudeCode + +Write-Step "Ensuring Codex is installed" +Ensure-Codex + +Write-Step "Ensuring Pi is installed" +Ensure-Pi + +Write-Step "Ensuring uv $MinUvVersion or newer is installed" +Ensure-Uv + +Write-Step "Installing or updating Free Claude Code" +Install-FreeClaudeCode + +Write-Step "Configuring PATH and verifying Free Claude Code" +Configure-AndConfirmFreeClaudeCode + +Write-Host "" +if ($DryRun) { + Write-Host "Dry run complete. No changes were made." +} +else { + Write-Host "Free Claude Code is installed and verified. Start the proxy with: fcc-server" + Write-Host "Run Claude Code with: fcc-claude" + Write-Host "Run Codex with: fcc-codex" + Write-Host "Run Pi with: fcc-pi" +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..1196f1b --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,499 @@ +#!/bin/sh +set -eu + +REPO_ARCHIVE_URL="https://github.com/Alishahryar1/free-claude-code/archive/refs/heads/main.zip" +PYTHON_VERSION="3.14.0" +MIN_UV_VERSION="0.11.0" +CLAUDE_INSTALL_URL="https://claude.ai/install.sh" +CODEX_INSTALL_URL="https://chatgpt.com/codex/install.sh" +PI_INSTALL_URL="https://pi.dev/install.sh" +UV_INSTALL_URL="https://astral.sh/uv/install.sh" + +dry_run=0 +voice_nim=0 +voice_local=0 +voice_all=0 +torch_backend="" +temporary_script="" + +show_usage() { + cat <<'USAGE' +Usage: install.sh [options] + +Installs Claude Code, Codex, and Pi if missing, ensures a compatible uv, and installs or updates Free Claude Code. + +Options: + --voice-nim Install NVIDIA NIM voice transcription support. + --voice-local Install local Whisper voice transcription support. + --voice-all Install all voice transcription backends. + --torch-backend VALUE Use a uv PyTorch backend, such as cu130. Requires local voice. + --dry-run Print commands without running them. + --help Show this help text. +USAGE +} + +fail() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +step() { + printf '\n==> %s\n' "$1" +} + +quote_arg() { + case "$1" in + *[!A-Za-z0-9_./:@%+=,-]*|"") + escaped=$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g') + printf '"%s"' "$escaped" + ;; + *) + printf '%s' "$1" + ;; + esac +} + +print_command() { + printf '+' + for arg in "$@"; do + printf ' ' + quote_arg "$arg" + done + printf '\n' +} + +run() { + print_command "$@" + if [ "$dry_run" -eq 1 ]; then + return 0 + fi + + if "$@"; then + return 0 + else + status=$? + fi + + fail "Command failed with exit code $status: $1" +} + +cleanup() { + if [ -n "$temporary_script" ] && [ -e "$temporary_script" ]; then + rm -f "$temporary_script" + fi +} + +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' HUP TERM + +add_path_entry() { + [ -n "$1" ] || return 0 + case ":$PATH:" in + *":$1:"*) ;; + *) PATH="$1:$PATH" ;; + esac +} + +add_known_bin_directories() { + if [ -n "${XDG_BIN_HOME:-}" ]; then + add_path_entry "$XDG_BIN_HOME" + fi + + if [ -n "${HOME:-}" ]; then + add_path_entry "$HOME/.local/bin" + add_path_entry "$HOME/.cargo/bin" + add_path_entry "${XDG_DATA_HOME:-$HOME/.local/share}/pi-node/current/bin" + fi + + export PATH + hash -r 2>/dev/null || true +} + +add_pi_bin_directories() { + [ "$dry_run" -eq 0 ] || return 0 + add_known_bin_directories + if command -v npm >/dev/null 2>&1; then + pi_npm_prefix=$(npm prefix -g 2>/dev/null || npm config get prefix 2>/dev/null || true) + if [ -n "$pi_npm_prefix" ]; then + add_path_entry "$pi_npm_prefix/bin" + export PATH + hash -r 2>/dev/null || true + fi + fi +} + +require_command() { + if [ "$dry_run" -eq 0 ] && ! command -v "$1" >/dev/null 2>&1; then + fail "$1 is required. Install it first, then rerun this installer." + fi +} + +download_and_run() { + url=$1 + interpreter=$2 + label=$3 + non_interactive=${4:-0} + + if [ "$dry_run" -eq 1 ]; then + print_command curl -fsSL "$url" -o "" + if [ "$non_interactive" -eq 1 ]; then + printf '+ CODEX_NON_INTERACTIVE=1 ' + quote_arg "$interpreter" + printf ' \n' + else + print_command "$interpreter" "" + fi + return 0 + fi + + temporary_script=$(mktemp "${TMPDIR:-/tmp}/fcc-install.XXXXXX") || fail "Unable to create a temporary file for $label." + print_command curl -fsSL "$url" -o "$temporary_script" + if curl -fsSL "$url" -o "$temporary_script"; then + : + else + status=$? + fail "Could not download the $label installer (curl exit code $status)." + fi + + if [ ! -s "$temporary_script" ]; then + fail "The downloaded $label installer was empty." + fi + + if [ "$non_interactive" -eq 1 ]; then + printf '+ CODEX_NON_INTERACTIVE=1 ' + quote_arg "$interpreter" + printf ' ' + quote_arg "$temporary_script" + printf '\n' + if CODEX_NON_INTERACTIVE=1 "$interpreter" "$temporary_script"; then + : + else + status=$? + fail "$label installation failed with exit code $status." + fi + else + print_command "$interpreter" "$temporary_script" + if "$interpreter" "$temporary_script"; then + : + else + status=$? + fail "$label installation failed with exit code $status." + fi + fi + + rm -f "$temporary_script" + temporary_script="" +} + +verify_command() { + command_name=$1 + display_name=$2 + + if [ "$dry_run" -eq 1 ]; then + print_command "$command_name" --version + return 0 + fi + + command_path=$(command -v "$command_name" 2>/dev/null) || fail "$display_name was installed, but '$command_name' is not available on PATH." + run "$command_path" --version +} + +pi_command_is_compatible() { + pi_command_path=$(command -v pi 2>/dev/null) || return 1 + pi_help=$("$pi_command_path" --help 2>/dev/null) || return 1 + case "$pi_help" in + *--extension*) ;; + *) return 1 ;; + esac + case "$pi_help" in + *--models*) return 0 ;; + *) return 1 ;; + esac +} + +verify_pi_command() { + if [ "$dry_run" -eq 1 ]; then + printf '+ pi --help (verify --extension and --models support)\n' + print_command pi --version + return 0 + fi + + pi_command_path=$(command -v pi 2>/dev/null) || fail "Pi was installed, but 'pi' is not available on PATH." + pi_command_is_compatible || fail "The 'pi' command at $pi_command_path is not a compatible Pi Coding Agent." + run "$pi_command_path" --version +} + +ensure_claude() { + if command -v claude >/dev/null 2>&1; then + printf 'Claude Code already found on PATH; verifying it.\n' + else + download_and_run "$CLAUDE_INSTALL_URL" bash "Claude Code" + add_known_bin_directories + fi + + verify_command claude "Claude Code" +} + +ensure_codex() { + if command -v codex >/dev/null 2>&1; then + printf 'Codex already found on PATH; verifying it.\n' + else + download_and_run "$CODEX_INSTALL_URL" sh "Codex" 1 + add_known_bin_directories + fi + + verify_command codex "Codex" +} + +ensure_pi() { + if [ "$dry_run" -eq 1 ] && command -v pi >/dev/null 2>&1; then + printf 'Pi already found on PATH; verifying it.\n' + elif pi_command_is_compatible; then + printf 'Pi already found on PATH; verifying it.\n' + else + if existing_pi_path=$(command -v pi 2>/dev/null); then + printf "The existing 'pi' command at %s is not Pi Coding Agent; installing Pi.\n" "$existing_pi_path" + fi + download_and_run "$PI_INSTALL_URL" sh "Pi" + add_pi_bin_directories + fi + + verify_pi_command +} + +current_uv_version() { + if output=$(uv --version); then + : + else + return 1 + fi + + case "$output" in + uv\ *) version=${output#uv } ;; + *) version=$output ;; + esac + version=${version%% *} + + case "$version" in + [0-9]*.[0-9]*.[0-9]*) printf '%s\n' "$version" ;; + *) return 1 ;; + esac +} + +version_ge() { + current=${1%%[-+]*} + minimum=${2%%[-+]*} + + old_ifs=$IFS + IFS=. + set -- $current + current_major=${1:-0} + current_minor=${2:-0} + current_patch=${3:-0} + set -- $minimum + minimum_major=${1:-0} + minimum_minor=${2:-0} + minimum_patch=${3:-0} + IFS=$old_ifs + + case "$current_major$current_minor$current_patch$minimum_major$minimum_minor$minimum_patch" in + *[!0-9]*) return 1 ;; + esac + + [ "$current_major" -gt "$minimum_major" ] && return 0 + [ "$current_major" -lt "$minimum_major" ] && return 1 + [ "$current_minor" -gt "$minimum_minor" ] && return 0 + [ "$current_minor" -lt "$minimum_minor" ] && return 1 + [ "$current_patch" -ge "$minimum_patch" ] +} + +verify_uv() { + if [ "$dry_run" -eq 1 ]; then + print_command uv --version + return 0 + fi + + command -v uv >/dev/null 2>&1 || fail "uv was installed, but it is not available on PATH." + version=$(current_uv_version) || fail "uv is present, but 'uv --version' did not return a valid version." + if ! version_ge "$version" "$MIN_UV_VERSION"; then + fail "uv $MIN_UV_VERSION or newer is required; found uv $version after installation." + fi + + printf 'Verified uv %s.\n' "$version" +} + +ensure_uv() { + if [ "$dry_run" -eq 1 ]; then + if command -v uv >/dev/null 2>&1; then + print_command uv --version + printf 'A compatible existing uv will be left unchanged; an obsolete one will be replaced by the standalone installer.\n' + else + printf 'uv is not installed; the current standalone uv would be installed.\n' + download_and_run "$UV_INSTALL_URL" sh "uv" + verify_uv + fi + return 0 + fi + + if command -v uv >/dev/null 2>&1; then + version=$(current_uv_version) || fail "uv is present, but 'uv --version' did not return a valid version." + if version_ge "$version" "$MIN_UV_VERSION"; then + printf 'uv %s already satisfies >=%s; leaving it unchanged.\n' "$version" "$MIN_UV_VERSION" + return 0 + fi + printf 'uv %s is below %s; installing the current standalone uv.\n' "$version" "$MIN_UV_VERSION" + else + printf 'uv is not installed; installing the current standalone uv.\n' + fi + + download_and_run "$UV_INSTALL_URL" sh "uv" + add_known_bin_directories + verify_uv +} + +parse_args() { + while [ "$#" -gt 0 ]; do + case "$1" in + --voice-nim) + voice_nim=1 + ;; + --voice-local) + voice_local=1 + ;; + --voice-all) + voice_all=1 + ;; + --torch-backend) + shift + [ "$#" -gt 0 ] || fail "--torch-backend requires a value." + torch_backend=$1 + [ -n "$torch_backend" ] || fail "--torch-backend requires a non-empty value." + ;; + --torch-backend=*) + torch_backend=${1#*=} + [ -n "$torch_backend" ] || fail "--torch-backend requires a non-empty value." + ;; + --dry-run) + dry_run=1 + ;; + --help|-h) + show_usage + exit 0 + ;; + *) + show_usage >&2 + fail "unknown option: $1" + ;; + esac + shift + done +} + +validate_args() { + include_local=$voice_local + if [ "$voice_all" -eq 1 ]; then + include_local=1 + fi + + if [ -n "$torch_backend" ] && [ "$include_local" -ne 1 ]; then + fail "--torch-backend requires --voice-local or --voice-all." + fi +} + +package_spec() { + include_nim=$voice_nim + include_local=$voice_local + + if [ "$voice_all" -eq 1 ]; then + include_nim=1 + include_local=1 + fi + + if [ "$include_nim" -eq 1 ] && [ "$include_local" -eq 1 ]; then + printf 'free-claude-code[voice,voice_local] @ %s' "$REPO_ARCHIVE_URL" + elif [ "$include_nim" -eq 1 ]; then + printf 'free-claude-code[voice] @ %s' "$REPO_ARCHIVE_URL" + elif [ "$include_local" -eq 1 ]; then + printf 'free-claude-code[voice_local] @ %s' "$REPO_ARCHIVE_URL" + else + printf 'free-claude-code @ %s' "$REPO_ARCHIVE_URL" + fi +} + +install_free_claude_code() { + spec=$(package_spec) + + if [ -n "$torch_backend" ]; then + run uv tool install --force --refresh-package free-claude-code --python "$PYTHON_VERSION" --torch-backend "$torch_backend" "$spec" + else + run uv tool install --force --refresh-package free-claude-code --python "$PYTHON_VERSION" "$spec" + fi +} + +configure_and_verify_free_claude_code() { + run uv tool update-shell + + if [ "$dry_run" -eq 1 ]; then + print_command uv tool dir --bin + printf '+ verify fcc-server, fcc-claude, fcc-codex, and fcc-pi in the uv tool bin directory\n' + print_command fcc-server --version + return 0 + fi + + print_command uv tool dir --bin + if tool_bin=$(uv tool dir --bin); then + : + else + status=$? + fail "Could not determine the uv tool bin directory (exit code $status)." + fi + [ -n "$tool_bin" ] || fail "uv returned an empty tool bin directory." + + add_path_entry "$tool_bin" + export PATH + hash -r 2>/dev/null || true + + for command_name in fcc-server fcc-claude fcc-codex fcc-pi; do + [ -x "$tool_bin/$command_name" ] || fail "Free Claude Code installation did not create $tool_bin/$command_name." + done + + run "$tool_bin/fcc-server" --version +} + +parse_args "$@" +validate_args +add_known_bin_directories + +step "Checking installation prerequisites" +require_command curl +require_command bash +require_command sh +require_command mktemp + +step "Ensuring Claude Code is installed" +ensure_claude + +step "Ensuring Codex is installed" +ensure_codex + +step "Ensuring Pi is installed" +ensure_pi + +step "Ensuring uv $MIN_UV_VERSION or newer is installed" +ensure_uv + +step "Installing or updating Free Claude Code" +install_free_claude_code + +step "Configuring PATH and verifying Free Claude Code" +configure_and_verify_free_claude_code + +if [ "$dry_run" -eq 1 ]; then + printf '\nDry run complete. No changes were made.\n' +else + printf '\nFree Claude Code is installed and verified. Start the proxy with: fcc-server\n' + printf 'Run Claude Code with: fcc-claude\n' + printf 'Run Codex with: fcc-codex\n' + printf 'Run Pi with: fcc-pi\n' +fi diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 new file mode 100644 index 0000000..06d0c87 --- /dev/null +++ b/scripts/uninstall.ps1 @@ -0,0 +1,271 @@ +param( + [switch] $DryRun, + [switch] $Help, + [Parameter(ValueFromRemainingArguments = $true)] + [object[]] $RemainingArgs = @() +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$PackageName = "free-claude-code" +$FccHomeDirname = ".fcc" +$FccCommands = @( + "fcc-server", + "fcc-claude", + "fcc-codex", + "fcc-pi", + "fcc-init", + "free-claude-code" +) +$script:UvPath = "" +$script:UvToolBin = "" + +function Show-Usage { + @" +Usage: uninstall.ps1 [options] + +Removes the Free Claude Code uv tool and deletes ~/.fcc/ after removal is verified. +Does not remove uv, Claude Code, Codex, Pi, the uv-managed Python runtime, or shared PATH entries. + +Options: + -DryRun Print commands without running them. + -Help Show this help text. +"@ +} + +function Write-Step { + param([string] $Message) + + Write-Host "" + Write-Host "==> $Message" +} + +function Format-Argument { + param([string] $Value) + + if ($Value -match '^[A-Za-z0-9_./:@%+=,\[\]\\-]+$') { + return $Value + } + return "'" + ($Value -replace "'", "''") + "'" +} + +function Format-Command { + param( + [string] $FilePath, + [string[]] $Arguments = @() + ) + + $parts = @($FilePath) + $Arguments + return ($parts | ForEach-Object { Format-Argument ([string] $_) }) -join " " +} + +function Get-ApplicationCommand { + param([string] $Name) + + $commands = @(Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue) + if ($commands.Count -eq 0) { + return $null + } + return $commands[0] +} + +function Invoke-NativeResult { + param( + [string] $FilePath, + [string[]] $Arguments + ) + + $previousErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $global:LASTEXITCODE = 0 + $output = (& $FilePath @Arguments 2>&1 | Out-String).Trim() + return [pscustomobject] @{ + ExitCode = $LASTEXITCODE + Output = $output + } + } + finally { + $ErrorActionPreference = $previousErrorActionPreference + } +} + +function Test-MissingUvToolError { + param([string] $Output) + + $normalized = $Output.ToLowerInvariant() + return $normalized.Contains($PackageName) -and $normalized.Contains("is not installed") +} + +function Add-PathEntry { + param([string] $PathEntry) + + if ([string]::IsNullOrWhiteSpace($PathEntry)) { + return + } + $separator = [IO.Path]::PathSeparator + $entries = @() + if (-not [string]::IsNullOrEmpty($env:Path)) { + $entries = $env:Path -split [regex]::Escape([string] $separator) + } + if ($entries -notcontains $PathEntry) { + $env:Path = "$PathEntry$separator$env:Path" + } +} + +function Add-KnownUvPaths { + Add-PathEntry (Join-Path $env:USERPROFILE ".local\bin") + Add-PathEntry (Join-Path $env:USERPROFILE ".cargo\bin") +} + +function Assert-NoFccProcessesRunning { + $running = @() + foreach ($commandName in $FccCommands) { + $processes = @(Get-Process -Name $commandName -ErrorAction SilentlyContinue) + if ($processes.Count -gt 0) { + $running += $commandName + } + } + if ($running.Count -gt 0) { + throw "Free Claude Code is still running ($($running -join ', ')). Stop those processes, then rerun uninstall." + } +} + +function Initialize-UvContext { + Add-KnownUvPaths + + if ($DryRun) { + Write-Host "+ uv tool dir --bin" + return + } + + $uvCommand = Get-ApplicationCommand "uv" + if (-not $uvCommand) { + throw "uv is required to remove the Free Claude Code tool. Install uv, then rerun this uninstaller; ~/.fcc was not deleted." + } + $script:UvPath = $uvCommand.Source + + $commandText = Format-Command -FilePath $script:UvPath -Arguments @("tool", "dir", "--bin") + Write-Host "+ $commandText" + $result = Invoke-NativeResult -FilePath $script:UvPath -Arguments @("tool", "dir", "--bin") + if ($result.ExitCode -ne 0) { + if (-not [string]::IsNullOrWhiteSpace($result.Output)) { + [Console]::Error.WriteLine($result.Output) + } + throw "Could not determine the uv tool bin directory (exit code $($result.ExitCode)); ~/.fcc was not deleted." + } + $script:UvToolBin = $result.Output.Trim() + if ([string]::IsNullOrWhiteSpace($script:UvToolBin)) { + throw "uv returned an empty tool bin directory; ~/.fcc was not deleted." + } +} + +function Uninstall-FreeClaudeCode { + Write-Host "+ uv tool uninstall $PackageName" + if ($DryRun) { + return + } + + $result = Invoke-NativeResult -FilePath $script:UvPath -Arguments @( + "tool", + "uninstall", + $PackageName + ) + if ($result.ExitCode -eq 0) { + if (-not [string]::IsNullOrWhiteSpace($result.Output)) { + Write-Host $result.Output + } + return + } + if (Test-MissingUvToolError -Output $result.Output) { + Write-Host "Free Claude Code uv tool is already absent; verifying its entry points." + return + } + if (-not [string]::IsNullOrWhiteSpace($result.Output)) { + [Console]::Error.WriteLine($result.Output) + } + throw "uv tool uninstall $PackageName failed with exit code $($result.ExitCode); ~/.fcc was not deleted." +} + +function Confirm-FccCommandsRemoved { + if ($DryRun) { + Write-Host "+ verify all Free Claude Code entry points are absent from the uv tool bin directory" + return + } + + $remaining = @() + $extensions = @("", ".exe", ".cmd", ".bat", ".ps1") + foreach ($commandName in $FccCommands) { + foreach ($extension in $extensions) { + $commandPath = Join-Path $script:UvToolBin "$commandName$extension" + if (Test-Path -LiteralPath $commandPath) { + $remaining += $commandPath + } + } + } + if ($remaining.Count -gt 0) { + throw "Free Claude Code entry points remain after uv uninstall: $($remaining -join ', '); ~/.fcc was not deleted." + } +} + +function Purge-FccHome { + $fccHome = Join-Path $env:USERPROFILE $FccHomeDirname + if (-not (Test-Path -LiteralPath $fccHome)) { + Write-Host "No FCC config directory at $fccHome; skipping purge." + return + } + + $commandText = @( + "Remove-Item", + "-LiteralPath", + (Format-Argument $fccHome), + "-Recurse", + "-Force" + ) -join " " + Write-Host "+ $commandText" + if ($DryRun) { + return + } + + Remove-Item -LiteralPath $fccHome -Recurse -Force + if (Test-Path -LiteralPath $fccHome) { + throw "FCC config directory still exists after deletion: $fccHome" + } +} + +if ($Help) { + Show-Usage + return +} +if ($RemainingArgs.Count -gt 0) { + Show-Usage + throw "Unknown option: $($RemainingArgs -join ' ')" +} +if ([string]::IsNullOrWhiteSpace($env:USERPROFILE)) { + throw "USERPROFILE is not set; cannot locate Free Claude Code data." +} + +Write-Step "Checking for running Free Claude Code processes" +Assert-NoFccProcessesRunning + +Write-Step "Locating the uv-managed Free Claude Code installation" +Initialize-UvContext + +Write-Step "Removing the Free Claude Code uv tool" +Uninstall-FreeClaudeCode + +Write-Step "Verifying Free Claude Code entry points were removed" +Confirm-FccCommandsRemoved + +Write-Step "Purging FCC config and data from ~/.fcc" +Purge-FccHome + +Write-Host "" +if ($DryRun) { + Write-Host "Dry run complete. No changes were made." +} +else { + Write-Host "Free Claude Code has been removed and verified." + Write-Host "uv, Claude Code, Codex, Pi, the uv-managed Python runtime, and shared PATH entries were left installed." +} diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100644 index 0000000..a9b9b54 --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,243 @@ +#!/bin/sh +set -eu + +PACKAGE_NAME="free-claude-code" +FCC_HOME_DIRNAME=".fcc" +FCC_COMMANDS="fcc-server fcc-claude fcc-codex fcc-pi fcc-init free-claude-code" + +dry_run=0 +uv_tool_bin="" + +show_usage() { + cat <<'USAGE' +Usage: uninstall.sh [options] + +Removes the Free Claude Code uv tool and deletes ~/.fcc/ after removal is verified. +Does not remove uv, Claude Code, Codex, Pi, the uv-managed Python runtime, or shared PATH entries. + +Options: + --dry-run Print commands without running them. + --help Show this help text. +USAGE +} + +fail() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +step() { + printf '\n==> %s\n' "$1" +} + +quote_arg() { + case "$1" in + *[!A-Za-z0-9_./:@%+=,-]*|"") + escaped=$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g') + printf '"%s"' "$escaped" + ;; + *) + printf '%s' "$1" + ;; + esac +} + +print_command() { + printf '+' + for arg in "$@"; do + printf ' ' + quote_arg "$arg" + done + printf '\n' +} + +run() { + print_command "$@" + if [ "$dry_run" -eq 1 ]; then + return 0 + fi + + if "$@"; then + return 0 + else + status=$? + fi + fail "Command failed with exit code $status: $1" +} + +is_missing_uv_tool_error() { + normalized=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]') + case "$normalized" in + *"$PACKAGE_NAME"*"is not installed"*) return 0 ;; + *) return 1 ;; + esac +} + +add_path_entry() { + [ -n "$1" ] || return 0 + case ":$PATH:" in + *":$1:"*) ;; + *) PATH="$1:$PATH" ;; + esac +} + +add_known_uv_paths() { + if [ -n "${XDG_BIN_HOME:-}" ]; then + add_path_entry "$XDG_BIN_HOME" + fi + add_path_entry "$HOME/.local/bin" + add_path_entry "$HOME/.cargo/bin" + export PATH + hash -r 2>/dev/null || true +} + +is_fcc_command_running() { + command_name=$1 + + if command -v pgrep >/dev/null 2>&1; then + if pgrep -x "$command_name" >/dev/null 2>&1; then + return 0 + fi + if pgrep -f "(^|/)${command_name}( |$)" >/dev/null 2>&1; then + return 0 + fi + return 1 + fi + + ps -A -o comm= 2>/dev/null | grep -qx "$command_name" +} + +assert_no_fcc_processes_running() { + running="" + for command_name in $FCC_COMMANDS; do + if is_fcc_command_running "$command_name"; then + running="${running} ${command_name}" + fi + done + + if [ -n "$running" ]; then + fail "Free Claude Code is still running (${running# }). Stop those processes, then rerun uninstall." + fi +} + +initialize_uv_context() { + add_known_uv_paths + + if [ "$dry_run" -eq 1 ]; then + print_command uv tool dir --bin + return 0 + fi + + if ! command -v uv >/dev/null 2>&1; then + fail "uv is required to remove the Free Claude Code tool. Install uv, then rerun this uninstaller; ~/.fcc was not deleted." + fi + + print_command uv tool dir --bin + if uv_tool_bin=$(uv tool dir --bin); then + : + else + status=$? + fail "Could not determine the uv tool bin directory (exit code $status); ~/.fcc was not deleted." + fi + [ -n "$uv_tool_bin" ] || fail "uv returned an empty tool bin directory; ~/.fcc was not deleted." +} + +uninstall_free_claude_code() { + print_command uv tool uninstall "$PACKAGE_NAME" + if [ "$dry_run" -eq 1 ]; then + return 0 + fi + + if output=$(uv tool uninstall "$PACKAGE_NAME" 2>&1); then + if [ -n "$output" ]; then + printf '%s\n' "$output" + fi + return 0 + else + status=$? + fi + + if is_missing_uv_tool_error "$output"; then + printf 'Free Claude Code uv tool is already absent; verifying its entry points.\n' + return 0 + fi + if [ -n "$output" ]; then + printf '%s\n' "$output" >&2 + fi + fail "uv tool uninstall $PACKAGE_NAME failed with exit code $status; ~/.fcc was not deleted." +} + +verify_fcc_commands_removed() { + if [ "$dry_run" -eq 1 ]; then + printf '+ verify all Free Claude Code entry points are absent from the uv tool bin directory\n' + return 0 + fi + + remaining="" + for command_name in $FCC_COMMANDS; do + command_path="$uv_tool_bin/$command_name" + if [ -e "$command_path" ] || [ -L "$command_path" ]; then + remaining="${remaining} ${command_path}" + fi + done + if [ -n "$remaining" ]; then + fail "Free Claude Code entry points remain after uv uninstall:${remaining}; ~/.fcc was not deleted." + fi +} + +purge_fcc_home() { + fcc_home="$HOME/$FCC_HOME_DIRNAME" + if [ ! -e "$fcc_home" ]; then + printf 'No FCC config directory at %s; skipping purge.\n' "$fcc_home" + return 0 + fi + + run rm -rf "$fcc_home" + if [ "$dry_run" -eq 0 ] && [ -e "$fcc_home" ]; then + fail "FCC config directory still exists after deletion: $fcc_home" + fi +} + +parse_args() { + while [ "$#" -gt 0 ]; do + case "$1" in + --dry-run) + dry_run=1 + ;; + --help|-h) + show_usage + exit 0 + ;; + *) + show_usage >&2 + fail "unknown option: $1" + ;; + esac + shift + done +} + +parse_args "$@" +[ -n "${HOME:-}" ] || fail "HOME is not set; cannot locate Free Claude Code data." + +step "Checking for running Free Claude Code processes" +assert_no_fcc_processes_running + +step "Locating the uv-managed Free Claude Code installation" +initialize_uv_context + +step "Removing the Free Claude Code uv tool" +uninstall_free_claude_code + +step "Verifying Free Claude Code entry points were removed" +verify_fcc_commands_removed + +step "Purging FCC config and data from ~/.fcc" +purge_fcc_home + +if [ "$dry_run" -eq 1 ]; then + printf '\nDry run complete. No changes were made.\n' +else + printf '\nFree Claude Code has been removed and verified.\n' + printf 'uv, Claude Code, Codex, Pi, the uv-managed Python runtime, and shared PATH entries were left installed.\n' +fi diff --git a/smoke/README.md b/smoke/README.md new file mode 100644 index 0000000..4087a00 --- /dev/null +++ b/smoke/README.md @@ -0,0 +1,184 @@ +# Product E2E Smoke Tests + +`smoke/` is local-only. It can launch subprocesses, call real providers, touch +local model servers, and optionally send/delete bot messages. Hermetic contracts +belong under `tests/` and must stay green with plain `uv run pytest`. + +## Taxonomy + +- `smoke/prereq/`: liveness checks that prove the server, routes, auth, CLI + scripts, provider pings, local `/models`, and bot permissions are reachable. + These are prerequisites only. +- `smoke/product/`: end-to-end product scenarios. Feature smoke coverage comes + from these tests, not from route/header/provider pings. +- `smoke/features.py`: source-of-truth feature map: + feature -> subfeature -> scenario -> env -> expected behavior -> failure class. + +## Required Local Commands + +```powershell +uv run pytest smoke --collect-only -q +uv run pytest smoke -n 0 -s --tb=short +``` + +The second command skips everything unless `FCC_LIVE_SMOKE=1` is set, but still +writes skip entries to `.smoke-results/`. + +## Product Smoke Run + +```powershell +$env:FCC_LIVE_SMOKE = "1" +uv run pytest smoke -n 0 -s --tb=short +``` + +Provider smoke scenarios can run providers in parallel while preserving +sequential execution within each provider: + +```powershell +$env:FCC_LIVE_SMOKE = "1" +$env:FCC_SMOKE_TARGETS = "providers" +uv run pytest smoke -n auto --dist=loadgroup -s --tb=short +``` + +Provider product E2E runs once per configured provider, independent of `MODEL`, +`MODEL_OPUS`, `MODEL_SONNET`, and `MODEL_HAIKU`. Defaults come from the provider +catalog/docs and can be overridden with `FCC_SMOKE_MODEL_`, for example +`FCC_SMOKE_MODEL_DEEPSEEK=deepseek-v4-pro` (or `deepseek-v4-flash`). If no provider smoke model is +configured, live product smoke fails as `missing_env` unless you explicitly set +`FCC_ALLOW_NO_PROVIDER_SMOKE=1`. + +## Targets + +Default targets do not send real bot messages or load voice backends: + +| Target | Product scenarios | Required environment | +| --- | --- | --- | +| `api` | messages, count_tokens full payload, errors, `/stop`, optimizations | configured provider only for streaming messages | +| `auth` | x-api-key, bearer, anthropic-auth-token, invalid/missing auth | none; test sets an isolated token | +| `cli` | `fcc-init`, server entrypoint, Claude CLI adaptive thinking, session cleanup | Claude CLI binary and provider only for real CLI | +| `clients` | VS Code and JetBrains protocol payloads | configured provider | +| `config` | env precedence, removed-env migration, proxy/timeouts | none | +| `extensibility` | provider runtime and platform factory construction | none | +| `messaging` | fake Discord/Telegram full flow, literal clear scopes, trees, persistence, voice cancel | none | +| `providers` | multi-turn text, adaptive thinking history, tools, disconnect, errors | configured providers, optional `FCC_SMOKE_MODEL_*` | +| `tools` | forced tool_use and tool_result continuation | tool-capable configured provider | +| `rate_limit` | disconnect cleanup and follow-up request | configured provider | +| `lmstudio` | local `/models` plus OpenAI-chat-backed Messages through proxy | running LM Studio server | +| `llamacpp` | local `/models` plus OpenAI-chat-backed Messages through proxy | running llama-server | +| `ollama` | local `/v1/models` plus OpenAI-chat-backed Messages through proxy | running Ollama server | + +Heavy/side-effectful targets are opt-in: + +| Target | Product scenarios | Required environment | +| --- | --- | --- | +| `nvidia_nim_cli` | Claude Code CLI feature matrix across NIM models | `NVIDIA_NIM_API_KEY`, Claude CLI | +| `openrouter_free_cli` | Claude Code CLI feature matrix across OpenRouter free models | `OPENROUTER_API_KEY`, Claude CLI | +| `telegram` | getMe, send, edit, delete, optional manual inbound | token and chat/user ID | +| `discord` | channel access, send, edit, delete, optional manual inbound | token and channel ID | +| `voice` | generated WAV through local Whisper or NVIDIA NIM transcription | `VOICE_NOTE_ENABLED=true`, `FCC_SMOKE_RUN_VOICE=1` | + +## Examples + +```powershell +$env:FCC_LIVE_SMOKE = "1" +$env:FCC_SMOKE_PROVIDER_MATRIX = "open_router,nvidia_nim,deepseek,lmstudio,llamacpp,ollama" +uv run pytest smoke/product -n 0 -s --tb=short +``` + +```powershell +$env:FCC_LIVE_SMOKE = "1" +$env:FCC_SMOKE_TARGETS = "ollama" +$env:OLLAMA_BASE_URL = "http://localhost:11434" +uv run pytest smoke/prereq smoke/product -n 0 -s --tb=short +``` + +```powershell +$env:FCC_LIVE_SMOKE = "1" +$env:FCC_SMOKE_TARGETS = "telegram,discord,voice" +$env:FCC_SMOKE_RUN_VOICE = "1" +uv run pytest smoke/product -n 0 -s --tb=short +``` + +```powershell +$env:FCC_LIVE_SMOKE = "1" +$env:FCC_SMOKE_TARGETS = "nvidia_nim_cli" +$env:FCC_SMOKE_NIM_MODELS = "z-ai/glm-5.2,moonshotai/kimi-k2.6,minimaxai/minimax-m2.7,nvidia/nemotron-3-super-120b-a12b,deepseek-ai/deepseek-v4-pro,deepseek-ai/deepseek-v4-flash" +uv run pytest smoke/product -n 0 -s --tb=short +``` + +```powershell +$env:FCC_LIVE_SMOKE = "1" +$env:FCC_SMOKE_TARGETS = "openrouter_free_cli" +$env:FCC_SMOKE_OPENROUTER_FREE_MODELS = "nvidia/nemotron-3-super-120b-a12b:free,openai/gpt-oss-120b:free,poolside/laguna-m.1:free" +uv run pytest smoke/product -n 0 -s --tb=short +``` + +```powershell +$env:FCC_LIVE_SMOKE = "1" +$env:FCC_SMOKE_TARGETS = "messaging,config,extensibility" +uv run pytest smoke/product -n 0 -s --tb=short +``` + +## Environment + +- `FCC_ENV_FILE`: explicit dotenv path for startup/config scenarios. +- `FCC_LIVE_SMOKE=1`: enables live smoke execution. +- `FCC_ALLOW_NO_PROVIDER_SMOKE=1`: permits no-provider live smoke for harness work. +- `FCC_SMOKE_TARGETS`: comma-separated targets, or `all`. +- `FCC_SMOKE_PROVIDER_MATRIX`: comma-separated provider prefixes to require. +- `FCC_SMOKE_MODEL_NVIDIA_NIM`, `FCC_SMOKE_MODEL_OPEN_ROUTER`, + `FCC_SMOKE_MODEL_MISTRAL`, `FCC_SMOKE_MODEL_MISTRAL_REASONING`, + `FCC_SMOKE_MODEL_MISTRAL_CODESTRAL`, + `FCC_SMOKE_MODEL_DEEPSEEK`, `FCC_SMOKE_MODEL_KIMI`, + `FCC_SMOKE_MODEL_WAFER`, `FCC_SMOKE_MODEL_MINIMAX`, + `FCC_SMOKE_MODEL_OPENCODE`, `FCC_SMOKE_MODEL_OPENCODE_GO`, + `FCC_SMOKE_MODEL_ZAI`, `FCC_SMOKE_MODEL_FIREWORKS`, `FCC_SMOKE_MODEL_CLOUDFLARE`, + `FCC_SMOKE_MODEL_GEMINI`, `FCC_SMOKE_MODEL_GROQ`, `FCC_SMOKE_MODEL_CEREBRAS`, + `FCC_SMOKE_MODEL_LMSTUDIO`, + `FCC_SMOKE_MODEL_LLAMACPP`, `FCC_SMOKE_MODEL_OLLAMA`: optional per-provider + smoke model overrides. Values may include the provider prefix or just the model + name for that provider. +- `FCC_SMOKE_MODEL_MISTRAL_REASONING`: optional override for the dedicated + Mistral native reasoning smoke, default `mistral/mistral-medium-3-5`. +- `FCC_SMOKE_NIM_MODELS`: optional comma-separated NVIDIA NIM CLI matrix models + that replace the default characterization set. +- `FCC_SMOKE_NIM_EXTRA_MODELS`: optional comma-separated NVIDIA NIM CLI matrix + models appended to the default or replacement set. +- `FCC_SMOKE_OPENROUTER_FREE_MODELS`: optional comma-separated OpenRouter free + CLI matrix models that replace the default characterization set. +- `FCC_SMOKE_OPENROUTER_FREE_EXTRA_MODELS`: optional comma-separated OpenRouter + free CLI matrix models appended to the default or replacement set. +- `FCC_SMOKE_TIMEOUT_S`: per-request/subprocess timeout, default `45`. +- `FCC_SMOKE_CLAUDE_BIN`: Claude CLI executable name, default `claude`. +- `FCC_SMOKE_TELEGRAM_CHAT_ID`: Telegram chat/user ID for send/edit/delete. +- `FCC_SMOKE_DISCORD_CHANNEL_ID`: Discord channel ID for send/edit/delete. +- `FCC_SMOKE_INTERACTIVE=1`: enables manual inbound Telegram/Discord checks. +- `FCC_SMOKE_RUN_VOICE=1`: allows voice transcription backends to load/run. + +## Windows / nested `uv run` + +Run smoke the same way you run tests (`uv run pytest smoke` from the repo). Child +processes use the **same Python interpreter** as the test runner, not nested +`uv run`, so Windows does not try to replace `free-claude-code.exe` while it is +locked. + +## Failure Classes + +Smoke artifacts are written to `.smoke-results/` and redact env values whose +names contain `KEY`, `TOKEN`, `SECRET`, `WEBHOOK`, or `AUTH`. + +- `missing_env`: required credentials, binary, provider config, local provider + server/model, or opt-in flag is absent. +- `upstream_unavailable`: a real provider or bot API is not reachable. +- `probe_timeout`: the smoke driver reached the target, but the CLI/probe did + not complete within the smoke timeout. +- `product_failure`: the app accepted the scenario but returned the wrong shape, + crashed, leaked state, or violated the product contract. +- `harness_bug`: the smoke test or driver made an invalid assumption. +- `target_disabled`: skipped because `FCC_SMOKE_TARGETS` intentionally selected + a different target. + +`product_failure` and `harness_bug` are failures. `missing_env`, +`upstream_unavailable`, and `probe_timeout` are skips except when the user +explicitly selected a provider in `FCC_SMOKE_PROVIDER_MATRIX`; +selected-but-missing providers fail. diff --git a/smoke/__init__.py b/smoke/__init__.py new file mode 100644 index 0000000..1eee0aa --- /dev/null +++ b/smoke/__init__.py @@ -0,0 +1 @@ +"""Local-only live smoke tests for free-claude-code.""" diff --git a/smoke/capabilities.py b/smoke/capabilities.py new file mode 100644 index 0000000..8001d9f --- /dev/null +++ b/smoke/capabilities.py @@ -0,0 +1,524 @@ +"""Hierarchical public capability map. + +This module is the architectural companion to ``smoke.features``. The feature +inventory says which public features must be covered; this map records the +subfeature contracts, owning module boundary, and coverage owners that protect +them while the internals are refactored. +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class CapabilityContract: + capability: str + subfeature: str + feature_id: str + owner: str + inputs: str + outputs: str + failure: str + pytest_tests: tuple[str, ...] + smoke_tests: tuple[str, ...] = () + + +CAPABILITY_CONTRACTS: tuple[CapabilityContract, ...] = ( + CapabilityContract( + "api_compatibility", + "routes_and_probes", + "anthropic_api_routes", + "free_claude_code.api.routes", + "Anthropic-compatible HTTP requests", + "JSON or Anthropic SSE responses", + "HTTPException or Anthropic error payload", + ("tests/api/test_api.py",), + ("test_probe_and_models_routes", "test_stop_endpoint_reports_no_messaging"), + ), + CapabilityContract( + "api_compatibility", + "head_options_probes", + "probe_routes", + "free_claude_code.api.routes", + "HEAD/OPTIONS probe requests", + "204 with Allow header", + "auth failure when configured", + ("tests/api/test_api.py::test_probe_endpoints_return_204_with_allow_headers",), + ("test_probe_and_models_routes",), + ), + CapabilityContract( + "api_compatibility", + "client_request_shapes", + "drop_in_claude_code_replacement", + "free_claude_code.api.handlers.messages.MessagesHandler", + "Claude Code, VS Code, and JetBrains shaped requests", + "compatible streaming response", + "provider or validation error in Anthropic shape", + ("tests/api/test_api.py", "tests/cli/test_cli.py"), + ("test_vscode_and_jetbrains_shaped_requests",), + ), + CapabilityContract( + "api_compatibility", + "responses_api", + "drop_in_codex_replacement", + "free_claude_code.api.handlers.responses.ResponsesHandler", + "OpenAI Responses requests from Codex", + "Responses SSE or JSON response", + "OpenAI-shaped error or conversion error", + ( + "tests/api/test_openai_responses.py", + "tests/core/openai_responses/test_sse.py", + "tests/cli/test_entrypoints.py", + "tests/cli/test_codex_model_catalog.py", + ), + ( + "test_probe_and_models_routes", + "test_provider_codex_responses_text_e2e", + ), + ), + CapabilityContract( + "api_compatibility", + "client_extensions", + "vscode_extension", + "free_claude_code.api.handlers.messages.MessagesHandler", + "VS Code beta query/header variants", + "accepted Anthropic-compatible response", + "HTTP error only for auth/provider failures", + (), + ("test_vscode_and_jetbrains_shaped_requests",), + ), + CapabilityContract( + "api_compatibility", + "client_extensions", + "intellij_extension", + "free_claude_code.api.handlers.messages.MessagesHandler", + "JetBrains/ACP request variants", + "accepted Anthropic-compatible response", + "HTTP error only for auth/provider failures", + (), + ("test_vscode_and_jetbrains_shaped_requests",), + ), + CapabilityContract( + "auth", + "anthropic_headers", + "optional_authentication", + "free_claude_code.api.dependencies.require_api_key", + "x-api-key, authorization, anthropic-auth-token", + "request accepted or 401", + "401 missing/invalid API key", + ("tests/api/test_auth.py",), + ("test_auth_token_is_enforced_for_all_supported_header_shapes",), + ), + CapabilityContract( + "provider_routing", + "provider_runtime", + "provider_matrix", + "free_claude_code.runtime.provider_manager.ProviderRuntimeManager", + "provider id and Settings", + "configured BaseProvider instance", + "503 for missing credentials; invalid_request_error for unknown provider", + ("tests/api/test_dependencies.py", "tests/providers/test_provider_runtime.py"), + ( + "test_configured_provider_models_stream_successfully", + "test_provider_matrix_presence_e2e", + ), + ), + CapabilityContract( + "provider_routing", + "claude_model_resolution", + "per_model_mapping", + "free_claude_code.application.routing.ModelRouter", + "Claude model name and configured MODEL_* values", + "provider id plus provider model name", + "settings validation rejects invalid provider prefixes", + ("tests/application/test_routing.py", "tests/config/test_config.py"), + ("test_model_mapping_configuration_is_consistent",), + ), + CapabilityContract( + "provider_routing", + "mixed_provider_resolution", + "mixed_provider_mapping", + "free_claude_code.application.routing.ModelRouter", + "Opus/Sonnet/Haiku/fallback model config", + "distinct provider selections per request", + "skip live execution when providers are not configured", + ("tests/application/test_routing.py", "tests/config/test_config.py"), + ("test_mixed_provider_model_mapping_when_configured",), + ), + CapabilityContract( + "provider_routing", + "provider_runtime_config", + "provider_proxy_timeout_config", + "free_claude_code.providers.runtime.ProviderRuntime", + "provider proxy, timeout, and rate-limit settings", + "provider client and instance-owned limiter config", + "provider construction failure", + ( + "tests/api/test_dependencies.py", + "tests/providers/test_provider_runtime.py", + "tests/providers/test_provider_rate_limit.py", + ), + ), + CapabilityContract( + "provider_routing", + "zero_cost_backends", + "zero_cost_provider_access", + "free_claude_code.providers.runtime.ProviderRuntime", + "configured free/local provider", + "streaming response from selected backend", + "missing env or upstream unavailable skip in smoke", + ("tests/api/test_dependencies.py", "tests/providers/"), + ("test_configured_provider_models_stream_successfully",), + ), + CapabilityContract( + "streaming_conversion", + "anthropic_sse_lifecycle", + "streaming_error_mapping", + "free_claude_code.core.failures.ExecutionFailure", + "provider stream failures before or after the HTTP commit boundary", + "protocol-specific JSON failure or terminal stream event", + "ordinary request failures remain distinct from terminal execution", + ( + "tests/api/test_execution_failure_contract.py", + "tests/api/test_ordinary_error_phases.py", + "tests/core/test_failure_protocol_mapping.py", + "tests/providers/test_execution_failure_boundary.py", + "tests/providers/test_failure_policy.py", + ), + ), + CapabilityContract( + "streaming_conversion", + "thinking_blocks", + "thinking_token_support", + "free_claude_code.core.anthropic.thinking", + "reasoning_content, reasoning_details, text, native thinking", + "Claude thinking blocks or suppression", + "thinking hidden when disabled", + ( + "tests/contracts/test_stream_contracts.py", + "tests/providers/test_converter.py", + "tests/providers/test_deepseek.py", + "tests/providers/test_nvidia_nim_request.py", + "tests/providers/test_open_router.py", + ), + ( + "test_per_model_thinking_config_e2e", + "test_provider_reasoning_tool_continuation_e2e", + ), + ), + CapabilityContract( + "streaming_conversion", + "heuristic_tools", + "heuristic_tool_parser", + "free_claude_code.core.anthropic.tools", + "textual tool-call output", + "structured Anthropic tool_use blocks", + "text fallback when malformed", + ("tests/providers/test_parsers.py", "tests/contracts/test_stream_contracts.py"), + ( + "test_live_tool_use_when_configured_model_supports_tools", + "test_provider_reasoning_tool_continuation_e2e", + ), + ), + CapabilityContract( + "streaming_conversion", + "subagent_task_control", + "subagent_control", + "free_claude_code.core.anthropic.streaming.AnthropicStreamLedger", + "Task tool call arguments", + "run_in_background=false", + "invalid JSON flushed as safe object", + ("tests/providers/test_subagent_interception.py",), + ), + CapabilityContract( + "request_behavior", + "local_optimizations", + "request_optimization", + "free_claude_code.api.optimization_handlers", + "Claude Code probe/title/suggestion/filepath requests", + "local MessagesResponse without provider call", + "falls through to provider when unmatched/disabled", + ( + "tests/api/test_optimization_handlers.py", + "tests/api/test_routes_optimizations.py", + ), + ("test_optimization_fast_paths_do_not_need_provider",), + ), + CapabilityContract( + "request_behavior", + "token_counting", + "count_tokens_contract", + "free_claude_code.core.anthropic.get_token_count", + "messages, system blocks, tools, images, thinking, tool results", + "positive input token estimate", + "500 with readable detail on unexpected failure", + ("tests/api/test_request_utils.py",), + ("test_count_tokens_accepts_thinking_tools_and_results",), + ), + CapabilityContract( + "config", + "env_precedence", + "config_env_precedence", + "free_claude_code.config.settings.Settings", + "process env, user env file, repo env file, FCC_ENV_FILE", + "deterministic settings values", + "validation error for invalid settings", + ("tests/config/test_config.py",), + ), + CapabilityContract( + "config", + "removed_env_migration", + "removed_env_migration", + "free_claude_code.config.settings.Settings", + "NIM_ENABLE_THINKING or ENABLE_THINKING in env or dotenv", + "startup succeeds and stale keys do not change thinking defaults", + "removed key ignored", + ("tests/config/test_config.py",), + ), + CapabilityContract( + "provider_runtime", + "rate_limit_and_disconnect", + "smart_rate_limiting", + "free_claude_code.providers.rate_limit.ProviderRateLimiter", + "concurrent provider requests and transient upstream/disconnect failures", + "provider-specific classification, proactive throttle, retry, cleanup", + "mapped provider error or smoke skip for upstream disconnect", + ( + "tests/providers/test_provider_rate_limit.py", + "tests/providers/test_nvidia_nim_degraded_retry.py", + ), + ("test_client_disconnect_mid_stream_does_not_crash_server",), + ), + CapabilityContract( + "provider_runtime", + "generation_hot_swap", + "provider_hot_swap", + "free_claude_code.runtime.provider_manager.ProviderRuntimeManager", + "validated Admin provider config and concurrent request streams", + "atomic generation publication with old-stream completion", + "failed candidate or persistence leaves current generation unchanged", + ( + "tests/runtime/test_provider_manager.py", + "tests/api/test_response_streams.py", + "tests/api/test_admin.py", + ), + ("test_provider_hot_swap_preserves_inflight_stream_e2e",), + ), + CapabilityContract( + "local_providers", + "lmstudio_messages", + "lmstudio_endpoint", + "free_claude_code.providers.lmstudio.LMStudioProvider", + "Anthropic Messages request and local LM Studio URL", + "Anthropic SSE converted from an OpenAI Chat upstream", + "typed provider failure for local upstream errors", + ("tests/providers/test_lmstudio.py",), + ("test_lmstudio_messages_e2e",), + ), + CapabilityContract( + "local_providers", + "llamacpp_openai_chat", + "llamacpp_endpoint", + "free_claude_code.providers.openai_chat.OpenAIChatProvider", + "OpenAI Chat request and llama.cpp URL", + "Anthropic SSE converted from OpenAI Chat chunks", + "typed provider failure for local upstream errors", + ("tests/providers/test_llamacpp.py",), + ("test_llamacpp_models_endpoint_when_available",), + ), + CapabilityContract( + "local_providers", + "ollama_openai_chat", + "ollama_endpoint", + "free_claude_code.providers.openai_chat.OpenAIChatProvider", + "OpenAI Chat request and local Ollama root URL", + "Anthropic SSE converted from OpenAI Chat chunks", + "typed provider failure for local upstream errors", + ("tests/providers/test_ollama.py",), + ("test_ollama_models_endpoint_when_available",), + ), + CapabilityContract( + "openrouter", + "openai_chat_provider", + "provider_matrix", + "free_claude_code.providers.open_router.OpenRouterProvider", + "OpenAI Chat request for OpenRouter", + "OpenAI stream mapped to Anthropic SSE", + "Anthropic SSE error shape", + ( + "tests/providers/test_open_router.py", + "tests/providers/test_openai_compat_5xx_retry.py", + ), + ("test_configured_provider_models_stream_successfully",), + ), + CapabilityContract( + "messaging", + "discord_telegram_platforms", + "discord_telegram_bot", + "free_claude_code.messaging.workflow.MessagingWorkflow", + "Discord/Telegram incoming messages and API callbacks", + "live progress edits and final transcript", + "platform retry/fallback or user-facing error", + ( + "tests/messaging/test_discord_platform.py", + "tests/messaging/test_telegram.py", + "tests/messaging/test_limiter.py", + "tests/messaging/test_platform_outbox.py", + "tests/runtime/test_application_runtime.py", + ), + ("test_telegram_bot_api_permissions", "test_discord_bot_api_permissions"), + ), + CapabilityContract( + "messaging", + "commands", + "messaging_commands", + "free_claude_code.messaging.commands", + "/stop, /clear, /stats command messages", + "chat-wide cleanup or literal reply-subtree deletion", + "terminal status edit, no-op feedback, or best-effort platform cleanup", + ( + "tests/messaging/test_handler.py", + "tests/messaging/test_handler_integration.py", + "tests/messaging/test_tree_ownership_concurrency.py", + ), + ( + "test_messaging_commands_stop_clear_stats_e2e", + "test_reply_clear_uses_literal_platform_subtree_e2e", + "test_messaging_startup_notice_is_clearable_e2e", + "test_messaging_active_stop_uses_status_only_e2e", + "test_messaging_queued_scoped_cancel_e2e", + ), + ), + CapabilityContract( + "messaging", + "tree_threading", + "tree_threading", + "free_claude_code.messaging.trees.TreeQueueManager", + "reply-based message graph", + "queued branches and scoped cancellation", + "node error propagation", + ( + "tests/messaging/test_tree_queue.py", + "tests/messaging/test_tree_concurrency.py", + "tests/messaging/test_message_tree_transitions.py", + "tests/messaging/test_tree_ownership_concurrency.py", + ), + ( + "test_tree_threading_e2e", + "test_messaging_queued_scoped_cancel_e2e", + "test_same_message_ids_are_isolated_by_chat_e2e", + ), + ), + CapabilityContract( + "persistence", + "restart_restore", + "restart_restore", + "free_claude_code.messaging.session.SessionStore", + "stored tree JSON", + "restored scoped reply lookup", + "stale pending work marked lost", + ("tests/messaging/test_restart_reply_restore.py",), + ), + CapabilityContract( + "persistence", + "session_store", + "session_persistence", + "free_claude_code.messaging.session.SessionStore", + "scoped tree data and message log", + "JSON persistence compatible with existing files", + "best-effort flush error logging", + ("tests/messaging/test_session_store_edge_cases.py",), + ), + CapabilityContract( + "voice", + "voice_transcription", + "voice_notes", + "free_claude_code.messaging.voice.Transcriber", + "Discord/Telegram audio file and voice backend settings", + "transcribed prompt routed to handler", + "missing optional extra or backend error shown to user", + ( + "tests/messaging/test_voice_handlers.py", + "tests/messaging/test_transcription.py", + "tests/messaging/test_transcription_nim.py", + "tests/messaging/test_platform_voice_flow.py", + ), + ("test_voice_transcription_backend_when_explicitly_enabled",), + ), + CapabilityContract( + "cli", + "package_entrypoints", + "package_cli_entrypoints", + "free_claude_code.cli.entrypoints", + "installed console scripts", + "config scaffold, package version, or uvicorn server startup", + "process cleanup in finally", + ("tests/cli/test_entrypoints.py", "tests/core/test_version.py"), + ( + "test_fcc_init_scaffolds_user_config", + "test_free_claude_code_entrypoint_starts_server", + "test_entrypoint_version_e2e", + ), + ), + CapabilityContract( + "cli", + "claude_cli_drop_in", + "claude_cli_drop_in", + "free_claude_code.cli.managed.session.ManagedClaudeSession", + "Claude CLI binary and proxy env", + "stream-json events and session id mapping", + "stderr/error event and process cleanup", + ("tests/cli/test_cli.py",), + ( + "test_claude_cli_prompt_when_available", + "test_claude_cli_provider_error_e2e", + "test_nvidia_nim_cli_matrix_e2e", + "test_openrouter_free_cli_matrix_e2e", + ), + ), + CapabilityContract( + "cli", + "codex_cli_drop_in", + "drop_in_codex_replacement", + "free_claude_code.cli.launchers.codex", + "Codex CLI binary and fcc provider env", + "Responses config, auth env, and native /model catalog injection", + "proxy preflight and catalog fail-open warning", + ( + "tests/cli/test_codex_model_catalog.py", + "tests/cli/test_entrypoints.py", + ), + (), + ), + CapabilityContract( + "cli", + "pi_cli_integration", + "pi_cli_integration", + "free_claude_code.cli.launchers.pi", + "Pi CLI binary, bundled extension, and FCC child-process environment", + "Anthropic Messages provider with an FCC-scoped live model catalog", + "proxy preflight, missing extension, or catalog failure exits without fallback", + ("tests/cli/test_entrypoints.py",), + ("test_pi_cli_prompt_e2e",), + ), + CapabilityContract( + "extensibility", + "provider_platform_abcs", + "extensible_provider_platform_abcs", + "free_claude_code.providers.runtime and free_claude_code.messaging.platforms.factory", + "new provider/platform implementations", + "registered BaseProvider or messaging component bundle", + "unknown platform returns None; unknown provider errors", + ( + "tests/contracts/test_feature_manifest.py", + "tests/providers/test_provider_runtime.py", + ), + ), +) + + +def capability_names() -> set[str]: + return {contract.capability for contract in CAPABILITY_CONTRACTS} + + +def contracted_feature_ids() -> set[str]: + return {contract.feature_id for contract in CAPABILITY_CONTRACTS} diff --git a/smoke/conftest.py b/smoke/conftest.py new file mode 100644 index 0000000..557decd --- /dev/null +++ b/smoke/conftest.py @@ -0,0 +1,126 @@ +from collections.abc import Iterator +from typing import Any + +import pytest + +from smoke.lib.config import ProviderModel, SmokeConfig, auth_headers +from smoke.lib.report import SmokeReport +from smoke.lib.server import RunningServer, start_server + +DISABLED_PROVIDER_MODEL = ProviderModel( + provider="smoke_disabled", + full_model="smoke_disabled/smoke-disabled", + source="smoke_disabled", +) + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + if "provider_model" in metafunc.fixturenames: + config = SmokeConfig.load() + metafunc.parametrize("provider_model", provider_model_params(config)) + + +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + if SmokeConfig.load().live: + return + skip = pytest.mark.skip(reason="set FCC_LIVE_SMOKE=1 to run local smoke tests") + for item in items: + item.add_marker(skip) + + +def pytest_configure(config: pytest.Config) -> None: + global _REPORT + smoke_config = SmokeConfig.load() + _REPORT = SmokeReport(smoke_config) + + +def pytest_runtest_setup(item: pytest.Item) -> None: + config = SmokeConfig.load() + target_marks = list(item.iter_markers("smoke_target")) + if not target_marks: + return + targets = [str(mark.args[0]) for mark in target_marks if mark.args] + if targets and not any(config.target_enabled(target) for target in targets): + pytest.skip(f"smoke target disabled: {', '.join(targets)}") + + +def pytest_runtest_logreport(report: pytest.TestReport) -> None: + if report.when == "setup" and not report.skipped: + return + if report.when == "teardown" and not report.failed: + return + if _REPORT is None: + return + markers = sorted( + str(name) for name in report.keywords if str(name).startswith("smoke_") + ) + detail = "" if report.longrepr is None else str(report.longrepr) + _REPORT.add( + nodeid=report.nodeid, + outcome=report.outcome, + duration_s=report.duration, + markers=markers, + detail=detail, + ) + + +def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: + if _REPORT is not None: + _REPORT.write() + + +@pytest.fixture(scope="session") +def smoke_config() -> SmokeConfig: + return SmokeConfig.load() + + +@pytest.fixture +def smoke_server(smoke_config: SmokeConfig) -> Iterator[RunningServer]: + with start_server(smoke_config) as server: + yield server + + +@pytest.fixture +def smoke_headers() -> dict[str, str]: + return auth_headers() + + +def provider_model_params(config: SmokeConfig) -> list[Any]: + """Return provider params grouped for pytest-xdist ``--dist=loadgroup``.""" + if not config.live: + return [_disabled_provider_param("set FCC_LIVE_SMOKE=1 to run provider smoke")] + + models = config.provider_smoke_models() + if not models: + return [_disabled_provider_param("missing_env: no configured provider smoke")] + + return [ + pytest.param( + model, + id=provider_model_id(model), + marks=pytest.mark.xdist_group(provider_xdist_group(model)), + ) + for model in models + ] + + +def _disabled_provider_param(reason: str) -> Any: + return pytest.param( + DISABLED_PROVIDER_MODEL, + id=provider_model_id(DISABLED_PROVIDER_MODEL), + marks=( + pytest.mark.skip(reason=reason), + pytest.mark.xdist_group(provider_xdist_group(DISABLED_PROVIDER_MODEL)), + ), + ) + + +def provider_model_id(provider_model: ProviderModel) -> str: + return provider_model.provider + + +def provider_xdist_group(provider_model: ProviderModel) -> str: + return f"provider:{provider_model.provider}" + + +_REPORT: SmokeReport | None = None diff --git a/smoke/features.py b/smoke/features.py new file mode 100644 index 0000000..7358805 --- /dev/null +++ b/smoke/features.py @@ -0,0 +1,565 @@ +"""Public feature inventory for contract, prerequisite, and product smoke tests. + +The inventory is intentionally explicit. README-advertised behavior and exposed +public surface area must have deterministic pytest contract coverage plus a +product E2E scenario when that behavior is a user-facing product path. Liveness +and route probes live in ``smoke/prereq`` and do not count as product coverage. +""" + +from dataclasses import dataclass +from typing import Literal + +FeatureSource = Literal["readme", "public_surface"] + + +@dataclass(frozen=True, slots=True) +class FeatureCoverage: + feature_id: str + title: str + source: FeatureSource + pytest_contract_tests: tuple[str, ...] + live_prereq_tests: tuple[str, ...] + product_e2e_tests: tuple[str, ...] + smoke_targets: tuple[str, ...] + required_env: tuple[str, ...] + skip_policy: str + product_e2e_reason: str = "" + + @property + def has_pytest_coverage(self) -> bool: + return bool(self.pytest_contract_tests) + + +README_FEATURES: tuple[str, ...] = ( + "zero_cost_provider_access", + "drop_in_claude_code_replacement", + "drop_in_codex_replacement", + "pi_cli_integration", + "provider_matrix", + "per_model_mapping", + "thinking_token_support", + "heuristic_tool_parser", + "discord_telegram_bot", + "optional_authentication", + "vscode_extension", + "intellij_extension", + "voice_notes", +) + + +FEATURE_INVENTORY: tuple[FeatureCoverage, ...] = ( + FeatureCoverage( + "zero_cost_provider_access", + "Configured provider accepts real conversation turns", + "readme", + ("tests/api/test_dependencies.py", "tests/providers/"), + ("test_configured_provider_models_stream_successfully",), + ("test_provider_text_multiturn_e2e",), + ("providers",), + ("configured provider credentials or local provider endpoint",), + "missing providers are missing_env unless FCC_ALLOW_NO_PROVIDER_SMOKE=1", + ), + FeatureCoverage( + "drop_in_claude_code_replacement", + "Claude-compatible API, CLI, and editor protocol flows work", + "readme", + ("tests/api/test_api.py", "tests/cli/test_cli.py"), + ("test_probe_and_models_routes", "test_claude_cli_prompt_when_available"), + ( + "test_api_basic_conversation_e2e", + "test_claude_cli_adaptive_thinking_e2e", + "test_claude_cli_provider_error_e2e", + "test_nvidia_nim_cli_matrix_e2e", + "test_openrouter_free_cli_matrix_e2e", + "test_vscode_protocol_e2e", + "test_jetbrains_protocol_e2e", + ), + ("api", "cli", "clients", "nvidia_nim_cli", "openrouter_free_cli"), + ( + "configured provider", + "FCC_SMOKE_CLAUDE_BIN for real Claude CLI", + "NVIDIA_NIM_API_KEY", + "OPENROUTER_API_KEY", + ), + "skip real CLI when binary is absent; configured providers must pass", + ), + FeatureCoverage( + "drop_in_codex_replacement", + "OpenAI Responses API and Codex CLI adapter route through the proxy", + "readme", + ( + "tests/api/test_openai_responses.py", + "tests/cli/test_entrypoints.py", + "tests/cli/test_codex_model_catalog.py", + "tests/core/openai_responses/test_sse.py", + ), + ("test_probe_and_models_routes",), + ("test_provider_codex_responses_text_e2e",), + ("api", "providers"), + ("configured provider credentials or local provider endpoint",), + "missing providers are missing_env unless FCC_ALLOW_NO_PROVIDER_SMOKE=1", + ), + FeatureCoverage( + "pi_cli_integration", + "Pi discovers FCC models and sends Anthropic Messages through the proxy", + "readme", + ("tests/cli/test_entrypoints.py",), + ("test_probe_and_models_routes",), + ("test_pi_cli_prompt_e2e",), + ("clients",), + ("Pi CLI", "configured provider credentials or local provider endpoint"), + "skip only when Pi is absent; configured providers must pass", + ), + FeatureCoverage( + "provider_matrix", + "Every configured provider prefix can satisfy conversation scenarios", + "readme", + ("tests/api/test_dependencies.py", "tests/providers/"), + ("test_configured_provider_models_stream_successfully",), + ("test_provider_matrix_presence_e2e", "test_provider_text_multiturn_e2e"), + ("providers",), + ("configured provider credentials/endpoints", "optional FCC_SMOKE_MODEL_*"), + "selected providers missing credentials are failing missing_env", + ), + FeatureCoverage( + "per_model_mapping", + "Opus, Sonnet, Haiku, and fallback mappings route explicitly", + "readme", + ("tests/application/test_routing.py", "tests/config/test_config.py"), + ("test_model_mapping_configuration_is_consistent",), + ("test_model_mapping_matrix_e2e",), + ("providers",), + ("MODEL", "MODEL_OPUS", "MODEL_SONNET", "MODEL_HAIKU"), + "skip only when live smoke is intentionally allowed to run with no provider", + ), + FeatureCoverage( + "mixed_provider_mapping", + "Model-specific overrides can route to different providers", + "public_surface", + ("tests/application/test_routing.py", "tests/config/test_config.py"), + ("test_mixed_provider_model_mapping_when_configured",), + ("test_model_mapping_matrix_e2e",), + ("providers",), + ("MODEL", "MODEL_OPUS", "MODEL_SONNET", "MODEL_HAIKU"), + "configured mixed-provider mappings must resolve consistently", + ), + FeatureCoverage( + "thinking_token_support", + "Thinking history, adaptive thinking, and redacted blocks are accepted", + "readme", + ( + "tests/contracts/test_stream_contracts.py", + "tests/providers/test_open_router.py", + ), + (), + ( + "test_provider_adaptive_thinking_history_e2e", + "test_provider_reasoning_tool_continuation_e2e", + "test_gemini_thought_signature_tool_continuation_e2e", + "test_claude_cli_adaptive_thinking_e2e", + "test_per_model_thinking_config_e2e", + ), + ("providers", "cli", "config"), + ("configured provider",), + "configured providers must not reject adaptive thinking payloads", + ), + FeatureCoverage( + "heuristic_tool_parser", + "Tool use and tool result continuation survive provider/client paths", + "readme", + ("tests/providers/test_parsers.py", "tests/contracts/test_stream_contracts.py"), + ("test_live_tool_use_when_configured_model_supports_tools",), + ( + "test_provider_interleaved_thinking_tool_e2e", + "test_provider_tool_result_continuation_e2e", + "test_gemini_thought_signature_tool_continuation_e2e", + "test_provider_reasoning_tool_continuation_e2e", + ), + ("tools", "providers"), + ("configured tool-capable provider",), + "tool-capable configured providers must emit or continue tool results", + ), + FeatureCoverage( + "request_optimization", + "Local request optimizations return product responses without providers", + "public_surface", + ( + "tests/api/test_optimization_handlers.py", + "tests/api/test_routes_optimizations.py", + ), + ("test_optimization_fast_paths_do_not_need_provider",), + ("test_api_request_optimizations_e2e",), + ("api",), + (), + "always runnable once the local smoke server starts", + ), + FeatureCoverage( + "smart_rate_limiting", + "Transient retries and disconnect cleanup preserve follow-up requests", + "public_surface", + ( + "tests/providers/test_provider_rate_limit.py", + "tests/providers/test_nvidia_nim_degraded_retry.py", + ), + ("test_client_disconnect_mid_stream_does_not_crash_server",), + ("test_provider_disconnect_e2e",), + ("rate_limit", "providers"), + ("configured provider",), + "upstream disconnects are skips only when classified upstream_unavailable", + ), + FeatureCoverage( + "provider_hot_swap", + "Provider config changes preserve active streams while new requests switch", + "public_surface", + ( + "tests/runtime/test_provider_manager.py", + "tests/api/test_response_streams.py", + "tests/api/test_admin.py", + ), + (), + ("test_provider_hot_swap_preserves_inflight_stream_e2e",), + ("api",), + (), + "credential-free local fake upstreams make the scenario always runnable", + ), + FeatureCoverage( + "discord_telegram_bot", + "Discord and Telegram product flows render progress and transcripts", + "readme", + ( + "tests/messaging/test_discord_platform.py", + "tests/messaging/test_telegram.py", + "tests/messaging/test_limiter.py", + "tests/messaging/test_platform_outbox.py", + "tests/runtime/test_application_runtime.py", + ), + ( + "test_telegram_bot_api_permissions", + "test_discord_bot_api_permissions", + ), + ( + "test_messaging_fake_full_flow_e2e", + "test_telegram_live_permissions_e2e", + "test_discord_live_permissions_e2e", + ), + ("messaging", "telegram", "discord"), + ("bot tokens/channels only for side-effectful live platform tests",), + "fake platform is required; real platforms skip without explicit env", + ), + FeatureCoverage( + "subagent_control", + "Task-like tool output is rendered and controlled as foreground work", + "public_surface", + ("tests/providers/test_subagent_interception.py",), + (), + ("test_messaging_subagent_control_e2e",), + ("messaging",), + (), + "fake platform exercises tool transcript behavior without spawning agents", + ), + FeatureCoverage( + "extensible_provider_platform_abcs", + "Provider and platform factories expose built-in extension points", + "public_surface", + ( + "tests/contracts/test_feature_manifest.py", + "tests/providers/test_provider_runtime.py", + ), + (), + ("test_provider_runtime_config_e2e", "test_platform_factory_e2e"), + ("extensibility",), + (), + "always runnable with isolated settings", + ), + FeatureCoverage( + "optional_authentication", + "Anthropic-style auth headers are enforced and accepted", + "readme", + ("tests/api/test_auth.py",), + ("test_auth_token_is_enforced_for_all_supported_header_shapes",), + ("test_api_auth_header_variants_e2e",), + ("auth",), + ("ANTHROPIC_AUTH_TOKEN",), + "product test starts an isolated token-protected server", + ), + FeatureCoverage( + "vscode_extension", + "VS Code protocol-shaped requests work against the proxy", + "readme", + ( + "tests/core/anthropic/test_models.py::test_messages_request_accepts_adaptive_thinking_type", + ), + ("test_vscode_and_jetbrains_shaped_requests",), + ("test_vscode_protocol_e2e",), + ("clients",), + ("configured provider",), + "extension source is external; protocol payload is covered here", + ), + FeatureCoverage( + "intellij_extension", + "JetBrains/ACP protocol-shaped requests work against the proxy", + "readme", + ( + "tests/core/anthropic/test_models.py::test_messages_request_accepts_adaptive_thinking_type", + ), + ("test_vscode_and_jetbrains_shaped_requests",), + ("test_jetbrains_protocol_e2e",), + ("clients",), + ("configured provider",), + "extension source is external; protocol payload is covered here", + ), + FeatureCoverage( + "voice_notes", + "Voice note intake, cancellation, and transcription backends work", + "readme", + ( + "tests/messaging/test_voice_handlers.py", + "tests/messaging/test_transcription.py", + ), + ("test_voice_transcription_backend_when_explicitly_enabled",), + ( + "test_voice_platform_fake_e2e", + "test_voice_local_backend_e2e", + "test_voice_nim_backend_e2e", + ), + ("messaging", "voice"), + ("VOICE_NOTE_ENABLED", "FCC_SMOKE_RUN_VOICE", "WHISPER_DEVICE"), + "fake cancellation is required; backend transcription is opt-in", + ), + FeatureCoverage( + "anthropic_api_routes", + "Messages, count_tokens, errors, and stop use Anthropic-compatible shapes", + "public_surface", + ("tests/api/test_api.py",), + ("test_probe_and_models_routes", "test_stop_endpoint_reports_no_messaging"), + ( + "test_api_basic_conversation_e2e", + "test_api_count_tokens_full_payload_e2e", + "test_api_error_shape_e2e", + "test_api_stop_e2e", + ), + ("api",), + ("configured provider for streaming messages",), + "route pings are prereqs; product route behavior is covered by E2E cases", + ), + FeatureCoverage( + "probe_routes", + "HEAD and OPTIONS compatibility probes are accepted", + "public_surface", + ("tests/api/test_api.py::test_probe_endpoints_return_204_with_allow_headers",), + ("test_probe_and_models_routes",), + (), + ("api",), + (), + "always runnable once the local smoke server starts", + "probe routes are compatibility prerequisites, not product behavior", + ), + FeatureCoverage( + "count_tokens_contract", + "Token counting accepts full Claude content payloads", + "public_surface", + ("tests/api/test_request_utils.py",), + ("test_count_tokens_accepts_thinking_tools_and_results",), + ("test_api_count_tokens_full_payload_e2e",), + ("api",), + (), + "always runnable once the local smoke server starts", + ), + FeatureCoverage( + "provider_proxy_timeout_config", + "Provider proxies and HTTP timeout settings reach provider config", + "public_surface", + ("tests/api/test_dependencies.py", "tests/providers/test_provider_runtime.py"), + (), + ("test_proxy_timeout_config_e2e",), + ("config",), + (), + "always runnable with isolated env files", + ), + FeatureCoverage( + "lmstudio_endpoint", + "LM Studio Messages and local no-key operation work when running", + "public_surface", + ("tests/providers/test_lmstudio.py",), + ("test_lmstudio_models_endpoint_when_available",), + ("test_lmstudio_messages_e2e",), + ("lmstudio",), + ("LM_STUDIO_BASE_URL with running LM Studio server",), + "skip when local upstream is unavailable", + ), + FeatureCoverage( + "llamacpp_endpoint", + "llama.cpp OpenAI Chat and local no-key operation work when running", + "public_surface", + ("tests/providers/test_llamacpp.py",), + ("test_llamacpp_models_endpoint_when_available",), + ("test_llamacpp_openai_chat_e2e",), + ("llamacpp",), + ("LLAMACPP_BASE_URL with running llama-server",), + "skip when local upstream is unavailable", + ), + FeatureCoverage( + "ollama_endpoint", + "Ollama OpenAI Chat and local no-key operation work when running", + "public_surface", + ("tests/providers/test_ollama.py",), + ("test_ollama_models_endpoint_when_available",), + ("test_ollama_openai_chat_e2e",), + ("ollama",), + ("OLLAMA_BASE_URL with running Ollama server",), + "skip when local upstream is unavailable", + ), + FeatureCoverage( + "package_cli_entrypoints", + "Installed package scripts scaffold config, report version, and start the server", + "public_surface", + ("tests/cli/test_entrypoints.py", "tests/core/test_version.py"), + ( + "test_fcc_init_scaffolds_user_config", + "test_free_claude_code_entrypoint_starts_server", + ), + ( + "test_entrypoint_init_e2e", + "test_entrypoint_server_e2e", + "test_entrypoint_version_e2e", + ), + ("cli",), + (), + "always runnable once uv project dependencies are available", + ), + FeatureCoverage( + "claude_cli_drop_in", + "Claude CLI can send adaptive thinking and tool-shaped history", + "public_surface", + ("tests/cli/test_cli.py",), + ("test_claude_cli_prompt_when_available",), + ( + "test_claude_cli_adaptive_thinking_e2e", + "test_claude_cli_multiturn_tool_protocol_e2e", + "test_nvidia_nim_cli_matrix_e2e", + "test_openrouter_free_cli_matrix_e2e", + ), + ("cli", "nvidia_nim_cli", "openrouter_free_cli"), + ( + "FCC_SMOKE_CLAUDE_BIN", + "configured provider", + "NVIDIA_NIM_API_KEY", + "OPENROUTER_API_KEY", + ), + "skip only when Claude CLI binary is absent", + ), + FeatureCoverage( + "messaging_commands", + "Messaging commands clear exact reply subtrees or whole managed chats", + "public_surface", + ( + "tests/messaging/test_handler.py", + "tests/messaging/test_handler_integration.py", + "tests/messaging/test_tree_ownership_concurrency.py", + ), + (), + ( + "test_messaging_commands_stop_clear_stats_e2e", + "test_reply_clear_uses_literal_platform_subtree_e2e", + "test_messaging_startup_notice_is_clearable_e2e", + "test_messaging_active_stop_uses_status_only_e2e", + "test_messaging_queued_scoped_cancel_e2e", + ), + ("messaging",), + (), + "required fake-platform product flow", + ), + FeatureCoverage( + "tree_threading", + "Reply-based branches fork sessions and stay scoped", + "public_surface", + ( + "tests/messaging/test_tree_queue.py", + "tests/messaging/test_tree_concurrency.py", + "tests/messaging/test_message_tree_transitions.py", + "tests/messaging/test_tree_ownership_concurrency.py", + ), + (), + ( + "test_tree_threading_e2e", + "test_messaging_queued_scoped_cancel_e2e", + "test_same_message_ids_are_isolated_by_chat_e2e", + ), + ("messaging",), + (), + "required fake-platform product flow", + ), + FeatureCoverage( + "restart_restore", + "Persisted tree state restores reply routing after restart", + "public_surface", + ("tests/messaging/test_restart_reply_restore.py",), + (), + ("test_restart_restore_and_session_persistence_e2e",), + ("messaging",), + (), + "required fake-platform persistence flow", + ), + FeatureCoverage( + "session_persistence", + "Session JSON preserves scoped trees and message logs", + "public_surface", + ("tests/messaging/test_session_store_edge_cases.py",), + (), + ("test_restart_restore_and_session_persistence_e2e",), + ("messaging",), + (), + "required fake-platform persistence flow", + ), + FeatureCoverage( + "config_env_precedence", + "FCC_ENV_FILE, dotenv, and process env precedence are deterministic", + "public_surface", + ("tests/config/test_config.py",), + (), + ("test_env_precedence_e2e",), + ("config",), + (), + "always runnable with isolated env files", + ), + FeatureCoverage( + "removed_env_migration", + "Removed thinking env vars are ignored without changing defaults", + "public_surface", + ("tests/config/test_config.py",), + (), + ("test_removed_env_migration_e2e",), + ("config",), + (), + "always runnable with isolated env files", + ), + FeatureCoverage( + "streaming_error_mapping", + "Canonical execution failures map to protocol-correct terminal output", + "public_surface", + ( + "tests/api/test_execution_failure_contract.py", + "tests/core/test_failure_protocol_mapping.py", + "tests/providers/test_execution_failure_boundary.py", + "tests/providers/test_failure_policy.py", + ), + (), + ( + "test_api_error_shape_e2e", + "test_provider_error_e2e", + "test_claude_cli_provider_error_e2e", + ), + ("api", "providers", "cli"), + ("configured provider for provider error scenario",), + "invalid request path is required; provider error path requires provider", + ), +) + + +def feature_ids(*, source: FeatureSource | None = None) -> set[str]: + """Return feature IDs covered by the inventory.""" + return { + feature.feature_id + for feature in FEATURE_INVENTORY + if source is None or feature.source == source + } diff --git a/smoke/lib/__init__.py b/smoke/lib/__init__.py new file mode 100644 index 0000000..5f127b6 --- /dev/null +++ b/smoke/lib/__init__.py @@ -0,0 +1 @@ +"""Shared helpers for local-only smoke tests.""" diff --git a/smoke/lib/child_process.py b/smoke/lib/child_process.py new file mode 100644 index 0000000..79e5dc3 --- /dev/null +++ b/smoke/lib/child_process.py @@ -0,0 +1,70 @@ +"""Child-process commands for smoke (avoid nested ``uv run`` on Windows). + +Nested ``uv run`` can try to refresh console scripts while they are locked +(``free-claude-code.exe`` in use), causing flaky smoke. The smoke runner is +already executed under the project environment (``uv run pytest``), so children +should use the same interpreter. +""" + +import subprocess +import sys +from collections.abc import Mapping, Sequence +from pathlib import Path + + +def python_exe() -> str: + return sys.executable + + +def cmd_python_c(script: str) -> list[str]: + return [python_exe(), "-c", script] + + +def cmd_fcc_init() -> list[str]: + return [ + python_exe(), + "-c", + "from free_claude_code.cli.entrypoints import init; init()", + ] + + +def cmd_fcc_version() -> list[str]: + return [ + python_exe(), + "-c", + ( + "import sys; " + "sys.argv = ['fcc-server', '--version']; " + "from free_claude_code.cli.entrypoints import serve; serve()" + ), + ] + + +def cmd_free_claude_code_serve() -> list[str]: + return [ + python_exe(), + "-c", + "from free_claude_code.cli.entrypoints import serve; serve()", + ] + + +def run_captured_text( + command: Sequence[str], + *, + cwd: str | Path | None = None, + env: Mapping[str, str] | None = None, + timeout: float | None = None, + check: bool = False, +) -> subprocess.CompletedProcess[str]: + """Run a smoke child process with deterministic captured text decoding.""" + return subprocess.run( + list(command), + cwd=cwd, + env=env, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + check=check, + ) diff --git a/smoke/lib/claude_cli_matrix.py b/smoke/lib/claude_cli_matrix.py new file mode 100644 index 0000000..3767372 --- /dev/null +++ b/smoke/lib/claude_cli_matrix.py @@ -0,0 +1,786 @@ +"""Claude Code CLI characterization helpers for provider smoke matrices.""" + +import json +import os +import re +import subprocess +import time +import uuid +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +from free_claude_code.cli.claude_env import build_claude_proxy_env +from smoke.lib.child_process import run_captured_text +from smoke.lib.config import ProviderModel, SmokeConfig, redacted +from smoke.lib.server import RunningServer + +REGRESSION_CLASSIFICATIONS = frozenset({"harness_bug", "product_failure"}) + +_HTTP_REGRESSION_PATTERNS = ( + r'POST /v1/messages[^"\n]* HTTP/1\.1" 4(?!01|03|04|08|09)\d\d', + r'POST /v1/messages[^"\n]* HTTP/1\.1" 5\d\d', +) +_UPSTREAM_UNAVAILABLE_MARKERS = ( + "upstream_unavailable", + "readtimeout", + "connecterror", + "connection refused", + "timed out", + "rate limit", + "overloaded", + "capacity", + "upstream provider", + "provider api request failed", + "httpstatuserror", +) +_HTTP_429_PATTERNS = ( + r'HTTP/1\.[01]" 429\b', + r"\bHTTP/1\.[01] 429\b", + r"\bstatus_code=429\b", + r"\bstatus[=:]\s*429\b", + r"\b429 Too Many Requests\b", +) +_MISSING_ENV_MARKERS = ( + "api key", + "not logged in", + "authentication", + "permission denied", +) +_EMPTY_MCP_CONFIG = '{"mcpServers":{}}' +_SUBAGENT_SYSTEM_PROMPT = ( + "You are a deterministic smoke-test coordinator. Use Agent when asked to " + "use a subagent." +) + + +@dataclass(frozen=True, slots=True) +class ClaudeCliRun: + command: tuple[str, ...] + returncode: int | None + stdout: str + stderr: str + duration_s: float + timed_out: bool = False + + @property + def combined_output(self) -> str: + return f"{self.stdout}\n{self.stderr}" + + +@dataclass(frozen=True, slots=True) +class CliMatrixOutcome: + model: str + full_model: str + source: str + feature: str + outcome: str + classification: str + duration_s: float + cli_returncode: int | None + token_evidence: dict[str, Any] + request_count: int + log_path: str + stdout_excerpt: str + stderr_excerpt: str + log_excerpt: str + + +def run_claude_cli( + *, + claude_bin: str, + server: RunningServer, + config: SmokeConfig, + cwd: Path, + prompt: str, + tools: str | None, + bare: bool = True, + pre_tool_args: tuple[str, ...] = (), + extra_args: tuple[str, ...] = (), + session_id: str | None = None, + resume_session_id: str | None = None, + no_session_persistence: bool = True, +) -> ClaudeCliRun: + """Run Claude Code CLI against the local smoke proxy.""" + cwd.mkdir(parents=True, exist_ok=True) + + cmd = list( + _build_claude_cli_command( + claude_bin=claude_bin, + prompt=prompt, + tools=tools, + bare=bare, + pre_tool_args=pre_tool_args, + extra_args=extra_args, + session_id=session_id, + resume_session_id=resume_session_id, + no_session_persistence=no_session_persistence, + ) + ) + + env = build_claude_proxy_env( + proxy_root_url=server.base_url, + auth_token=config.settings.anthropic_auth_token, + base_env=os.environ, + ) + env["TERM"] = "dumb" + env["NO_COLOR"] = "1" + env["PYTHONIOENCODING"] = "utf-8" + + started = time.monotonic() + try: + result = run_captured_text( + cmd, + cwd=cwd, + env=env, + timeout=config.timeout_s, + check=False, + ) + except subprocess.TimeoutExpired as exc: + return ClaudeCliRun( + command=tuple(cmd), + returncode=None, + stdout=_coerce_timeout_text(exc.stdout), + stderr=_coerce_timeout_text(exc.stderr), + duration_s=time.monotonic() - started, + timed_out=True, + ) + + return ClaudeCliRun( + command=tuple(cmd), + returncode=result.returncode, + stdout=_coerce_timeout_text(result.stdout), + stderr=_coerce_timeout_text(result.stderr), + duration_s=time.monotonic() - started, + ) + + +def _build_claude_cli_command( + *, + claude_bin: str, + prompt: str, + tools: str | None, + bare: bool = True, + pre_tool_args: tuple[str, ...] = (), + extra_args: tuple[str, ...] = (), + session_id: str | None = None, + resume_session_id: str | None = None, + no_session_persistence: bool = True, +) -> tuple[str, ...]: + cmd: list[str] = [claude_bin] + if bare: + cmd.append("--bare") + if resume_session_id: + cmd.extend(["--resume", resume_session_id]) + if session_id: + cmd.extend(["--session-id", session_id]) + cmd.extend( + [ + "--output-format", + "stream-json", + "--include-partial-messages", + "--verbose", + "--permission-mode", + "bypassPermissions", + "--dangerously-skip-permissions", + "--model", + "sonnet", + ] + ) + if no_session_persistence: + cmd.append("--no-session-persistence") + cmd.extend(pre_tool_args) + if tools is not None: + cmd.extend(["--tools", tools]) + if tools: + cmd.extend(["--allowedTools", tools]) + cmd.extend(extra_args) + cmd.extend(["-p", prompt]) + return tuple(cmd) + + +def run_cli_feature_probes( + *, + claude_bin: str, + server: RunningServer, + smoke_config: SmokeConfig, + provider_model: ProviderModel, + model_dir: Path, + marker_prefix: str, +) -> list[CliMatrixOutcome]: + return [ + _basic_text( + claude_bin, server, smoke_config, provider_model, model_dir, marker_prefix + ), + _thinking( + claude_bin, server, smoke_config, provider_model, model_dir, marker_prefix + ), + _tool_use_roundtrip( + claude_bin, server, smoke_config, provider_model, model_dir, marker_prefix + ), + _interleaved_thinking_tool( + claude_bin, server, smoke_config, provider_model, model_dir, marker_prefix + ), + _subagent_task( + claude_bin, server, smoke_config, provider_model, model_dir, marker_prefix + ), + _compact_command( + claude_bin, server, smoke_config, provider_model, model_dir, marker_prefix + ), + ] + + +def read_log_offset(log_path: Path) -> int: + """Return the current text length of a smoke server log.""" + if not log_path.is_file(): + return 0 + return len(log_path.read_text(encoding="utf-8", errors="replace")) + + +def read_log_delta(log_path: Path, offset: int) -> str: + """Return smoke server log text written after ``offset``.""" + if not log_path.is_file(): + return "" + text = log_path.read_text(encoding="utf-8", errors="replace") + return text[offset:] + + +def token_evidence( + *, + feature: str, + marker: str, + run: ClaudeCliRun, + log_delta: str, +) -> dict[str, Any]: + """Collect compact evidence for a CLI feature probe.""" + combined = f"{run.combined_output}\n{log_delta}" + lower = combined.lower() + return { + "feature": feature, + "marker_present": bool(marker and marker in combined), + "thinking_delta_count": combined.count("thinking_delta"), + "tool_use_count": combined.count('"tool_use"'), + "tool_result_count": combined.count('"tool_result"'), + "agent_catalog_present": _tool_catalog_has(log_delta, "Agent"), + "agent_tool_count": _agent_tool_count(combined), + "agent_result_count": _agent_result_count(combined), + "task_tool_count": combined.count('"name": "Task"') + + combined.count('"name":"Task"'), + "run_in_background_false": "run_in_background" in combined and "false" in lower, + "compact_boundary": "compact_boundary" in combined, + "compact_metadata": "compact_metadata" in combined, + "http_422": 'HTTP/1.1" 422' in combined, + "http_500": bool(re.search(r'HTTP/1\.1" 5\d\d', combined)), + "timed_out": run.timed_out, + } + + +def classify_probe( + *, + run: ClaudeCliRun, + log_delta: str, + marker: str, + requires_tool_result: bool = False, + requires_agent: bool = False, + requires_task: bool = False, + requires_compact: bool = False, +) -> tuple[str, str]: + """Classify a probe without failing compatibility characterization failures.""" + combined = f"{run.combined_output}\n{log_delta}" + lower = combined.lower() + + if _has_proxy_regression(log_delta): + return "failed", "product_failure" + if run.returncode != 0 and any( + marker_text in lower for marker_text in _MISSING_ENV_MARKERS + ): + return "skipped", "missing_env" + if run.timed_out: + return "failed", "probe_timeout" + if requires_agent and not _tool_catalog_has(log_delta, "Agent"): + return "failed", "harness_bug" + + marker_ok = not marker or marker in combined + tool_ok = not requires_tool_result or '"tool_result"' in combined + agent_ok = not requires_agent or ( + _agent_tool_count(combined) > 0 and _agent_result_count(combined) > 0 + ) + task_ok = not requires_task or ( + ('"name": "Task"' in combined or '"name":"Task"' in combined) + and "run_in_background" in combined + and "false" in lower + ) + compact_ok = not requires_compact or ( + "compact_boundary" in combined + or "compact_metadata" in combined + or "/compact" in combined + or "compact" in lower + ) + cli_ok = run.returncode == 0 + + if cli_ok and marker_ok and tool_ok and agent_ok and task_ok and compact_ok: + return "passed", "passed" + if _has_upstream_unavailable_text(combined): + return "failed", "upstream_unavailable" + if not _has_proxy_request(log_delta): + return "failed", "harness_bug" + return "failed", "model_feature_failure" + + +def make_outcome( + *, + model: str, + full_model: str, + source: str, + feature: str, + marker: str, + run: ClaudeCliRun, + log_delta: str, + log_path: Path, + requires_tool_result: bool = False, + requires_agent: bool = False, + requires_task: bool = False, + requires_compact: bool = False, +) -> CliMatrixOutcome: + """Build one report outcome from a CLI run and its server log delta.""" + outcome, classification = classify_probe( + run=run, + log_delta=log_delta, + marker=marker, + requires_tool_result=requires_tool_result, + requires_agent=requires_agent, + requires_task=requires_task, + requires_compact=requires_compact, + ) + evidence = token_evidence( + feature=feature, + marker=marker, + run=run, + log_delta=log_delta, + ) + return CliMatrixOutcome( + model=model, + full_model=full_model, + source=source, + feature=feature, + outcome=outcome, + classification=classification, + duration_s=round(run.duration_s, 3), + cli_returncode=run.returncode, + token_evidence=evidence, + request_count=_request_count(log_delta), + log_path=str(log_path), + stdout_excerpt=_excerpt(run.stdout), + stderr_excerpt=_excerpt(run.stderr), + log_excerpt=_excerpt(log_delta), + ) + + +def write_matrix_report( + config: SmokeConfig, + outcomes: list[CliMatrixOutcome], + *, + target: str, + filename_prefix: str, +) -> Path: + """Write a Claude CLI compatibility matrix report.""" + config.results_dir.mkdir(parents=True, exist_ok=True) + path = ( + config.results_dir + / f"{filename_prefix}-matrix-{config.worker_id}-{int(time.time())}.json" + ) + payload = { + "started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "worker_id": config.worker_id, + "target": target, + "models": sorted({outcome.full_model for outcome in outcomes}), + "outcomes": [asdict(outcome) for outcome in outcomes], + } + path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + return path + + +def regression_failures(outcomes: list[CliMatrixOutcome]) -> list[str]: + """Return report lines for classifications that should fail pytest.""" + return [ + f"{outcome.full_model} {outcome.feature}: {outcome.classification}" + for outcome in outcomes + if outcome.classification in REGRESSION_CLASSIFICATIONS + ] + + +def _basic_text( + claude_bin: str, + server: RunningServer, + smoke_config: SmokeConfig, + provider_model: ProviderModel, + model_dir: Path, + marker_prefix: str, +) -> CliMatrixOutcome: + marker = _marker(marker_prefix, "BASIC") + return _run_probe( + claude_bin=claude_bin, + server=server, + smoke_config=smoke_config, + provider_model=provider_model, + workspace=model_dir / "basic_text", + feature="basic_text", + marker=marker, + prompt=f"Reply with exactly {marker} and no other text.", + tools="", + ) + + +def _thinking( + claude_bin: str, + server: RunningServer, + smoke_config: SmokeConfig, + provider_model: ProviderModel, + model_dir: Path, + marker_prefix: str, +) -> CliMatrixOutcome: + marker = _marker(marker_prefix, "THINK") + return _run_probe( + claude_bin=claude_bin, + server=server, + smoke_config=smoke_config, + provider_model=provider_model, + workspace=model_dir / "thinking", + feature="thinking", + marker=marker, + prompt=( + "Think privately about the request, then reply with exactly " + f"{marker} and no other text." + ), + tools="", + extra_args=("--effort", "high"), + ) + + +def _tool_use_roundtrip( + claude_bin: str, + server: RunningServer, + smoke_config: SmokeConfig, + provider_model: ProviderModel, + model_dir: Path, + marker_prefix: str, +) -> CliMatrixOutcome: + marker = _marker(marker_prefix, "TOOL") + workspace = model_dir / "tool_use_roundtrip" + (workspace / "smoke-read.txt").parent.mkdir(parents=True, exist_ok=True) + (workspace / "smoke-read.txt").write_text(marker, encoding="utf-8") + return _run_probe( + claude_bin=claude_bin, + server=server, + smoke_config=smoke_config, + provider_model=provider_model, + workspace=workspace, + feature="tool_use_roundtrip", + marker=marker, + prompt=( + "Use the Read tool to read smoke-read.txt. Reply with exactly the " + "secret token from that file and no other text." + ), + tools="Read", + requires_tool_result=True, + ) + + +def _interleaved_thinking_tool( + claude_bin: str, + server: RunningServer, + smoke_config: SmokeConfig, + provider_model: ProviderModel, + model_dir: Path, + marker_prefix: str, +) -> CliMatrixOutcome: + marker = _marker(marker_prefix, "INTERLEAVED") + workspace = model_dir / "interleaved_thinking_tool" + (workspace / "smoke-interleaved.txt").parent.mkdir(parents=True, exist_ok=True) + (workspace / "smoke-interleaved.txt").write_text(marker, encoding="utf-8") + return _run_probe( + claude_bin=claude_bin, + server=server, + smoke_config=smoke_config, + provider_model=provider_model, + workspace=workspace, + feature="interleaved_thinking_tool", + marker=marker, + prompt=( + "Think privately, use Read on smoke-interleaved.txt, then reply with " + "exactly the secret token from that file and no other text." + ), + tools="Read", + extra_args=("--effort", "high"), + requires_tool_result=True, + ) + + +def _subagent_task( + claude_bin: str, + server: RunningServer, + smoke_config: SmokeConfig, + provider_model: ProviderModel, + model_dir: Path, + marker_prefix: str, +) -> CliMatrixOutcome: + marker = _marker(marker_prefix, "TASK") + workspace = model_dir / "subagent_task" + (workspace / "smoke-subagent.txt").parent.mkdir(parents=True, exist_ok=True) + (workspace / "smoke-subagent.txt").write_text(marker, encoding="utf-8") + agents = json.dumps( + { + "smoke_reader": { + "description": "Reads one requested file and returns its token.", + "prompt": ( + "Read the requested file with Read and return only the token " + "inside it." + ), + "tools": ["Read"], + "permissionMode": "bypassPermissions", + "background": False, + } + } + ) + bare, tools, pre_tool_args, extra_args = _subagent_probe_options(agents) + return _run_probe( + claude_bin=claude_bin, + server=server, + smoke_config=smoke_config, + provider_model=provider_model, + workspace=workspace, + feature="subagent_task", + marker=marker, + prompt=( + "Use the smoke_reader subagent to read smoke-subagent.txt. After the " + "first agent result, reply with exactly the token and stop. Do not " + "call any other tools." + ), + tools=tools, + bare=bare, + pre_tool_args=pre_tool_args, + extra_args=extra_args, + requires_tool_result=True, + requires_agent=True, + ) + + +def _subagent_probe_options( + agents: str, +) -> tuple[bool, str, tuple[str, ...], tuple[str, ...]]: + return ( + False, + "Agent,Read", + ( + "--setting-sources", + "local", + "--strict-mcp-config", + "--mcp-config", + _EMPTY_MCP_CONFIG, + "--system-prompt", + _SUBAGENT_SYSTEM_PROMPT, + ), + ("--agents", agents), + ) + + +def _compact_command( + claude_bin: str, + server: RunningServer, + smoke_config: SmokeConfig, + provider_model: ProviderModel, + model_dir: Path, + marker_prefix: str, +) -> CliMatrixOutcome: + marker = _marker(marker_prefix, "COMPACT") + workspace = model_dir / "compact_command" + session_id = str(uuid.uuid4()) + offset = read_log_offset(server.log_path) + first = run_claude_cli( + claude_bin=claude_bin, + server=server, + config=smoke_config, + cwd=workspace, + prompt=f"Remember this smoke token: {marker}. Reply with exactly {marker}.", + tools="", + session_id=session_id, + no_session_persistence=False, + ) + second = run_claude_cli( + claude_bin=claude_bin, + server=server, + config=smoke_config, + cwd=workspace, + prompt=f"/compact preserve {marker}", + tools="", + resume_session_id=session_id, + no_session_persistence=False, + ) + log_delta = read_log_delta(server.log_path, offset) + run = ClaudeCliRun( + command=(*first.command, "&&", *second.command), + returncode=second.returncode if first.returncode == 0 else first.returncode, + stdout=f"{first.stdout}\n{second.stdout}", + stderr=f"{first.stderr}\n{second.stderr}", + duration_s=first.duration_s + second.duration_s, + timed_out=first.timed_out or second.timed_out, + ) + return make_outcome( + model=provider_model.model_name, + full_model=provider_model.full_model, + source=provider_model.source, + feature="compact_command", + marker="", + run=run, + log_delta=log_delta, + log_path=server.log_path, + requires_compact=True, + ) + + +def _run_probe( + *, + claude_bin: str, + server: RunningServer, + smoke_config: SmokeConfig, + provider_model: ProviderModel, + workspace: Path, + feature: str, + marker: str, + prompt: str, + tools: str | None, + bare: bool = True, + pre_tool_args: tuple[str, ...] = (), + extra_args: tuple[str, ...] = (), + requires_tool_result: bool = False, + requires_agent: bool = False, + requires_task: bool = False, +) -> CliMatrixOutcome: + offset = read_log_offset(server.log_path) + run = run_claude_cli( + claude_bin=claude_bin, + server=server, + config=smoke_config, + cwd=workspace, + prompt=prompt, + tools=tools, + bare=bare, + pre_tool_args=pre_tool_args, + extra_args=extra_args, + ) + log_delta = read_log_delta(server.log_path, offset) + return make_outcome( + model=provider_model.model_name, + full_model=provider_model.full_model, + source=provider_model.source, + feature=feature, + marker=marker, + run=run, + log_delta=log_delta, + log_path=server.log_path, + requires_tool_result=requires_tool_result, + requires_agent=requires_agent, + requires_task=requires_task, + ) + + +def _has_proxy_regression(log_delta: str) -> bool: + if "CREATE_MESSAGE_ERROR" in log_delta: + return True + return any(re.search(pattern, log_delta) for pattern in _HTTP_REGRESSION_PATTERNS) + + +def _has_proxy_request(log_delta: str) -> bool: + return ( + "POST /v1/messages" in log_delta + or "API_REQUEST:" in log_delta + or '"event": "free_claude_code.api.request.received"' in log_delta + or ( + '"http_method": "POST"' in log_delta + and '"http_path": "/v1/messages"' in log_delta + ) + ) + + +def _tool_catalog_has(log_delta: str, tool_name: str) -> bool: + catalog = _first_tool_catalog(log_delta) + return ( + f"'name': '{tool_name}'" in catalog + or f'"name": "{tool_name}"' in catalog + or f'"name":"{tool_name}"' in catalog + ) + + +def _first_tool_catalog(log_delta: str) -> str: + for line in log_delta.splitlines(): + if "FULL_PAYLOAD" not in line: + continue + single_index = line.find("'tools': [") + double_index = line.find('"tools": [') + if single_index == -1 and double_index == -1: + continue + start = single_index if single_index != -1 else double_index + end_candidates = [ + index + for marker in ("'tool_choice'", '"tool_choice"', "'thinking'", '"thinking"') + if (index := line.find(marker, start)) != -1 + ] + end = min(end_candidates) if end_candidates else len(line) + return line[start:end] + return "" + + +def _agent_tool_count(text: str) -> int: + return ( + text.count('"name": "Agent"') + + text.count('"name":"Agent"') + + len( + re.findall( + r"'type': 'tool_use'[^}\n]+?'name': 'Agent'", + text, + flags=re.DOTALL, + ) + ) + ) + + +def _agent_result_count(text: str) -> int: + return text.count("agentId:") + text.count('"agentId"') + text.count("'agentId'") + + +def _has_upstream_unavailable_text(text: str) -> bool: + lower = text.lower() + if any(marker_text in lower for marker_text in _UPSTREAM_UNAVAILABLE_MARKERS): + return True + return any( + re.search(pattern, text, flags=re.IGNORECASE) for pattern in _HTTP_429_PATTERNS + ) + + +def _request_count(log_delta: str) -> int: + access_log_count = log_delta.count("POST /v1/messages") + service_log_count = log_delta.count("API_REQUEST:") + structured_log_count = log_delta.count( + '"event": "free_claude_code.api.request.received"' + ) + return max(access_log_count, service_log_count, structured_log_count) + + +def _marker(scope: str, prefix: str) -> str: + return f"FCC_{scope}_{prefix}_{uuid.uuid4().hex[:8].upper()}" + + +def _excerpt(value: str | None, *, max_chars: int = 2400) -> str: + if value is None: + value = "" + if len(value) <= max_chars: + return redacted(value) + return redacted(value[-max_chars:]) + + +def _coerce_timeout_text(value: str | bytes | None) -> str: + if value is None: + return "" + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return value diff --git a/smoke/lib/config.py b/smoke/lib/config.py new file mode 100644 index 0000000..4165340 --- /dev/null +++ b/smoke/lib/config.py @@ -0,0 +1,445 @@ +"""Smoke-suite configuration loaded from the real developer environment.""" + +import os +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path + +from free_claude_code.config.model_refs import parse_model_name, parse_provider_type +from free_claude_code.config.provider_catalog import ( + PROVIDER_CATALOG, + SUPPORTED_PROVIDER_IDS, +) +from free_claude_code.config.settings import Settings, get_settings + +DEFAULT_TARGETS = frozenset( + { + "api", + "auth", + "cli", + "clients", + "config", + "extensibility", + "llamacpp", + "lmstudio", + "messaging", + "ollama", + "providers", + "rate_limit", + "tools", + } +) +SIDE_EFFECT_TARGETS = frozenset({"discord", "telegram", "voice"}) +OPT_IN_TARGETS = frozenset({"nvidia_nim_cli", "openrouter_free_cli"}) +ALL_TARGETS = DEFAULT_TARGETS | SIDE_EFFECT_TARGETS | OPT_IN_TARGETS +TARGET_ALIASES = { + "contract": "api", + "nim_cli": "nvidia_nim_cli", + "openrouter_cli": "openrouter_free_cli", + "openrouter_free": "openrouter_free_cli", + "optimizations": "api", + "thinking": "providers", + "vscode": "clients", +} +SECRET_KEY_PARTS = ("KEY", "TOKEN", "SECRET", "WEBHOOK", "AUTH") + +PROVIDER_SMOKE_DEFAULT_MODELS: dict[str, str] = { + "nvidia_nim": "nvidia_nim/nvidia/nemotron-3-super-120b-a12b", + "open_router": "open_router/moonshotai/kimi-k2.6:free", + "mistral": "mistral/devstral-small-latest", + "mistral_codestral": "mistral_codestral/codestral-latest", + "deepseek": "deepseek/deepseek-v4-pro", + "lmstudio": "lmstudio/local-model", + "llamacpp": "llamacpp/local-model", + "ollama": "ollama/llama3.1", + "wafer": "wafer/DeepSeek-V4-Pro", + "minimax": "minimax/MiniMax-M3", + "opencode": "opencode/gpt-5.3-codex", + "opencode_go": "opencode_go/minimax-m2.7", + "vercel": "vercel/openai/gpt-5.5", + "huggingface": "huggingface/openai/gpt-oss-120b:fastest", + "cohere": "cohere/command-a-plus-05-2026", + "github_models": "github_models/openai/gpt-4.1", + "zai": "zai/glm-5.2", + "gemini": "gemini/models/gemini-3.1-flash-lite", + "groq": "groq/llama-3.3-70b-versatile", + "sambanova": "sambanova/Meta-Llama-3.3-70B-Instruct", + "cerebras": "cerebras/llama3.1-8b", + "cloudflare": "cloudflare/@cf/moonshotai/kimi-k2.6", +} +MISTRAL_REASONING_SMOKE_DEFAULT_MODEL = "mistral/mistral-medium-3-5" + +NVIDIA_NIM_CLI_DEFAULT_MODELS: tuple[str, ...] = ( + "z-ai/glm-5.2", + "moonshotai/kimi-k2.6", + "minimaxai/minimax-m2.7", + "nvidia/nemotron-3-super-120b-a12b", + "deepseek-ai/deepseek-v4-pro", + "deepseek-ai/deepseek-v4-flash", +) + +OPENROUTER_FREE_CLI_DEFAULT_MODELS: tuple[str, ...] = ( + "nvidia/nemotron-3-super-120b-a12b:free", + "openai/gpt-oss-120b:free", + "poolside/laguna-m.1:free", +) + + +TARGET_REQUIRED_ENV: dict[str, tuple[str, ...]] = { + "api": (), + "auth": (), + "cli": ("FCC_SMOKE_CLAUDE_BIN", "configured provider for Claude CLI prompt"), + "clients": (), + "config": (), + "extensibility": (), + "messaging": (), + "providers": ("configured provider credentials/endpoints or FCC_SMOKE_MODEL_*",), + "rate_limit": ("configured provider model",), + "tools": ("configured tool-capable provider model",), + "lmstudio": ("LM_STUDIO_BASE_URL with a running LM Studio server",), + "llamacpp": ("LLAMACPP_BASE_URL with a running llama-server",), + "ollama": ("OLLAMA_BASE_URL with a running Ollama server",), + "nvidia_nim_cli": ( + "NVIDIA_NIM_API_KEY", + "FCC_SMOKE_CLAUDE_BIN or claude on PATH", + ), + "openrouter_free_cli": ( + "OPENROUTER_API_KEY", + "FCC_SMOKE_CLAUDE_BIN or claude on PATH", + ), + "telegram": ( + "TELEGRAM_BOT_TOKEN", + "ALLOWED_TELEGRAM_USER_ID or FCC_SMOKE_TELEGRAM_CHAT_ID", + ), + "discord": ( + "DISCORD_BOT_TOKEN", + "ALLOWED_DISCORD_CHANNELS or FCC_SMOKE_DISCORD_CHANNEL_ID", + ), + "voice": ("VOICE_NOTE_ENABLED=true", "FCC_SMOKE_RUN_VOICE=1"), +} + + +@dataclass(frozen=True, slots=True) +class ProviderModel: + provider: str + full_model: str + source: str + + @property + def model_name(self) -> str: + return parse_model_name(self.full_model) + + +@dataclass(frozen=True, slots=True) +class SmokeConfig: + root: Path + results_dir: Path + live: bool + interactive: bool + targets: frozenset[str] + provider_matrix: frozenset[str] + timeout_s: float + prompt: str + claude_bin: str + worker_id: str + settings: Settings + + @classmethod + def load(cls) -> SmokeConfig: + root = Path(__file__).resolve().parents[2] + get_settings.cache_clear() + settings = get_settings() + return cls( + root=root, + results_dir=root / ".smoke-results", + live=os.getenv("FCC_LIVE_SMOKE") == "1", + interactive=os.getenv("FCC_SMOKE_INTERACTIVE") == "1", + targets=_parse_targets(os.getenv("FCC_SMOKE_TARGETS")), + provider_matrix=_parse_csv(os.getenv("FCC_SMOKE_PROVIDER_MATRIX")), + timeout_s=float(os.getenv("FCC_SMOKE_TIMEOUT_S", "45")), + prompt=os.getenv("FCC_SMOKE_PROMPT", "Reply with exactly: FCC_SMOKE_PONG"), + claude_bin=os.getenv("FCC_SMOKE_CLAUDE_BIN", "claude"), + worker_id=os.getenv("PYTEST_XDIST_WORKER", "main"), + settings=settings, + ) + + def target_enabled(self, *names: str) -> bool: + return any(name in self.targets for name in names) + + def provider_models(self) -> list[ProviderModel]: + candidates = ( + ("MODEL", self.settings.model), + ("MODEL_OPUS", self.settings.model_opus), + ("MODEL_SONNET", self.settings.model_sonnet), + ("MODEL_HAIKU", self.settings.model_haiku), + ) + seen: set[str] = set() + models: list[ProviderModel] = [] + for source, model in candidates: + if not model or model in seen: + continue + provider = parse_provider_type(model) + if self.provider_matrix and provider not in self.provider_matrix: + continue + if not self.has_provider_configuration(provider): + continue + seen.add(model) + models.append( + ProviderModel(provider=provider, full_model=model, source=source) + ) + return models + + def provider_smoke_models(self) -> list[ProviderModel]: + """Return one smoke model per configured provider, independent of MODEL_*.""" + models: list[ProviderModel] = [] + mapped_providers = {model.provider for model in self.provider_models()} + for provider in SUPPORTED_PROVIDER_IDS: + if self.provider_matrix and provider not in self.provider_matrix: + continue + if not self.has_provider_configuration(provider): + continue + if not self._include_provider_in_smoke(provider, mapped_providers): + continue + full_model, source = _provider_smoke_model(provider) + models.append( + ProviderModel(provider=provider, full_model=full_model, source=source) + ) + return models + + def nvidia_nim_cli_models(self) -> list[ProviderModel]: + """Return the NVIDIA NIM models for Claude Code CLI characterization.""" + return [ + ProviderModel(provider="nvidia_nim", full_model=full_model, source=source) + for full_model, source in nvidia_nim_cli_model_refs().items() + ] + + def openrouter_free_cli_models(self) -> list[ProviderModel]: + """Return OpenRouter free models for Claude Code CLI characterization.""" + return [ + ProviderModel(provider="open_router", full_model=full_model, source=source) + for full_model, source in openrouter_free_cli_model_refs().items() + ] + + def mistral_reasoning_smoke_model(self) -> ProviderModel | None: + """Return a Mistral model expected to accept native reasoning input.""" + if self.provider_matrix and "mistral" not in self.provider_matrix: + return None + if not self.has_provider_configuration("mistral"): + return None + override_env = "FCC_SMOKE_MODEL_MISTRAL_REASONING" + if override := os.getenv(override_env): + full_model = _normalize_provider_model("mistral", override) + source = override_env + else: + full_model = MISTRAL_REASONING_SMOKE_DEFAULT_MODEL + source = "mistral_reasoning_default" + return ProviderModel(provider="mistral", full_model=full_model, source=source) + + def _include_provider_in_smoke( + self, provider: str, mapped_providers: set[str] + ) -> bool: + descriptor = PROVIDER_CATALOG[provider] + if not descriptor.local: + return True + if provider in mapped_providers: + return True + if self.provider_matrix and provider in self.provider_matrix: + return True + return bool(os.getenv(f"FCC_SMOKE_MODEL_{provider.upper()}")) + + def has_provider_configuration(self, provider: str) -> bool: + if provider == "nvidia_nim": + return bool(self.settings.nvidia_nim_api_key.strip()) + if provider == "open_router": + return bool(self.settings.open_router_api_key.strip()) + if provider == "mistral": + return bool(self.settings.mistral_api_key.strip()) + if provider == "mistral_codestral": + return bool(self.settings.codestral_api_key.strip()) + if provider == "deepseek": + return bool(self.settings.deepseek_api_key.strip()) + if provider == "kimi": + return bool(self.settings.kimi_api_key.strip()) + if provider == "lmstudio": + return bool(self.settings.lm_studio_base_url.strip()) + if provider == "llamacpp": + return bool(self.settings.llamacpp_base_url.strip()) + if provider == "ollama": + return bool(self.settings.ollama_base_url.strip()) + if provider == "wafer": + return bool(self.settings.wafer_api_key.strip()) + if provider == "minimax": + return bool(self.settings.minimax_api_key.strip()) + if provider == "fireworks": + return bool(self.settings.fireworks_api_key.strip()) + if provider == "opencode": + return bool(self.settings.opencode_api_key.strip()) + if provider == "opencode_go": + return bool(self.settings.opencode_api_key.strip()) + if provider == "vercel": + return bool(self.settings.vercel_ai_gateway_api_key.strip()) + if provider == "huggingface": + return bool(self.settings.huggingface_api_key.strip()) + if provider == "cohere": + return bool(self.settings.cohere_api_key.strip()) + if provider == "github_models": + return bool(self.settings.github_models_token.strip()) + if provider == "zai": + return bool(self.settings.zai_api_key.strip()) + if provider == "gemini": + return bool(self.settings.gemini_api_key.strip()) + if provider == "groq": + return bool(self.settings.groq_api_key.strip()) + if provider == "sambanova": + return bool(self.settings.sambanova_api_key.strip()) + if provider == "cerebras": + return bool(self.settings.cerebras_api_key.strip()) + if provider == "cloudflare": + return bool( + self.settings.cloudflare_api_token.strip() + and self.settings.cloudflare_account_id.strip() + ) + return False + + +def _parse_csv(raw: str | None) -> frozenset[str]: + if not raw: + return frozenset() + return frozenset(part.strip() for part in raw.split(",") if part.strip()) + + +def _parse_csv_ordered(raw: str | None) -> tuple[str, ...]: + if not raw: + return () + return tuple(part.strip() for part in raw.split(",") if part.strip()) + + +def _parse_targets(raw: str | None) -> frozenset[str]: + if not raw: + return DEFAULT_TARGETS + parsed = _parse_csv(raw) + if "all" in parsed: + return ALL_TARGETS + return frozenset(TARGET_ALIASES.get(target, target) for target in parsed) + + +def _provider_smoke_model(provider: str) -> tuple[str, str]: + override_env = f"FCC_SMOKE_MODEL_{provider.upper()}" + if override := os.getenv(override_env): + return _normalize_provider_model(provider, override), override_env + + default = PROVIDER_SMOKE_DEFAULT_MODELS.get(provider) + if default is None: + descriptor = PROVIDER_CATALOG[provider] + default = f"{descriptor.provider_id}/smoke-default" + return default, "provider_default" + + +def _normalize_provider_model(provider: str, raw_model: str) -> str: + model = raw_model.strip() + if not model: + msg = f"FCC_SMOKE_MODEL_{provider.upper()} must not be empty" + raise ValueError(msg) + if "/" not in model: + return f"{provider}/{model}" + prefix = parse_provider_type(model) + if prefix == provider: + return model + if prefix in SUPPORTED_PROVIDER_IDS: + msg = ( + f"FCC_SMOKE_MODEL_{provider.upper()} must use provider prefix " + f"{provider!r}, got {model!r}" + ) + raise ValueError(msg) + return f"{provider}/{model}" + + +def nvidia_nim_cli_model_refs( + env: Mapping[str, str] | None = None, +) -> dict[str, str]: + """Return normalized NIM CLI matrix model refs in deterministic order. + + Values are returned as ``full_model -> source`` so callers can preserve both + de-duplicated order and provenance in reports. + """ + source = env if env is not None else os.environ + explicit_models = _parse_csv_ordered(source.get("FCC_SMOKE_NIM_MODELS")) + extra_models = _parse_csv_ordered(source.get("FCC_SMOKE_NIM_EXTRA_MODELS")) + + if "FCC_SMOKE_NIM_MODELS" in source and not explicit_models: + raise ValueError("FCC_SMOKE_NIM_MODELS must list at least one model") + + models: list[tuple[str, str]] = [] + base_models = explicit_models or NVIDIA_NIM_CLI_DEFAULT_MODELS + base_source = ( + "FCC_SMOKE_NIM_MODELS" if explicit_models else "nvidia_nim_cli_default" + ) + models.extend((model, base_source) for model in base_models) + models.extend((model, "FCC_SMOKE_NIM_EXTRA_MODELS") for model in extra_models) + + normalized: dict[str, str] = {} + for raw_model, model_source in models: + full_model = _normalize_provider_model("nvidia_nim", raw_model) + normalized.setdefault(full_model, model_source) + return normalized + + +def openrouter_free_cli_model_refs( + env: Mapping[str, str] | None = None, +) -> dict[str, str]: + """Return normalized OpenRouter free CLI matrix model refs in deterministic order.""" + source = env if env is not None else os.environ + explicit_models = _parse_csv_ordered(source.get("FCC_SMOKE_OPENROUTER_FREE_MODELS")) + extra_models = _parse_csv_ordered( + source.get("FCC_SMOKE_OPENROUTER_FREE_EXTRA_MODELS") + ) + + if "FCC_SMOKE_OPENROUTER_FREE_MODELS" in source and not explicit_models: + raise ValueError( + "FCC_SMOKE_OPENROUTER_FREE_MODELS must list at least one model" + ) + + models: list[tuple[str, str]] = [] + base_models = explicit_models or OPENROUTER_FREE_CLI_DEFAULT_MODELS + base_source = ( + "FCC_SMOKE_OPENROUTER_FREE_MODELS" + if explicit_models + else "openrouter_free_cli_default" + ) + models.extend((model, base_source) for model in base_models) + models.extend( + (model, "FCC_SMOKE_OPENROUTER_FREE_EXTRA_MODELS") for model in extra_models + ) + + normalized: dict[str, str] = {} + for raw_model, model_source in models: + full_model = _normalize_provider_model("open_router", raw_model) + normalized.setdefault(full_model, model_source) + return normalized + + +def auth_headers(token: str | None = None) -> dict[str, str]: + settings = get_settings() + resolved = token if token is not None else settings.anthropic_auth_token + headers = { + "anthropic-version": "2023-06-01", + "content-type": "application/json", + } + if resolved: + headers["x-api-key"] = resolved + return headers + + +def redacted(value: str, env: Mapping[str, str] | None = None) -> str: + """Redact known secrets from a string before writing smoke artifacts.""" + if not value: + return value + + source = env if env is not None else os.environ + result = value + for key, secret in source.items(): + if not secret or len(secret) < 4: + continue + if any(part in key.upper() for part in SECRET_KEY_PARTS): + result = result.replace(secret, f"") + return result diff --git a/smoke/lib/e2e.py b/smoke/lib/e2e.py new file mode 100644 index 0000000..a366a7e --- /dev/null +++ b/smoke/lib/e2e.py @@ -0,0 +1,756 @@ +"""Reusable product E2E smoke drivers.""" + +import asyncio +import json +import os +import subprocess +import time +import uuid +import wave +from collections.abc import AsyncGenerator, Awaitable, Callable, Iterator +from contextlib import contextmanager +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import httpx +import pytest + +from free_claude_code.cli.claude_env import build_claude_proxy_env +from free_claude_code.config.provider_catalog import SUPPORTED_PROVIDER_IDS +from free_claude_code.core.anthropic.stream_contracts import ( + SSEEvent, + assert_anthropic_stream_contract, + event_index, + has_tool_use, + parse_sse_lines, + text_content, +) +from free_claude_code.messaging.models import IncomingMessage, MessageScope +from free_claude_code.messaging.session import SessionStore +from free_claude_code.messaging.voice import VoiceCancellationResult +from free_claude_code.messaging.workflow import MessagingWorkflow +from smoke.lib.child_process import run_captured_text +from smoke.lib.config import ProviderModel, SmokeConfig, auth_headers +from smoke.lib.server import RunningServer, start_server +from smoke.lib.skips import fail_missing_env + + +@dataclass(slots=True) +class ConversationTurn: + request: dict[str, Any] + events: list[SSEEvent] + + @property + def assistant_content(self) -> list[dict[str, Any]]: + return assistant_content_from_events(self.events) + + @property + def text(self) -> str: + return text_content(self.events) + + +class SmokeServerDriver: + """Start a local proxy server for a product scenario.""" + + def __init__( + self, + config: SmokeConfig, + *, + name: str, + env_overrides: dict[str, str] | None = None, + command: list[str] | None = None, + ) -> None: + self.config = config + self.name = name + self.env_overrides = env_overrides + self.command = command + + @contextmanager + def run(self) -> Iterator[RunningServer]: + with start_server( + self.config, + env_overrides=self.env_overrides, + command=self.command, + name=self.name, + ) as server: + yield server + + +class ConversationDriver: + """Drive multi-turn Anthropic-compatible conversations through the server.""" + + def __init__(self, server: RunningServer, config: SmokeConfig) -> None: + self.server = server + self.config = config + self.messages: list[dict[str, Any]] = [] + self.turns: list[ConversationTurn] = [] + + def ask( + self, + text: str, + *, + model: str = "fcc-smoke-default", + max_tokens: int = 256, + extra: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + append_assistant: bool = True, + ) -> ConversationTurn: + self.messages.append({"role": "user", "content": text}) + payload = { + "model": model, + "max_tokens": max_tokens, + "messages": list(self.messages), + } + if extra: + payload.update(extra) + turn = self.stream(payload, headers=headers) + if append_assistant: + self.messages.append( + {"role": "assistant", "content": turn.assistant_content or turn.text} + ) + return turn + + def stream( + self, + payload: dict[str, Any], + *, + headers: dict[str, str] | None = None, + ) -> ConversationTurn: + request_headers = headers or auth_headers() + with httpx.stream( + "POST", + f"{self.server.base_url}/v1/messages", + headers=request_headers, + json=payload, + timeout=self.config.timeout_s, + ) as response: + if response.status_code != 200: + body = response.read().decode("utf-8", errors="replace") + raise AssertionError( + f"stream request failed: HTTP {response.status_code} {body[:1000]}" + ) + events = parse_sse_lines(response.iter_lines()) + assert_anthropic_stream_contract(events) + turn = ConversationTurn(payload, events) + self.turns.append(turn) + return turn + + def stream_expect_http_error( + self, + payload: dict[str, Any], + *, + expected_status: int, + ) -> dict[str, Any]: + response = httpx.post( + f"{self.server.base_url}/v1/messages", + headers=auth_headers(), + json=payload, + timeout=self.config.timeout_s, + ) + assert response.status_code == expected_status, response.text + return response.json() + + +class ProviderMatrixDriver: + """Resolve provider models and enforce matrix semantics for product smoke.""" + + ALL_PROVIDERS: tuple[str, ...] = SUPPORTED_PROVIDER_IDS + + def __init__(self, config: SmokeConfig) -> None: + self.config = config + + def configured_models(self) -> list[ProviderModel]: + return self.config.provider_models() + + def provider_smoke_models(self) -> list[ProviderModel]: + selected = self.config.provider_matrix + missing_selected = [ + provider + for provider in selected + if provider in self.ALL_PROVIDERS + and not self.config.has_provider_configuration(provider) + ] + if missing_selected: + fail_missing_env( + "selected providers are not configured: " + + ", ".join(sorted(missing_selected)) + ) + + models = self.config.provider_smoke_models() + if not models and os.getenv("FCC_ALLOW_NO_PROVIDER_SMOKE") != "1": + fail_missing_env( + "no configured provider smoke models; set FCC_ALLOW_NO_PROVIDER_SMOKE=1 " + "only for no-provider smoke collection" + ) + return models + + def first_model(self) -> ProviderModel: + models = self.provider_smoke_models() + if not models: + pytest.skip("missing_env: no configured provider model") + return models[0] + + +class ClientProtocolDriver: + """Build recorded/representative client protocol requests.""" + + @staticmethod + def vscode_headers() -> dict[str, str]: + headers = auth_headers() + headers.update( + { + "anthropic-beta": "messages-2023-12-15", + "user-agent": "Claude-Code-VSCode product smoke", + } + ) + return headers + + @staticmethod + def jetbrains_headers(config: SmokeConfig) -> dict[str, str]: + headers = auth_headers() + token = config.settings.anthropic_auth_token + if token: + headers.pop("x-api-key", None) + headers["authorization"] = f"Bearer {token}" + headers["user-agent"] = "JetBrains-ACP product smoke" + return headers + + @staticmethod + def adaptive_thinking_payload() -> dict[str, Any]: + return { + "model": "claude-opus-4-7", + "max_tokens": 256, + "messages": [ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "unsigned thought"}, + {"type": "redacted_thinking", "data": "opaque"}, + {"type": "text", "text": "Hello."}, + ], + }, + {"role": "user", "content": "Reply with exactly FCC_SMOKE_CLIENT"}, + ], + "thinking": {"type": "adaptive", "budget_tokens": 1024}, + } + + @staticmethod + def tool_result_payload() -> dict[str, Any]: + return { + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 256, + "messages": [ + {"role": "user", "content": "Use echo_smoke once."}, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_client_smoke", + "name": "echo_smoke", + "input": {"value": "FCC_SMOKE_CLIENT"}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_client_smoke", + "content": "FCC_SMOKE_CLIENT", + } + ], + }, + ], + "tools": [echo_tool_schema()], + "thinking": {"type": "adaptive"}, + } + + @staticmethod + def run_claude_prompt( + *, + claude_bin: str, + server: RunningServer, + config: SmokeConfig, + cwd: Path, + prompt: str, + model: str | None = None, + ) -> subprocess.CompletedProcess[str]: + env = build_claude_proxy_env( + proxy_root_url=server.base_url, + auth_token=config.settings.anthropic_auth_token, + base_env=os.environ, + ) + command = [ + claude_bin, + "--bare", + "--no-session-persistence", + "--tools", + "", + "--system-prompt", + "Reply with exactly the requested smoke token and no other text.", + ] + if model is not None: + command.extend(["--model", model]) + command.extend(["-p", prompt]) + return run_captured_text( + command, + cwd=cwd, + env=env, + timeout=config.timeout_s, + check=False, + ) + + +class FakePlatform: + """In-memory platform that exercises the real message handler.""" + + def __init__(self, name: str) -> None: + self.name = name + self.handler: Callable[[IncomingMessage], Awaitable[None]] | None = None + self.sent: list[dict[str, Any]] = [] + self.edits: list[dict[str, Any]] = [] + self.deletes: list[dict[str, Any]] = [] + self._counter = 0 + self._tasks: list[asyncio.Future[Any]] = [] + self._pending_voice: dict[ + tuple[MessageScope, str], VoiceCancellationResult + ] = {} + + async def start(self) -> None: + return None + + async def quiesce(self) -> None: + return None + + async def close(self) -> None: + for task in self._tasks: + if not task.done(): + task.cancel() + if self._tasks: + await asyncio.gather(*self._tasks, return_exceptions=True) + + @property + def is_connected(self) -> bool: + return True + + def on_message(self, handler: Callable[[IncomingMessage], Awaitable[None]]) -> None: + self.handler = handler + + def continue_message_sequence_after(self, previous: FakePlatform) -> None: + """Model platform-owned message IDs surviving an FCC restart.""" + self._counter = previous._counter + + async def emit(self, incoming: IncomingMessage) -> None: + assert self.handler is not None + await self.handler(incoming) + + def fire_and_forget(self, task: Awaitable[Any]) -> None: + self._tasks.append(asyncio.ensure_future(task)) + + 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: + self._counter += 1 + message_id = f"{self.name}_msg_{self._counter}" + self.sent.append( + { + "chat_id": chat_id, + "message_id": message_id, + "text": text, + "reply_to": reply_to, + "parse_mode": parse_mode, + "message_thread_id": message_thread_id, + } + ) + return message_id + + async def edit_message( + self, + chat_id: str, + message_id: str, + text: str, + parse_mode: str | None = None, + ) -> None: + self.edits.append( + { + "chat_id": chat_id, + "message_id": message_id, + "text": text, + "parse_mode": parse_mode, + } + ) + + async def delete_message(self, chat_id: str, message_id: str) -> None: + self.deletes.append({"chat_id": chat_id, "message_id": message_id}) + + 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: + message_id = await self.send_message( + chat_id, + text, + reply_to=reply_to, + parse_mode=parse_mode, + message_thread_id=message_thread_id, + ) + return None if fire_and_forget else message_id + + 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: + await self.edit_message(chat_id, message_id, text, parse_mode=parse_mode) + + async def queue_delete_messages( + self, + chat_id: str, + message_ids: list[str], + fire_and_forget: bool = True, + ) -> None: + for message_id in message_ids: + await self.delete_message(chat_id, message_id) + + def seed_pending_voice( + self, chat_id: str, voice_message_id: str, status_message_id: str + ) -> None: + scope = MessageScope(platform=self.name, chat_id=chat_id) + result = VoiceCancellationResult( + scope=scope, + voice_message_id=voice_message_id, + status_message_id=status_message_id, + delete_message_ids=frozenset({voice_message_id, status_message_id}), + ) + self._pending_voice[(scope, voice_message_id)] = result + self._pending_voice[(scope, status_message_id)] = result + + async def cancel_pending_voice( + self, scope: MessageScope, reply_id: str + ) -> VoiceCancellationResult | None: + result = self._pending_voice.get((scope, reply_id)) + if result is None: + return None + self._pending_voice.pop((scope, result.voice_message_id), None) + if result.status_message_id is not None: + self._pending_voice.pop((scope, result.status_message_id), None) + delete_message_ids = {result.voice_message_id, result.status_message_id} + if reply_id == result.status_message_id: + delete_message_ids = {result.status_message_id} + return VoiceCancellationResult( + scope=result.scope, + voice_message_id=result.voice_message_id, + status_message_id=result.status_message_id, + delete_message_ids=frozenset( + message_id + for message_id in delete_message_ids + if message_id is not None + ), + ) + + async def cancel_all_pending_voices( + self, + ) -> tuple[VoiceCancellationResult, ...]: + results = tuple( + { + (result.scope, result.voice_message_id): result + for result in self._pending_voice.values() + }.values() + ) + self._pending_voice.clear() + return results + + async def cancel_pending_voices_in_scope( + self, + scope: MessageScope, + ) -> tuple[VoiceCancellationResult, ...]: + results = tuple( + { + result.voice_message_id: result + for (entry_scope, _reference_id), result in self._pending_voice.items() + if entry_scope == scope + }.values() + ) + for result in results: + self._pending_voice.pop((scope, result.voice_message_id), None) + if result.status_message_id is not None: + self._pending_voice.pop((scope, result.status_message_id), None) + return results + + @property + def pending_voice_count(self) -> int: + return len( + { + (result.scope, result.voice_message_id) + for result in self._pending_voice.values() + } + ) + + +class FakeCLISession: + def __init__(self, events: list[dict[str, Any]]) -> None: + self.events = events + self.calls: list[dict[str, Any]] = [] + self.is_busy = False + + async def start_task( + self, prompt: str, session_id: str | None = None, fork_session: bool = False + ) -> AsyncGenerator[dict[str, Any]]: + self.calls.append( + {"prompt": prompt, "session_id": session_id, "fork_session": fork_session} + ) + self.is_busy = True + try: + for event in self.events: + await asyncio.sleep(0) + yield event + finally: + self.is_busy = False + + +class FakeCLIManager: + def __init__(self, event_batches: list[list[dict[str, Any]]] | None = None) -> None: + self.event_batches = event_batches or [default_cli_events("fake_session_1")] + self.sessions: list[FakeCLISession] = [] + self.registered: list[tuple[str, str]] = [] + self.removed: list[str] = [] + self.stopped = False + + async def get_or_create_session( + self, session_id: str | None = None + ) -> tuple[FakeCLISession, str, bool]: + index = len(self.sessions) + events = self.event_batches[min(index, len(self.event_batches) - 1)] + session = FakeCLISession(events) + self.sessions.append(session) + return session, session_id or f"pending_{index}", session_id is None + + async def register_real_session_id( + self, temp_id: str, real_session_id: str + ) -> bool: + self.registered.append((temp_id, real_session_id)) + return True + + async def stop_all(self) -> None: + self.stopped = True + + async def remove_session(self, session_id: str) -> bool: + self.removed.append(session_id) + return True + + def get_stats(self) -> dict[str, int]: + return {"active_sessions": len(self.sessions), "pending_sessions": 0} + + +@dataclass(slots=True) +class FakePlatformDriver: + platform_name: str + tmp_path: Path + event_batches: list[list[dict[str, Any]]] | None = None + platform: FakePlatform = field(init=False) + cli_manager: FakeCLIManager = field(init=False) + session_store: SessionStore = field(init=False) + workflow: MessagingWorkflow = field(init=False) + + def __post_init__(self) -> None: + self.platform = FakePlatform(self.platform_name) + self.cli_manager = FakeCLIManager(self.event_batches) + self.session_store = SessionStore( + storage_path=str(self.tmp_path / f"{self.platform_name}-sessions.json") + ) + self.workflow = MessagingWorkflow( + self.platform, + self.cli_manager, + self.session_store, + platform_name=self.platform_name, + voice_cancellation=self.platform, + ) + self.platform.on_message(self.workflow.handle_message) + + async def send( + self, + text: str, + *, + chat_id: str = "chat_1", + message_id: str | None = None, + reply_to: str | None = None, + ) -> IncomingMessage: + incoming = await self.emit( + text, + chat_id=chat_id, + message_id=message_id, + reply_to=reply_to, + ) + await self.wait_for_idle() + return incoming + + async def emit( + self, + text: str, + *, + chat_id: str = "chat_1", + message_id: str | None = None, + reply_to: str | None = None, + ) -> IncomingMessage: + """Deliver a message without waiting for background claims to finish.""" + incoming = IncomingMessage( + text=text, + chat_id=chat_id, + user_id="user_1", + message_id=message_id or f"in_{uuid.uuid4().hex[:8]}", + platform=self.platform_name, + reply_to_message_id=reply_to, + ) + await self.platform.emit(incoming) + return incoming + + async def wait_for_idle(self, *, timeout_s: float = 5.0) -> None: + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + pending = [task for task in self.platform._tasks if not task.done()] + if not pending and await self._all_tree_nodes_terminal(): + self.session_store.flush_pending_save() + return + await asyncio.sleep(0.02) + raise AssertionError("fake platform did not become idle") + + async def _all_tree_nodes_terminal(self) -> bool: + snapshot = await self.workflow.tree_queue.snapshot() + for tree in snapshot.trees.values(): + for node in tree.nodes.values(): + if node.get("state") in {"pending", "in_progress"}: + return False + return True + + +class VoiceFixtureDriver: + @staticmethod + def write_tone_wav(path: Path) -> None: + import math + + sample_rate = 16000 + duration_s = 0.25 + amplitude = 8000 + frames = bytearray() + for i in range(int(sample_rate * duration_s)): + sample = int(amplitude * math.sin(2 * math.pi * 440 * i / sample_rate)) + frames.extend(sample.to_bytes(2, byteorder="little", signed=True)) + with wave.open(str(path), "wb") as wav: + wav.setnchannels(1) + wav.setsampwidth(2) + wav.setframerate(sample_rate) + wav.writeframes(bytes(frames)) + + +def echo_tool_schema() -> dict[str, Any]: + return { + "name": "echo_smoke", + "description": "Echo a test value.", + "input_schema": { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + }, + } + + +def assistant_content_from_events(events: list[SSEEvent]) -> list[dict[str, Any]]: + blocks: dict[int, dict[str, Any]] = {} + block_order: list[int] = [] + for event in events: + if event.event == "content_block_start": + index = event_index(event) + block = event.data.get("content_block", {}) + if isinstance(block, dict): + blocks[index] = dict(block) + block_order.append(index) + continue + if event.event == "content_block_delta": + index = event_index(event) + block = blocks.get(index) + delta = event.data.get("delta", {}) + if not isinstance(block, dict) or not isinstance(delta, dict): + continue + delta_type = delta.get("type") + if delta_type == "text_delta": + block["text"] = str(block.get("text", "")) + str(delta.get("text", "")) + elif delta_type == "thinking_delta": + block["thinking"] = str(block.get("thinking", "")) + str( + delta.get("thinking", "") + ) + elif delta_type == "input_json_delta": + block["_partial_json"] = str(block.get("_partial_json", "")) + str( + delta.get("partial_json", "") + ) + + content: list[dict[str, Any]] = [] + for index in block_order: + block = blocks[index] + if block.get("type") == "tool_use": + partial = str(block.pop("_partial_json", "")) + if partial: + try: + block["input"] = json.loads(partial) + except json.JSONDecodeError: + block["input"] = {} + content.append(block) + return content + + +def tool_use_blocks(events: list[SSEEvent]) -> list[dict[str, Any]]: + return [ + block + for block in assistant_content_from_events(events) + if block.get("type") == "tool_use" + ] + + +def default_cli_events(session_id: str) -> list[dict[str, Any]]: + return [ + {"type": "session_info", "session_id": session_id}, + { + "type": "assistant", + "message": { + "content": [ + {"type": "thinking", "thinking": "Inspect the request."}, + { + "type": "tool_use", + "id": "toolu_fake", + "name": "Read", + "input": {"file_path": "README.md"}, + }, + { + "type": "tool_result", + "tool_use_id": "toolu_fake", + "content": "Free Claude Code", + }, + {"type": "text", "text": "Fake platform answer."}, + ] + }, + }, + {"type": "exit", "code": 0, "stderr": None}, + ] + + +def assert_product_stream(events: list[SSEEvent]) -> None: + assert_anthropic_stream_contract(events) + assert text_content(events).strip() or has_tool_use(events), ( + "product stream emitted neither text nor tool_use" + ) diff --git a/smoke/lib/http.py b/smoke/lib/http.py new file mode 100644 index 0000000..e8811bb --- /dev/null +++ b/smoke/lib/http.py @@ -0,0 +1,69 @@ +"""HTTP helpers for live smoke requests.""" + +from typing import Any + +import httpx + +from free_claude_code.core.anthropic.stream_contracts import SSEEvent, parse_sse_lines + +from .config import SmokeConfig, auth_headers, redacted +from .server import RunningServer + + +def message_payload( + text: str, + *, + model: str = "claude-3-5-sonnet-20241022", + max_tokens: int = 128, + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "model": model, + "max_tokens": max_tokens, + "messages": [{"role": "user", "content": text}], + } + if extra: + payload.update(extra) + return payload + + +def post_json( + server: RunningServer, + path: str, + payload: dict[str, Any], + config: SmokeConfig, + *, + headers: dict[str, str] | None = None, +) -> httpx.Response: + request_headers = headers or auth_headers() + response = httpx.post( + f"{server.base_url}{path}", + headers=request_headers, + json=payload, + timeout=config.timeout_s, + ) + return response + + +def collect_message_stream( + server: RunningServer, + payload: dict[str, Any], + config: SmokeConfig, + *, + headers: dict[str, str] | None = None, +) -> list[SSEEvent]: + request_headers = headers or auth_headers() + with httpx.stream( + "POST", + f"{server.base_url}/v1/messages", + headers=request_headers, + json=payload, + timeout=config.timeout_s, + ) as response: + if response.status_code != 200: + body = response.read().decode("utf-8", errors="replace") + raise AssertionError( + f"stream request failed: HTTP {response.status_code} " + f"{redacted(body[:1000])}" + ) + return parse_sse_lines(response.iter_lines()) diff --git a/smoke/lib/local_providers.py b/smoke/lib/local_providers.py new file mode 100644 index 0000000..39bf88e --- /dev/null +++ b/smoke/lib/local_providers.py @@ -0,0 +1,72 @@ +"""Helpers for local-provider smoke availability checks.""" + +from urllib.parse import urljoin + +import httpx +import pytest + +from free_claude_code.providers.openai_chat import openai_v1_base_url + +LOCAL_PROVIDER_PROBE_TIMEOUT_S = 1.5 +_ROOT_OR_V1_PROVIDERS = frozenset({"llamacpp", "ollama"}) + + +def first_local_provider_model_id( + provider: str, + base_url: str, + *, + timeout_s: float, +) -> str: + """Return the first local model id, or skip when the local server is absent.""" + base_url = base_url.strip() + if not base_url: + pytest.skip(f"missing_env: {provider} base URL is not configured") + + timeout = min(timeout_s, LOCAL_PROVIDER_PROBE_TIMEOUT_S) + if provider in _ROOT_OR_V1_PROVIDERS: + base_url = openai_v1_base_url(base_url) + return _first_openai_compatible_model_id( + provider, + base_url, + timeout_s=timeout, + ) + + +def _first_openai_compatible_model_id( + provider: str, + base_url: str, + *, + timeout_s: float, +) -> str: + models_url = urljoin(base_url.rstrip("/") + "/", "models") + response = _get_local_provider_response(provider, models_url, timeout_s=timeout_s) + assert response.status_code == 200, response.text + payload = response.json() + data = payload.get("data") if isinstance(payload, dict) else None + if isinstance(data, list): + for item in data: + if isinstance(item, dict) and isinstance(item.get("id"), str): + return item["id"] + pytest.skip(f"missing_env: {provider} local server has no loaded models") + pytest.fail("product_failure: local /models did not expose a model id") + + +def _get_local_provider_response( + provider: str, + url: str, + *, + timeout_s: float, +) -> httpx.Response: + try: + response = httpx.get(url, timeout=timeout_s) + except httpx.TimeoutException as exc: + pytest.skip(f"missing_env: {provider} local server is not reachable: {exc}") + except httpx.NetworkError as exc: + pytest.skip(f"missing_env: {provider} local server is not running: {exc}") + + if response.status_code in {404, 405, 502, 503}: + pytest.skip( + f"missing_env: {provider} local server is not available at {url}: " + f"HTTP {response.status_code}" + ) + return response diff --git a/smoke/lib/report.py b/smoke/lib/report.py new file mode 100644 index 0000000..25860b7 --- /dev/null +++ b/smoke/lib/report.py @@ -0,0 +1,105 @@ +"""Small JSON report writer for smoke runs.""" + +import json +import time +from dataclasses import asdict, dataclass + +from .config import SmokeConfig, redacted + + +@dataclass(slots=True) +class SmokeOutcome: + nodeid: str + outcome: str + classification: str + duration_s: float + markers: list[str] + detail: str + + +class SmokeReport: + def __init__(self, config: SmokeConfig) -> None: + self.config = config + self.started_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + self.outcomes: list[SmokeOutcome] = [] + + def add( + self, + *, + nodeid: str, + outcome: str, + duration_s: float, + markers: list[str], + detail: str = "", + ) -> None: + self.outcomes.append( + SmokeOutcome( + nodeid=nodeid, + outcome=outcome, + classification=classify_outcome( + nodeid=nodeid, outcome=outcome, detail=detail + ), + duration_s=duration_s, + markers=markers, + detail=redacted(detail), + ) + ) + + def write(self) -> None: + self.config.results_dir.mkdir(parents=True, exist_ok=True) + path = ( + self.config.results_dir + / f"report-{self.config.worker_id}-{int(time.time())}.json" + ) + payload = { + "started_at": self.started_at, + "worker_id": self.config.worker_id, + "targets": sorted(self.config.targets), + "outcomes": [asdict(outcome) for outcome in self.outcomes], + } + path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + + +def classify_outcome(*, nodeid: str, outcome: str, detail: str) -> str: + """Classify smoke outcomes for triage reports.""" + if outcome == "passed": + return "passed" + + text = f"{nodeid}\n{detail}".lower() + if outcome == "skipped": + if "smoke target disabled" in text: + return "target_disabled" + if "missing_env" in text: + return "missing_env" + if any( + marker in text + for marker in ( + "upstream_unavailable", + "connection refused", + "connecterror", + "readtimeout", + "timed out", + "not reachable", + ) + ): + return "upstream_unavailable" + return "missing_env" + + if "harness_bug" in text: + return "harness_bug" + if "missing_env" in text: + return "missing_env" + if any( + marker in text + for marker in ( + "upstream_unavailable", + "connection refused", + "connecterror", + "readtimeout", + "timed out", + "not reachable", + "upstream provider", + ) + ): + return "upstream_unavailable" + return "product_failure" diff --git a/smoke/lib/report_summary.py b/smoke/lib/report_summary.py new file mode 100644 index 0000000..86eed35 --- /dev/null +++ b/smoke/lib/report_summary.py @@ -0,0 +1,51 @@ +"""Summarize smoke JSON reports for local and workflow triage.""" + +import json +from collections import Counter +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True, slots=True) +class SmokeSummary: + reports: int + outcomes: int + classifications: dict[str, int] + + @property + def has_regression(self) -> bool: + return bool( + self.classifications.get("product_failure", 0) + or self.classifications.get("harness_bug", 0) + ) + + +def summarize_reports(results_dir: Path) -> SmokeSummary: + """Read all report JSON files and count outcome classifications.""" + counts: Counter[str] = Counter() + reports = 0 + outcomes = 0 + for path in sorted(results_dir.glob("report-*.json")): + reports += 1 + payload = json.loads(path.read_text(encoding="utf-8")) + for outcome in payload.get("outcomes", []): + if not isinstance(outcome, dict): + continue + outcomes += 1 + counts[str(outcome.get("classification") or "unknown")] += 1 + return SmokeSummary( + reports=reports, + outcomes=outcomes, + classifications=dict(sorted(counts.items())), + ) + + +def format_summary(summary: SmokeSummary) -> str: + """Return a compact human-readable summary.""" + parts = [ + f"reports={summary.reports}", + f"outcomes={summary.outcomes}", + ] + parts.extend(f"{name}={count}" for name, count in summary.classifications.items()) + status = "regression" if summary.has_regression else "ok" + return f"smoke_summary status={status} " + " ".join(parts) diff --git a/smoke/lib/server.py b/smoke/lib/server.py new file mode 100644 index 0000000..553e8c6 --- /dev/null +++ b/smoke/lib/server.py @@ -0,0 +1,118 @@ +"""Subprocess lifecycle helpers for local smoke servers.""" + +import os +import socket +import subprocess +import time +from collections.abc import Iterable, Iterator +from contextlib import contextmanager, suppress +from dataclasses import dataclass +from pathlib import Path + +import httpx + +from .child_process import cmd_free_claude_code_serve +from .config import SmokeConfig, redacted + + +@dataclass(slots=True) +class RunningServer: + base_url: str + port: int + log_path: Path + process: subprocess.Popen[bytes] + + +def find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +@contextmanager +def start_server( + config: SmokeConfig, + *, + env_overrides: dict[str, str] | None = None, + env_unset: Iterable[str] = (), + command: list[str] | None = None, + name: str = "server", +) -> Iterator[RunningServer]: + port = find_free_port() + config.results_dir.mkdir(parents=True, exist_ok=True) + log_path = config.results_dir / f"{name}-{config.worker_id}-{port}.log" + + env = os.environ.copy() + for key in env_unset: + env.pop(key, None) + env.update( + { + "HOST": "127.0.0.1", + "PORT": str(port), + "LOG_FILE": str(log_path), + "FCC_OPEN_BROWSER": "0", + "MESSAGING_PLATFORM": "none", + "PYTHONUNBUFFERED": "1", + } + ) + if env_overrides: + env.update(env_overrides) + + cmd = command or cmd_free_claude_code_serve() + + with log_path.open("ab") as log_file: + process = subprocess.Popen( + cmd, + cwd=config.root, + env=env, + stdout=log_file, + stderr=subprocess.STDOUT, + ) + running = RunningServer( + base_url=f"http://127.0.0.1:{port}", + port=port, + log_path=log_path, + process=process, + ) + try: + _wait_for_health(running, timeout_s=config.timeout_s) + yield running + finally: + _stop_process(process) + + +def _wait_for_health(server: RunningServer, *, timeout_s: float) -> None: + deadline = time.monotonic() + timeout_s + last_error = "" + while time.monotonic() < deadline: + if server.process.poll() is not None: + break + try: + response = httpx.get(f"{server.base_url}/health", timeout=2.0) + if response.status_code == 200: + return + last_error = f"HTTP {response.status_code}: {response.text[:200]}" + except Exception as exc: + last_error = f"{type(exc).__name__}: {exc}" + time.sleep(0.25) + + log_excerpt = "" + with suppress(OSError): + log_excerpt = server.log_path.read_text(encoding="utf-8", errors="replace")[ + -2000: + ] + raise AssertionError( + "Smoke server did not become healthy. " + f"last_error={last_error!r} log={redacted(log_excerpt)!r}" + ) + + +def _stop_process(process: subprocess.Popen[bytes]) -> None: + if process.poll() is not None: + return + process.terminate() + try: + process.wait(timeout=8) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) diff --git a/smoke/lib/skips.py b/smoke/lib/skips.py new file mode 100644 index 0000000..e8407fa --- /dev/null +++ b/smoke/lib/skips.py @@ -0,0 +1,67 @@ +"""Skip helpers for expected live-smoke environment gaps.""" + +import httpx +import pytest + +from free_claude_code.core.anthropic.stream_contracts import SSEEvent, text_content + +UPSTREAM_UNAVAILABLE_MARKERS = ( + "connection refused", + "connecterror", + "connect timeout", + "readtimeout", + "server disconnected", + "service unavailable", + "temporary failure", + "timed out", + "upstream provider", +) + + +def is_upstream_unavailable_text(text: str) -> bool: + normalized = text.lower() + return any(marker in normalized for marker in UPSTREAM_UNAVAILABLE_MARKERS) + + +def skip_upstream_unavailable(reason: str) -> None: + pytest.skip(f"upstream_unavailable: {reason}") + + +def fail_missing_env(reason: str) -> None: + pytest.fail(f"missing_env: {reason}") + + +def skip_if_upstream_unavailable_exception(exc: Exception) -> None: + if isinstance( + exc, + ( + httpx.ConnectError, + httpx.ConnectTimeout, + httpx.ReadTimeout, + httpx.RemoteProtocolError, + httpx.ProxyError, + ), + ): + skip_upstream_unavailable(f"{type(exc).__name__}: {exc}") + if is_upstream_unavailable_text(f"{type(exc).__name__}: {exc}"): + skip_upstream_unavailable(f"{type(exc).__name__}: {exc}") + + +def skip_if_upstream_unavailable_events(events: list[SSEEvent]) -> None: + text = text_content(events) + if is_upstream_unavailable_text(text): + skip_upstream_unavailable(text[:500]) + + for event in events: + if getattr(event, "event", None) != "error": + continue + data = getattr(event, "data", {}) + message = "" + if isinstance(data, dict): + error = data.get("error") + if isinstance(error, dict): + message = str(error.get("message", "")) + else: + message = str(data) + if is_upstream_unavailable_text(message): + skip_upstream_unavailable(message[:500]) diff --git a/smoke/prereq/__init__.py b/smoke/prereq/__init__.py new file mode 100644 index 0000000..54472fd --- /dev/null +++ b/smoke/prereq/__init__.py @@ -0,0 +1 @@ +"""Live prerequisite checks for product smoke.""" diff --git a/smoke/prereq/test_api_prereq_live.py b/smoke/prereq/test_api_prereq_live.py new file mode 100644 index 0000000..da42b00 --- /dev/null +++ b/smoke/prereq/test_api_prereq_live.py @@ -0,0 +1,194 @@ +from typing import Any + +import httpx +import pytest + +from smoke.lib.config import SmokeConfig +from smoke.lib.http import post_json +from smoke.lib.server import RunningServer + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("api")] + + +def test_probe_and_models_routes( + smoke_server: RunningServer, smoke_headers: dict[str, str] +) -> None: + with httpx.Client(base_url=smoke_server.base_url, headers=smoke_headers) as client: + assert client.get("/health").json()["status"] == "healthy" + + root = client.get("/") + assert root.status_code == 200 + assert root.json()["status"] == "ok" + + models = client.get("/v1/models") + assert models.status_code == 200 + assert models.json()["data"] + + for path in ( + "/", + "/health", + "/v1/messages", + "/v1/responses", + "/v1/messages/count_tokens", + ): + head = client.head(path) + assert head.status_code == 204, (path, head.status_code, head.text) + options = client.options(path) + assert options.status_code == 204, (path, options.status_code, options.text) + + +def test_count_tokens_accepts_thinking_tools_and_results( + smoke_server: RunningServer, + smoke_config: SmokeConfig, + smoke_headers: dict[str, str], +) -> None: + payload: dict[str, Any] = { + "model": "claude-3-5-sonnet-20241022", + "messages": [ + {"role": "user", "content": "Use the tool."}, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Need to inspect the file."}, + { + "type": "tool_use", + "id": "toolu_smoke", + "name": "Read", + "input": {"file_path": "README.md"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_smoke", + "content": "Free Claude Code", + } + ], + }, + ], + "tools": [ + { + "name": "Read", + "description": "Read a file", + "input_schema": { + "type": "object", + "properties": {"file_path": {"type": "string"}}, + "required": ["file_path"], + }, + } + ], + } + response = post_json( + smoke_server, + "/v1/messages/count_tokens", + payload, + smoke_config, + headers=smoke_headers, + ) + assert response.status_code == 200, response.text + assert response.json()["input_tokens"] > 0 + + +def test_optimization_fast_paths_do_not_need_provider( + smoke_server: RunningServer, + smoke_config: SmokeConfig, + smoke_headers: dict[str, str], +) -> None: + cases: tuple[tuple[str, dict[str, Any], str], ...] = ( + ( + "quota", + { + "model": "claude-3-5-sonnet-20241022", + "max_tokens": 1, + "messages": [{"role": "user", "content": "quota"}], + }, + "Quota check passed.", + ), + ( + "title", + { + "model": "claude-3-5-sonnet-20241022", + "system": ( + "Generate a concise, sentence-case title (3-7 words). " + 'Return JSON with a single "title" field.' + ), + "messages": [{"role": "user", "content": "hello"}], + }, + "Conversation", + ), + ( + "prefix", + { + "model": "claude-3-5-sonnet-20241022", + "messages": [ + { + "role": "user", + "content": "extract command\nCommand: git status --short", + } + ], + }, + "git", + ), + ( + "suggestion", + { + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "[SUGGESTION MODE: next]"}], + }, + "", + ), + ( + "filepath", + { + "model": "claude-3-5-sonnet-20241022", + "system": "Extract any file paths that this command output contains.", + "messages": [ + { + "role": "user", + "content": "Command: cat smoke/test_api_live.py\nOutput: file contents\n", + } + ], + }, + "smoke/test_api_live.py", + ), + ) + for name, payload, expected_text in cases: + response = post_json( + smoke_server, "/v1/messages", payload, smoke_config, headers=smoke_headers + ) + assert response.status_code == 200, (name, response.text) + text = response.json()["content"][0]["text"] + assert expected_text in text + + +def test_invalid_messages_returns_anthropic_error( + smoke_server: RunningServer, + smoke_config: SmokeConfig, + smoke_headers: dict[str, str], +) -> None: + response = post_json( + smoke_server, + "/v1/messages", + {"model": "claude-3-5-sonnet-20241022", "messages": []}, + smoke_config, + headers=smoke_headers, + ) + assert response.status_code == 400 + payload = response.json() + assert payload["type"] == "error" + assert payload["error"]["type"] == "invalid_request_error" + + +def test_stop_endpoint_reports_no_messaging( + smoke_server: RunningServer, smoke_headers: dict[str, str] +) -> None: + response = httpx.post( + f"{smoke_server.base_url}/stop", + headers=smoke_headers, + timeout=5, + ) + assert response.status_code == 503 + assert response.json()["detail"] == "Messaging system not initialized" diff --git a/smoke/prereq/test_auth_prereq_live.py b/smoke/prereq/test_auth_prereq_live.py new file mode 100644 index 0000000..ba35320 --- /dev/null +++ b/smoke/prereq/test_auth_prereq_live.py @@ -0,0 +1,50 @@ +from pathlib import Path + +import httpx +import pytest + +from smoke.lib.config import SmokeConfig +from smoke.lib.server import start_server + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("auth")] + + +def test_auth_token_is_enforced_for_all_supported_header_shapes( + smoke_config: SmokeConfig, tmp_path: Path +) -> None: + token = "fcc-smoke-token" + env_file = tmp_path / "auth.env" + env_file.write_text(f'ANTHROPIC_AUTH_TOKEN="{token}"\n', encoding="utf-8") + + with start_server( + smoke_config, + env_overrides={ + "ANTHROPIC_AUTH_TOKEN": token, + "FCC_ENV_FILE": str(env_file), + "MESSAGING_PLATFORM": "none", + }, + name="auth", + ) as server: + assert httpx.get(f"{server.base_url}/").status_code == 401 + assert ( + httpx.get(f"{server.base_url}/", headers={"x-api-key": "wrong"}).status_code + == 401 + ) + assert ( + httpx.get(f"{server.base_url}/", headers={"x-api-key": token}).status_code + == 200 + ) + assert ( + httpx.get( + f"{server.base_url}/", + headers={"authorization": f"Bearer {token}"}, + ).status_code + == 200 + ) + assert ( + httpx.get( + f"{server.base_url}/", + headers={"anthropic-auth-token": token}, + ).status_code + == 200 + ) diff --git a/smoke/prereq/test_cli_prereq_live.py b/smoke/prereq/test_cli_prereq_live.py new file mode 100644 index 0000000..d541168 --- /dev/null +++ b/smoke/prereq/test_cli_prereq_live.py @@ -0,0 +1,82 @@ +import os +import shutil +from pathlib import Path + +import pytest + +from free_claude_code.cli.claude_env import build_claude_proxy_env +from smoke.lib.child_process import ( + cmd_fcc_init, + cmd_free_claude_code_serve, + run_captured_text, +) +from smoke.lib.config import SmokeConfig +from smoke.lib.server import start_server +from smoke.lib.skips import skip_upstream_unavailable + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("cli")] + + +def test_fcc_init_scaffolds_user_config( + smoke_config: SmokeConfig, tmp_path: Path +) -> None: + env = os.environ.copy() + env["HOME"] = str(tmp_path) + env["USERPROFILE"] = str(tmp_path) + result = run_captured_text( + cmd_fcc_init(), + cwd=smoke_config.root, + env=env, + timeout=smoke_config.timeout_s, + check=False, + ) + assert result.returncode == 0, result.stderr or result.stdout + assert (tmp_path / ".fcc" / ".env").is_file() + + +def test_free_claude_code_entrypoint_starts_server(smoke_config: SmokeConfig) -> None: + with start_server( + smoke_config, + command=cmd_free_claude_code_serve(), + env_overrides={"MESSAGING_PLATFORM": "none"}, + name="entrypoint", + ) as server: + assert server.process.poll() is None + + +def test_claude_cli_prompt_when_available( + smoke_config: SmokeConfig, tmp_path: Path +) -> None: + claude_bin = shutil.which(smoke_config.claude_bin) + if not claude_bin: + pytest.skip(f"Claude CLI not found: {smoke_config.claude_bin}") + models = smoke_config.provider_models() + if not models: + pytest.skip("no configured provider model available for Claude CLI smoke") + + with start_server( + smoke_config, + env_overrides={"MODEL": models[0].full_model, "MESSAGING_PLATFORM": "none"}, + name="claude-cli", + ) as server: + env = build_claude_proxy_env( + proxy_root_url=server.base_url, + auth_token=smoke_config.settings.anthropic_auth_token, + base_env=os.environ, + ) + result = run_captured_text( + [claude_bin, "-p", "Reply with exactly FCC_SMOKE_PONG"], + cwd=tmp_path, + env=env, + timeout=smoke_config.timeout_s, + check=False, + ) + server_log = server.log_path.read_text(encoding="utf-8", errors="replace") + assert result.returncode == 0, result.stderr or result.stdout + assert "POST /v1/messages" in server_log, ( + "Claude CLI did not call the local Anthropic-compatible endpoint" + ) + if "FCC_SMOKE_PONG" not in result.stdout: + skip_upstream_unavailable( + "Claude CLI reached the local proxy but returned no smoke token" + ) diff --git a/smoke/prereq/test_client_shapes_prereq_live.py b/smoke/prereq/test_client_shapes_prereq_live.py new file mode 100644 index 0000000..3f173cb --- /dev/null +++ b/smoke/prereq/test_client_shapes_prereq_live.py @@ -0,0 +1,47 @@ +import pytest + +from smoke.lib.config import SmokeConfig, auth_headers +from smoke.lib.http import message_payload, post_json +from smoke.lib.server import RunningServer + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("clients")] + + +def test_vscode_and_jetbrains_shaped_requests( + smoke_server: RunningServer, + smoke_config: SmokeConfig, +) -> None: + payload = message_payload("quota", max_tokens=1) + + vscode_headers = auth_headers() + vscode_headers.update( + { + "anthropic-beta": "messages-2023-12-15", + "user-agent": "Claude-Code-VSCode smoke", + } + ) + vscode = post_json( + smoke_server, + "/v1/messages?beta=true", + payload, + smoke_config, + headers=vscode_headers, + ) + assert vscode.status_code == 200, vscode.text + assert vscode.json()["content"][0]["text"] == "Quota check passed." + + jetbrains_headers = auth_headers() + token = smoke_config.settings.anthropic_auth_token + if token: + jetbrains_headers.pop("x-api-key", None) + jetbrains_headers["authorization"] = f"Bearer {token}" + jetbrains_headers["user-agent"] = "JetBrains-ACP smoke" + jetbrains = post_json( + smoke_server, + "/v1/messages", + payload, + smoke_config, + headers=jetbrains_headers, + ) + assert jetbrains.status_code == 200, jetbrains.text + assert jetbrains.json()["content"][0]["text"] == "Quota check passed." diff --git a/smoke/prereq/test_local_provider_endpoints_prereq_live.py b/smoke/prereq/test_local_provider_endpoints_prereq_live.py new file mode 100644 index 0000000..cbffa9c --- /dev/null +++ b/smoke/prereq/test_local_provider_endpoints_prereq_live.py @@ -0,0 +1,34 @@ +import pytest + +from smoke.lib.config import SmokeConfig +from smoke.lib.local_providers import first_local_provider_model_id + + +@pytest.mark.live +@pytest.mark.smoke_target("lmstudio") +def test_lmstudio_models_endpoint_when_available(smoke_config: SmokeConfig) -> None: + first_local_provider_model_id( + "lmstudio", + smoke_config.settings.lm_studio_base_url, + timeout_s=smoke_config.timeout_s, + ) + + +@pytest.mark.live +@pytest.mark.smoke_target("llamacpp") +def test_llamacpp_models_endpoint_when_available(smoke_config: SmokeConfig) -> None: + first_local_provider_model_id( + "llamacpp", + smoke_config.settings.llamacpp_base_url, + timeout_s=smoke_config.timeout_s, + ) + + +@pytest.mark.live +@pytest.mark.smoke_target("ollama") +def test_ollama_models_endpoint_when_available(smoke_config: SmokeConfig) -> None: + first_local_provider_model_id( + "ollama", + smoke_config.settings.ollama_base_url, + timeout_s=smoke_config.timeout_s, + ) diff --git a/smoke/prereq/test_messaging_prereq_live.py b/smoke/prereq/test_messaging_prereq_live.py new file mode 100644 index 0000000..a53a3a4 --- /dev/null +++ b/smoke/prereq/test_messaging_prereq_live.py @@ -0,0 +1,111 @@ +import os +import time + +import httpx +import pytest + +from smoke.lib.config import SmokeConfig + + +@pytest.mark.live +@pytest.mark.smoke_target("telegram") +def test_telegram_bot_api_permissions(smoke_config: SmokeConfig) -> None: + token = smoke_config.settings.telegram_bot_token + if not token: + pytest.skip("TELEGRAM_BOT_TOKEN is not configured") + + base_url = f"https://api.telegram.org/bot{token}" + get_me = httpx.get(f"{base_url}/getMe", timeout=smoke_config.timeout_s) + assert get_me.status_code == 200, get_me.text + assert get_me.json()["ok"] is True + + chat_id = os.getenv("FCC_SMOKE_TELEGRAM_CHAT_ID") or ( + smoke_config.settings.allowed_telegram_user_id or "" + ) + if not chat_id: + pytest.skip("FCC_SMOKE_TELEGRAM_CHAT_ID or ALLOWED_TELEGRAM_USER_ID required") + + marker = f"FCC smoke {int(time.time())}" + sent = httpx.post( + f"{base_url}/sendMessage", + json={"chat_id": chat_id, "text": marker}, + timeout=smoke_config.timeout_s, + ) + assert sent.status_code == 200, sent.text + message_id = sent.json()["result"]["message_id"] + + edited = httpx.post( + f"{base_url}/editMessageText", + json={"chat_id": chat_id, "message_id": message_id, "text": marker + " edit"}, + timeout=smoke_config.timeout_s, + ) + assert edited.status_code == 200, edited.text + + deleted = httpx.post( + f"{base_url}/deleteMessage", + json={"chat_id": chat_id, "message_id": message_id}, + timeout=smoke_config.timeout_s, + ) + assert deleted.status_code == 200, deleted.text + + +@pytest.mark.live +@pytest.mark.smoke_target("discord") +def test_discord_bot_api_permissions(smoke_config: SmokeConfig) -> None: + token = smoke_config.settings.discord_bot_token + channel_id = os.getenv("FCC_SMOKE_DISCORD_CHANNEL_ID") + if not channel_id and smoke_config.settings.allowed_discord_channels: + channel_id = smoke_config.settings.allowed_discord_channels.split(",", 1)[0] + if not token: + pytest.skip("DISCORD_BOT_TOKEN is not configured") + if not channel_id: + pytest.skip("FCC_SMOKE_DISCORD_CHANNEL_ID or ALLOWED_DISCORD_CHANNELS required") + + headers = {"authorization": f"Bot {token}"} + base_url = "https://discord.com/api/v10" + + channel = httpx.get( + f"{base_url}/channels/{channel_id}", + headers=headers, + timeout=smoke_config.timeout_s, + ) + assert channel.status_code == 200, channel.text + + marker = f"FCC smoke {int(time.time())}" + sent = httpx.post( + f"{base_url}/channels/{channel_id}/messages", + headers=headers, + json={"content": marker}, + timeout=smoke_config.timeout_s, + ) + assert sent.status_code == 200, sent.text + message_id = sent.json()["id"] + + edited = httpx.patch( + f"{base_url}/channels/{channel_id}/messages/{message_id}", + headers=headers, + json={"content": marker + " edit"}, + timeout=smoke_config.timeout_s, + ) + assert edited.status_code == 200, edited.text + + deleted = httpx.delete( + f"{base_url}/channels/{channel_id}/messages/{message_id}", + headers=headers, + timeout=smoke_config.timeout_s, + ) + assert deleted.status_code in {200, 204}, deleted.text + + +@pytest.mark.live +@pytest.mark.smoke_target("telegram") +@pytest.mark.smoke_target("discord") +def test_interactive_inbound_messaging_requires_explicit_mode( + smoke_config: SmokeConfig, +) -> None: + if not smoke_config.interactive: + pytest.skip("set FCC_SMOKE_INTERACTIVE=1 for manual inbound messaging checks") + pytest.skip( + "manual inbound check: start the server, send a nonce from the real client, " + "and verify threaded progress plus /stop, /clear, and /stats" + ) diff --git a/smoke/prereq/test_provider_prereq_live.py b/smoke/prereq/test_provider_prereq_live.py new file mode 100644 index 0000000..b483763 --- /dev/null +++ b/smoke/prereq/test_provider_prereq_live.py @@ -0,0 +1,104 @@ +import time + +import httpx +import pytest + +from free_claude_code.core.anthropic.stream_contracts import ( + assert_anthropic_stream_contract, + text_content, + thinking_content, +) +from smoke.lib.config import ProviderModel, SmokeConfig, auth_headers +from smoke.lib.e2e import ProviderMatrixDriver +from smoke.lib.http import collect_message_stream, message_payload +from smoke.lib.server import start_server +from smoke.lib.skips import ( + skip_if_upstream_unavailable_events, + skip_if_upstream_unavailable_exception, +) + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("providers")] + + +def test_model_mapping_configuration_is_consistent(smoke_config: SmokeConfig) -> None: + models = smoke_config.provider_models() + if not models: + pytest.skip("no configured provider models with usable credentials/base URLs") + for provider_model in models: + assert "/" in provider_model.full_model + assert provider_model.model_name + + +def test_mixed_provider_model_mapping_when_configured( + smoke_config: SmokeConfig, +) -> None: + models = smoke_config.provider_models() + providers = {provider_model.provider for provider_model in models} + if len(providers) < 2: + pytest.skip("configure MODEL_* with at least two provider prefixes") + + sources = {provider_model.source for provider_model in models} + assert sources <= {"MODEL", "MODEL_OPUS", "MODEL_SONNET", "MODEL_HAIKU"} + assert len(providers) >= 2 + + +def test_configured_provider_models_stream_successfully( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + try: + with start_server( + smoke_config, + env_overrides={ + "MODEL": provider_model.full_model, + "MESSAGING_PLATFORM": "none", + }, + name=f"provider-{provider_model.provider}", + ) as server: + events = collect_message_stream( + server, + message_payload(smoke_config.prompt, model="fcc-smoke-default"), + smoke_config, + ) + skip_if_upstream_unavailable_events(events) + assert_anthropic_stream_contract(events) + has_text = bool(text_content(events).strip()) + has_thinking = bool(thinking_content(events).strip()) + assert has_text or has_thinking, ( + "provider returned no visible text or thinking content" + ) + except Exception as exc: + skip_if_upstream_unavailable_exception(exc) + raise AssertionError( + f"{provider_model.source}={provider_model.full_model}: " + f"{type(exc).__name__}: {exc}" + ) from exc + + +@pytest.mark.smoke_target("rate_limit") +def test_client_disconnect_mid_stream_does_not_crash_server( + smoke_config: SmokeConfig, +) -> None: + provider_model = ProviderMatrixDriver(smoke_config).first_model() + + with start_server( + smoke_config, + env_overrides={ + "MODEL": provider_model.full_model, + "MESSAGING_PLATFORM": "none", + }, + name="disconnect", + ) as server: + with httpx.stream( + "POST", + f"{server.base_url}/v1/messages", + headers=auth_headers(), + json=message_payload(smoke_config.prompt, model="fcc-smoke-default"), + timeout=smoke_config.timeout_s, + ) as response: + assert response.status_code == 200, response.read() + for _line in response.iter_lines(): + break + + time.sleep(0.5) + health = httpx.get(f"{server.base_url}/health", timeout=5) + assert health.status_code == 200 diff --git a/smoke/prereq/test_tools_prereq_live.py b/smoke/prereq/test_tools_prereq_live.py new file mode 100644 index 0000000..79e6f5b --- /dev/null +++ b/smoke/prereq/test_tools_prereq_live.py @@ -0,0 +1,61 @@ +import pytest + +from free_claude_code.core.anthropic.stream_contracts import ( + assert_anthropic_stream_contract, + has_tool_use, +) +from smoke.lib.config import SmokeConfig +from smoke.lib.http import collect_message_stream, message_payload +from smoke.lib.server import start_server +from smoke.lib.skips import ( + skip_if_upstream_unavailable_events, + skip_if_upstream_unavailable_exception, +) + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("tools")] + + +def test_live_tool_use_when_configured_model_supports_tools( + smoke_config: SmokeConfig, +) -> None: + models = smoke_config.provider_models() + if not models: + pytest.skip("no configured provider model available for tool-use smoke") + provider_model = models[0] + + payload = message_payload( + "Use the echo_smoke tool once with value FCC_SMOKE_TOOL.", + model="fcc-smoke-default", + max_tokens=256, + extra={ + "tools": [ + { + "name": "echo_smoke", + "description": "Echo a test value.", + "input_schema": { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + }, + } + ], + "tool_choice": {"type": "tool", "name": "echo_smoke"}, + }, + ) + + with start_server( + smoke_config, + env_overrides={ + "MODEL": provider_model.full_model, + "MESSAGING_PLATFORM": "none", + }, + name="tools", + ) as server: + try: + events = collect_message_stream(server, payload, smoke_config) + except Exception as exc: + skip_if_upstream_unavailable_exception(exc) + raise + skip_if_upstream_unavailable_events(events) + assert_anthropic_stream_contract(events) + assert has_tool_use(events), "model did not emit a tool_use block" diff --git a/smoke/prereq/test_voice_prereq_live.py b/smoke/prereq/test_voice_prereq_live.py new file mode 100644 index 0000000..78091eb --- /dev/null +++ b/smoke/prereq/test_voice_prereq_live.py @@ -0,0 +1,62 @@ +import math +import os +import wave +from pathlib import Path + +import pytest + +from free_claude_code.messaging.transcription import TranscriptionService +from free_claude_code.messaging.voice import Transcriber +from free_claude_code.providers.nvidia_nim.voice import NvidiaNimTranscriber +from smoke.lib.config import SmokeConfig + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("voice")] + + +@pytest.mark.asyncio +async def test_voice_transcription_backend_when_explicitly_enabled( + smoke_config: SmokeConfig, tmp_path: Path +) -> None: + if not smoke_config.settings.voice_note_enabled: + pytest.skip("VOICE_NOTE_ENABLED is false") + if os.getenv("FCC_SMOKE_RUN_VOICE") != "1": + pytest.skip("set FCC_SMOKE_RUN_VOICE=1 to run transcription smoke") + + wav_path = tmp_path / "smoke-tone.wav" + _write_tone_wav(wav_path) + transcriber: Transcriber + if smoke_config.settings.whisper_device == "nvidia_nim": + transcriber = NvidiaNimTranscriber( + model=smoke_config.settings.whisper_model, + api_key=smoke_config.settings.nvidia_nim_api_key, + ) + else: + transcriber = TranscriptionService( + model=smoke_config.settings.whisper_model, + device=smoke_config.settings.whisper_device, + huggingface_api_key=smoke_config.settings.huggingface_api_key, + ) + try: + text = await transcriber.transcribe(wav_path) + except ImportError as exc: + pytest.skip(str(exc)) + finally: + await transcriber.close() + assert isinstance(text, str) + assert text.strip() + + +def _write_tone_wav(path: Path) -> None: + sample_rate = 16000 + duration_s = 0.25 + amplitude = 8000 + frames = bytearray() + for i in range(int(sample_rate * duration_s)): + sample = int(amplitude * math.sin(2 * math.pi * 440 * i / sample_rate)) + frames.extend(sample.to_bytes(2, byteorder="little", signed=True)) + + with wave.open(str(path), "wb") as wav: + wav.setnchannels(1) + wav.setsampwidth(2) + wav.setframerate(sample_rate) + wav.writeframes(bytes(frames)) diff --git a/smoke/product/__init__.py b/smoke/product/__init__.py new file mode 100644 index 0000000..662aac9 --- /dev/null +++ b/smoke/product/__init__.py @@ -0,0 +1 @@ +"""Product-level end-to-end smoke scenarios.""" diff --git a/smoke/product/test_api_product_live.py b/smoke/product/test_api_product_live.py new file mode 100644 index 0000000..54465cb --- /dev/null +++ b/smoke/product/test_api_product_live.py @@ -0,0 +1,200 @@ +from typing import Any + +import httpx +import pytest + +from smoke.lib.config import SmokeConfig +from smoke.lib.e2e import ( + ConversationDriver, + ProviderMatrixDriver, + SmokeServerDriver, + assert_product_stream, + echo_tool_schema, +) + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("api")] + + +def test_api_basic_conversation_e2e(smoke_config: SmokeConfig) -> None: + provider_model = ProviderMatrixDriver(smoke_config).first_model() + with SmokeServerDriver( + smoke_config, + name="product-api-basic", + env_overrides={ + "MODEL": provider_model.full_model, + "MESSAGING_PLATFORM": "none", + }, + ).run() as server: + turn = ConversationDriver(server, smoke_config).ask( + "Reply with one short sentence." + ) + + assert_product_stream(turn.events) + assert turn.text.strip() + + +def test_api_count_tokens_full_payload_e2e( + smoke_server, + smoke_config: SmokeConfig, + smoke_headers: dict[str, str], +) -> None: + payload: dict[str, Any] = { + "model": "claude-3-5-sonnet-20241022", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Use the image and tool."}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "iVBORw0KGgo=", + }, + }, + ], + }, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Need the tool."}, + {"type": "redacted_thinking", "data": "opaque"}, + { + "type": "tool_use", + "id": "toolu_smoke", + "name": "echo_smoke", + "input": {"value": "FCC"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_smoke", + "content": "FCC", + } + ], + }, + ], + "tools": [echo_tool_schema()], + "thinking": {"type": "adaptive", "budget_tokens": 1024}, + } + response = httpx.post( + f"{smoke_server.base_url}/v1/messages/count_tokens", + headers=smoke_headers, + json=payload, + timeout=smoke_config.timeout_s, + ) + assert response.status_code == 200, response.text + assert response.json()["input_tokens"] > 0 + + +def test_api_request_optimizations_e2e( + smoke_server, + smoke_config: SmokeConfig, + smoke_headers: dict[str, str], +) -> None: + cases: tuple[tuple[str, dict[str, Any], str], ...] = ( + ( + "quota", + { + "model": "claude-3-5-sonnet-20241022", + "max_tokens": 1, + "messages": [{"role": "user", "content": "quota"}], + }, + "Quota check passed.", + ), + ( + "title", + { + "model": "claude-3-5-sonnet-20241022", + "system": ( + "Generate a concise, sentence-case title (3-7 words). " + 'Return JSON with a single "title" field.' + ), + "messages": [{"role": "user", "content": "hello"}], + }, + "Conversation", + ), + ( + "prefix", + { + "model": "claude-3-5-sonnet-20241022", + "messages": [ + { + "role": "user", + "content": ( + "extract command\n" + "Command: git status --short" + ), + } + ], + }, + "git", + ), + ( + "suggestion", + { + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "[SUGGESTION MODE: next]"}], + }, + "", + ), + ( + "filepath", + { + "model": "claude-3-5-sonnet-20241022", + "system": "Extract any file paths that this command output contains.", + "messages": [ + { + "role": "user", + "content": ( + "Command: cat smoke/product/test_api_product_live.py\n" + "Output: file contents\n" + ), + } + ], + }, + "smoke/product/test_api_product_live.py", + ), + ) + for name, payload, expected_text in cases: + response = httpx.post( + f"{smoke_server.base_url}/v1/messages", + headers=smoke_headers, + json=payload, + timeout=smoke_config.timeout_s, + ) + assert response.status_code == 200, (name, response.text) + text = response.json()["content"][0]["text"] + assert expected_text in text + + +def test_api_error_shape_e2e( + smoke_server, + smoke_config: SmokeConfig, + smoke_headers: dict[str, str], +) -> None: + response = httpx.post( + f"{smoke_server.base_url}/v1/messages", + headers=smoke_headers, + json={"model": "claude-3-5-sonnet-20241022", "messages": []}, + timeout=smoke_config.timeout_s, + ) + assert response.status_code == 400 + payload = response.json() + assert payload["type"] == "error" + assert payload["error"]["type"] == "invalid_request_error" + + +def test_api_stop_e2e(smoke_server, smoke_headers: dict[str, str]) -> None: + response = httpx.post( + f"{smoke_server.base_url}/stop", + headers=smoke_headers, + timeout=5, + ) + assert response.status_code == 503 + assert response.json()["detail"] == "Messaging system not initialized" diff --git a/smoke/product/test_auth_product_live.py b/smoke/product/test_auth_product_live.py new file mode 100644 index 0000000..6bd259c --- /dev/null +++ b/smoke/product/test_auth_product_live.py @@ -0,0 +1,51 @@ +import httpx +import pytest + +from smoke.lib.config import SmokeConfig +from smoke.lib.e2e import SmokeServerDriver + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("auth")] + + +def test_api_auth_header_variants_e2e(smoke_config: SmokeConfig, tmp_path) -> None: + token = "product-smoke-token" + env_file = tmp_path / "auth-product.env" + env_file.write_text(f'ANTHROPIC_AUTH_TOKEN="{token}"\n', encoding="utf-8") + with SmokeServerDriver( + smoke_config, + name="product-auth", + env_overrides={ + "ANTHROPIC_AUTH_TOKEN": token, + "FCC_ENV_FILE": str(env_file), + "MESSAGING_PLATFORM": "none", + }, + ).run() as server: + unauth = httpx.get( + f"{server.base_url}/v1/models", timeout=smoke_config.timeout_s + ) + x_api_key = httpx.get( + f"{server.base_url}/v1/models", + headers={"x-api-key": token}, + timeout=smoke_config.timeout_s, + ) + bearer = httpx.get( + f"{server.base_url}/v1/models", + headers={"authorization": f"Bearer {token}"}, + timeout=smoke_config.timeout_s, + ) + anthropic = httpx.get( + f"{server.base_url}/v1/models", + headers={"anthropic-auth-token": token}, + timeout=smoke_config.timeout_s, + ) + invalid = httpx.get( + f"{server.base_url}/v1/models", + headers={"x-api-key": "wrong"}, + timeout=smoke_config.timeout_s, + ) + + assert unauth.status_code == 401 + assert x_api_key.status_code == 200 + assert bearer.status_code == 200 + assert anthropic.status_code == 200 + assert invalid.status_code == 401 diff --git a/smoke/product/test_cli_package_product_live.py b/smoke/product/test_cli_package_product_live.py new file mode 100644 index 0000000..cf269c9 --- /dev/null +++ b/smoke/product/test_cli_package_product_live.py @@ -0,0 +1,116 @@ +import asyncio +import os +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.cli.managed.manager import ManagedClaudeSessionManager +from free_claude_code.cli.managed.session import ManagedClaudeSession +from free_claude_code.core.version import package_version +from smoke.lib.child_process import cmd_fcc_init, cmd_fcc_version, run_captured_text +from smoke.lib.config import SmokeConfig + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("cli")] + + +def test_entrypoint_init_e2e(smoke_config: SmokeConfig, tmp_path: Path) -> None: + env = os.environ.copy() + env["HOME"] = str(tmp_path) + env["USERPROFILE"] = str(tmp_path) + result = run_captured_text( + cmd_fcc_init(), + cwd=smoke_config.root, + env=env, + timeout=smoke_config.timeout_s, + check=False, + ) + assert result.returncode == 0, result.stderr or result.stdout + env_file = tmp_path / ".fcc" / ".env" + assert env_file.is_file() + assert env_file.read_text(encoding="utf-8").strip() + + +def test_entrypoint_version_e2e(smoke_config: SmokeConfig, tmp_path: Path) -> None: + env = os.environ.copy() + env["HOME"] = str(tmp_path) + env["USERPROFILE"] = str(tmp_path) + + result = run_captured_text( + cmd_fcc_version(), + cwd=smoke_config.root, + env=env, + timeout=smoke_config.timeout_s, + check=False, + ) + + assert result.returncode == 0 + assert result.stdout == f"free-claude-code {package_version()}\n" + assert result.stderr == "" + assert not (tmp_path / ".fcc" / ".env").exists() + + +@pytest.mark.asyncio +async def test_cli_session_resume_fork_e2e(tmp_path: Path) -> None: + session = ManagedClaudeSession(str(tmp_path), "http://127.0.0.1:8082") + process = AsyncMock() + process.stdout.read.side_effect = [b""] + process.stderr.read.return_value = b"" + process.wait.return_value = 0 + process.returncode = 0 + + with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as spawn: + spawn.return_value = process + async for _event in session.start_task( + "resume this", + session_id="sess_product", + fork_session=True, + ): + pass + + args = spawn.call_args[0] + assert args[:3] == ("claude", "--resume", "sess_product") + assert "--fork-session" in args + assert "-p" in args + assert "resume this" in args + + +@pytest.mark.asyncio +async def test_cli_process_cleanup_e2e(tmp_path: Path) -> None: + manager = ManagedClaudeSessionManager( + workspace_path=str(tmp_path), + proxy_root_url="http://127.0.0.1:8082", + ) + session, pending_id, is_new = await manager.get_or_create_session() + assert is_new is True + assert pending_id.startswith("pending_") + + mocked_stop = AsyncMock(return_value=True) + with patch.object(session, "stop", mocked_stop): + await manager.stop_all() + + mocked_stop.assert_awaited_once() + assert manager.get_stats() == { + "active_sessions": 0, + "pending_sessions": 0, + "busy_count": 0, + } + + +@pytest.mark.asyncio +async def test_cli_session_stop_kills_child_e2e(tmp_path: Path) -> None: + session = ManagedClaudeSession(str(tmp_path), "http://127.0.0.1:8082") + process = MagicMock() + process.pid = 123456 + process.returncode = None + process.wait = AsyncMock(side_effect=[asyncio.TimeoutError, 0]) + session.process = process + + with patch( + "free_claude_code.cli.managed.session.kill_pid_tree_best_effort" + ) as kill_tree: + stopped = await session.stop() + + assert stopped is True + kill_tree.assert_called_once_with(process.pid) + process.kill.assert_called_once() diff --git a/smoke/product/test_client_product_live.py b/smoke/product/test_client_product_live.py new file mode 100644 index 0000000..fec8f66 --- /dev/null +++ b/smoke/product/test_client_product_live.py @@ -0,0 +1,286 @@ +import json +import os +import shutil +import subprocess +import threading +from collections.abc import Iterator +from contextlib import contextmanager +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import pytest + +from smoke.lib.config import SmokeConfig +from smoke.lib.e2e import ( + ClientProtocolDriver, + ConversationDriver, + ProviderMatrixDriver, + SmokeServerDriver, + assert_product_stream, +) + +pytestmark = [pytest.mark.live] + + +@pytest.mark.smoke_target("clients") +def test_vscode_protocol_e2e(smoke_config: SmokeConfig) -> None: + provider_model = ProviderMatrixDriver(smoke_config).first_model() + with SmokeServerDriver( + smoke_config, + name="product-vscode", + env_overrides={ + "MODEL": provider_model.full_model, + "MESSAGING_PLATFORM": "none", + }, + ).run() as server: + turn = ConversationDriver(server, smoke_config).stream( + ClientProtocolDriver.adaptive_thinking_payload(), + headers=ClientProtocolDriver.vscode_headers(), + ) + + assert_product_stream(turn.events) + + +@pytest.mark.smoke_target("clients") +def test_jetbrains_protocol_e2e(smoke_config: SmokeConfig) -> None: + provider_model = ProviderMatrixDriver(smoke_config).first_model() + with SmokeServerDriver( + smoke_config, + name="product-jetbrains", + env_overrides={ + "MODEL": provider_model.full_model, + "MESSAGING_PLATFORM": "none", + }, + ).run() as server: + driver = ConversationDriver(server, smoke_config) + first = driver.stream( + ClientProtocolDriver.tool_result_payload(), + headers=ClientProtocolDriver.jetbrains_headers(smoke_config), + ) + + assert_product_stream(first.events) + + +@pytest.mark.smoke_target("clients") +def test_pi_cli_prompt_e2e(smoke_config: SmokeConfig, tmp_path: Path) -> None: + if not shutil.which("pi"): + pytest.skip("missing_env: Pi CLI not found") + uv_bin = shutil.which("uv") + if not uv_bin: + pytest.skip("missing_env: uv not found") + provider_model = ProviderMatrixDriver(smoke_config).first_model() + auth_token = "fcc-pi-smoke-token" + + with SmokeServerDriver( + smoke_config, + name="product-pi-cli", + env_overrides={ + "MODEL": provider_model.full_model, + "ANTHROPIC_AUTH_TOKEN": auth_token, + "MESSAGING_PLATFORM": "none", + }, + ).run() as server: + env = os.environ.copy() + env.update( + { + "HOST": "127.0.0.1", + "PORT": str(server.port), + "FCC_OPEN_BROWSER": "0", + "ANTHROPIC_AUTH_TOKEN": auth_token, + "PI_CODING_AGENT_DIR": str(tmp_path / "pi-agent"), + } + ) + result = subprocess.run( + [ + uv_bin, + "run", + "--project", + str(smoke_config.root), + "--no-sync", + "fcc-pi", + "--no-session", + "--no-approve", + "--print", + "Reply with exactly FCC_SMOKE_PI", + ], + cwd=tmp_path, + env=env, + check=False, + capture_output=True, + text=True, + timeout=smoke_config.timeout_s + 15, + ) + server_log = server.log_path.read_text(encoding="utf-8", errors="replace") + + assert result.returncode == 0, result.stderr or result.stdout + assert "FCC_SMOKE_PI" in result.stdout + assert "POST /v1/messages" in server_log + + +@pytest.mark.smoke_target("cli") +def test_claude_cli_adaptive_thinking_e2e( + smoke_config: SmokeConfig, tmp_path: Path +) -> None: + claude_bin = shutil.which(smoke_config.claude_bin) + if not claude_bin: + pytest.skip(f"missing_env: Claude CLI not found: {smoke_config.claude_bin}") + provider_model = ProviderMatrixDriver(smoke_config).first_model() + + with SmokeServerDriver( + smoke_config, + name="product-claude-cli-adaptive", + env_overrides={ + "MODEL": provider_model.full_model, + "MESSAGING_PLATFORM": "none", + }, + ).run() as server: + result = ClientProtocolDriver.run_claude_prompt( + claude_bin=claude_bin, + server=server, + config=smoke_config, + cwd=tmp_path, + prompt="think hard, then reply with exactly FCC_SMOKE_CLI", + ) + server_log = server.log_path.read_text(encoding="utf-8", errors="replace") + + assert result.returncode == 0, result.stderr or result.stdout + assert "POST /v1/messages" in server_log + assert " 422 " not in server_log + assert 'HTTP/1.1" 422' not in server_log + assert "400 Bad Request" not in result.stdout + assert "FCC_SMOKE_CLI" in result.stdout + + +@pytest.mark.smoke_target("cli") +def test_claude_cli_provider_error_e2e( + smoke_config: SmokeConfig, tmp_path: Path +) -> None: + claude_bin = shutil.which(smoke_config.claude_bin) + if not claude_bin: + pytest.skip(f"missing_env: Claude CLI not found: {smoke_config.claude_bin}") + broken_model = "lmstudio/fcc-smoke-failing-model" + + with ( + _deliberately_failing_openai_provider() as ( + provider_base_url, + provider_requests, + ), + SmokeServerDriver( + smoke_config, + name="product-claude-cli-provider-error", + env_overrides={ + "MODEL": broken_model, + "MODEL_OPUS": broken_model, + "MODEL_SONNET": broken_model, + "MODEL_HAIKU": broken_model, + "LM_STUDIO_BASE_URL": provider_base_url, + "MESSAGING_PLATFORM": "none", + }, + ).run() as server, + ): + result = ClientProtocolDriver.run_claude_prompt( + claude_bin=claude_bin, + server=server, + config=smoke_config, + cwd=tmp_path, + prompt="Reply with exactly FCC_SMOKE_UNREACHABLE.", + model="claude-sonnet-4-5-20250929", + ) + server_log = server.log_path.read_text(encoding="utf-8", errors="replace") + + combined = f"{result.stdout}\n{result.stderr}" + lower = combined.lower() + downstream_requests = sum( + "POST /v1/messages" in line and "HTTP/1.1" in line + for line in server_log.splitlines() + ) + + assert result.returncode != 0 + assert "empty or malformed" not in lower + assert "proxy or gateway intercepting" not in lower + assert "api error" in lower or "selected model" in lower + assert "fcc smoke provider rejected the request deliberately" in lower + assert downstream_requests == 1, server_log + assert provider_requests == ["/v1/chat/completions"] + + +@contextmanager +def _deliberately_failing_openai_provider() -> Iterator[tuple[str, list[str]]]: + provider_requests: list[str] = [] + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + if self.path == "/v1/models": + self._write_json( + HTTPStatus.OK, + { + "object": "list", + "data": [ + { + "id": "fcc-smoke-failing-model", + "object": "model", + } + ], + }, + ) + return + if self.path == "/api/v0/models": + self._write_json(HTTPStatus.OK, {"data": []}) + return + self._write_json(HTTPStatus.NOT_FOUND, {"error": "not found"}) + + def do_POST(self) -> None: + length = int(self.headers.get("content-length", "0")) + self.rfile.read(length) + provider_requests.append(self.path) + self._write_json( + HTTPStatus.BAD_REQUEST, + { + "error": { + "type": "invalid_request_error", + "code": "fcc_smoke_failure", + "message": "FCC smoke provider rejected the request deliberately.", + } + }, + ) + + def log_message(self, format: str, *args: object) -> None: + return + + def _write_json(self, status: HTTPStatus, payload: object) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + port = int(server.server_address[1]) + yield f"http://127.0.0.1:{port}/v1", provider_requests + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +@pytest.mark.smoke_target("cli") +def test_claude_cli_multiturn_tool_protocol_e2e(smoke_config: SmokeConfig) -> None: + provider_model = ProviderMatrixDriver(smoke_config).first_model() + with SmokeServerDriver( + smoke_config, + name="product-claude-cli-protocol", + env_overrides={ + "MODEL": provider_model.full_model, + "MESSAGING_PLATFORM": "none", + }, + ).run() as server: + turn = ConversationDriver(server, smoke_config).stream( + ClientProtocolDriver.tool_result_payload() + ) + + assert_product_stream(turn.events) diff --git a/smoke/product/test_config_extensibility_product_live.py b/smoke/product/test_config_extensibility_product_live.py new file mode 100644 index 0000000..7461574 --- /dev/null +++ b/smoke/product/test_config_extensibility_product_live.py @@ -0,0 +1,176 @@ +import os + +import pytest + +from free_claude_code.config.provider_catalog import PROVIDER_CATALOG +from free_claude_code.config.settings import Settings +from free_claude_code.messaging.platforms.factory import create_messaging_components +from free_claude_code.providers.runtime import build_provider_config +from smoke.lib.child_process import ( + cmd_free_claude_code_serve, + cmd_python_c, + run_captured_text, +) +from smoke.lib.config import SmokeConfig +from smoke.lib.e2e import SmokeServerDriver + +pytestmark = [pytest.mark.live] + + +@pytest.mark.smoke_target("config") +def test_env_precedence_e2e(smoke_config: SmokeConfig, tmp_path) -> None: + env_file = tmp_path / "product.env" + env_file.write_text( + 'MODEL="open_router/test/model"\nANTHROPIC_AUTH_TOKEN="dotenv-token"\n', + encoding="utf-8", + ) + env = os.environ.copy() + env["FCC_ENV_FILE"] = str(env_file) + env["MODEL"] = "nvidia_nim/process-model" + env["ANTHROPIC_AUTH_TOKEN"] = "process-token" + script = ( + "from free_claude_code.config.settings import get_settings; " + "s=get_settings(); " + "print(s.model); print(s.anthropic_auth_token)" + ) + result = run_captured_text( + cmd_python_c(script), + cwd=smoke_config.root, + env=env, + timeout=smoke_config.timeout_s, + check=False, + ) + assert result.returncode == 0, result.stderr + lines = result.stdout.splitlines() + assert lines == ["nvidia_nim/process-model", "dotenv-token"] + + +@pytest.mark.smoke_target("config") +def test_removed_env_migration_e2e(smoke_config: SmokeConfig, tmp_path) -> None: + env_file = tmp_path / "removed.env" + env_file.write_text('NIM_ENABLE_THINKING="true"\n', encoding="utf-8") + env = os.environ.copy() + env["FCC_ENV_FILE"] = str(env_file) + result = run_captured_text( + cmd_python_c( + "from free_claude_code.config.settings import Settings; Settings()" + ), + cwd=smoke_config.root, + env=env, + timeout=smoke_config.timeout_s, + check=False, + ) + assert result.returncode == 0, result.stderr + + +@pytest.mark.smoke_target("config") +def test_per_model_thinking_config_e2e(smoke_config: SmokeConfig, tmp_path) -> None: + env_file = tmp_path / "thinking.env" + env_file.write_text( + 'ENABLE_MODEL_THINKING="false"\n' + 'ENABLE_OPUS_THINKING="true"\n' + "ENABLE_SONNET_THINKING=\n" + 'ENABLE_HAIKU_THINKING="false"\n', + encoding="utf-8", + ) + env = os.environ.copy() + env["FCC_ENV_FILE"] = str(env_file) + script = ( + "from free_claude_code.application.routing import ModelRouter; " + "from free_claude_code.config.settings import Settings; " + "s=Settings(); " + "r=ModelRouter(s); " + "print(r.resolve('claude-opus-4-20250514').thinking_enabled); " + "print(r.resolve('claude-sonnet-4-20250514').thinking_enabled); " + "print(r.resolve('claude-haiku-4-20250514').thinking_enabled); " + "print(r.resolve('unknown-model').thinking_enabled)" + ) + result = run_captured_text( + cmd_python_c(script), + cwd=smoke_config.root, + env=env, + timeout=smoke_config.timeout_s, + check=False, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.splitlines() == ["True", "False", "False", "False"] + + +@pytest.mark.smoke_target("config") +def test_proxy_timeout_config_e2e(smoke_config: SmokeConfig, tmp_path) -> None: + env_file = tmp_path / "timeouts.env" + env_file.write_text( + 'MODEL="open_router/test/model"\n' + 'OPENROUTER_API_KEY="key"\n' + 'OPENROUTER_PROXY="socks5://127.0.0.1:9999"\n' + 'HTTP_READ_TIMEOUT="321"\n' + 'HTTP_CONNECT_TIMEOUT="7"\n' + 'HTTP_WRITE_TIMEOUT="8"\n', + encoding="utf-8", + ) + env = os.environ.copy() + env["FCC_ENV_FILE"] = str(env_file) + script = ( + "from free_claude_code.config.settings import Settings; " + "from free_claude_code.config.provider_catalog import PROVIDER_CATALOG; " + "from free_claude_code.providers.runtime import build_provider_config; " + "s=Settings(); c=build_provider_config(PROVIDER_CATALOG['open_router'], s); " + "print(c.proxy); print(c.http_read_timeout); " + "print(c.http_connect_timeout); print(c.http_write_timeout)" + ) + result = run_captured_text( + cmd_python_c(script), + cwd=smoke_config.root, + env=env, + timeout=smoke_config.timeout_s, + check=False, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.splitlines() == [ + "socks5://127.0.0.1:9999", + "321.0", + "7.0", + "8.0", + ] + + +@pytest.mark.smoke_target("extensibility") +def test_provider_runtime_config_e2e() -> None: + settings_kwargs: dict[str, str] = {} + for descriptor in PROVIDER_CATALOG.values(): + if descriptor.credential_attr is not None: + settings_kwargs[_settings_init_key(descriptor.credential_attr)] = ( + f"{descriptor.provider_id}-key" + ) + if descriptor.base_url_attr is not None and descriptor.default_base_url: + settings_kwargs[_settings_init_key(descriptor.base_url_attr)] = ( + descriptor.default_base_url + ) + settings = Settings.model_validate(settings_kwargs) + for descriptor in PROVIDER_CATALOG.values(): + config = build_provider_config(descriptor, settings) + assert config.base_url + assert config.api_key + + +def _settings_init_key(field_name: str) -> str: + alias = Settings.model_fields[field_name].validation_alias + return alias if isinstance(alias, str) else field_name + + +@pytest.mark.smoke_target("extensibility") +def test_platform_factory_e2e() -> None: + assert create_messaging_components("not-a-platform") is None + assert create_messaging_components("telegram") is None + assert create_messaging_components("discord") is None + + +@pytest.mark.smoke_target("cli") +def test_entrypoint_server_e2e(smoke_config: SmokeConfig) -> None: + with SmokeServerDriver( + smoke_config, + name="product-entrypoint", + command=cmd_free_claude_code_serve(), + env_overrides={"MESSAGING_PLATFORM": "none"}, + ).run() as server: + assert server.process.poll() is None diff --git a/smoke/product/test_live_platform_product_live.py b/smoke/product/test_live_platform_product_live.py new file mode 100644 index 0000000..7c72784 --- /dev/null +++ b/smoke/product/test_live_platform_product_live.py @@ -0,0 +1,125 @@ +import os +import time + +import httpx +import pytest + +from smoke.lib.config import SmokeConfig + +pytestmark = [pytest.mark.live] + + +@pytest.mark.smoke_target("telegram") +def test_telegram_live_permissions_e2e(smoke_config: SmokeConfig) -> None: + token = smoke_config.settings.telegram_bot_token + if not token: + pytest.skip("missing_env: TELEGRAM_BOT_TOKEN is not configured") + + base_url = f"https://api.telegram.org/bot{token}" + get_me = httpx.get(f"{base_url}/getMe", timeout=smoke_config.timeout_s) + assert get_me.status_code == 200, get_me.text + assert get_me.json()["ok"] is True + + chat_id = os.getenv("FCC_SMOKE_TELEGRAM_CHAT_ID") or ( + smoke_config.settings.allowed_telegram_user_id or "" + ) + if not chat_id: + pytest.skip( + "missing_env: FCC_SMOKE_TELEGRAM_CHAT_ID or " + "ALLOWED_TELEGRAM_USER_ID required" + ) + + marker = f"FCC product smoke {int(time.time())}" + sent = httpx.post( + f"{base_url}/sendMessage", + json={"chat_id": chat_id, "text": marker}, + timeout=smoke_config.timeout_s, + ) + assert sent.status_code == 200, sent.text + message_id = sent.json()["result"]["message_id"] + + edited = httpx.post( + f"{base_url}/editMessageText", + json={"chat_id": chat_id, "message_id": message_id, "text": marker + " edit"}, + timeout=smoke_config.timeout_s, + ) + assert edited.status_code == 200, edited.text + + deleted = httpx.post( + f"{base_url}/deleteMessage", + json={"chat_id": chat_id, "message_id": message_id}, + timeout=smoke_config.timeout_s, + ) + assert deleted.status_code == 200, deleted.text + + +@pytest.mark.smoke_target("discord") +def test_discord_live_permissions_e2e(smoke_config: SmokeConfig) -> None: + token = smoke_config.settings.discord_bot_token + channel_id = os.getenv("FCC_SMOKE_DISCORD_CHANNEL_ID") + if not channel_id and smoke_config.settings.allowed_discord_channels: + channel_id = smoke_config.settings.allowed_discord_channels.split(",", 1)[0] + if not token: + pytest.skip("missing_env: DISCORD_BOT_TOKEN is not configured") + if not channel_id: + pytest.skip( + "missing_env: FCC_SMOKE_DISCORD_CHANNEL_ID or " + "ALLOWED_DISCORD_CHANNELS required" + ) + + headers = {"authorization": f"Bot {token}"} + base_url = "https://discord.com/api/v10" + + channel = httpx.get( + f"{base_url}/channels/{channel_id}", + headers=headers, + timeout=smoke_config.timeout_s, + ) + assert channel.status_code == 200, channel.text + + marker = f"FCC product smoke {int(time.time())}" + sent = httpx.post( + f"{base_url}/channels/{channel_id}/messages", + headers=headers, + json={"content": marker}, + timeout=smoke_config.timeout_s, + ) + assert sent.status_code == 200, sent.text + message_id = sent.json()["id"] + + edited = httpx.patch( + f"{base_url}/channels/{channel_id}/messages/{message_id}", + headers=headers, + json={"content": marker + " edit"}, + timeout=smoke_config.timeout_s, + ) + assert edited.status_code == 200, edited.text + + deleted = httpx.delete( + f"{base_url}/channels/{channel_id}/messages/{message_id}", + headers=headers, + timeout=smoke_config.timeout_s, + ) + assert deleted.status_code in {200, 204}, deleted.text + + +@pytest.mark.smoke_target("telegram") +def test_telegram_live_manual_inbound_e2e(smoke_config: SmokeConfig) -> None: + if not smoke_config.interactive: + pytest.skip("missing_env: set FCC_SMOKE_INTERACTIVE=1 for manual inbound smoke") + pytest.skip( + "manual product smoke: send the printed nonce to Telegram and verify " + "progress edits, final transcript, /stop, /clear, /stats, and voice note " + "behavior from the real client" + ) + + +@pytest.mark.smoke_target("discord") +def test_discord_live_manual_inbound_e2e(smoke_config: SmokeConfig) -> None: + if not smoke_config.interactive: + pytest.skip("missing_env: set FCC_SMOKE_INTERACTIVE=1 for manual inbound smoke") + pytest.skip( + "manual product smoke: send the printed nonce to Discord and verify " + "progress edits, final transcript, /stop, /clear, /stats, and voice note " + "behavior from the real client" + ) diff --git a/smoke/product/test_local_provider_product_live.py b/smoke/product/test_local_provider_product_live.py new file mode 100644 index 0000000..c572a67 --- /dev/null +++ b/smoke/product/test_local_provider_product_live.py @@ -0,0 +1,58 @@ +import pytest + +from smoke.lib.config import SmokeConfig +from smoke.lib.e2e import ConversationDriver, SmokeServerDriver, assert_product_stream +from smoke.lib.local_providers import first_local_provider_model_id + +pytestmark = [pytest.mark.live] + + +@pytest.mark.smoke_target("lmstudio") +def test_lmstudio_messages_e2e(smoke_config: SmokeConfig) -> None: + _local_provider_messages_e2e( + smoke_config, + provider="lmstudio", + base_url=smoke_config.settings.lm_studio_base_url, + ) + + +@pytest.mark.smoke_target("llamacpp") +def test_llamacpp_openai_chat_e2e(smoke_config: SmokeConfig) -> None: + _local_provider_messages_e2e( + smoke_config, + provider="llamacpp", + base_url=smoke_config.settings.llamacpp_base_url, + ) + + +@pytest.mark.smoke_target("ollama") +def test_ollama_openai_chat_e2e(smoke_config: SmokeConfig) -> None: + _local_provider_messages_e2e( + smoke_config, + provider="ollama", + base_url=smoke_config.settings.ollama_base_url, + ) + + +def _local_provider_messages_e2e( + smoke_config: SmokeConfig, + *, + provider: str, + base_url: str, +) -> None: + model_id = first_local_provider_model_id( + provider, + base_url, + timeout_s=smoke_config.timeout_s, + ) + + with SmokeServerDriver( + smoke_config, + name=f"product-{provider}-messages", + env_overrides={"MODEL": f"{provider}/{model_id}", "MESSAGING_PLATFORM": "none"}, + ).run() as server: + turn = ConversationDriver(server, smoke_config).ask( + "Reply with one short sentence." + ) + + assert_product_stream(turn.events) diff --git a/smoke/product/test_messaging_product_live.py b/smoke/product/test_messaging_product_live.py new file mode 100644 index 0000000..eb4ffd0 --- /dev/null +++ b/smoke/product/test_messaging_product_live.py @@ -0,0 +1,484 @@ +import asyncio +import json + +import pytest + +from free_claude_code.messaging.models import MessageScope +from free_claude_code.messaging.platforms.ports import MessagingStartupNotice +from free_claude_code.messaging.trees import MessageState, TreeIdentity +from smoke.lib.e2e import FakeCLISession, FakePlatformDriver, default_cli_events + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("messaging")] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("platform_name", ["discord", "telegram"]) +async def test_messaging_fake_full_flow_e2e(platform_name: str, tmp_path) -> None: + driver = FakePlatformDriver(platform_name, tmp_path) + + incoming = await driver.send("Please inspect README.", message_id="root_1") + + node = await driver.workflow.tree_queue.get_node( + incoming.scope, + incoming.message_id, + ) + assert node is not None + assert node.state == MessageState.COMPLETED + assert driver.platform.sent + assert driver.platform.edits + edit_text = "\n".join(edit["text"] for edit in driver.platform.edits) + assert "Fake platform answer" in edit_text + assert "Read" in edit_text + + +@pytest.mark.asyncio +@pytest.mark.parametrize("platform_name", ["discord", "telegram"]) +async def test_messaging_subagent_control_e2e(platform_name: str, tmp_path) -> None: + task_events = [ + {"type": "session_info", "session_id": "sess_task"}, + { + "type": "assistant", + "message": { + "content": [ + {"type": "thinking", "thinking": "Need a focused worker."}, + { + "type": "tool_use", + "id": "toolu_task", + "name": "Task", + "input": {"description": "inspect", "prompt": "inspect"}, + }, + {"type": "text", "text": "Subagent result rendered."}, + ] + }, + }, + {"type": "exit", "code": 0, "stderr": None}, + ] + driver = FakePlatformDriver(platform_name, tmp_path, event_batches=[task_events]) + + await driver.send("Delegate this safely.", message_id="root_task") + + edit_text = "\n".join(edit["text"] for edit in driver.platform.edits) + assert "Subagent" in edit_text + assert "Tool calls" in edit_text + + +@pytest.mark.asyncio +@pytest.mark.parametrize("platform_name", ["discord", "telegram"]) +async def test_messaging_commands_stop_clear_stats_e2e( + platform_name: str, tmp_path +) -> None: + driver = FakePlatformDriver(platform_name, tmp_path) + root = await driver.send("start work", message_id="root_1") + root_status_id = driver.platform.sent[-1]["message_id"] + + await driver.send("/stats", message_id="stats_1") + await driver.send("/stop", message_id="stop_1", reply_to=root.message_id) + await driver.send("/clear", message_id="clear_1", reply_to=root.message_id) + global_prompt = await driver.send("clear globally", message_id="global_prompt") + global_status_id = driver.platform.sent[-1]["message_id"] + await driver.send("/clear", message_id="clear_all") + + sent_text = "\n".join(sent["text"] for sent in driver.platform.sent) + deleted = {entry["message_id"] for entry in driver.platform.deletes} + assert "Stats" in sent_text + assert "Nothing to stop for that message" in sent_text + assert { + root.message_id, + root_status_id, + global_prompt.message_id, + global_status_id, + "clear_1", + "clear_all", + } <= deleted + assert driver.session_store.load_conversation_snapshot().trees == {} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("platform_name", ["discord", "telegram"]) +async def test_reply_clear_uses_literal_platform_subtree_e2e( + platform_name: str, + tmp_path, +) -> None: + driver = FakePlatformDriver(platform_name, tmp_path) + root = await driver.send("root", message_id="root") + root_status = driver.platform.sent[-1]["message_id"] + sibling = await driver.send( + "prompt sibling", + message_id="sibling", + reply_to=root.message_id, + ) + sibling_status = driver.platform.sent[-1]["message_id"] + descendant = await driver.send( + "status descendant", + message_id="descendant", + reply_to=root_status, + ) + descendant_status = driver.platform.sent[-1]["message_id"] + + await driver.send( + "/clear", + message_id="clear-status", + reply_to=root_status, + ) + + deleted = {entry["message_id"] for entry in driver.platform.deletes} + assert { + root_status, + descendant.message_id, + descendant_status, + "clear-status", + } <= deleted + assert {root.message_id, sibling.message_id, sibling_status}.isdisjoint(deleted) + root_view = await driver.workflow.tree_queue.get_node(root.scope, root.message_id) + sibling_view = await driver.workflow.tree_queue.get_node( + sibling.scope, sibling.message_id + ) + assert root_view is not None and root_view.state is MessageState.ERROR + assert root_view.session_id is None + assert sibling_view is not None and sibling_view.state is MessageState.COMPLETED + assert ( + await driver.workflow.tree_queue.get_node( + descendant.scope, descendant.message_id + ) + is None + ) + + await driver.send( + "fresh continuation", + message_id="fresh", + reply_to=root.message_id, + ) + fresh_call = next( + call + for session in driver.cli_manager.sessions + for call in session.calls + if call["prompt"] == "fresh continuation" + ) + assert fresh_call["session_id"] is None + + +@pytest.mark.asyncio +async def test_messaging_startup_notice_is_clearable_e2e(tmp_path) -> None: + first_driver = FakePlatformDriver("telegram", tmp_path) + scope = MessageScope(platform="telegram", chat_id="chat_1") + + await first_driver.workflow.publish_startup_notice( + MessagingStartupNotice( + chat_id=scope.chat_id, + transport_label="Bot API", + ) + ) + first_startup_id = first_driver.platform.sent[-1]["message_id"] + first_driver.session_store.flush_pending_save() + + driver = FakePlatformDriver("telegram", tmp_path) + await driver.platform.send_message("untracked", "advance fake message ID") + await driver.workflow.publish_startup_notice( + MessagingStartupNotice( + chat_id=scope.chat_id, + transport_label="Bot API", + ) + ) + second_startup = driver.platform.sent[-1] + second_startup_id = second_startup["message_id"] + assert second_startup["text"] == ( + "🚀 *Claude Code Proxy is online\\!* \\(Bot API\\)" + ) + assert second_startup["parse_mode"] == "MarkdownV2" + assert driver.session_store.get_tracked_message_ids_for_chat( + scope.platform, scope.chat_id + ) == [first_startup_id, second_startup_id] + + await driver.send("/clear", message_id="clear_startup") + + deleted = {entry["message_id"] for entry in driver.platform.deletes} + assert {first_startup_id, second_startup_id, "clear_startup"} <= deleted + assert ( + driver.session_store.get_tracked_message_ids_for_chat( + scope.platform, scope.chat_id + ) + == [] + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("platform_name", ["discord", "telegram"]) +async def test_messaging_active_stop_uses_status_only_e2e( + platform_name: str, + tmp_path, + monkeypatch, +) -> None: + driver = FakePlatformDriver(platform_name, tmp_path) + started = asyncio.Event() + + class GatedActiveSession(FakeCLISession): + async def start_task( + self, + prompt: str, + session_id: str | None = None, + fork_session: bool = False, + ): + self.calls.append( + { + "prompt": prompt, + "session_id": session_id, + "fork_session": fork_session, + } + ) + self.is_busy = True + try: + yield { + "type": "assistant", + "message": { + "content": [{"type": "text", "text": "partial answer"}] + }, + } + started.set() + await asyncio.Event().wait() + finally: + self.is_busy = False + + async def controlled_session(session_id: str | None = None): + session = GatedActiveSession([]) + driver.cli_manager.sessions.append(session) + return session, session_id or "pending_0", session_id is None + + monkeypatch.setattr( + driver.cli_manager, + "get_or_create_session", + controlled_session, + ) + root = await driver.emit("active work", message_id="root_active") + await started.wait() + status_id = driver.platform.sent[-1]["message_id"] + sent_before_stop = len(driver.platform.sent) + + await driver.emit( + "/stop", + message_id="stop_active", + reply_to=root.message_id, + ) + await driver.wait_for_idle() + + assert len(driver.platform.sent) == sent_before_stop + stopped_edits = [ + edit + for edit in driver.platform.edits + if edit["message_id"] == status_id and "Stopped" in edit["text"] + ] + assert stopped_edits + assert "partial answer" in stopped_edits[-1]["text"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("platform_name", ["discord", "telegram"]) +async def test_tree_threading_e2e(platform_name: str, tmp_path) -> None: + batches = [default_cli_events("sess_root"), default_cli_events("sess_branch")] + driver = FakePlatformDriver(platform_name, tmp_path, event_batches=batches) + + root = await driver.send("root prompt", message_id="root_1") + branch = await driver.send( + "branch prompt", message_id="branch_1", reply_to=root.message_id + ) + + branch_node = await driver.workflow.tree_queue.get_node( + branch.scope, + branch.message_id, + ) + assert branch_node is not None + assert branch_node.parent_id == root.message_id + assert driver.cli_manager.sessions[1].calls[0]["session_id"] == "sess_root" + assert driver.cli_manager.sessions[1].calls[0]["fork_session"] is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize("platform_name", ["discord", "telegram"]) +async def test_messaging_queued_scoped_cancel_e2e( + platform_name: str, + tmp_path, + monkeypatch, +) -> None: + driver = FakePlatformDriver(platform_name, tmp_path) + root_started = asyncio.Event() + release_root = asyncio.Event() + + class GatedRootSession(FakeCLISession): + async def start_task( + self, + prompt: str, + session_id: str | None = None, + fork_session: bool = False, + ): + self.calls.append( + { + "prompt": prompt, + "session_id": session_id, + "fork_session": fork_session, + } + ) + self.is_busy = True + root_started.set() + try: + await release_root.wait() + for event in self.events: + await asyncio.sleep(0) + yield event + finally: + self.is_busy = False + + async def controlled_session(session_id: str | None = None): + index = len(driver.cli_manager.sessions) + events = default_cli_events(f"sess_{index}") + session = GatedRootSession(events) if index == 0 else FakeCLISession(events) + driver.cli_manager.sessions.append(session) + return session, session_id or f"pending_{index}", session_id is None + + monkeypatch.setattr( + driver.cli_manager, + "get_or_create_session", + controlled_session, + ) + + root = await driver.emit("root", message_id="root") + await root_started.wait() + cancelled = await driver.emit( + "cancel me", + message_id="cancelled", + reply_to=root.message_id, + ) + cancelled_status_id = driver.platform.sent[-1]["message_id"] + survivor = await driver.emit( + "run me", + message_id="survivor", + reply_to=root.message_id, + ) + sent_before_stop = len(driver.platform.sent) + await driver.emit( + "/stop", + message_id="stop-cancelled", + reply_to=cancelled.message_id, + ) + assert len(driver.platform.sent) == sent_before_stop + + release_root.set() + await driver.wait_for_idle() + + prompts = [ + call["prompt"] + for session in driver.cli_manager.sessions + for call in session.calls + ] + assert prompts == ["root", "run me"] + cancelled_view = await driver.workflow.tree_queue.get_node( + cancelled.scope, + cancelled.message_id, + ) + survivor_view = await driver.workflow.tree_queue.get_node( + survivor.scope, + survivor.message_id, + ) + assert cancelled_view is not None + assert cancelled_view.state is MessageState.ERROR + assert survivor_view is not None + assert survivor_view.state is MessageState.COMPLETED + + rendered = "\n".join( + entry["text"] for entry in driver.platform.sent + driver.platform.edits + ) + assert "position 1" in rendered + assert "position 2" in rendered + assert "Stopped" in rendered + assert any( + edit["message_id"] == cancelled_status_id and "Stopped" in edit["text"] + for edit in driver.platform.edits + ) + driver.session_store.flush_pending_save() + persisted = driver.session_store.load_conversation_snapshot() + root_identity = TreeIdentity(scope=root.scope, root_id=root.message_id) + assert persisted.trees[root_identity].nodes["survivor"]["state"] == "completed" + + +@pytest.mark.asyncio +async def test_restart_restore_and_session_persistence_e2e(tmp_path) -> None: + first = FakePlatformDriver("telegram", tmp_path) + root = await first.send("persist me", message_id="root_1") + first.session_store.flush_pending_save() + + session_file = tmp_path / "telegram-sessions.json" + payload = json.loads(session_file.read_text(encoding="utf-8")) + assert payload["conversation"]["trees"] + assert payload["managed_messages"] + + restored = FakePlatformDriver("telegram", tmp_path) + restored.platform.continue_message_sequence_after(first.platform) + restored.workflow.restore() + saved = restored.session_store.load_conversation_snapshot() + assert saved.trees + identity = TreeIdentity(scope=root.scope, root_id=root.message_id) + assert saved.get_tree(identity) is not None + + reply = await restored.send( + "continue from disk", + message_id="reply_1", + reply_to=root.message_id, + ) + reply_view = await restored.workflow.tree_queue.get_node( + reply.scope, + reply.message_id, + ) + assert reply_view is not None + assert reply_view.parent_id == root.message_id + call = restored.cli_manager.sessions[0].calls[0] + assert call["session_id"] == "fake_session_1" + assert call["fork_session"] is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize("platform_name", ["discord", "telegram"]) +async def test_same_message_ids_are_isolated_by_chat_e2e( + platform_name: str, + tmp_path, +) -> None: + driver = FakePlatformDriver(platform_name, tmp_path) + first = await driver.send("first chat", chat_id="chat_a", message_id="42") + second = await driver.send("second chat", chat_id="chat_b", message_id="42") + + snapshot = await driver.workflow.tree_queue.snapshot() + assert set(snapshot.trees) == { + TreeIdentity(scope=first.scope, root_id="42"), + TreeIdentity(scope=second.scope, root_id="42"), + } + + reply = await driver.send( + "first chat reply", + chat_id="chat_a", + message_id="43", + reply_to="42", + ) + reply_view = await driver.workflow.tree_queue.get_node(reply.scope, "43") + assert reply_view is not None + assert reply_view.parent_id == "42" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("platform_name", ["discord", "telegram"]) +async def test_voice_platform_fake_e2e(platform_name: str, tmp_path) -> None: + driver = FakePlatformDriver(platform_name, tmp_path) + driver.platform.seed_pending_voice("chat_1", "voice_msg_1", "voice_status_1") + + await driver.send("/clear", message_id="clear_voice", reply_to="voice_msg_1") + + driver.platform.seed_pending_voice("chat_1", "voice_msg_2", "voice_status_2") + sent_before_stop = len(driver.platform.sent) + await driver.send("/stop", message_id="stop_voice") + + deleted = {entry["message_id"] for entry in driver.platform.deletes} + assert {"voice_msg_1", "voice_status_1", "clear_voice"} <= deleted + assert driver.platform.pending_voice_count == 0 + sent_text = "\n".join(sent["text"] for sent in driver.platform.sent) + assert "Voice note cancelled" not in sent_text + assert len(driver.platform.sent) == sent_before_stop + assert any( + edit["message_id"] == "voice_status_2" and "Stopped" in edit["text"] + for edit in driver.platform.edits + ) diff --git a/smoke/product/test_nvidia_nim_cli_product_live.py b/smoke/product/test_nvidia_nim_cli_product_live.py new file mode 100644 index 0000000..910c379 --- /dev/null +++ b/smoke/product/test_nvidia_nim_cli_product_live.py @@ -0,0 +1,68 @@ +import shutil +from pathlib import Path + +import pytest + +from smoke.lib.claude_cli_matrix import ( + CliMatrixOutcome, + regression_failures, + run_cli_feature_probes, + write_matrix_report, +) +from smoke.lib.config import SmokeConfig +from smoke.lib.e2e import SmokeServerDriver + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("nvidia_nim_cli")] + + +def test_nvidia_nim_cli_matrix_e2e(smoke_config: SmokeConfig, tmp_path: Path) -> None: + if not smoke_config.has_provider_configuration("nvidia_nim"): + pytest.skip("missing_env: NVIDIA_NIM_API_KEY is not configured") + + claude_bin = shutil.which(smoke_config.claude_bin) + if not claude_bin: + pytest.skip(f"missing_env: Claude CLI not found: {smoke_config.claude_bin}") + + provider_models = smoke_config.nvidia_nim_cli_models() + if not provider_models: + pytest.skip("missing_env: no NVIDIA NIM CLI smoke models configured") + + outcomes: list[CliMatrixOutcome] = [] + for provider_model in provider_models: + with SmokeServerDriver( + smoke_config, + name=f"product-nvidia-nim-cli-{_slug(provider_model.model_name)}", + env_overrides={ + "MODEL": provider_model.full_model, + "MESSAGING_PLATFORM": "none", + "ENABLE_MODEL_THINKING": "true", + "LOG_RAW_API_PAYLOADS": "true", + "LOG_RAW_SSE_EVENTS": "true", + }, + ).run() as server: + outcomes.extend( + run_cli_feature_probes( + claude_bin=claude_bin, + server=server, + smoke_config=smoke_config, + provider_model=provider_model, + model_dir=tmp_path / _slug(provider_model.model_name), + marker_prefix="NIM", + ) + ) + + report_path = write_matrix_report( + smoke_config, + outcomes, + target="nvidia_nim_cli", + filename_prefix="nvidia-nim-cli", + ) + failures = regression_failures(outcomes) + assert not failures, ( + f"NVIDIA NIM CLI matrix regressions written to {report_path}:\n" + + "\n".join(failures) + ) + + +def _slug(value: str) -> str: + return "".join(char if char.isalnum() else "-" for char in value).strip("-") diff --git a/smoke/product/test_openrouter_free_cli_product_live.py b/smoke/product/test_openrouter_free_cli_product_live.py new file mode 100644 index 0000000..994803b --- /dev/null +++ b/smoke/product/test_openrouter_free_cli_product_live.py @@ -0,0 +1,70 @@ +import shutil +from pathlib import Path + +import pytest + +from smoke.lib.claude_cli_matrix import ( + CliMatrixOutcome, + regression_failures, + run_cli_feature_probes, + write_matrix_report, +) +from smoke.lib.config import SmokeConfig +from smoke.lib.e2e import SmokeServerDriver + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("openrouter_free_cli")] + + +def test_openrouter_free_cli_matrix_e2e( + smoke_config: SmokeConfig, tmp_path: Path +) -> None: + if not smoke_config.has_provider_configuration("open_router"): + pytest.skip("missing_env: OPENROUTER_API_KEY is not configured") + + claude_bin = shutil.which(smoke_config.claude_bin) + if not claude_bin: + pytest.skip(f"missing_env: Claude CLI not found: {smoke_config.claude_bin}") + + provider_models = smoke_config.openrouter_free_cli_models() + if not provider_models: + pytest.skip("missing_env: no OpenRouter free CLI smoke models configured") + + outcomes: list[CliMatrixOutcome] = [] + for provider_model in provider_models: + with SmokeServerDriver( + smoke_config, + name=f"product-openrouter-free-cli-{_slug(provider_model.model_name)}", + env_overrides={ + "MODEL": provider_model.full_model, + "MESSAGING_PLATFORM": "none", + "ENABLE_MODEL_THINKING": "true", + "LOG_RAW_API_PAYLOADS": "true", + "LOG_RAW_SSE_EVENTS": "true", + }, + ).run() as server: + outcomes.extend( + run_cli_feature_probes( + claude_bin=claude_bin, + server=server, + smoke_config=smoke_config, + provider_model=provider_model, + model_dir=tmp_path / _slug(provider_model.model_name), + marker_prefix="OPENROUTER_FREE", + ) + ) + + report_path = write_matrix_report( + smoke_config, + outcomes, + target="openrouter_free_cli", + filename_prefix="openrouter-free-cli", + ) + failures = regression_failures(outcomes) + assert not failures, ( + f"OpenRouter free CLI matrix regressions written to {report_path}:\n" + + "\n".join(failures) + ) + + +def _slug(value: str) -> str: + return "".join(char if char.isalnum() else "-" for char in value).strip("-") diff --git a/smoke/product/test_provider_product_live.py b/smoke/product/test_provider_product_live.py new file mode 100644 index 0000000..3006e9d --- /dev/null +++ b/smoke/product/test_provider_product_live.py @@ -0,0 +1,583 @@ +from typing import Any + +import httpx +import pytest + +from free_claude_code.application.routing import ModelRouter +from free_claude_code.core.anthropic.stream_contracts import ( + SSEEvent, + parse_sse_lines, +) +from smoke.lib.config import ProviderModel, SmokeConfig, auth_headers +from smoke.lib.e2e import ( + ConversationDriver, + ProviderMatrixDriver, + SmokeServerDriver, + assert_product_stream, + echo_tool_schema, + tool_use_blocks, +) +from smoke.lib.skips import ( + skip_if_upstream_unavailable_events, + skip_if_upstream_unavailable_exception, +) + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("providers")] + + +def test_provider_matrix_presence_e2e(smoke_config: SmokeConfig) -> None: + models = ProviderMatrixDriver(smoke_config).provider_smoke_models() + assert models or smoke_config.provider_matrix == frozenset() + + +def test_model_mapping_matrix_e2e(smoke_config: SmokeConfig) -> None: + models = ProviderMatrixDriver(smoke_config).configured_models() + sources = {model.source for model in models} + assert sources <= {"MODEL", "MODEL_OPUS", "MODEL_SONNET", "MODEL_HAIKU"} + for model in models: + assert model.provider + assert model.model_name + + +def test_provider_text_multiturn_e2e( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + _run_provider_scenario(smoke_config, provider_model, _scenario_text_multiturn) + + +def test_provider_adaptive_thinking_history_e2e( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + _run_provider_scenario( + smoke_config, provider_model, _scenario_adaptive_thinking_history + ) + + +def test_provider_interleaved_thinking_tool_e2e( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + _run_provider_scenario(smoke_config, provider_model, _scenario_interleaved_history) + + +@pytest.mark.smoke_target("tools") +def test_provider_tool_use_then_text_history_e2e( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + """OpenAI-compatible path: history with tool_use + assistant text after tool (issue #206).""" + _run_provider_scenario( + smoke_config, provider_model, _scenario_tool_use_then_text_in_history + ) + + +@pytest.mark.smoke_target("tools") +def test_provider_tool_result_continuation_e2e( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + _run_provider_scenario( + smoke_config, provider_model, _scenario_tool_result_continuation + ) + + +@pytest.mark.smoke_target("tools") +def test_gemini_thought_signature_tool_continuation_e2e( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + if provider_model.provider != "gemini": + pytest.skip("gemini-specific smoke scenario") + _run_provider_scenario( + smoke_config, + provider_model, + _scenario_gemini_thought_signature_tool_continuation, + ) + + +@pytest.mark.smoke_target("tools") +def test_provider_reasoning_tool_continuation_e2e( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + if not _provider_smoke_thinking_enabled(smoke_config): + pytest.skip("the configured Claude route does not enable thinking") + _run_provider_scenario( + smoke_config, provider_model, _scenario_reasoning_tool_continuation + ) + + +def test_mistral_native_reasoning_model_e2e(smoke_config: SmokeConfig) -> None: + provider_model = smoke_config.mistral_reasoning_smoke_model() + if provider_model is None: + pytest.skip("missing_env: mistral is not configured") + + payload = { + "model": "claude-opus-4-7", + "max_tokens": 256, + "messages": [{"role": "user", "content": "Reply with one short sentence."}], + "thinking": {"type": "adaptive"}, + } + with _server_for_provider( + smoke_config, provider_model, "mistral-native-reasoning" + ) as server: + turn = ConversationDriver(server, smoke_config).stream(payload) + + _assert_provider_product_stream(turn.events) + event_text = "\n".join(event.raw for event in turn.events) + assert "thinking_delta" in event_text, ( + f"{provider_model.source}={provider_model.full_model} completed without " + "native Mistral thinking output" + ) + + +@pytest.mark.smoke_target("rate_limit") +def test_provider_disconnect_e2e( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + _run_provider_scenario(smoke_config, provider_model, _scenario_disconnect) + + +def test_provider_error_e2e(smoke_config: SmokeConfig) -> None: + provider_model = ProviderMatrixDriver(smoke_config).first_model() + broken_model = f"{provider_model.provider}/fcc-smoke-missing-model" + with ( + SmokeServerDriver( + smoke_config, + name=f"product-provider-error-{provider_model.provider}", + env_overrides={"MODEL": broken_model, "MESSAGING_PLATFORM": "none"}, + ).run() as server, + httpx.Client(timeout=smoke_config.timeout_s) as client, + ): + response = client.post( + f"{server.base_url}/v1/messages", + headers=auth_headers(), + json={ + "model": "fcc-smoke-default", + "max_tokens": 32, + "messages": [{"role": "user", "content": "hello"}], + }, + ) + + assert response.status_code >= 400 + assert response.headers["content-type"].startswith("application/json") + assert response.headers["x-should-retry"] == "false" + payload = response.json() + assert payload["type"] == "error" + assert payload["error"]["type"] + assert payload["error"]["message"] + assert payload["request_id"] == response.headers["request-id"] + + +def test_provider_codex_responses_text_e2e( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + try: + with ( + _server_for_provider( + smoke_config, provider_model, "codex-responses" + ) as server, + httpx.stream( + "POST", + f"{server.base_url}/v1/responses", + headers=_openai_auth_headers(smoke_config), + json={ + "model": provider_model.full_model, + "input": smoke_config.prompt, + "max_output_tokens": 128, + "stream": True, + }, + timeout=smoke_config.timeout_s, + ) as response, + ): + assert response.status_code == 200, response.read() + events = parse_sse_lines(response.iter_lines()) + except Exception as exc: + skip_if_upstream_unavailable_exception(exc) + raise + + skip_if_upstream_unavailable_events(events) + names = [event.event for event in events] + assert names[0] == "response.created", names + assert names[-1] == "response.completed", names + assert any(event.event == "response.output_text.delta" for event in events), names + + +def test_openrouter_native_e2e(smoke_config: SmokeConfig) -> None: + models = [ + model + for model in ProviderMatrixDriver(smoke_config).provider_smoke_models() + if model.provider == "open_router" + ] + if not models: + pytest.skip("missing_env: open_router is not configured") + + provider_model = models[0] + with SmokeServerDriver( + smoke_config, + name="product-openrouter-native", + env_overrides={ + "MODEL": provider_model.full_model, + "MESSAGING_PLATFORM": "none", + }, + ).run() as server: + turn = ConversationDriver(server, smoke_config).stream( + { + "model": "claude-opus-4-7", + "max_tokens": 256, + "messages": [ + { + "role": "user", + "content": "Reply with one short sentence.", + } + ], + "thinking": {"type": "adaptive", "budget_tokens": 1024}, + } + ) + _assert_provider_product_stream(turn.events) + + +def _run_provider_scenario( + smoke_config: SmokeConfig, + provider_model: ProviderModel, + scenario, +) -> None: + try: + scenario(smoke_config, provider_model) + except Exception as exc: + skip_if_upstream_unavailable_exception(exc) + raise AssertionError( + f"{provider_model.source}={provider_model.full_model}: " + f"{type(exc).__name__}: {exc}" + ) from exc + + +def _assert_provider_product_stream(events: list[SSEEvent]) -> None: + skip_if_upstream_unavailable_events(events) + assert_product_stream(events) + + +def _tool_use_blocks_or_skip( + events: list[SSEEvent], message: str +) -> list[dict[str, Any]]: + skip_if_upstream_unavailable_events(events) + blocks = tool_use_blocks(events) + assert blocks, message + return blocks + + +def _provider_smoke_thinking_enabled(smoke_config: SmokeConfig) -> bool: + return ( + ModelRouter(smoke_config.settings) + .resolve("claude-sonnet-4-5-20250929") + .thinking_enabled + ) + + +def _scenario_text_multiturn( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + with _server_for_provider(smoke_config, provider_model, "text") as server: + driver = ConversationDriver(server, smoke_config) + first = driver.ask("Reply with one short sentence.") + second = driver.ask("Reply with a different short sentence.") + _assert_provider_product_stream(first.events) + _assert_provider_product_stream(second.events) + + +def _scenario_adaptive_thinking_history( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + payload = { + "model": "claude-opus-4-7", + "max_tokens": 256, + "messages": [ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "unsigned hidden thought"}, + {"type": "redacted_thinking", "data": "opaque"}, + {"type": "text", "text": "Hello."}, + ], + }, + {"role": "user", "content": "Reply with one short sentence."}, + ], + "thinking": {"type": "adaptive", "budget_tokens": 1024}, + } + with _server_for_provider(smoke_config, provider_model, "adaptive") as server: + turn = ConversationDriver(server, smoke_config).stream(payload) + _assert_provider_product_stream(turn.events) + + +def _scenario_interleaved_history( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + payload = { + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 256, + "messages": [ + {"role": "user", "content": "Use the tool."}, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Need to inspect first."}, + {"type": "text", "text": "I will call the tool."}, + { + "type": "tool_use", + "id": "toolu_interleaved", + "name": "echo_smoke", + "input": {"value": "FCC_INTERLEAVED"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_interleaved", + "content": "FCC_INTERLEAVED", + } + ], + }, + ], + "tools": [echo_tool_schema()], + "thinking": {"type": "adaptive"}, + } + with _server_for_provider(smoke_config, provider_model, "interleaved") as server: + turn = ConversationDriver(server, smoke_config).stream(payload) + _assert_provider_product_stream(turn.events) + + +def _scenario_tool_use_then_text_in_history( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + tool_id = "toolu_206_smoke" + payload = { + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 256, + "messages": [ + {"role": "user", "content": "We will use echo_smoke once in this session."}, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": tool_id, + "name": "echo_smoke", + "input": {"value": "FCC_206_SMOKE"}, + }, + { + "type": "text", + "text": "Narration after the tool call (issue #206 shape).", + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": tool_id, + "content": "FCC_206_SMOKE", + }, + ], + }, + { + "role": "user", + "content": "Reply in one short sentence: did you see the echo value?", + }, + ], + "tools": [echo_tool_schema()], + } + with _server_for_provider(smoke_config, provider_model, "tool-206") as server: + turn = ConversationDriver(server, smoke_config).stream(payload) + _assert_provider_product_stream(turn.events) + + +def _scenario_tool_result_continuation( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + first_payload = { + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 256, + "messages": [ + {"role": "user", "content": "Use echo_smoke once with value FCC_TOOL."} + ], + "tools": [echo_tool_schema()], + "tool_choice": {"type": "tool", "name": "echo_smoke"}, + "thinking": {"type": "adaptive"}, + } + with _server_for_provider(smoke_config, provider_model, "tool") as server: + driver = ConversationDriver(server, smoke_config) + first = driver.stream(first_payload) + tool_uses = _tool_use_blocks_or_skip( + first.events, "provider did not emit a tool_use block" + ) + tool_use = tool_uses[0] + second_payload = { + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 256, + "messages": [ + first_payload["messages"][0], + {"role": "assistant", "content": first.assistant_content}, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": tool_use["id"], + "content": "FCC_TOOL", + } + ], + }, + ], + "tools": [echo_tool_schema()], + } + second = driver.stream(second_payload) + _assert_provider_product_stream(first.events) + _assert_provider_product_stream(second.events) + + +def _scenario_gemini_thought_signature_tool_continuation( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + first_payload = { + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 256, + "messages": [ + {"role": "user", "content": "Use echo_smoke once with value FCC_TOOL."} + ], + "tools": [echo_tool_schema()], + "tool_choice": {"type": "tool", "name": "echo_smoke"}, + "thinking": {"type": "adaptive", "budget_tokens": 1024}, + } + with _server_for_provider( + smoke_config, provider_model, "gemini-signature" + ) as server: + driver = ConversationDriver(server, smoke_config) + first = driver.stream(first_payload) + tool_uses = _tool_use_blocks_or_skip( + first.events, "gemini did not emit a tool_use block" + ) + tool_use = tool_uses[0] + signature = _gemini_tool_thought_signature(tool_use) + assert signature, ( + "gemini tool_use did not preserve extra_content.google.thought_signature" + ) + second_payload = { + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 256, + "messages": [ + first_payload["messages"][0], + {"role": "assistant", "content": first.assistant_content}, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": tool_use["id"], + "content": "FCC_TOOL", + } + ], + }, + ], + "tools": [echo_tool_schema()], + "thinking": {"type": "adaptive", "budget_tokens": 1024}, + } + second = driver.stream(second_payload) + _assert_provider_product_stream(first.events) + _assert_provider_product_stream(second.events) + + +def _gemini_tool_thought_signature(tool_use: dict[str, Any]) -> str | None: + extra_content = tool_use.get("extra_content") + if not isinstance(extra_content, dict): + return None + google = extra_content.get("google") + if not isinstance(google, dict): + return None + signature = google.get("thought_signature") + return signature if isinstance(signature, str) and signature else None + + +def _scenario_reasoning_tool_continuation( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + payload = { + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 256, + "messages": [ + {"role": "user", "content": "Use echo_smoke once with value FCC_TOOL."}, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Need to return the echo result."}, + { + "type": "tool_use", + "id": "toolu_reasoning_smoke", + "name": "echo_smoke", + "input": {"value": "FCC_TOOL"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_reasoning_smoke", + "content": "FCC_TOOL", + } + ], + }, + ], + "tools": [echo_tool_schema()], + "thinking": {"type": "adaptive"}, + } + with _server_for_provider(smoke_config, provider_model, "reasoning-tool") as server: + turn = ConversationDriver(server, smoke_config).stream(payload) + _assert_provider_product_stream(turn.events) + + +def _scenario_disconnect( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + with _server_for_provider(smoke_config, provider_model, "disconnect") as server: + with httpx.stream( + "POST", + f"{server.base_url}/v1/messages", + headers=auth_headers(), + json={ + "model": "fcc-smoke-default", + "max_tokens": 512, + "messages": [{"role": "user", "content": smoke_config.prompt}], + }, + timeout=smoke_config.timeout_s, + ) as response: + assert response.status_code == 200, response.read() + for _line in response.iter_lines(): + break + health = httpx.get(f"{server.base_url}/health", timeout=5) + assert health.status_code == 200 + followup = ConversationDriver(server, smoke_config).ask( + "Reply with one short sentence." + ) + _assert_provider_product_stream(followup.events) + + +def _server_for_provider( + smoke_config: SmokeConfig, provider_model: ProviderModel, name: str +): + return SmokeServerDriver( + smoke_config, + name=f"product-provider-{provider_model.provider}-{name}", + env_overrides={ + "MODEL": provider_model.full_model, + "MESSAGING_PLATFORM": "none", + }, + ).run() + + +def _openai_auth_headers(smoke_config: SmokeConfig) -> dict[str, str]: + headers = {"content-type": "application/json"} + token = smoke_config.settings.anthropic_auth_token + if token: + headers["authorization"] = f"Bearer {token}" + return headers diff --git a/smoke/product/test_runtime_ownership_product_live.py b/smoke/product/test_runtime_ownership_product_live.py new file mode 100644 index 0000000..3652770 --- /dev/null +++ b/smoke/product/test_runtime_ownership_product_live.py @@ -0,0 +1,311 @@ +"""Credential-free subprocess coverage for provider generation replacement.""" + +import json +import threading +import time +from contextlib import ExitStack +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +import httpx +import pytest + +from free_claude_code.config.env_template import load_env_template +from free_claude_code.config.provider_catalog import PROVIDER_CATALOG +from smoke.lib.config import SmokeConfig +from smoke.lib.server import RunningServer, start_server + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("api")] + + +class FakeOpenAIUpstream: + """Minimal OpenAI-chat upstream with an optionally held response stream.""" + + def __init__(self, label: str, *, hold_stream: bool) -> None: + self.label = label + self.hold_stream = hold_stream + self.chat_started = threading.Event() + self.release_stream = threading.Event() + self.chat_requests: list[dict[str, Any]] = [] + self._server: ThreadingHTTPServer | None = None + self._thread: threading.Thread | None = None + + @property + def base_url(self) -> str: + if self._server is None: + raise RuntimeError("Fake upstream is not running") + port = int(self._server.server_address[1]) + return f"http://127.0.0.1:{port}/v1" + + def __enter__(self) -> FakeOpenAIUpstream: + upstream = self + + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, format: str, *args: Any) -> None: + return + + def do_GET(self) -> None: + if self.path.rstrip("/").endswith("/models"): + upstream._send_models(self) + return + self.send_error(404) + + def do_POST(self) -> None: + if self.path.rstrip("/").endswith("/chat/completions"): + upstream._send_chat(self) + return + self.send_error(404) + + self._server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self._server.daemon_threads = True + self._thread = threading.Thread( + target=self._server.serve_forever, + name=f"fake-openai-{self.label}", + daemon=True, + ) + self._thread.start() + return self + + def __exit__(self, *_exc: object) -> None: + self.release_stream.set() + if self._server is not None: + self._server.shutdown() + self._server.server_close() + if self._thread is not None: + self._thread.join(timeout=5) + + def _send_models(self, handler: BaseHTTPRequestHandler) -> None: + payload = json.dumps( + { + "object": "list", + "data": [ + { + "id": f"model-{self.label}", + "object": "model", + "created": 0, + "owned_by": "smoke", + } + ], + } + ).encode() + handler.send_response(200) + handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(payload))) + handler.end_headers() + handler.wfile.write(payload) + + def _send_chat(self, handler: BaseHTTPRequestHandler) -> None: + length = int(handler.headers.get("content-length", "0")) + body = handler.rfile.read(length) + request = json.loads(body) if body else {} + self.chat_requests.append(request) + handler.send_response(200) + handler.send_header("Content-Type", "text/event-stream") + handler.send_header("Cache-Control", "no-cache") + handler.send_header("Connection", "close") + handler.end_headers() + try: + self._write_chunk( + handler, + content=f"provider-{self.label}-start ", + finish_reason=None, + ) + if self.hold_stream: + time.sleep(0.85) + self._write_chunk( + handler, + content=f"provider-{self.label}-held ", + finish_reason=None, + ) + self.chat_started.set() + if self.hold_stream and not self.release_stream.wait(timeout=30): + return + self._write_chunk( + handler, + content=f"provider-{self.label}-finish", + finish_reason=None, + ) + self._write_chunk(handler, content=None, finish_reason="stop") + handler.wfile.write(b"data: [DONE]\n\n") + handler.wfile.flush() + except OSError: + return + + def _write_chunk( + self, + handler: BaseHTTPRequestHandler, + *, + content: str | None, + finish_reason: str | None, + ) -> None: + delta: dict[str, str] = {} + if content is not None: + delta = {"role": "assistant", "content": content} + payload = { + "id": f"chatcmpl-{self.label}", + "object": "chat.completion.chunk", + "created": 0, + "model": f"model-{self.label}", + "choices": [ + { + "index": 0, + "delta": delta, + "finish_reason": finish_reason, + } + ], + } + handler.wfile.write(f"data: {json.dumps(payload)}\n\n".encode()) + handler.wfile.flush() + + +def _message_payload(*, stream: bool) -> dict[str, Any]: + return { + "model": "claude-sonnet-4-20250514", + "max_tokens": 64, + "messages": [{"role": "user", "content": "generation smoke"}], + "stream": stream, + } + + +def _write_initial_managed_config(home: Path, upstream: FakeOpenAIUpstream) -> None: + config_path = home / ".fcc" / ".env" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text( + load_env_template() + + "\n" + + "\n".join( + [ + "MODEL=lmstudio/model-a", + "MODEL_OPUS=", + "MODEL_SONNET=", + "MODEL_HAIKU=", + f"LM_STUDIO_BASE_URL={upstream.base_url}", + "ANTHROPIC_AUTH_TOKEN=", + "ENABLE_MODEL_THINKING=false", + "MESSAGING_PLATFORM=none", + "", + ] + ), + encoding="utf-8", + ) + + +def _wait_for_generation_close(server: RunningServer, generation_id: int) -> None: + deadline = time.monotonic() + 10 + generation_field = f'"generation_id": {generation_id}' + while time.monotonic() < deadline: + text = server.log_path.read_text(encoding="utf-8", errors="replace") + if "provider_generation.closed" in text and generation_field in text: + return + time.sleep(0.1) + raise AssertionError( + f"generation {generation_id} close trace was not written:\n" + + server.log_path.read_text(encoding="utf-8", errors="replace")[-3000:] + ) + + +def test_provider_hot_swap_preserves_inflight_stream_e2e( + smoke_config: SmokeConfig, + tmp_path: Path, +) -> None: + home = tmp_path / "home" + downstream_started = threading.Event() + old_body: list[str] = [] + old_errors: list[BaseException] = [] + credential_env_keys = { + descriptor.credential_env + for descriptor in PROVIDER_CATALOG.values() + if descriptor.credential_env is not None + } + + with ExitStack() as stack: + upstream_a = stack.enter_context(FakeOpenAIUpstream("a", hold_stream=True)) + upstream_b = stack.enter_context(FakeOpenAIUpstream("b", hold_stream=False)) + _write_initial_managed_config(home, upstream_a) + server = stack.enter_context( + start_server( + smoke_config, + name="runtime-ownership", + env_overrides={ + "HOME": str(home), + "USERPROFILE": str(home), + }, + env_unset=credential_env_keys + | { + "ANTHROPIC_AUTH_TOKEN", + "ENABLE_MODEL_THINKING", + "FCC_ENV_FILE", + "LM_STUDIO_BASE_URL", + "MODEL", + "MODEL_HAIKU", + "MODEL_OPUS", + "MODEL_SONNET", + }, + ) + ) + + def consume_old_stream() -> None: + try: + with httpx.stream( + "POST", + f"{server.base_url}/v1/messages", + json=_message_payload(stream=True), + timeout=30, + ) as response: + response.raise_for_status() + downstream_started.set() + old_body.append("".join(response.iter_text())) + except BaseException as exc: + old_errors.append(exc) + downstream_started.set() + + old_thread = threading.Thread( + target=consume_old_stream, + name="old-generation-consumer", + ) + old_thread.start() + assert downstream_started.wait(timeout=10) + assert upstream_a.chat_started.is_set() + assert old_errors == [] + + apply_response = httpx.post( + f"{server.base_url}/admin/api/config/apply", + json={ + "values": { + "MODEL": "lmstudio/model-b", + "LM_STUDIO_BASE_URL": upstream_b.base_url, + } + }, + timeout=smoke_config.timeout_s, + ) + assert apply_response.status_code == 200, apply_response.text + apply_body = apply_response.json() + assert apply_body["applied"] is True + assert apply_body["pending_fields"] == [] + assert apply_body["restart"]["required"] is False + + new_response = httpx.post( + f"{server.base_url}/v1/messages", + json=_message_payload(stream=False), + timeout=smoke_config.timeout_s, + ) + assert new_response.status_code == 200, new_response.text + new_text = "".join( + block.get("text", "") for block in new_response.json()["content"] + ) + assert new_text == "provider-b-start provider-b-finish" + assert not old_body + + upstream_a.release_stream.set() + old_thread.join(timeout=10) + assert not old_thread.is_alive() + assert old_errors == [] + assert "provider-a-start " in old_body[0] + assert "provider-a-held " in old_body[0] + assert "provider-a-finish" in old_body[0] + assert len(upstream_a.chat_requests) == 1 + assert len(upstream_b.chat_requests) == 1 + _wait_for_generation_close(server, generation_id=1) diff --git a/smoke/product/test_voice_product_live.py b/smoke/product/test_voice_product_live.py new file mode 100644 index 0000000..23947d2 --- /dev/null +++ b/smoke/product/test_voice_product_live.py @@ -0,0 +1,68 @@ +import os +from pathlib import Path + +import pytest + +from free_claude_code.messaging.transcription import TranscriptionService +from free_claude_code.providers.nvidia_nim.voice import NvidiaNimTranscriber +from smoke.lib.config import SmokeConfig +from smoke.lib.e2e import VoiceFixtureDriver + +pytestmark = [pytest.mark.live] + + +@pytest.mark.smoke_target("voice") +@pytest.mark.asyncio +async def test_voice_local_backend_e2e( + smoke_config: SmokeConfig, tmp_path: Path +) -> None: + if not smoke_config.settings.voice_note_enabled: + pytest.skip("missing_env: VOICE_NOTE_ENABLED is false") + if os.getenv("FCC_SMOKE_RUN_VOICE") != "1": + pytest.skip("missing_env: set FCC_SMOKE_RUN_VOICE=1 to run voice product smoke") + if smoke_config.settings.whisper_device not in {"cpu", "cuda"}: + pytest.skip("missing_env: WHISPER_DEVICE must be cpu or cuda") + + wav_path = tmp_path / "voice-local-product.wav" + VoiceFixtureDriver.write_tone_wav(wav_path) + transcriber = TranscriptionService( + model=smoke_config.settings.whisper_model, + device=smoke_config.settings.whisper_device, + huggingface_api_key=smoke_config.settings.huggingface_api_key, + ) + try: + text = await transcriber.transcribe(wav_path) + except ImportError as exc: + pytest.skip(f"missing_env: {exc}") + finally: + await transcriber.close() + + assert isinstance(text, str) + assert text.strip() + + +@pytest.mark.smoke_target("voice") +@pytest.mark.asyncio +async def test_voice_nim_backend_e2e(smoke_config: SmokeConfig, tmp_path: Path) -> None: + if not smoke_config.settings.voice_note_enabled: + pytest.skip("missing_env: VOICE_NOTE_ENABLED is false") + if os.getenv("FCC_SMOKE_RUN_VOICE") != "1": + pytest.skip("missing_env: set FCC_SMOKE_RUN_VOICE=1 to run voice product smoke") + if smoke_config.settings.whisper_device != "nvidia_nim": + pytest.skip("missing_env: WHISPER_DEVICE must be nvidia_nim") + if not smoke_config.settings.nvidia_nim_api_key.strip(): + pytest.skip("missing_env: NVIDIA_NIM_API_KEY is required") + + wav_path = tmp_path / "voice-nim-product.wav" + VoiceFixtureDriver.write_tone_wav(wav_path) + transcriber = NvidiaNimTranscriber( + model=smoke_config.settings.whisper_model, + api_key=smoke_config.settings.nvidia_nim_api_key, + ) + try: + text = await transcriber.transcribe(wav_path) + finally: + await transcriber.close() + + assert isinstance(text, str) + assert text.strip() diff --git a/src/free_claude_code/__init__.py b/src/free_claude_code/__init__.py new file mode 100644 index 0000000..9710af1 --- /dev/null +++ b/src/free_claude_code/__init__.py @@ -0,0 +1 @@ +"""Free Claude Code package.""" diff --git a/src/free_claude_code/api/__init__.py b/src/free_claude_code/api/__init__.py new file mode 100644 index 0000000..df901bf --- /dev/null +++ b/src/free_claude_code/api/__init__.py @@ -0,0 +1 @@ +"""HTTP API adapter for Free Claude Code.""" diff --git a/src/free_claude_code/api/admin_routes.py b/src/free_claude_code/api/admin_routes.py new file mode 100644 index 0000000..7d78582 --- /dev/null +++ b/src/free_claude_code/api/admin_routes.py @@ -0,0 +1,200 @@ +"""Local admin UI routes and APIs.""" + +import ipaddress +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +import httpx +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request +from fastapi.responses import FileResponse +from pydantic import BaseModel, Field + +from free_claude_code.config.admin.manifest import FIELD_BY_KEY +from free_claude_code.config.admin.persistence import validate_updates +from free_claude_code.config.admin.values import load_config_response + +from .dependencies import get_services +from .ports import ApiServices + +router = APIRouter() + +STATIC_DIR = Path(__file__).resolve().parent / "admin_static" +LOCAL_PROVIDER_PATHS = { + "lmstudio": "/models", + "llamacpp": "/models", + "ollama": "/api/tags", +} + + +class AdminConfigPayload(BaseModel): + """Partial config update submitted by the admin UI.""" + + values: dict[str, Any] = Field(default_factory=dict) + + +def _is_loopback_host(host: str | None) -> bool: + if host is None: + return False + normalized = host.strip().strip("[]").lower() + if normalized == "localhost": + return True + try: + return ipaddress.ip_address(normalized).is_loopback + except ValueError: + return False + + +def _origin_is_local(origin: str | None) -> bool: + if not origin: + return True + parsed = urlsplit(origin) + return _is_loopback_host(parsed.hostname) + + +def require_loopback_admin(request: Request) -> None: + """Allow admin access only from the local machine.""" + + client_host = request.client.host if request.client else None + if not _is_loopback_host(client_host): + raise HTTPException(status_code=403, detail="Admin UI is local-only") + + origin = request.headers.get("origin") + if not _origin_is_local(origin): + raise HTTPException(status_code=403, detail="Admin UI is local-only") + + +def _asset_response(filename: str) -> FileResponse: + path = STATIC_DIR / filename + if not path.is_file(): + raise HTTPException(status_code=404, detail="Admin asset not found") + return FileResponse(path) + + +@router.get("/admin", include_in_schema=False) +async def admin_page(request: Request): + require_loopback_admin(request) + return _asset_response("index.html") + + +@router.get("/admin/assets/{filename}", include_in_schema=False) +async def admin_asset(filename: str, request: Request): + require_loopback_admin(request) + if filename not in {"admin.css", "admin.js"}: + raise HTTPException(status_code=404, detail="Admin asset not found") + return _asset_response(filename) + + +@router.get("/admin/api/config") +async def get_admin_config(request: Request): + require_loopback_admin(request) + return load_config_response() + + +@router.post("/admin/api/config/validate") +async def validate_admin_config(payload: AdminConfigPayload, request: Request): + require_loopback_admin(request) + return validate_updates(_filtered_values(payload.values)) + + +@router.post("/admin/api/config/apply") +async def apply_admin_config( + payload: AdminConfigPayload, + request: Request, + background_tasks: BackgroundTasks, + services: ApiServices = Depends(get_services), +): + require_loopback_admin(request) + result = await services.admin.apply_admin_config(_filtered_values(payload.values)) + restart = result.get("restart") + if isinstance(restart, dict) and restart.get("automatic"): + background_tasks.add_task(services.admin.request_restart) + return result + + +@router.get("/admin/api/status") +async def admin_status( + request: Request, + services: ApiServices = Depends(get_services), +): + require_loopback_admin(request) + return services.admin.admin_status() + + +@router.get("/admin/api/providers/local-status") +async def local_provider_status(request: Request): + require_loopback_admin(request) + config = load_config_response() + values = {field["key"]: field["value"] for field in config["fields"]} + checks = [] + for provider_id, path in LOCAL_PROVIDER_PATHS.items(): + base_url = _local_provider_url(provider_id, values) + checks.append(await _check_local_provider(provider_id, base_url, path)) + return {"providers": checks} + + +@router.post("/admin/api/providers/{provider_id}/test") +async def test_provider( + provider_id: str, + request: Request, + services: ApiServices = Depends(get_services), +): + require_loopback_admin(request) + return await services.admin.test_provider(provider_id) + + +@router.post("/admin/api/models/refresh") +async def refresh_models( + request: Request, + services: ApiServices = Depends(get_services), +): + require_loopback_admin(request) + return await services.admin.refresh_models() + + +def _filtered_values(values: dict[str, Any]) -> dict[str, Any]: + return {key: value for key, value in values.items() if key in FIELD_BY_KEY} + + +def _local_provider_url(provider_id: str, values: dict[str, str]) -> str: + if provider_id == "lmstudio": + return values.get("LM_STUDIO_BASE_URL", "") + if provider_id == "llamacpp": + return values.get("LLAMACPP_BASE_URL", "") + if provider_id == "ollama": + return values.get("OLLAMA_BASE_URL", "") + return "" + + +async def _check_local_provider( + provider_id: str, base_url: str, path: str +) -> dict[str, Any]: + clean_url = base_url.strip().rstrip("/") + if not clean_url: + return { + "provider_id": provider_id, + "status": "missing_url", + "label": "Missing URL", + "base_url": base_url, + } + + url = f"{clean_url}{path}" + try: + async with httpx.AsyncClient(timeout=1.5) as client: + response = await client.get(url) + ok = 200 <= response.status_code < 300 + return { + "provider_id": provider_id, + "status": "reachable" if ok else "offline", + "label": "Reachable" if ok else "Offline", + "base_url": base_url, + "status_code": response.status_code, + } + except Exception as exc: + return { + "provider_id": provider_id, + "status": "offline", + "label": "Offline", + "base_url": base_url, + "error_type": type(exc).__name__, + } diff --git a/src/free_claude_code/api/admin_static/admin.css b/src/free_claude_code/api/admin_static/admin.css new file mode 100644 index 0000000..659902b --- /dev/null +++ b/src/free_claude_code/api/admin_static/admin.css @@ -0,0 +1,736 @@ +:root { + color-scheme: dark; + + /* Color Palette */ + --bg: #090a0f; + --panel: #11131c; + --panel-strong: #171b26; + --card: #151822; + --card-hover: #1e2230; + --input: #0a0b10; + --input-focus: #0f1118; + --text: #f3f4f6; + --text-strong: #ffffff; + --muted: #9ca3af; + --line: rgba(255, 255, 255, 0.06); + --line-strong: rgba(255, 255, 255, 0.12); + + /* Brand and Accent Colors */ + --accent: #10b981; + --accent-dark: #059669; + --accent-muted: rgba(16, 185, 129, 0.08); + --accent-border: rgba(16, 185, 129, 0.3); + + /* Status Colors */ + --ok: #10b981; + --ok-bg: rgba(16, 185, 129, 0.08); + --ok-border: rgba(16, 185, 129, 0.25); + + --warn: #f59e0b; + --warn-bg: rgba(245, 158, 11, 0.08); + --warn-border: rgba(245, 158, 11, 0.25); + + --error: #ef4444; + --error-bg: rgba(239, 68, 68, 0.08); + --error-border: rgba(239, 68, 68, 0.25); + + --neutral: #6b7280; + --neutral-bg: rgba(107, 114, 128, 0.08); + --neutral-border: rgba(107, 114, 128, 0.25); + + --info: #3b82f6; + + /* Box Shadow & Glows */ + --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2); + --shadow: 0 12px 34px rgba(0, 0, 0, 0.5); + --glow-accent: 0 0 12px rgba(16, 185, 129, 0.15); + + /* Borders and Radius */ + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --radius-full: 9999px; + + /* Typography */ + --font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + + /* Transitions */ + --transition-fast: 0.15s ease; + --transition-normal: 0.22s cubic-bezier(0.4, 0, 0.2, 1); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + background: var(--bg); + color: var(--text); + font-family: var(--font-sans); + letter-spacing: -0.01em; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Custom Scrollbars */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} +::-webkit-scrollbar-track { + background: var(--bg); +} +::-webkit-scrollbar-thumb { + background: var(--panel-strong); + border-radius: var(--radius-full); +} +::-webkit-scrollbar-thumb:hover { + background: var(--muted); +} + +button, +input, +select, +textarea { + font: inherit; + color: inherit; +} + +/* App Shell Layout */ +.app-shell { + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + min-height: 100vh; + padding-bottom: 96px; /* Space for action bar */ +} + +/* Sidebar Navigation */ +.sidebar { + position: sticky; + top: 0; + height: 100vh; + border-right: 1px solid var(--line); + background: var(--bg); + padding: 24px 20px; + display: flex; + flex-direction: column; + gap: 32px; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; +} + +.brand-mark { + display: grid; + width: 40px; + height: 40px; + place-items: center; + border-radius: var(--radius-md); + background: linear-gradient(135deg, var(--accent), var(--accent-dark)); + color: #ffffff; + font-weight: 800; + font-size: 15px; + box-shadow: var(--glow-accent); +} + +.brand h1 { + font-size: 16px; + font-weight: 700; + line-height: 1.2; + color: var(--text-strong); + margin: 0; +} + +.brand p { + color: var(--muted); + font-size: 12px; + margin: 0; +} + +.section-nav { + display: grid; + gap: 6px; +} + +.nav-link { + display: flex; + align-items: center; + width: 100%; + min-height: 40px; + border: 1px solid transparent; + border-radius: var(--radius-md); + background: transparent; + color: var(--muted); + padding: 10px 14px; + text-align: left; + cursor: pointer; + font-weight: 600; + font-size: 14px; + transition: all var(--transition-fast); +} + +.nav-link:hover { + background: var(--panel-strong); + color: var(--text-strong); +} + +.nav-link.active { + background: var(--accent-muted); + border-color: var(--accent-border); + color: var(--accent); + box-shadow: var(--shadow-sm); +} + +/* Accessible focus outlines */ +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +.nav-link:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* Main Content Area */ +.main { + min-width: 0; + padding: 40px 48px; +} + +.topbar { + margin-bottom: 32px; +} + +.topbar h2 { + font-size: 30px; + font-weight: 800; + letter-spacing: -0.02em; + color: var(--text-strong); + margin: 0; +} + +/* Provider Strip & Panels */ +.provider-strip, +.settings-section { + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: var(--panel); + box-shadow: var(--shadow); + margin-bottom: 24px; + transition: border-color var(--transition-normal); +} + +.provider-strip { + padding: 24px; +} + +.strip-header, +.section-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; +} + +.strip-header h3, +.section-heading h3 { + font-size: 18px; + font-weight: 700; + color: var(--text-strong); + margin: 0; +} + +.section-heading p { + color: var(--muted); + font-size: 13px; + line-height: 1.4; + margin: 4px 0 0 0; +} + +/* Provider Status Cards */ +.provider-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 16px; +} + +.provider-card { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 16px; + min-height: 140px; + border: 1px solid var(--line); + border-radius: var(--radius-md); + padding: 16px; + background: var(--card); + transition: all var(--transition-normal); +} + +.provider-card:hover { + border-color: var(--line-strong); + transform: translateY(-2px); + box-shadow: var(--shadow-sm); + background: var(--card-hover); +} + +.provider-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.provider-title strong { + font-size: 14px; + font-weight: 700; + color: var(--text-strong); +} + +/* Modern status pills with color indicators */ +.status-pill { + display: inline-flex; + align-items: center; + min-height: 22px; + border: 1px solid var(--neutral-border); + border-radius: var(--radius-full); + padding: 2px 10px; + background: var(--neutral-bg); + color: var(--muted); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; +} + +.status-pill.ok { + color: var(--ok); + background: var(--ok-bg); + border-color: var(--ok-border); +} + +.status-pill.warn { + color: var(--warn); + background: var(--warn-bg); + border-color: var(--warn-border); +} + +.status-pill.error { + color: var(--error); + background: var(--error-bg); + border-color: var(--error-border); +} + +.provider-meta { + color: var(--muted); + font-size: 12px; + line-height: 1.4; + word-break: break-all; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +/* Button & Form Controls styling */ +.test-button, +.ghost-button, +.secondary-button, +.primary-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 36px; + border-radius: var(--radius-md); + border: 1px solid var(--line-strong); + padding: 6px 14px; + font-weight: 600; + font-size: 13px; + cursor: pointer; + transition: all var(--transition-fast); + text-decoration: none; +} + +.test-button { + background: var(--panel-strong); + color: var(--text); + border-color: var(--line); + margin-top: auto; + align-self: flex-start; +} + +.test-button:hover:not(:disabled) { + background: var(--card-hover); + border-color: var(--accent); + color: var(--accent); +} + +.test-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.secondary-button { + background: transparent; + color: var(--text); + border-color: var(--line-strong); +} + +.secondary-button:hover:not(:disabled) { + background: var(--panel-strong); + border-color: var(--text); +} + +.primary-button { + border-color: var(--accent); + background: var(--accent); + color: #06100b; +} + +.primary-button:hover:not(:disabled) { + background: var(--accent-dark); +} + +.primary-button:disabled { + cursor: not-allowed; + border-color: var(--line); + background: var(--panel-strong); + color: var(--muted); +} + +.ghost-button { + background: transparent; + color: var(--muted); + border-color: transparent; +} + +.ghost-button:hover:not(:disabled) { + color: var(--text-strong); + background: var(--panel-strong); +} + +/* Forms layout */ +.form-sections { + display: grid; + gap: 24px; +} + +.settings-section { + padding: 24px; + scroll-margin-top: 24px; +} + +.section-heading { + border-bottom: 1px solid var(--line); + padding-bottom: 16px; + margin-bottom: 24px; +} + +.field-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; +} + +/* Field Grouping */ +.field { + display: flex; + flex-direction: column; + gap: 8px; + align-content: start; +} + +.field label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 13px; + font-weight: 700; + color: var(--text-strong); +} + +.field-source { + color: var(--accent); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + background: var(--accent-muted); + padding: 1px 6px; + border-radius: var(--radius-sm); +} + +/* Form Inputs, Select, Textareas */ +.field input[type="text"], +.field input[type="number"], +.field input[type="password"], +.field select, +.field textarea { + width: 100%; + min-height: 40px; + border: 1px solid var(--line-strong); + border-radius: var(--radius-md); + background: var(--input); + color: var(--text-strong); + padding: 10px 14px; + font-size: 14px; + transition: all var(--transition-fast); +} + +/* Custom Dropdown Styling for Select Elements */ +.field select { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%239ca3af' stroke-width='2.5'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M19.5 8.25l-7.5 7.5-7.5-7.5'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 14px center; + background-size: 14px; + padding-right: 36px; +} + +/* Custom Dropdown Styling for Datalist Input Elements (Model Routing) */ +.field input[list] { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%239ca3af' stroke-width='2.5'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M19.5 8.25l-7.5 7.5-7.5-7.5'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 14px center; + background-size: 14px; + padding-right: 36px; +} + +/* Hide WebKit's native picker indicator to avoid double arrow icons */ +.field input[list]::-webkit-calendar-picker-indicator { + display: none !important; +} + + +.field input:focus, +.field select:focus, +.field textarea:focus { + border-color: var(--accent); + background: var(--input-focus); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15); + outline: none; +} + +/* Checkbox specific layout styling */ +.field input[type="checkbox"] { + align-self: flex-start; + width: 20px; + height: 20px; + accent-color: var(--accent); + cursor: pointer; + margin: 4px 0; +} + +.field textarea { + min-height: 100px; + resize: vertical; + line-height: 1.5; +} + +.field input:disabled, +.field select:disabled, +.field textarea:disabled { + background: rgba(255, 255, 255, 0.02); + border-color: var(--line); + color: var(--muted); + cursor: not-allowed; +} + +.field-description { + color: var(--muted); + font-size: 12px; + line-height: 1.4; + margin-top: 2px; +} + +/* Show/Hide Advanced fields */ +.field.advanced-field { + display: none; +} + +.settings-section.show-advanced .advanced-field { + display: flex; +} + +.advanced-toggle { + margin-top: 20px; + font-size: 12px; + border: 1px solid var(--line-strong); +} + +/* Fixed Bottom Action Bar */ +.action-bar { + position: fixed; + right: 0; + bottom: 0; + left: 260px; + z-index: 100; + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(200px, 1fr) auto; + gap: 20px; + align-items: center; + min-height: 80px; + border-top: 1px solid var(--line); + background: rgba(9, 10, 15, 0.82); + padding: 16px 40px; + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.3); +} + +.action-meta { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.action-meta strong { + font-size: 14px; + font-weight: 700; + color: var(--text-strong); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.action-meta span { + color: var(--muted); + font-size: 11px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.message-area { + min-width: 0; + color: var(--muted); + font-size: 13px; + font-weight: 500; + line-height: 1.4; +} + +.message-area.error { + color: var(--error); + font-weight: 600; +} + +.message-area.ok { + color: var(--ok); + font-weight: 600; +} + +.action-buttons { + display: flex; + gap: 12px; +} + +.action-buttons button { + min-width: 100px; +} + +/* Tablet Layout Optimization */ +@media (max-width: 900px) { + .app-shell { + display: flex; + flex-direction: column; + padding-bottom: 180px; /* More room for stacked action bar */ + } + + .sidebar { + position: relative; + height: auto; + border-right: 0; + border-bottom: 1px solid var(--line); + padding: 20px; + gap: 20px; + } + + .section-nav { + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + } + + .main { + padding: 24px 20px; + } + + .action-bar { + left: 0; + grid-template-columns: 1fr; + gap: 12px; + padding: 16px 20px; + min-height: auto; + background: rgba(9, 10, 15, 0.95); + } + + .action-meta { + flex-direction: row; + justify-content: space-between; + align-items: center; + } + + .action-meta span { + text-align: right; + } + + .action-buttons { + width: 100%; + } + + .action-buttons button { + flex: 1; + min-height: 44px; /* Better touch target on mobile */ + } +} + +/* Mobile Layout Optimization */ +@media (max-width: 600px) { + .brand { + justify-content: center; + text-align: center; + flex-direction: column; + } + + .brand-mark { + width: 48px; + height: 48px; + } + + .section-nav { + grid-template-columns: 1fr; + } + + .topbar { + text-align: center; + } + + .topbar h2 { + font-size: 24px; + } + + .provider-grid { + grid-template-columns: 1fr; + } + + .field-grid { + grid-template-columns: 1fr; + } + + .action-meta { + flex-direction: column; + align-items: stretch; + gap: 2px; + } + + .action-meta span { + text-align: left; + } +} diff --git a/src/free_claude_code/api/admin_static/admin.js b/src/free_claude_code/api/admin_static/admin.js new file mode 100644 index 0000000..3de3f0f --- /dev/null +++ b/src/free_claude_code/api/admin_static/admin.js @@ -0,0 +1,475 @@ +const state = { + config: null, + fields: new Map(), + localStatus: new Map(), + modelOptions: [], + activeView: "providers", +}; + +const MASKED_SECRET = "********"; +const VIEW_GROUPS = [ + { + id: "providers", + label: "Providers", + title: "Providers", + sections: ["providers", "runtime"], + containerId: "providersSections", + }, + { + id: "model_config", + label: "Model Config", + title: "Model Config", + sections: ["models", "thinking", "web_tools"], + containerId: "modelConfigSections", + }, + { + id: "messaging", + label: "Messaging", + title: "Messaging", + sections: ["messaging", "voice"], + containerId: "messagingSections", + }, +]; + +const byId = (id) => document.getElementById(id); + +function sourceLabel(source) { + const labels = { + default: "default", + template: "template", + repo_env: "repo .env", + managed_env: "", + explicit_env_file: "FCC_ENV_FILE", + process: "process env", + }; + return Object.prototype.hasOwnProperty.call(labels, source) ? labels[source] : source; +} + +function sourceText(field) { + const parts = []; + const label = sourceLabel(field.source); + if (label) { + parts.push(label); + } + if (field.locked) { + parts.push("locked"); + } + return parts.join(" "); +} + +function statusClass(status) { + if (["configured", "reachable", "running"].includes(status)) return "ok"; + if (["missing_key", "missing_url", "unknown"].includes(status)) return "warn"; + if (["offline", "error"].includes(status)) return "error"; + return "neutral"; +} + +async function api(path, options = {}) { + const response = await fetch(path, { + headers: { "Content-Type": "application/json", ...(options.headers || {}) }, + ...options, + }); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + return response.json(); +} + +async function load() { + showMessage("Loading admin config"); + const config = await api("/admin/api/config"); + state.config = config; + state.fields = new Map(config.fields.map((field) => [field.key, field])); + renderNav(); + renderProviders(config.provider_status); + renderSections(config.sections, config.fields); + byId("configPath").textContent = config.paths.managed; + await validate(false); + await refreshLocalStatus(); + updateDirtyState(); + showMessage(""); +} + +function renderNav() { + const nav = byId("sectionNav"); + nav.innerHTML = ""; + VIEW_GROUPS.forEach((view, index) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = `nav-link${index === 0 ? " active" : ""}`; + button.dataset.view = view.id; + button.textContent = view.label; + if (index === 0) { + button.setAttribute("aria-current", "page"); + } + button.addEventListener("click", () => { + setActiveView(view.id, { scroll: true }); + }); + nav.appendChild(button); + }); + setActiveView(state.activeView, { scroll: false }); +} + +function setActiveView(viewId, { scroll = false } = {}) { + const activeView = + VIEW_GROUPS.find((view) => view.id === viewId) || VIEW_GROUPS[0]; + state.activeView = activeView.id; + byId("pageTitle").textContent = activeView.title; + + document.querySelectorAll(".nav-link").forEach((link) => { + const selected = link.dataset.view === activeView.id; + link.classList.toggle("active", selected); + if (selected) { + link.setAttribute("aria-current", "page"); + } else { + link.removeAttribute("aria-current"); + } + }); + + document.querySelectorAll(".admin-view").forEach((view) => { + const selected = view.dataset.view === activeView.id; + view.classList.toggle("active", selected); + view.hidden = !selected; + }); + + if (scroll) { + window.scrollTo({ top: 0, behavior: "smooth" }); + } +} + +function renderProviders(providerStatus) { + const grid = byId("providerGrid"); + grid.innerHTML = ""; + providerStatus.forEach((provider) => { + const card = document.createElement("article"); + card.className = "provider-card"; + card.dataset.provider = provider.provider_id; + + const title = document.createElement("div"); + title.className = "provider-title"; + title.innerHTML = `${provider.display_name || provider.provider_id}`; + + const pill = document.createElement("span"); + pill.className = `status-pill ${statusClass(provider.status)}`; + pill.textContent = provider.label; + title.appendChild(pill); + + const meta = document.createElement("div"); + meta.className = "provider-meta"; + meta.textContent = + provider.kind === "local" + ? provider.base_url || "No local URL configured" + : provider.credential_env; + + const button = document.createElement("button"); + button.type = "button"; + button.className = "test-button"; + button.textContent = provider.kind === "local" ? "Test" : "Refresh models"; + button.addEventListener("click", () => testProvider(provider.provider_id, button)); + + card.append(title, meta, button); + grid.appendChild(card); + }); +} + +function updateProviderCard(providerId, status, label, metaText) { + const card = document.querySelector(`[data-provider="${providerId}"]`); + if (!card) return; + const pill = card.querySelector(".status-pill"); + pill.className = `status-pill ${statusClass(status)}`; + pill.textContent = label; + if (metaText) { + card.querySelector(".provider-meta").textContent = metaText; + } +} + +function renderSections(sections, fields) { + VIEW_GROUPS.forEach((view) => { + byId(view.containerId).innerHTML = ""; + }); + + const sectionById = new Map(sections.map((section) => [section.id, section])); + const bySection = new Map(); + sections.forEach((section) => bySection.set(section.id, [])); + fields.forEach((field) => { + if (!bySection.has(field.section)) bySection.set(field.section, []); + bySection.get(field.section).push(field); + }); + + VIEW_GROUPS.forEach((view) => { + const container = byId(view.containerId); + view.sections.forEach((sectionId) => { + const section = sectionById.get(sectionId); + const sectionFields = bySection.get(sectionId) || []; + if (!section || sectionFields.length === 0) return; + + const sectionEl = document.createElement("section"); + sectionEl.className = "settings-section"; + sectionEl.id = `section-${section.id}`; + + const heading = document.createElement("div"); + heading.className = "section-heading"; + heading.innerHTML = `

${section.label}

${section.description}

`; + sectionEl.appendChild(heading); + + const grid = document.createElement("div"); + grid.className = "field-grid"; + sectionFields.forEach((field) => { + grid.appendChild(renderField(field)); + }); + sectionEl.appendChild(grid); + + if (sectionFields.some((field) => field.advanced)) { + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "ghost-button advanced-toggle"; + toggle.textContent = "Show advanced"; + toggle.addEventListener("click", () => { + const showing = sectionEl.classList.toggle("show-advanced"); + toggle.textContent = showing ? "Hide advanced" : "Show advanced"; + }); + sectionEl.appendChild(toggle); + } + + container.appendChild(sectionEl); + }); + }); +} + +function renderField(field) { + const wrapper = document.createElement("div"); + wrapper.className = `field${field.advanced ? " advanced-field" : ""}`; + wrapper.dataset.key = field.key; + + const label = document.createElement("label"); + label.htmlFor = `field-${field.key}`; + const labelText = document.createElement("span"); + labelText.textContent = field.label; + label.appendChild(labelText); + + const source = sourceText(field); + if (source) { + const sourceEl = document.createElement("span"); + sourceEl.className = "field-source"; + sourceEl.textContent = source; + label.appendChild(sourceEl); + } + + const input = inputForField(field); + input.id = `field-${field.key}`; + input.dataset.key = field.key; + input.dataset.original = field.value || ""; + input.dataset.secret = field.secret ? "true" : "false"; + input.dataset.configured = field.configured ? "true" : "false"; + input.disabled = field.locked; + input.addEventListener("input", updateDirtyState); + input.addEventListener("change", updateDirtyState); + + wrapper.append(label, input); + if (field.description) { + const description = document.createElement("div"); + description.className = "field-description"; + description.textContent = field.description; + wrapper.appendChild(description); + } + return wrapper; +} + +function inputForField(field) { + if (field.type === "boolean") { + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = String(field.value).toLowerCase() === "true"; + input.dataset.original = input.checked ? "true" : "false"; + return input; + } + + if (field.type === "tri_boolean") { + const select = document.createElement("select"); + [ + ["", "Inherit"], + ["true", "Enabled"], + ["false", "Disabled"], + ].forEach(([value, label]) => select.appendChild(option(value, label))); + select.value = field.value || ""; + return select; + } + + if (field.type === "select") { + const select = document.createElement("select"); + field.options.forEach((value) => select.appendChild(option(value, value))); + select.value = field.value || field.options[0] || ""; + return select; + } + + if (field.type === "textarea") { + const textarea = document.createElement("textarea"); + textarea.value = field.value || ""; + return textarea; + } + + const input = document.createElement("input"); + input.type = field.type === "number" ? "number" : "text"; + if (field.type === "secret") { + input.type = "password"; + input.placeholder = field.configured + ? "Configured - enter a new value to replace" + : "Not configured"; + input.value = ""; + input.autocomplete = "off"; + } else { + input.value = field.value || ""; + } + if (field.key.startsWith("MODEL")) { + input.setAttribute("list", "model-options"); + } + return input; +} + +function option(value, label) { + const optionEl = document.createElement("option"); + optionEl.value = value; + optionEl.textContent = label; + return optionEl; +} + +function readFieldValue(input) { + if (input.type === "checkbox") return input.checked ? "true" : "false"; + if (input.dataset.secret === "true" && input.dataset.configured === "true") { + return input.value ? input.value : MASKED_SECRET; + } + return input.value; +} + +function changedValues() { + const values = {}; + document.querySelectorAll("[data-key]").forEach((input) => { + if (input.disabled || !input.matches("input, select, textarea")) return; + const value = readFieldValue(input); + if (value !== input.dataset.original) { + values[input.dataset.key] = value; + } + }); + return values; +} + +function updateDirtyState() { + const count = Object.keys(changedValues()).length; + byId("dirtyState").textContent = + count === 0 ? "No changes" : `${count} unsaved change${count === 1 ? "" : "s"}`; + byId("applyButton").disabled = count === 0; +} + +async function validate(showResult = true) { + const result = await api("/admin/api/config/validate", { + method: "POST", + body: JSON.stringify({ values: changedValues() }), + }); + if (showResult) { + showValidationResult(result); + } + return result; +} + +function showValidationResult(result) { + if (result.valid) { + showMessage("Config shape is valid", "ok"); + } else { + showMessage(result.errors.join("; "), "error"); + } +} + +async function apply() { + const result = await api("/admin/api/config/apply", { + method: "POST", + body: JSON.stringify({ values: changedValues() }), + }); + if (!result.applied) { + showValidationResult(result); + return; + } + const restart = result.restart || {}; + if (restart.required && restart.automatic) { + showMessage("Applied. Restarting server...", "ok"); + byId("applyButton").disabled = true; + setTimeout(() => { + window.location.href = restart.admin_url || "/admin"; + }, 1600); + return; + } + const pending = restart.required ? restart.fields || [] : result.pending_fields || []; + await load(); + showMessage( + pending.length + ? `Applied. Restart fcc-server to use: ${pending.join(", ")}` + : "Applied", + "ok", + ); +} + +async function refreshLocalStatus() { + const result = await api("/admin/api/providers/local-status"); + result.providers.forEach((provider) => { + state.localStatus.set(provider.provider_id, provider); + const meta = provider.status_code + ? `${provider.base_url} returned HTTP ${provider.status_code}` + : provider.base_url; + updateProviderCard(provider.provider_id, provider.status, provider.label, meta); + }); +} + +async function testProvider(providerId, button) { + const original = button.textContent; + button.disabled = true; + button.textContent = "Testing"; + try { + const result = await api(`/admin/api/providers/${providerId}/test`, { + method: "POST", + body: "{}", + }); + if (result.ok) { + updateProviderCard( + providerId, + "reachable", + `${result.models.length} models`, + result.models.slice(0, 3).join(", ") || "No models returned", + ); + state.modelOptions = Array.from( + new Set([ + ...state.modelOptions, + ...result.models.map((model) => `${providerId}/${model}`), + ]), + ).sort(); + syncModelDatalist(); + } else { + updateProviderCard(providerId, "offline", result.error_type, result.error_type); + } + } finally { + button.disabled = false; + button.textContent = original; + } +} + +function syncModelDatalist() { + let datalist = byId("model-options"); + if (!datalist) { + datalist = document.createElement("datalist"); + datalist.id = "model-options"; + document.body.appendChild(datalist); + } + datalist.innerHTML = ""; + state.modelOptions.forEach((model) => datalist.appendChild(option(model, model))); +} + +function showMessage(message, kind = "") { + const area = byId("messageArea"); + area.textContent = message; + area.className = `message-area ${kind}`.trim(); +} + +byId("validateButton").addEventListener("click", () => validate(true)); +byId("applyButton").addEventListener("click", apply); + +load().catch((error) => { + showMessage(error.message, "error"); +}); diff --git a/src/free_claude_code/api/admin_static/index.html b/src/free_claude_code/api/admin_static/index.html new file mode 100644 index 0000000..92c86c2 --- /dev/null +++ b/src/free_claude_code/api/admin_static/index.html @@ -0,0 +1,77 @@ + + + + + + Free Claude Code Admin + + + + +
+ + +
+
+
+

Providers

+
+
+ +
+
+
+
+

Providers

+
+
+
+
+
+ + + + +
+
+ +
+
+ No changes + +
+
+
+ + +
+
+
+ + + diff --git a/src/free_claude_code/api/app.py b/src/free_claude_code/api/app.py new file mode 100644 index 0000000..330bdd1 --- /dev/null +++ b/src/free_claude_code/api/app.py @@ -0,0 +1,118 @@ +"""Pure FastAPI application factory.""" + +from typing import Any + +from fastapi import FastAPI, Request +from fastapi.exception_handlers import request_validation_exception_handler +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from loguru import logger + +from free_claude_code.application.errors import ApplicationError +from free_claude_code.core.anthropic import anthropic_error_payload +from free_claude_code.core.diagnostics import ( + redacted_exception_traceback, + safe_exception_message, +) +from free_claude_code.core.openai_responses import openai_error_payload +from free_claude_code.core.trace import ( + extract_claude_session_id_from_headers, + trace_event, +) +from free_claude_code.core.version import package_version + +from .admin_routes import router as admin_router +from .ports import ApiServices +from .request_errors import ordinary_application_error_response +from .request_ids import ( + RequestCorrelationMiddleware, + attach_request_id_headers, + get_request_id, +) +from .routes import router +from .validation_log import summarize_request_validation_body + + +def create_app(services: ApiServices) -> FastAPI: + """Create the HTTP adapter around explicitly supplied runtime services.""" + app = FastAPI(title="Claude Code Proxy", version=package_version()) + app.state.services = services + app.add_middleware(RequestCorrelationMiddleware) + + app.include_router(admin_router) + app.include_router(router) + + @app.exception_handler(RequestValidationError) + async def validation_error_handler(request: Request, exc: RequestValidationError): + """Log request shape for 422 debugging without content values.""" + body: Any + try: + body = await request.json() + except Exception as error: + body = {"_json_error": type(error).__name__} + + message_summary, tool_names = summarize_request_validation_body(body) + trace_event( + stage="ingress", + event="server.request.validation_failed", + source="api", + path=request.url.path, + query=dict(request.query_params), + error_locs=[list(error.get("loc", ())) for error in exc.errors()], + error_types=[str(error.get("type", "")) for error in exc.errors()], + message_summary=message_summary, + tool_names=tool_names, + ) + return await request_validation_exception_handler(request, exc) + + @app.exception_handler(ApplicationError) + async def application_error_handler(request: Request, exc: ApplicationError): + """Serialize defensive application failures in the selected wire protocol.""" + return ordinary_application_error_response( + exc, + wire_api=( + "responses" if request.url.path == "/v1/responses" else "messages" + ), + request_id=get_request_id(request), + ) + + @app.exception_handler(Exception) + async def general_error_handler(request: Request, exc: Exception): + """Handle general errors and return Anthropic format.""" + request_id = get_request_id(request) + claude_sid = extract_claude_session_id_from_headers(request.headers) + settings = services.requests.current_settings() + with logger.contextualize( + http_method=request.method, + http_path=request.url.path, + claude_session_id=claude_sid, + request_id=request_id, + ): + if settings.log_api_error_tracebacks: + logger.error("General Error: {}", safe_exception_message(exc)) + logger.error(redacted_exception_traceback(exc)) + else: + logger.error( + "General Error: path={} method={} exc_type={}", + request.url.path, + request.method, + type(exc).__name__, + ) + message = safe_exception_message(exc) + if request.url.path == "/v1/responses": + content = openai_error_payload(message=message, error_type="api_error") + else: + content = anthropic_error_payload( + error_type="api_error", + message=message, + request_id=request_id, + ) + response = JSONResponse(status_code=500, content=content) + attach_request_id_headers( + response, + request_id=request_id, + path=request.url.path, + ) + return response + + return app diff --git a/src/free_claude_code/api/command_utils.py b/src/free_claude_code/api/command_utils.py new file mode 100644 index 0000000..951c163 --- /dev/null +++ b/src/free_claude_code/api/command_utils.py @@ -0,0 +1,164 @@ +"""Command parsing utilities for API optimizations.""" + +import re +import shlex + +_ENV_ASSIGNMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=.*$") + + +def _is_env_assignment(part: str) -> bool: + """Return True when a token is a shell-style env assignment.""" + return bool(_ENV_ASSIGNMENT_RE.match(part)) + + +def _strip_env_assignments(parts: list[str]) -> list[str]: + """Return command parts after leading shell-style env assignments.""" + cmd_start = 0 + for i, part in enumerate(parts): + if _is_env_assignment(part): + cmd_start = i + 1 + else: + break + return parts[cmd_start:] + + +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 _is_env_assignment(part): + 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: + parts = command.split() + if not parts: + return "none" + cmd_parts = _strip_env_assignments(parts) + return cmd_parts[0] if cmd_parts 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" + + cmd_parts = _strip_env_assignments(parts) + if not cmd_parts: + return "\n" + + base_cmd = 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 cmd_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 = [] + + skip_next = False + for part in cmd_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 ValueError: + return "\n" diff --git a/src/free_claude_code/api/dependencies.py b/src/free_claude_code/api/dependencies.py new file mode 100644 index 0000000..3557c60 --- /dev/null +++ b/src/free_claude_code/api/dependencies.py @@ -0,0 +1,74 @@ +"""FastAPI dependencies for the explicit runtime service boundary.""" + +import secrets + +from fastapi import Depends, HTTPException, Request +from loguru import logger + +from free_claude_code.application.errors import UnknownProviderError +from free_claude_code.application.ports import ProviderPort, RequestRuntimeLease +from free_claude_code.config.provider_catalog import PROVIDER_CATALOG +from free_claude_code.config.settings import Settings + +from .ports import ApiServices + + +def get_services(request: Request) -> ApiServices: + """Return the complete services supplied when the app was constructed.""" + return request.app.state.services + + +def get_settings(services: ApiServices = Depends(get_services)) -> Settings: + """Return the current request-runtime settings snapshot.""" + return services.requests.current_settings() + + +def resolve_provider( + provider_type: str, + *, + lease: RequestRuntimeLease, +) -> ProviderPort: + """Resolve a provider through one retained generation.""" + should_log_init = not lease.is_provider_cached(provider_type) + try: + provider = lease.resolve_provider(provider_type) + except UnknownProviderError: + logger.error( + "Unknown provider_type: '{}'. Supported: {}", + provider_type, + ", ".join(f"'{key}'" for key in PROVIDER_CATALOG), + ) + raise + if should_log_init: + logger.info("Provider initialized: {}", provider_type) + return provider + + +def require_api_key( + request: Request, + settings: Settings = Depends(get_settings), +) -> None: + """Require the configured Anthropic-style server API key.""" + anthropic_auth_token = settings.anthropic_auth_token.strip() + if not anthropic_auth_token: + return + + header = ( + request.headers.get("x-api-key") + or request.headers.get("authorization") + or request.headers.get("anthropic-auth-token") + ) + if not header: + raise HTTPException(status_code=401, detail="Missing API key") + + token = header.strip() + if header.lower().startswith("bearer "): + token = header.split(" ", 1)[1].strip() + if token and ":" in token: + token = token.split(":", 1)[0].strip() + + if not secrets.compare_digest( + token.encode("utf-8"), + anthropic_auth_token.encode("utf-8"), + ): + raise HTTPException(status_code=401, detail="Invalid API key") diff --git a/src/free_claude_code/api/detection.py b/src/free_claude_code/api/detection.py new file mode 100644 index 0000000..1dfbc81 --- /dev/null +++ b/src/free_claude_code/api/detection.py @@ -0,0 +1,150 @@ +"""Request detection utilities for API optimizations. + +Detects quota checks, title generation, prefix detection, safety classifier, +suggestion mode, and filepath extraction requests to enable targeted handling. +""" + +from free_claude_code.core.anthropic import MessagesRequest, extract_text_from_content + + +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. + + Matches Claude Code session title prompts (sentence-case title, JSON + \"title\" field, etc.). + """ + if not request_data.system or request_data.tools: + return False + system_text = extract_text_from_content(request_data.system).lower() + if "title" not in system_text: + return False + return "sentence-case title" in system_text or ( + "return json" in system_text + and "field" in system_text + and ("coding session" in system_text or "this session" 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 TypeError: + return False, "" + + return False, "" + + +def is_safety_classifier_request(request_data: MessagesRequest) -> bool: + """Return whether this is Claude Code's auto-mode safety classifier prompt.""" + if request_data.tools: + return False + + system_text = ( + extract_text_from_content(request_data.system) if request_data.system else "" + ) + messages_text = "".join( + extract_text_from_content(message.content) for message in request_data.messages + ) + combined = f"{system_text}\n{messages_text}" + has_verdict_instruction = "yes" in combined or "no" in combined + return "" in combined and has_verdict_instruction + + +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, "", "" + + 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 diff --git a/src/free_claude_code/api/handlers/__init__.py b/src/free_claude_code/api/handlers/__init__.py new file mode 100644 index 0000000..e14c5d7 --- /dev/null +++ b/src/free_claude_code/api/handlers/__init__.py @@ -0,0 +1,7 @@ +"""Product-flow handlers for public API routes.""" + +from .messages import MessagesHandler +from .responses import ResponsesHandler +from .token_count import TokenCountHandler + +__all__ = ["MessagesHandler", "ResponsesHandler", "TokenCountHandler"] diff --git a/src/free_claude_code/api/handlers/messages.py b/src/free_claude_code/api/handlers/messages.py new file mode 100644 index 0000000..36ea7e6 --- /dev/null +++ b/src/free_claude_code/api/handlers/messages.py @@ -0,0 +1,358 @@ +"""Claude Messages API product flow.""" + +import asyncio +from collections.abc import AsyncIterator, Callable +from dataclasses import dataclass, replace + +from fastapi.responses import JSONResponse, Response +from loguru import logger + +from free_claude_code.api.detection import is_safety_classifier_request +from free_claude_code.api.optimization_handlers import try_optimizations +from free_claude_code.api.request_errors import ( + http_status_for_unexpected_api_exception, + log_unexpected_api_exception, + require_non_empty_messages, + unexpected_http_exception, +) +from free_claude_code.api.request_ids import new_request_id +from free_claude_code.api.response_streams import ( + EmptyStreamError, + anthropic_sse_streaming_response, + terminal_execution_error_response, + trace_terminal_execution_error, +) +from free_claude_code.api.web_tools.egress import ( + WebFetchEgressPolicy, + web_fetch_allowed_scheme_set, +) +from free_claude_code.api.web_tools.request import ( + is_web_server_tool_request, + unsupported_server_tool_error, +) +from free_claude_code.api.web_tools.streaming import stream_web_server_tool_response +from free_claude_code.application.errors import ApplicationError, InvalidRequestError +from free_claude_code.application.execution import ProviderExecutor, TokenCounter +from free_claude_code.application.ports import ProviderResolver +from free_claude_code.application.routing import ModelRouter, RoutedMessagesRequest +from free_claude_code.config.settings import Settings +from free_claude_code.core.anthropic import ( + MessagesRequest, + aggregate_anthropic_sse_to_message, + anthropic_error_payload, + anthropic_error_type_for_failure, + anthropic_failure_payload, + anthropic_status_for_error_type, + get_token_count, +) +from free_claude_code.core.diagnostics import safe_exception_message +from free_claude_code.core.failures import ExecutionFailure, find_execution_failure +from free_claude_code.core.trace import trace_event + + +@dataclass(frozen=True) +class _MessagesStreamResult: + body: AsyncIterator[str] + + +@dataclass(frozen=True) +class _MessagesCompleteResult: + response: object + + +_MessagesResult = _MessagesStreamResult | _MessagesCompleteResult +MessageIntercept = Callable[[RoutedMessagesRequest], _MessagesResult | None] + + +class MessagesHandler: + """Handle Anthropic-compatible Messages requests.""" + + def __init__( + self, + settings: Settings, + provider_resolver: ProviderResolver, + *, + model_router: ModelRouter | None = None, + token_counter: TokenCounter = get_token_count, + provider_executor: ProviderExecutor | None = None, + generation_id: int | None = None, + ) -> None: + self._settings = settings + self._model_router = model_router or ModelRouter(settings) + self._token_counter = token_counter + self._provider_executor = provider_executor or ProviderExecutor( + provider_resolver, + token_counter=token_counter, + generation_id=generation_id, + log_raw_payloads=settings.log_raw_api_payloads, + ) + self._message_intercepts: tuple[MessageIntercept, ...] = ( + self._intercept_web_server_tool, + self._intercept_local_optimization, + ) + + async def create( + self, request_data: MessagesRequest, *, request_id: str | None = None + ) -> object: + """Create an Anthropic-compatible message response.""" + request_id = request_id or new_request_id() + try: + require_non_empty_messages(request_data.messages) + routed = self._model_router.resolve_messages_request(request_data) + routed = self._apply_message_routing_policies(routed) + self._reject_unsupported_server_tools(routed) + + result = self._run_message_intercepts(routed) + if result is None: + logger.debug("No optimization matched, routing to provider") + result = _MessagesStreamResult( + self._provider_executor.stream( + routed, + wire_api="messages", + raw_log_label="FULL_PAYLOAD", + raw_log_payload=routed.request.model_dump(), + request_id=request_id, + ) + ) + return await self._to_public_response( + result, + stream=request_data.stream, + request_id=request_id, + ) + except ApplicationError: + raise + except ExecutionFailure as exc: + return self._execution_failure_response(exc, request_id=request_id) + except Exception as exc: + failure = find_execution_failure(exc) + if failure is not None: + return self._execution_failure_response(failure, request_id=request_id) + raise unexpected_http_exception( + self._settings, exc, context="CREATE_MESSAGE_ERROR" + ) from exc + + async def _to_public_response( + self, + result: _MessagesResult, + *, + stream: bool | None, + request_id: str, + ) -> object: + if isinstance(result, _MessagesCompleteResult): + return result.response + if stream is False: + # Non-streaming clients (e.g. Claude Code utility calls) need a + # complete JSON Message; the internal pipeline is always SSE, so + # serving that raw here breaks the client SDK's response parse. + try: + message, error = await aggregate_anthropic_sse_to_message(result.body) + except GeneratorExit: + raise + except asyncio.CancelledError: + raise + except ExecutionFailure as exc: + return self._execution_failure_response(exc, request_id=request_id) + except BaseExceptionGroup as exc: + failure = find_execution_failure(exc) + if failure is not None: + return self._execution_failure_response( + failure, request_id=request_id + ) + return self._unexpected_execution_error_response( + exc, + request_id=request_id, + context="CREATE_MESSAGE_NON_STREAM_ERROR", + ) + except Exception as exc: + return self._unexpected_execution_error_response( + exc, + request_id=request_id, + context="CREATE_MESSAGE_NON_STREAM_ERROR", + ) + if error is not None: + error_type, message_text = _stream_error_fields(error) + status_code = anthropic_status_for_error_type(error_type) + trace_terminal_execution_error( + wire_api="messages", + request_id=request_id, + status_code=status_code, + error_type=error_type, + ) + return terminal_execution_error_response( + status_code=status_code, + content=anthropic_error_payload( + error_type=error_type, + message=message_text, + request_id=request_id, + ), + ) + return JSONResponse(content=message) + return await anthropic_sse_streaming_response( + result.body, + pre_start_error_response=lambda exc: self._pre_start_error_response( + exc, request_id=request_id + ), + request_id=request_id, + ) + + def _pre_start_error_response( + self, exc: BaseException, *, request_id: str + ) -> Response: + failure = find_execution_failure(exc) + if failure is not None: + return self._execution_failure_response(failure, request_id=request_id) + context = ( + "CREATE_MESSAGE_EMPTY_STREAM" + if isinstance(exc, EmptyStreamError) + else "CREATE_MESSAGE_STREAM_START_ERROR" + ) + return self._unexpected_execution_error_response( + exc, + request_id=request_id, + context=context, + ) + + def _execution_failure_response( + self, failure: ExecutionFailure, *, request_id: str + ) -> JSONResponse: + error_type = anthropic_error_type_for_failure(failure) + trace_terminal_execution_error( + wire_api="messages", + request_id=request_id, + status_code=failure.status_code, + error_type=error_type, + error=failure, + ) + return terminal_execution_error_response( + status_code=failure.status_code, + content=anthropic_failure_payload(failure, request_id=request_id), + ) + + def _unexpected_execution_error_response( + self, + exc: BaseException, + *, + request_id: str, + context: str, + ) -> JSONResponse: + log_unexpected_api_exception( + self._settings, + exc, + context=context, + request_id=request_id, + ) + status_code = http_status_for_unexpected_api_exception(exc) + trace_terminal_execution_error( + wire_api="messages", + request_id=request_id, + status_code=status_code, + error_type="api_error", + error=exc, + ) + return terminal_execution_error_response( + status_code=status_code, + content=anthropic_error_payload( + error_type="api_error", + message=safe_exception_message(exc), + request_id=request_id, + ), + ) + + def _reject_unsupported_server_tools(self, routed: RoutedMessagesRequest) -> None: + tool_err = unsupported_server_tool_error( + routed.request, + web_tools_enabled=self._settings.enable_web_server_tools, + ) + if tool_err is not None: + raise InvalidRequestError(tool_err) + + def _apply_message_routing_policies( + self, routed: RoutedMessagesRequest + ) -> RoutedMessagesRequest: + if not is_safety_classifier_request(routed.request): + return routed + changed = routed.resolved.thinking_enabled + trace_event( + stage="routing", + event="free_claude_code.api.optimization.safety_classifier_no_thinking", + source="api", + model=routed.request.model, + changed=changed, + ) + if not changed: + return routed + return RoutedMessagesRequest( + request=routed.request, + resolved=replace(routed.resolved, thinking_enabled=False), + ) + + def _run_message_intercepts( + self, routed: RoutedMessagesRequest + ) -> _MessagesResult | None: + for intercept in self._message_intercepts: + result = intercept(routed) + if result is not None: + return result + return None + + def _intercept_web_server_tool( + self, routed: RoutedMessagesRequest + ) -> _MessagesResult | None: + if not self._settings.enable_web_server_tools: + return None + if not is_web_server_tool_request(routed.request): + return None + + input_tokens = self._token_counter( + routed.request.messages, routed.request.system, routed.request.tools + ) + trace_event( + stage="routing", + event="free_claude_code.api.optimization.web_server_tool", + source="api", + model=routed.request.model, + ) + egress = WebFetchEgressPolicy( + allow_private_network_targets=self._settings.web_fetch_allow_private_networks, + allowed_schemes=web_fetch_allowed_scheme_set( + self._settings.web_fetch_allowed_schemes + ), + ) + return _MessagesStreamResult( + stream_web_server_tool_response( + routed.request, + input_tokens=input_tokens, + web_fetch_egress=egress, + verbose_client_errors=self._settings.log_api_error_tracebacks, + ), + ) + + def _intercept_local_optimization( + self, routed: RoutedMessagesRequest + ) -> _MessagesResult | None: + optimized = try_optimizations(routed.request, self._settings) + if optimized is None: + return None + trace_event( + stage="routing", + event="free_claude_code.api.optimization.short_circuit", + source="api", + model=routed.request.model, + ) + return _MessagesCompleteResult(optimized) + + +def _stream_error_fields(error: dict[str, object]) -> tuple[str, str]: + raw_type = error.get("type") + error_type = ( + raw_type.strip() + if isinstance(raw_type, str) and raw_type.strip() + else "api_error" + ) + raw_message = error.get("message") + message = ( + raw_message.strip() + if isinstance(raw_message, str) and raw_message.strip() + else "Provider request failed unexpectedly." + ) + return error_type, message diff --git a/src/free_claude_code/api/handlers/responses.py b/src/free_claude_code/api/handlers/responses.py new file mode 100644 index 0000000..b2ffcef --- /dev/null +++ b/src/free_claude_code/api/handlers/responses.py @@ -0,0 +1,183 @@ +"""OpenAI Responses API product flow for Codex clients.""" + +from fastapi.responses import JSONResponse + +from free_claude_code.api.request_errors import ( + http_status_for_unexpected_api_exception, + log_unexpected_api_exception, + require_non_empty_messages, +) +from free_claude_code.api.request_ids import new_request_id +from free_claude_code.api.response_streams import ( + openai_responses_sse_streaming_response, + terminal_execution_error_response, + trace_terminal_execution_error, +) +from free_claude_code.application.errors import ApplicationError, InvalidRequestError +from free_claude_code.application.execution import ProviderExecutor +from free_claude_code.application.ports import ProviderResolver +from free_claude_code.application.routing import ModelRouter +from free_claude_code.config.settings import Settings +from free_claude_code.core.anthropic import MessagesRequest +from free_claude_code.core.diagnostics import safe_exception_message +from free_claude_code.core.failures import ExecutionFailure, find_execution_failure +from free_claude_code.core.openai_responses import ( + OpenAIResponsesAdapter, + OpenAIResponsesRequest, + openai_error_type_for_failure, + openai_failure_payload, +) + + +class ResponsesHandler: + """Handle streaming OpenAI Responses-compatible requests.""" + + def __init__( + self, + settings: Settings, + provider_resolver: ProviderResolver, + *, + model_router: ModelRouter | None = None, + responses_adapter: OpenAIResponsesAdapter | None = None, + provider_executor: ProviderExecutor | None = None, + generation_id: int | None = None, + ) -> None: + self._settings = settings + self._model_router = model_router or ModelRouter(settings) + self._responses_adapter = responses_adapter or OpenAIResponsesAdapter() + self._provider_executor = provider_executor or ProviderExecutor( + provider_resolver, + generation_id=generation_id, + log_raw_payloads=settings.log_raw_api_payloads, + ) + + async def create( + self, request_data: OpenAIResponsesRequest, *, request_id: str | None = None + ) -> object: + """Create a streaming OpenAI Responses-compatible response.""" + request_id = request_id or new_request_id() + request_payload = request_data.model_dump(mode="json", exclude_none=True) + if request_data.stream is False: + raise InvalidRequestError( + "FCC /v1/responses supports streaming only; omit stream or set stream=true." + ) + + try: + anthropic_payload = self._responses_adapter.to_anthropic_payload( + request_data + ) + response_request = MessagesRequest(**anthropic_payload) + require_non_empty_messages(response_request.messages) + routed = self._model_router.resolve_messages_request(response_request) + + streamed = self._provider_executor.stream( + routed, + wire_api="responses", + raw_log_label="FULL_RESPONSES_PAYLOAD", + raw_log_payload=request_payload, + request_id=request_id, + ) + return await openai_responses_sse_streaming_response( + self._responses_adapter.iter_sse_from_anthropic( + streamed, + request_data, + on_post_start_terminal_failure=lambda exc: ( + self._trace_post_start_terminal_failure( + exc, + request_id=request_id, + ) + ), + ), + headers=self._responses_adapter.sse_headers, + pre_start_error_response=lambda exc: self._pre_start_error_response( + exc, request_id=request_id + ), + ) + except OpenAIResponsesAdapter.ConversionError as exc: + raise InvalidRequestError(str(exc)) from exc + except ApplicationError: + raise + except ExecutionFailure as exc: + return self._execution_failure_response(exc, request_id=request_id) + except Exception as exc: + failure = find_execution_failure(exc) + if failure is not None: + return self._execution_failure_response(failure, request_id=request_id) + log_unexpected_api_exception( + self._settings, + exc, + context="CREATE_RESPONSE_ERROR", + ) + return JSONResponse( + status_code=http_status_for_unexpected_api_exception(exc), + content=self._responses_adapter.error_payload( + message=safe_exception_message(exc), + error_type="api_error", + ), + ) + + def _pre_start_error_response( + self, exc: BaseException, *, request_id: str + ) -> JSONResponse: + failure = find_execution_failure(exc) + if failure is not None: + return self._execution_failure_response(failure, request_id=request_id) + log_unexpected_api_exception( + self._settings, + exc, + context="CREATE_RESPONSE_STREAM_START_ERROR", + request_id=request_id, + ) + status_code = http_status_for_unexpected_api_exception(exc) + trace_terminal_execution_error( + wire_api="responses", + request_id=request_id, + status_code=status_code, + error_type="api_error", + error=exc, + ) + return terminal_execution_error_response( + status_code=status_code, + content=self._responses_adapter.error_payload( + message=safe_exception_message(exc), + error_type="api_error", + ), + ) + + def _execution_failure_response( + self, + failure: ExecutionFailure, + *, + request_id: str, + ) -> JSONResponse: + error_type = openai_error_type_for_failure(failure) + trace_terminal_execution_error( + wire_api="responses", + request_id=request_id, + status_code=failure.status_code, + error_type=error_type, + error=failure, + ) + return terminal_execution_error_response( + status_code=failure.status_code, + content=openai_failure_payload(failure), + ) + + @staticmethod + def _trace_post_start_terminal_failure( + exc: BaseException, + *, + request_id: str, + ) -> None: + failure = find_execution_failure(exc) + trace_terminal_execution_error( + wire_api="responses", + request_id=request_id, + status_code=failure.status_code if failure is not None else 500, + error_type=( + openai_error_type_for_failure(failure) + if failure is not None + else "api_error" + ), + error=exc, + ) diff --git a/src/free_claude_code/api/handlers/token_count.py b/src/free_claude_code/api/handlers/token_count.py new file mode 100644 index 0000000..3ee838d --- /dev/null +++ b/src/free_claude_code/api/handlers/token_count.py @@ -0,0 +1,85 @@ +"""Anthropic token-count API product flow.""" + +from fastapi import HTTPException +from loguru import logger + +from free_claude_code.api.request_errors import ( + http_status_for_unexpected_api_exception, + log_unexpected_api_exception, + require_non_empty_messages, +) +from free_claude_code.api.request_ids import new_request_id +from free_claude_code.application.errors import ApplicationError +from free_claude_code.application.execution import TokenCounter +from free_claude_code.application.routing import ModelRouter +from free_claude_code.config.settings import Settings +from free_claude_code.core.anthropic import ( + TokenCountRequest, + TokenCountResponse, + anthropic_request_snapshot, + get_token_count, +) +from free_claude_code.core.diagnostics import safe_exception_message +from free_claude_code.core.trace import trace_event + + +class TokenCountHandler: + """Handle Anthropic-compatible token count requests.""" + + def __init__( + self, + settings: Settings, + *, + model_router: ModelRouter | None = None, + token_counter: TokenCounter = get_token_count, + ) -> None: + self._settings = settings + self._model_router = model_router or ModelRouter(settings) + self._token_counter = token_counter + + def count( + self, request_data: TokenCountRequest, *, request_id: str | None = None + ) -> TokenCountResponse: + """Count tokens for a request after applying configured model routing.""" + request_id = request_id or new_request_id() + with logger.contextualize(request_id=request_id): + try: + require_non_empty_messages(request_data.messages) + routed = self._model_router.resolve_token_count_request(request_data) + tokens = self._token_counter( + routed.request.messages, routed.request.system, routed.request.tools + ) + trace_event( + stage="routing", + event="free_claude_code.api.route.resolved", + source="api", + request_id=request_id, + kind="count_tokens", + provider_id=routed.resolved.provider_id, + provider_model=routed.resolved.provider_model, + provider_model_ref=routed.resolved.provider_model_ref, + gateway_model=routed.request.model, + ) + trace_event( + stage="ingress", + event="free_claude_code.api.count_tokens.completed", + source="api", + request_id=request_id, + message_count=len(routed.request.messages), + input_tokens=tokens, + snapshot=anthropic_request_snapshot(routed.request), + ) + return TokenCountResponse(input_tokens=tokens) + except ApplicationError: + raise + except Exception as exc: + log_unexpected_api_exception( + self._settings, + exc, + context="COUNT_TOKENS_ERROR", + request_id=request_id, + ) + raise HTTPException( + status_code=http_status_for_unexpected_api_exception(exc), + detail=safe_exception_message(exc), + ) from exc diff --git a/src/free_claude_code/api/model_catalog.py b/src/free_claude_code/api/model_catalog.py new file mode 100644 index 0000000..210531f --- /dev/null +++ b/src/free_claude_code/api/model_catalog.py @@ -0,0 +1,152 @@ +"""Model-list response construction for Claude-compatible clients.""" + +from typing import Literal + +from pydantic import BaseModel + +from free_claude_code.application.ports import RequestRuntimePort +from free_claude_code.config.model_refs import configured_chat_model_refs +from free_claude_code.config.settings import Settings +from free_claude_code.core.gateway_model_ids import ( + gateway_model_id, + no_thinking_gateway_model_id, +) + +DISCOVERED_MODEL_CREATED_AT = "1970-01-01T00:00:00Z" + + +class ModelResponse(BaseModel): + object: Literal["model"] = "model" + created: int = 0 + owned_by: str = "free-claude-code" + created_at: str + display_name: str + id: str + type: Literal["model"] = "model" + + +class ModelsListResponse(BaseModel): + object: Literal["list"] = "list" + data: list[ModelResponse] + first_id: str | None + has_more: bool + last_id: str | None + + +SUPPORTED_CLAUDE_MODELS = [ + ModelResponse( + id="claude-opus-4-20250514", + display_name="Claude Opus 4", + created_at="2025-05-14T00:00:00Z", + ), + ModelResponse( + id="claude-sonnet-4-20250514", + display_name="Claude Sonnet 4", + created_at="2025-05-14T00:00:00Z", + ), + ModelResponse( + id="claude-haiku-4-20250514", + display_name="Claude Haiku 4", + created_at="2025-05-14T00:00:00Z", + ), + ModelResponse( + id="claude-3-opus-20240229", + display_name="Claude 3 Opus", + created_at="2024-02-29T00:00:00Z", + ), + ModelResponse( + id="claude-3-5-sonnet-20241022", + display_name="Claude 3.5 Sonnet", + created_at="2024-10-22T00:00:00Z", + ), + ModelResponse( + id="claude-3-haiku-20240307", + display_name="Claude 3 Haiku", + created_at="2024-03-07T00:00:00Z", + ), + ModelResponse( + id="claude-3-5-haiku-20241022", + display_name="Claude 3.5 Haiku", + created_at="2024-10-22T00:00:00Z", + ), +] + + +def build_models_list_response( + settings: Settings, runtime: RequestRuntimePort +) -> ModelsListResponse: + """Return configured, cached, and compatibility model ids.""" + models: list[ModelResponse] = [] + seen: set[str] = set() + + for ref in configured_chat_model_refs(settings): + supports_thinking = runtime.cached_model_supports_thinking( + ref.provider_id, ref.model_id + ) + _append_provider_model_variants( + models, + seen, + ref.model_ref, + supports_thinking=supports_thinking, + ) + + for model_info in runtime.cached_prefixed_model_infos(): + _append_provider_model_variants( + models, + seen, + model_info.model_id, + supports_thinking=model_info.supports_thinking, + ) + + for model in SUPPORTED_CLAUDE_MODELS: + _append_unique_model(models, seen, model) + + return ModelsListResponse( + data=models, + first_id=models[0].id if models else None, + has_more=False, + last_id=models[-1].id if models else None, + ) + + +def _discovered_model_response(model_id: str, *, display_name: str) -> ModelResponse: + return ModelResponse( + id=model_id, + display_name=display_name, + created_at=DISCOVERED_MODEL_CREATED_AT, + ) + + +def _append_unique_model( + models: list[ModelResponse], seen: set[str], model: ModelResponse +) -> None: + if model.id in seen: + return + seen.add(model.id) + models.append(model) + + +def _append_provider_model_variants( + models: list[ModelResponse], + seen: set[str], + provider_model_ref: str, + *, + supports_thinking: bool | None = None, +) -> None: + if supports_thinking is not False: + _append_unique_model( + models, + seen, + _discovered_model_response( + gateway_model_id(provider_model_ref), + display_name=provider_model_ref, + ), + ) + _append_unique_model( + models, + seen, + _discovered_model_response( + no_thinking_gateway_model_id(provider_model_ref), + display_name=f"{provider_model_ref} (no thinking)", + ), + ) diff --git a/src/free_claude_code/api/optimization_handlers.py b/src/free_claude_code/api/optimization_handlers.py new file mode 100644 index 0000000..452dadc --- /dev/null +++ b/src/free_claude_code/api/optimization_handlers.py @@ -0,0 +1,157 @@ +"""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 free_claude_code.config.settings import Settings +from free_claude_code.core.anthropic import ( + MessagesRequest, + MessagesResponse, + Usage, +) + +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, +) + + +def _text_response( + request_data: MessagesRequest, + text: str, + *, + input_tokens: int, + output_tokens: int, +) -> MessagesResponse: + return MessagesResponse( + id=f"msg_{uuid.uuid4()}", + model=request_data.model, + content=[{"type": "text", "text": text}], + stop_reason="end_turn", + usage=Usage(input_tokens=input_tokens, output_tokens=output_tokens), + ) + + +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 _text_response( + request_data, + extract_command_prefix(command), + 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 _text_response( + request_data, + "Quota check passed.", + 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 _text_response( + request_data, + "Conversation", + 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 _text_response( + request_data, + "", + 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 _text_response( + request_data, + filepaths, + 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/src/free_claude_code/api/ports.py b/src/free_claude_code/api/ports.py new file mode 100644 index 0000000..778f3d9 --- /dev/null +++ b/src/free_claude_code/api/ports.py @@ -0,0 +1,32 @@ +"""Runtime capabilities consumed by the HTTP API adapter.""" + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, Protocol + +from free_claude_code.application.ports import RequestRuntimePort, TaskController + + +class AdminRuntimePort(Protocol): + """Runtime operations exposed by the local Admin API.""" + + async def apply_admin_config( + self, updates: Mapping[str, Any] + ) -> dict[str, Any]: ... + + def admin_status(self) -> dict[str, Any]: ... + + async def test_provider(self, provider_id: str) -> dict[str, Any]: ... + + async def refresh_models(self) -> dict[str, Any]: ... + + async def request_restart(self) -> None: ... + + +@dataclass(frozen=True, slots=True) +class ApiServices: + """Complete runtime boundary required to construct the API application.""" + + requests: RequestRuntimePort + admin: AdminRuntimePort + tasks: TaskController diff --git a/src/free_claude_code/api/request_errors.py b/src/free_claude_code/api/request_errors.py new file mode 100644 index 0000000..4e67fa5 --- /dev/null +++ b/src/free_claude_code/api/request_errors.py @@ -0,0 +1,99 @@ +"""Shared API request validation and safe error logging.""" + +from typing import Any, Literal + +from fastapi import HTTPException +from fastapi.responses import JSONResponse +from loguru import logger + +from free_claude_code.application.errors import ApplicationError, InvalidRequestError +from free_claude_code.config.settings import Settings +from free_claude_code.core.anthropic import ( + anthropic_error_payload, + anthropic_error_type_for_failure, +) +from free_claude_code.core.diagnostics import ( + redacted_exception_traceback, + safe_exception_message, +) +from free_claude_code.core.openai_responses import ( + openai_error_payload, + openai_error_type_for_failure, +) + +WireApi = Literal["messages", "responses"] + + +def require_non_empty_messages(messages: list[Any]) -> None: + if not messages: + raise InvalidRequestError("messages cannot be empty") + + +def ordinary_application_error_response( + error: ApplicationError, + *, + wire_api: WireApi, + request_id: str, +) -> JSONResponse: + """Serialize a deterministic application error without terminal headers.""" + if wire_api == "responses": + return JSONResponse( + status_code=error.status_code, + content=openai_error_payload( + message=error.message, + error_type=openai_error_type_for_failure(error.kind), + ), + ) + return JSONResponse( + status_code=error.status_code, + content=anthropic_error_payload( + error_type=anthropic_error_type_for_failure(error.kind), + message=error.message, + request_id=request_id, + ), + ) + + +def http_status_for_unexpected_api_exception(_exc: BaseException) -> int: + return 500 + + +def log_unexpected_api_exception( + settings: Settings, + exc: BaseException, + *, + context: str, + request_id: str | None = None, +) -> None: + """Log API failures without echoing exception text unless opted in.""" + if settings.log_api_error_tracebacks: + if request_id is not None: + logger.error( + "{} request_id={}: {}", + context, + request_id, + safe_exception_message(exc), + ) + else: + logger.error("{}: {}", context, safe_exception_message(exc)) + logger.error(redacted_exception_traceback(exc)) + return + if request_id is not None: + logger.error( + "{} request_id={} exc_type={}", + context, + request_id, + type(exc).__name__, + ) + else: + logger.error("{} exc_type={}", context, type(exc).__name__) + + +def unexpected_http_exception( + settings: Settings, exc: Exception, *, context: str +) -> HTTPException: + log_unexpected_api_exception(settings, exc, context=context) + return HTTPException( + status_code=http_status_for_unexpected_api_exception(exc), + detail=safe_exception_message(exc), + ) diff --git a/src/free_claude_code/api/request_ids.py b/src/free_claude_code/api/request_ids.py new file mode 100644 index 0000000..6ffe5ae --- /dev/null +++ b/src/free_claude_code/api/request_ids.py @@ -0,0 +1,98 @@ +"""Ingress-owned HTTP request correlation.""" + +import uuid + +from fastapi import Request, Response +from loguru import logger +from starlette.datastructures import Headers, MutableHeaders +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from free_claude_code.core.trace import extract_claude_session_id_from_headers + +REQUEST_ID_HEADER = "request-id" +OPENAI_REQUEST_ID_HEADER = "x-request-id" +_REQUEST_ID_STATE_ATTRIBUTE = "fcc_request_id" +_OPENAI_REQUEST_ID_PATHS = frozenset({"/v1/responses", "/v1/models"}) + + +class RequestCorrelationMiddleware: + """Own one request id and logging context for the full ASGI response.""" + + def __init__(self, app: ASGIApp) -> None: + self._app = app + + async def __call__( + self, + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + if scope["type"] != "http": + await self._app(scope, receive, send) + return + + request_id = new_request_id() + state = scope.setdefault("state", {}) + state[_REQUEST_ID_STATE_ATTRIBUTE] = request_id + method = scope.get("method", "") + path = scope.get("path", "") + request_headers = Headers(scope=scope) + claude_sid = extract_claude_session_id_from_headers(request_headers) + + async def send_with_correlation(message: Message) -> None: + if message["type"] == "http.response.start": + message = dict(message) + raw_headers = list(message.get("headers", ())) + _set_request_id_headers( + MutableHeaders(raw=raw_headers), + request_id=request_id, + path=path, + ) + message["headers"] = raw_headers + await send(message) + + with logger.contextualize( + http_method=method, + http_path=path, + claude_session_id=claude_sid, + request_id=request_id, + ): + await self._app(scope, receive, send_with_correlation) + + +def new_request_id() -> str: + """Return a new opaque FCC request identifier.""" + return f"req_{uuid.uuid4().hex}" + + +def set_request_id(request: Request, request_id: str) -> None: + """Attach the ingress correlation identifier to request state.""" + setattr(request.state, _REQUEST_ID_STATE_ATTRIBUTE, request_id) + + +def get_request_id(request: Request) -> str: + """Return the ingress correlation identifier, creating a fallback if needed.""" + request_id = getattr(request.state, _REQUEST_ID_STATE_ATTRIBUTE, None) + if isinstance(request_id, str) and request_id: + return request_id + request_id = new_request_id() + set_request_id(request, request_id) + return request_id + + +def attach_request_id_headers( + response: Response, *, request_id: str, path: str +) -> None: + """Attach correlation when an outer server-error boundary bypasses middleware.""" + _set_request_id_headers(response.headers, request_id=request_id, path=path) + + +def _set_request_id_headers( + headers: MutableHeaders, + *, + request_id: str, + path: str, +) -> None: + headers[REQUEST_ID_HEADER] = request_id + if path in _OPENAI_REQUEST_ID_PATHS: + headers[OPENAI_REQUEST_ID_HEADER] = request_id diff --git a/src/free_claude_code/api/response_streams.py b/src/free_claude_code/api/response_streams.py new file mode 100644 index 0000000..a66833e --- /dev/null +++ b/src/free_claude_code/api/response_streams.py @@ -0,0 +1,386 @@ +"""FastAPI streaming response wrappers for public API wire formats.""" + +import asyncio +from collections.abc import ( + AsyncIterator, + Awaitable, + Callable, + Mapping, +) +from typing import Any, Literal + +from fastapi.responses import JSONResponse, Response, StreamingResponse +from starlette.background import BackgroundTask +from starlette.responses import ContentStream +from starlette.types import Receive, Scope, Send + +from free_claude_code.core.anthropic import anthropic_error_type_for_failure +from free_claude_code.core.anthropic.streaming import ( + ANTHROPIC_SSE_RESPONSE_HEADERS, + anthropic_terminal_error_frame, + anthropic_terminal_failure_frame, +) +from free_claude_code.core.async_iterators import try_close_async_iterator +from free_claude_code.core.diagnostics import safe_exception_message +from free_claude_code.core.failures import find_execution_failure +from free_claude_code.core.trace import close_stream_input, trace_event + +TERMINAL_EXECUTION_ERROR_HEADERS = {"x-should-retry": "false"} + +PreStartErrorResponse = Callable[[BaseException], Response] +TerminalFrameEmitter = Callable[[BaseException], str] +TerminalFailureObserver = Callable[[BaseException], None] +ReleaseResponseResource = Callable[[], Awaitable[None]] +WireApi = Literal["messages", "responses"] + + +class EmptyStreamError(RuntimeError): + """Raised when a public stream ends before emitting any protocol chunk.""" + + +class ManagedStreamingResponse(StreamingResponse): + """Own body closure and one response-scoped runtime release callback.""" + + def __init__( + self, + content: ContentStream, + status_code: int = 200, + headers: Mapping[str, str] | None = None, + media_type: str | None = None, + background: BackgroundTask | None = None, + ) -> None: + super().__init__( + content, + status_code=status_code, + headers=headers, + media_type=media_type, + background=background, + ) + self._release: ReleaseResponseResource | None = None + self._cleanup_task: asyncio.Task[None] | None = None + + def bind_release(self, release: ReleaseResponseResource) -> None: + """Bind the resource retained for this response before ASGI execution.""" + if self._release is not None: + raise RuntimeError("A response resource release is already bound.") + if self._cleanup_task is not None: + raise RuntimeError("Cannot bind a resource after response cleanup started.") + self._release = release + + async def aclose(self) -> None: + """Close the body and release its runtime resource exactly once.""" + await self._close(preserved_error=None) + + async def _close(self, *, preserved_error: BaseException | None) -> None: + task = self._cleanup_task + if task is None: + task = asyncio.create_task( + self._cleanup(preserved_error=preserved_error), + name="fcc-api-response-cleanup", + ) + self._cleanup_task = task + await _wait_for_cleanup(task) + + async def __call__( + self, + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + preserved_error: BaseException | None = None + try: + await super().__call__(scope, receive, send) + except BaseException as exc: + preserved_error = exc + raise + finally: + await self._close(preserved_error=preserved_error) + + async def _cleanup(self, *, preserved_error: BaseException | None) -> None: + try: + await close_stream_input( + self.body_iterator, + owner="ManagedStreamingResponse", + source="api", + preserved_error=preserved_error, + ) + except Exception as exc: + _trace_response_cleanup_failure("close_body", exc) + + release = self._release + if release is None: + return + try: + await release() + except Exception as exc: + _trace_response_cleanup_failure("release_resource", exc) + + +async def _wait_for_cleanup(task: asyncio.Task[None]) -> None: + """Wait through repeated caller cancellation, then restore cancellation.""" + cancellation: asyncio.CancelledError | None = None + while not task.done(): + try: + await asyncio.shield(task) + except asyncio.CancelledError as exc: + cancellation = exc + + # Ordinary defensive failures are trace-only; cancellation remains control flow. + try: + task.result() + except asyncio.CancelledError: + if cancellation is not None: + raise cancellation from None + raise + except Exception as exc: + _trace_response_cleanup_failure("cleanup_task", exc) + + if cancellation is not None: + raise cancellation + + +def _trace_response_cleanup_failure(operation: str, exc: BaseException) -> None: + trace_event( + stage="egress", + event="free_claude_code.api.response.cleanup_failed", + source="api", + operation=operation, + exc_type=type(exc).__name__, + ) + + +async def bind_response_lifetime( + response: object, + release: ReleaseResponseResource, +) -> object: + """Retain a runtime resource until a response body is fully consumed.""" + if isinstance(response, ManagedStreamingResponse): + response.bind_release(release) + return response + if isinstance(response, StreamingResponse): + error = TypeError("Streaming API responses must use ManagedStreamingResponse.") + try: + await close_stream_input( + response.body_iterator, + owner="bind_response_lifetime", + source="api", + preserved_error=error, + ) + finally: + await release() + raise error + await release() + return response + + +def terminal_execution_error_response( + *, status_code: int, content: dict[str, Any] +) -> JSONResponse: + """Return a final provider-execution error without enabling client retries.""" + return JSONResponse( + status_code=status_code, + content=content, + headers=dict(TERMINAL_EXECUTION_ERROR_HEADERS), + ) + + +def trace_terminal_execution_error( + *, + wire_api: WireApi, + request_id: str, + status_code: int, + error_type: str, + error: BaseException | None = None, +) -> None: + """Record one correlated terminal-execution decision at the HTTP boundary.""" + fields: dict[str, object] = { + "stage": "egress", + "event": "free_claude_code.api.response.terminal_execution_error", + "source": "api", + "wire_api": wire_api, + "request_id": request_id, + "status_code": status_code, + "error_type": error_type, + "client_should_retry": False, + } + failure = find_execution_failure(error) if error is not None else None + if error is not None: + fields["exc_type"] = type(failure or error).__name__ + if failure is not None: + fields["failure_kind"] = failure.kind.value + fields["provider_retryable"] = failure.retryable + trace_event(**fields) + + +async def _first_chunk_streaming_response( + body: AsyncIterator[str], + *, + headers: Mapping[str, str], + pre_start_error_response: PreStartErrorResponse, + terminal_frame: TerminalFrameEmitter | None, + terminal_failure_observer: TerminalFailureObserver | None, +) -> Response: + try: + first_chunk = await anext(body) + except StopAsyncIteration: + error = EmptyStreamError("Stream ended before emitting a response.") + await _close_pre_start_body(body, preserved_error=error) + return pre_start_error_response(error) + except GeneratorExit as exc: + await _close_pre_start_body(body, preserved_error=exc) + raise + except asyncio.CancelledError as exc: + await _close_pre_start_body(body, preserved_error=exc) + raise + except BaseExceptionGroup as exc: + await _close_pre_start_body(body, preserved_error=exc) + return pre_start_error_response(exc) + except Exception as exc: + await _close_pre_start_body(body, preserved_error=exc) + return pre_start_error_response(exc) + + return ManagedStreamingResponse( + _PrefetchedStream( + first_chunk, + body, + terminal_frame=terminal_frame, + terminal_failure_observer=terminal_failure_observer, + ), + media_type="text/event-stream", + headers=dict(headers), + ) + + +async def _close_pre_start_body( + body: AsyncIterator[str], + *, + preserved_error: BaseException, +) -> None: + task = asyncio.create_task( + close_stream_input( + body, + owner="first_chunk_streaming_response", + source="api", + preserved_error=preserved_error, + ), + name="fcc-api-pre-start-stream-cleanup", + ) + await _wait_for_cleanup(task) + + +class _PrefetchedStream(AsyncIterator[str]): + """Replay one prefetched frame while retaining ownership of the tail.""" + + def __init__( + self, + first_chunk: str, + body: AsyncIterator[str], + *, + terminal_frame: TerminalFrameEmitter | None, + terminal_failure_observer: TerminalFailureObserver | None, + ) -> None: + self._first_chunk: str | None = first_chunk + self._body = body + self._terminal_frame = terminal_frame + self._terminal_failure_observer = terminal_failure_observer + self._done = False + self._closed = False + + def __aiter__(self) -> _PrefetchedStream: + return self + + async def __anext__(self) -> str: + if self._closed or self._done: + raise StopAsyncIteration + if self._first_chunk is not None: + first_chunk = self._first_chunk + self._first_chunk = None + return first_chunk + try: + return await anext(self._body) + except StopAsyncIteration: + self._done = True + raise + except BaseExceptionGroup as exc: + return self._terminal_chunk(find_execution_failure(exc) or exc) + except Exception as exc: + return self._terminal_chunk(exc) + + async def aclose(self) -> None: + if self._closed: + return + self._closed = True + self._done = True + close_error = await try_close_async_iterator(self._body) + if close_error is not None: + raise close_error + + def _terminal_chunk(self, exc: BaseException) -> str: + terminal_frame = self._terminal_frame + if terminal_frame is None: + raise exc + self._done = True + if self._terminal_failure_observer is not None: + self._terminal_failure_observer(exc) + return terminal_frame(exc) + + +async def anthropic_sse_streaming_response( + body: AsyncIterator[str], + *, + pre_start_error_response: PreStartErrorResponse, + request_id: str, +) -> Response: + """Return a streaming response for Anthropic-style SSE streams.""" + return await _first_chunk_streaming_response( + body, + headers=ANTHROPIC_SSE_RESPONSE_HEADERS, + pre_start_error_response=pre_start_error_response, + terminal_frame=_anthropic_terminal_frame, + terminal_failure_observer=lambda exc: _trace_anthropic_terminal_failure( + exc, + request_id=request_id, + ), + ) + + +def _anthropic_terminal_frame(exc: BaseException) -> str: + failure = find_execution_failure(exc) + if failure is not None: + return anthropic_terminal_failure_frame(failure) + return anthropic_terminal_error_frame(safe_exception_message(exc)) + + +def _trace_anthropic_terminal_failure( + exc: BaseException, + *, + request_id: str, +) -> None: + failure = find_execution_failure(exc) + trace_terminal_execution_error( + wire_api="messages", + request_id=request_id, + status_code=failure.status_code if failure is not None else 500, + error_type=( + anthropic_error_type_for_failure(failure) + if failure is not None + else "api_error" + ), + error=exc, + ) + + +async def openai_responses_sse_streaming_response( + body: AsyncIterator[str], + *, + headers: Mapping[str, str], + pre_start_error_response: PreStartErrorResponse, +) -> Response: + """Return a streaming response for OpenAI Responses-style SSE.""" + return await _first_chunk_streaming_response( + body, + headers=headers, + pre_start_error_response=pre_start_error_response, + terminal_frame=None, + terminal_failure_observer=None, + ) diff --git a/src/free_claude_code/api/routes.py b/src/free_claude_code/api/routes.py new file mode 100644 index 0000000..2ff1813 --- /dev/null +++ b/src/free_claude_code/api/routes.py @@ -0,0 +1,221 @@ +"""FastAPI route handlers.""" + +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from loguru import logger + +from free_claude_code.application.errors import ApplicationError +from free_claude_code.application.ports import ProviderResolver, RequestRuntimeLease +from free_claude_code.config.model_refs import parse_provider_type +from free_claude_code.config.settings import Settings +from free_claude_code.core.anthropic import ( + MessagesRequest, + TokenCountRequest, + get_token_count, +) +from free_claude_code.core.openai_responses import OpenAIResponsesRequest +from free_claude_code.core.trace import trace_event + +from .dependencies import ( + get_services, + get_settings, + require_api_key, + resolve_provider, +) +from .handlers import MessagesHandler, ResponsesHandler, TokenCountHandler +from .model_catalog import ModelsListResponse, build_models_list_response +from .ports import ApiServices +from .request_errors import ordinary_application_error_response +from .request_ids import get_request_id +from .response_streams import bind_response_lifetime + +router = APIRouter() + + +def _provider_resolver(lease: RequestRuntimeLease) -> ProviderResolver: + return lambda provider_type: resolve_provider(provider_type, lease=lease) + + +async def _create_messages_response( + services: ApiServices, + request_data: MessagesRequest, + *, + request_id: str, +) -> object: + lease: RequestRuntimeLease | None = None + try: + lease = await services.requests.acquire() + handler = MessagesHandler( + lease.settings, + provider_resolver=_provider_resolver(lease), + token_counter=get_token_count, + generation_id=lease.generation_id, + ) + response = await handler.create(request_data, request_id=request_id) + except ApplicationError as exc: + if lease is not None: + await lease.release() + return ordinary_application_error_response( + exc, + wire_api="messages", + request_id=request_id, + ) + except BaseException: + if lease is not None: + await lease.release() + raise + assert lease is not None + return await bind_response_lifetime(response, lease.release) + + +async def _create_responses_response( + services: ApiServices, + request_data: OpenAIResponsesRequest, + *, + request_id: str, +) -> object: + lease: RequestRuntimeLease | None = None + try: + lease = await services.requests.acquire() + handler = ResponsesHandler( + lease.settings, + provider_resolver=_provider_resolver(lease), + generation_id=lease.generation_id, + ) + response = await handler.create(request_data, request_id=request_id) + except ApplicationError as exc: + if lease is not None: + await lease.release() + return ordinary_application_error_response( + exc, + wire_api="responses", + request_id=request_id, + ) + except BaseException: + if lease is not None: + await lease.release() + raise + assert lease is not None + return await bind_response_lifetime(response, lease.release) + + +def _probe_response(allow: str) -> Response: + return Response(status_code=204, headers={"Allow": allow}) + + +@router.post("/v1/messages") +async def create_message( + request: Request, + request_data: MessagesRequest, + services: ApiServices = Depends(get_services), + _auth=Depends(require_api_key), +): + """Create a message (streaming by default; stream=false gets aggregated JSON).""" + return await _create_messages_response( + services, + request_data, + request_id=get_request_id(request), + ) + + +@router.api_route("/v1/messages", methods=["HEAD", "OPTIONS"]) +async def probe_messages(_auth=Depends(require_api_key)): + return _probe_response("POST, HEAD, OPTIONS") + + +@router.post("/v1/responses") +async def create_response( + request: Request, + request_data: OpenAIResponsesRequest, + services: ApiServices = Depends(get_services), + _auth=Depends(require_api_key), +): + """Create an OpenAI Responses-compatible response through this proxy.""" + return await _create_responses_response( + services, + request_data, + request_id=get_request_id(request), + ) + + +@router.api_route("/v1/responses", methods=["HEAD", "OPTIONS"]) +async def probe_responses(_auth=Depends(require_api_key)): + return _probe_response("POST, HEAD, OPTIONS") + + +@router.post("/v1/messages/count_tokens") +async def count_tokens( + request: Request, + request_data: TokenCountRequest, + settings: Settings = Depends(get_settings), + _auth=Depends(require_api_key), +): + """Count tokens for a request.""" + handler = TokenCountHandler(settings, token_counter=get_token_count) + return handler.count(request_data, request_id=get_request_id(request)) + + +@router.api_route("/v1/messages/count_tokens", methods=["HEAD", "OPTIONS"]) +async def probe_count_tokens(_auth=Depends(require_api_key)): + return _probe_response("POST, HEAD, OPTIONS") + + +@router.get("/") +async def root( + settings: Settings = Depends(get_settings), + _auth=Depends(require_api_key), +): + return { + "status": "ok", + "provider": parse_provider_type(settings.model), + "model": settings.model, + } + + +@router.api_route("/", methods=["HEAD", "OPTIONS"]) +async def probe_root(): + return _probe_response("GET, HEAD, OPTIONS") + + +@router.get("/health") +async def health(): + return {"status": "healthy"} + + +@router.api_route("/health", methods=["HEAD", "OPTIONS"]) +async def probe_health(): + return _probe_response("GET, HEAD, OPTIONS") + + +@router.get("/v1/models", response_model=ModelsListResponse) +async def list_models( + services: ApiServices = Depends(get_services), + settings: Settings = Depends(get_settings), + _auth=Depends(require_api_key), +): + """List the model ids this proxy advertises to compatible clients.""" + trace_event(stage="ingress", event="free_claude_code.api.models.list", source="api") + return build_models_list_response(settings, services.requests) + + +@router.post("/stop") +async def stop_cli( + services: ApiServices = Depends(get_services), + _auth=Depends(require_api_key), +): + """Stop all CLI sessions and pending tasks.""" + result = await services.tasks.stop_all() + if result is None: + raise HTTPException(status_code=503, detail="Messaging system not initialized") + if result.source is not None: + logger.info("STOP_CLI: source={} cancelled_count=N/A", result.source) + return {"status": "stopped", "source": result.source} + + count = result.cancelled_count or 0 + trace_event( + stage="ingress", + event="free_claude_code.api.cli.stop_via_messaging_workflow", + source="api", + cancelled_nodes=count, + ) + logger.info("STOP_CLI: source=messaging_workflow cancelled_count={}", count) + return {"status": "stopped", "cancelled_count": count} diff --git a/src/free_claude_code/api/validation_log.py b/src/free_claude_code/api/validation_log.py new file mode 100644 index 0000000..87d812d --- /dev/null +++ b/src/free_claude_code/api/validation_log.py @@ -0,0 +1,46 @@ +"""Safe metadata summaries for HTTP 422 validation logging (no raw text content).""" + +from typing import Any + + +def summarize_request_validation_body( + body: Any, +) -> tuple[list[dict[str, Any]], list[str]]: + """Return message shape summary and tool name list for debug logs.""" + messages = body.get("messages") if isinstance(body, dict) else None + message_summary: list[dict[str, Any]] = [] + if isinstance(messages, list): + for msg in messages: + if not isinstance(msg, dict): + message_summary.append({"message_kind": type(msg).__name__}) + continue + content = msg.get("content") + item: dict[str, Any] = { + "role": msg.get("role"), + "content_kind": type(content).__name__, + } + if isinstance(content, list): + item["block_types"] = [ + block.get("type", "dict") + if isinstance(block, dict) + else type(block).__name__ + for block in content[:12] + ] + item["block_keys"] = [ + sorted(str(key) for key in block)[:12] + for block in content[:5] + if isinstance(block, dict) + ] + elif isinstance(content, str): + item["content_length"] = len(content) + message_summary.append(item) + + tool_names: list[str] = [] + if isinstance(body, dict) and isinstance(body.get("tools"), list): + tool_names = [ + str(tool.get("name", "")) + for tool in body["tools"] + if isinstance(tool, dict) + ] + + return message_summary, tool_names diff --git a/src/free_claude_code/api/web_tools/__init__.py b/src/free_claude_code/api/web_tools/__init__.py new file mode 100644 index 0000000..e0fd14c --- /dev/null +++ b/src/free_claude_code/api/web_tools/__init__.py @@ -0,0 +1,17 @@ +"""Submodules for Anthropic web server tool handling (search/fetch, egress, streaming).""" + +from .egress import ( + WebFetchEgressPolicy, + WebFetchEgressViolation, + enforce_web_fetch_egress, +) +from .request import is_web_server_tool_request +from .streaming import stream_web_server_tool_response + +__all__ = [ + "WebFetchEgressPolicy", + "WebFetchEgressViolation", + "enforce_web_fetch_egress", + "is_web_server_tool_request", + "stream_web_server_tool_response", +] diff --git a/src/free_claude_code/api/web_tools/constants.py b/src/free_claude_code/api/web_tools/constants.py new file mode 100644 index 0000000..db6546a --- /dev/null +++ b/src/free_claude_code/api/web_tools/constants.py @@ -0,0 +1,17 @@ +"""Limits and defaults for outbound web server tool HTTP.""" + +from free_claude_code.core.version import package_version + +_REQUEST_TIMEOUT_S = 20.0 +_MAX_SEARCH_RESULTS = 10 +_MAX_FETCH_CHARS = 24_000 +# Hard cap on raw bytes read from HTTP responses before decode / HTML parse (memory bound). +_MAX_WEB_FETCH_RESPONSE_BYTES = 2 * 1024 * 1024 +# Drain at most this many bytes from redirect responses before following Location. +_REDIRECT_RESPONSE_BODY_CAP_BYTES = 65_536 +_MAX_WEB_FETCH_REDIRECTS = 10 +_WEB_FETCH_REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308}) + +_WEB_TOOL_HTTP_HEADERS = { + "User-Agent": f"Mozilla/5.0 compatible; free-claude-code/{package_version()}", +} diff --git a/src/free_claude_code/api/web_tools/egress.py b/src/free_claude_code/api/web_tools/egress.py new file mode 100644 index 0000000..1aec722 --- /dev/null +++ b/src/free_claude_code/api/web_tools/egress.py @@ -0,0 +1,105 @@ +"""Egress policy for user-controlled web_fetch URLs (SSRF guard).""" + +import ipaddress +import socket +from dataclasses import dataclass +from urllib.parse import urlparse + + +@dataclass(frozen=True, slots=True) +class WebFetchEgressPolicy: + """Egress rules for user-influenced web_fetch URLs.""" + + allow_private_network_targets: bool + allowed_schemes: frozenset[str] + + +class WebFetchEgressViolation(ValueError): + """Raised when a web_fetch URL is rejected by egress policy (SSRF guard).""" + + +def web_fetch_allowed_scheme_set(raw_schemes: str) -> frozenset[str]: + """Return normalized schemes allowed for web_fetch.""" + + return frozenset( + part.strip().lower() for part in raw_schemes.split(",") if part.strip() + ) + + +def _port_for_url(parsed) -> int: + if parsed.port is not None: + return parsed.port + return 443 if (parsed.scheme or "").lower() == "https" else 80 + + +def _stream_getaddrinfo_or_raise(host: str, port: int) -> list[tuple]: + try: + return socket.getaddrinfo( + host, port, type=socket.SOCK_STREAM, proto=socket.IPPROTO_TCP + ) + except OSError as exc: + raise WebFetchEgressViolation( + f"Could not resolve host {host!r}: {exc}" + ) from exc + + +def get_validated_stream_addrinfos_for_egress( + url: str, policy: WebFetchEgressPolicy +) -> list[tuple]: + """Resolve and validate a URL for web_fetch, returning getaddrinfo rows for pinning. + + Each HTTP connect pins to only these `getaddrinfo` results so a malicious DNS + server cannot rebind to a disallowed address between resolution and the TCP + connect (used by :func:`api.web_tools.outbound._run_web_fetch`). + """ + parsed = urlparse(url) + scheme = (parsed.scheme or "").lower() + if scheme not in policy.allowed_schemes: + raise WebFetchEgressViolation( + f"URL scheme {scheme!r} is not allowed for web_fetch" + ) + + host = parsed.hostname + if host is None or host == "": + raise WebFetchEgressViolation("web_fetch URL must include a host") + + port = _port_for_url(parsed) + + if policy.allow_private_network_targets: + return _stream_getaddrinfo_or_raise(host, port) + + host_lower = host.lower() + if host_lower == "localhost" or host_lower.endswith(".localhost"): + raise WebFetchEgressViolation("localhost targets are not allowed for web_fetch") + if host_lower.endswith(".local"): + raise WebFetchEgressViolation(".local hostnames are not allowed for web_fetch") + + try: + parsed_ip = ipaddress.ip_address(host) + except ValueError: + parsed_ip = None + + if parsed_ip is not None: + if not parsed_ip.is_global: + raise WebFetchEgressViolation( + f"Non-public IP host {host!r} is not allowed for web_fetch" + ) + return _stream_getaddrinfo_or_raise(host, port) + + infos = _stream_getaddrinfo_or_raise(host, port) + for *_, sockaddr in infos: + addr = sockaddr[0] + try: + resolved = ipaddress.ip_address(addr) + except ValueError: + continue + if not resolved.is_global: + raise WebFetchEgressViolation( + f"Host {host!r} resolves to a non-public address ({resolved})" + ) + return infos + + +def enforce_web_fetch_egress(url: str, policy: WebFetchEgressPolicy) -> None: + """Validate ``url`` (scheme, host, and resolved addresses) for web_fetch.""" + get_validated_stream_addrinfos_for_egress(url, policy) diff --git a/src/free_claude_code/api/web_tools/outbound.py b/src/free_claude_code/api/web_tools/outbound.py new file mode 100644 index 0000000..d043561 --- /dev/null +++ b/src/free_claude_code/api/web_tools/outbound.py @@ -0,0 +1,276 @@ +"""Outbound HTTP for web_search / web_fetch (client, body caps, logging).""" + +import asyncio +import socket +from collections.abc import AsyncIterator +from urllib.parse import urljoin, urlparse + +import aiohttp +import httpx +from aiohttp import ClientSession, ClientTimeout, TCPConnector +from aiohttp.abc import AbstractResolver, ResolveResult +from loguru import logger + +from . import constants +from .constants import ( + _MAX_FETCH_CHARS, + _MAX_SEARCH_RESULTS, + _REDIRECT_RESPONSE_BODY_CAP_BYTES, + _REQUEST_TIMEOUT_S, + _WEB_FETCH_REDIRECT_STATUSES, + _WEB_TOOL_HTTP_HEADERS, +) +from .egress import ( + WebFetchEgressPolicy, + WebFetchEgressViolation, + get_validated_stream_addrinfos_for_egress, +) +from .parsers import HTMLTextParser, SearchResultParser + + +def _safe_public_host_for_logs(url: str) -> str: + host = urlparse(url).hostname or "" + return host[:253] + + +def _log_web_tool_failure( + tool_name: str, + error: BaseException, + *, + fetch_url: str | None = None, +) -> None: + exc_type = type(error).__name__ + if isinstance(error, WebFetchEgressViolation): + host = _safe_public_host_for_logs(fetch_url) if fetch_url else "" + logger.warning( + "web_tool_egress_rejected tool={} exc_type={} host={!r}", + tool_name, + exc_type, + host, + ) + return + if tool_name == "web_fetch" and fetch_url: + logger.warning( + "web_tool_failure tool={} exc_type={} host={!r}", + tool_name, + exc_type, + _safe_public_host_for_logs(fetch_url), + ) + else: + logger.warning("web_tool_failure tool={} exc_type={}", tool_name, exc_type) + + +def _web_tool_client_error_summary( + tool_name: str, + error: BaseException, + *, + verbose: bool, +) -> str: + if verbose: + return f"{tool_name} failed: {type(error).__name__}" + return "Web tool request failed." + + +async def _iter_response_body_under_cap( + response: httpx.Response, max_bytes: int +) -> AsyncIterator[bytes]: + if max_bytes <= 0: + return + received = 0 + async for chunk in response.aiter_bytes(chunk_size=65_536): + if received >= max_bytes: + break + remaining = max_bytes - received + if len(chunk) <= remaining: + received += len(chunk) + yield chunk + if received >= max_bytes: + break + else: + yield chunk[:remaining] + break + + +async def _drain_response_body_capped(response: httpx.Response, max_bytes: int) -> None: + async for _ in _iter_response_body_under_cap(response, max_bytes): + pass + + +async def _read_response_body_capped(response: httpx.Response, max_bytes: int) -> bytes: + return b"".join( + [piece async for piece in _iter_response_body_under_cap(response, max_bytes)] + ) + + +_NUMERIC_RESOLVE_FLAGS = socket.AI_NUMERICHOST | socket.AI_NUMERICSERV +_NAME_RESOLVE_FLAGS = socket.NI_NUMERICHOST | socket.NI_NUMERICSERV + + +def getaddrinfo_rows_to_resolve_results( + host: str, addrinfos: list[tuple] +) -> list[ResolveResult]: + """Map :func:`socket.getaddrinfo` rows to aiohttp :class:`ResolveResult` (ThreadedResolver logic).""" + out: list[ResolveResult] = [] + for family, _type, proto, _canon, sockaddr in addrinfos: + if family == socket.AF_INET6: + if len(sockaddr) < 3: + continue + if sockaddr[3]: + resolved_host, port = socket.getnameinfo(sockaddr, _NAME_RESOLVE_FLAGS) + else: + resolved_host, port = sockaddr[:2] + else: + assert family == socket.AF_INET, family + resolved_host, port = sockaddr[0], sockaddr[1] + resolved_host = str(resolved_host) + port = int(port) + out.append( + ResolveResult( + hostname=host, + host=resolved_host, + port=int(port), + family=family, + proto=proto, + flags=_NUMERIC_RESOLVE_FLAGS, + ) + ) + return out + + +class _PinnedEgressStaticResolver(AbstractResolver): + """Return only pre-validated :class:`ResolveResult` for the outbound request.""" + + def __init__(self, results: list[ResolveResult]) -> None: + self._results = results + + async def resolve( + self, host: str, port: int = 0, family: int = socket.AF_INET + ) -> list[ResolveResult]: + return self._results + + async def close(self) -> None: # pragma: no cover - aiohttp contract + return + + +async def _read_aiohttp_body_capped( + response: aiohttp.ClientResponse, max_bytes: int +) -> bytes: + received = 0 + parts: list[bytes] = [] + async for chunk in response.content.iter_chunked(65_536): + if received >= max_bytes: + break + remaining = max_bytes - received + if len(chunk) <= remaining: + received += len(chunk) + parts.append(chunk) + else: + parts.append(chunk[:remaining]) + break + return b"".join(parts) + + +async def _drain_aiohttp_body_capped( + response: aiohttp.ClientResponse, max_bytes: int +) -> None: + if max_bytes <= 0: + return + received = 0 + async for chunk in response.content.iter_chunked(65_536): + received += len(chunk) + if received >= max_bytes: + break + + +async def _run_web_search(query: str) -> list[dict[str, str]]: + async with ( + httpx.AsyncClient( + timeout=_REQUEST_TIMEOUT_S, + follow_redirects=True, + headers=_WEB_TOOL_HTTP_HEADERS, + ) as client, + client.stream( + "GET", + "https://lite.duckduckgo.com/lite/", + params={"q": query}, + ) as response, + ): + response.raise_for_status() + body_bytes = await _read_response_body_capped( + response, constants._MAX_WEB_FETCH_RESPONSE_BYTES + ) + text = body_bytes.decode("utf-8", errors="replace") + parser = SearchResultParser() + parser.feed(text) + return parser.results[:_MAX_SEARCH_RESULTS] + + +async def _run_web_fetch(url: str, egress: WebFetchEgressPolicy) -> dict[str, str]: + """Fetch URL with manual redirects; each hop is DNS-pinned to validated addresses.""" + current_url = url + redirect_hops = 0 + timeout = ClientTimeout(total=_REQUEST_TIMEOUT_S) + + while True: + addr_infos = await asyncio.to_thread( + get_validated_stream_addrinfos_for_egress, current_url, egress + ) + host = urlparse(current_url).hostname or "" + results = getaddrinfo_rows_to_resolve_results(host, addr_infos) + resolver = _PinnedEgressStaticResolver(results) + connector = TCPConnector( + resolver=resolver, + force_close=True, + ) + try: + async with ( + ClientSession( + timeout=timeout, + headers=_WEB_TOOL_HTTP_HEADERS, + connector=connector, + ) as session, + session.get(current_url, allow_redirects=False) as response, + ): + if response.status in _WEB_FETCH_REDIRECT_STATUSES: + await _drain_aiohttp_body_capped( + response, _REDIRECT_RESPONSE_BODY_CAP_BYTES + ) + if redirect_hops >= constants._MAX_WEB_FETCH_REDIRECTS: + raise WebFetchEgressViolation( + "web_fetch exceeded maximum redirects " + f"({constants._MAX_WEB_FETCH_REDIRECTS})" + ) + location = response.headers.get("location") + if not location or not location.strip(): + raise WebFetchEgressViolation( + "web_fetch redirect response missing Location header" + ) + current_url = urljoin(str(response.url), location.strip()) + redirect_hops += 1 + continue + response.raise_for_status() + content_type = response.headers.get("content-type", "text/plain") + final_url = str(response.url) + encoding = response.get_encoding() or "utf-8" + body_bytes = await _read_aiohttp_body_capped( + response, constants._MAX_WEB_FETCH_RESPONSE_BYTES + ) + finally: + await connector.close() + + break + + text = body_bytes.decode(encoding, errors="replace") + title = final_url + data = text + if "html" in content_type.lower(): + parser = HTMLTextParser() + parser.feed(text) + title = parser.title or final_url + data = "\n".join(parser.text_parts) + return { + "url": final_url, + "title": title, + "media_type": "text/plain", + "data": data[:_MAX_FETCH_CHARS], + } diff --git a/src/free_claude_code/api/web_tools/parsers.py b/src/free_claude_code/api/web_tools/parsers.py new file mode 100644 index 0000000..797af7c --- /dev/null +++ b/src/free_claude_code/api/web_tools/parsers.py @@ -0,0 +1,102 @@ +"""HTML parsing for web_search / web_fetch.""" + +import html +import re +from html.parser import HTMLParser +from typing import Any +from urllib.parse import parse_qs, unquote, urlparse + + +class SearchResultParser(HTMLParser): + """DuckDuckGo lite HTML: extract result links and titles.""" + + def __init__(self) -> None: + super().__init__() + self.results: list[dict[str, str]] = [] + self._href: str | None = None + self._title_parts: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag != "a": + return + href = dict(attrs).get("href") + if not href or "uddg=" not in href: + return + parsed = urlparse(href) + query = parse_qs(parsed.query) + uddg = query.get("uddg", [""])[0] + if not uddg: + return + self._href = unquote(uddg) + self._title_parts = [] + + def handle_data(self, data: str) -> None: + if self._href is not None: + self._title_parts.append(data) + + def handle_endtag(self, tag: str) -> None: + if tag != "a" or self._href is None: + return + title = " ".join("".join(self._title_parts).split()) + if title and not any(result["url"] == self._href for result in self.results): + self.results.append({"title": html.unescape(title), "url": self._href}) + self._href = None + self._title_parts = [] + + +class HTMLTextParser(HTMLParser): + """Strip scripts/styles and collect visible text + title for fetch previews.""" + + def __init__(self) -> None: + super().__init__() + self.title = "" + self.text_parts: list[str] = [] + self._in_title = False + self._skip_depth = 0 + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag in {"script", "style", "noscript"}: + self._skip_depth += 1 + elif tag == "title": + self._in_title = True + + def handle_endtag(self, tag: str) -> None: + if tag in {"script", "style", "noscript"} and self._skip_depth: + self._skip_depth -= 1 + elif tag == "title": + self._in_title = False + + def handle_data(self, data: str) -> None: + text = " ".join(data.split()) + if not text: + return + if self._in_title: + self.title = f"{self.title} {text}".strip() + elif not self._skip_depth: + self.text_parts.append(text) + + +def content_text(content: Any) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for item in content: + if isinstance(item, dict): + parts.append(str(item.get("text", ""))) + else: + parts.append(str(getattr(item, "text", ""))) + return "\n".join(part for part in parts if part) + return str(content) + + +def extract_query(text: str) -> str: + match = re.search(r"query:\s*(.+)", text, flags=re.IGNORECASE | re.DOTALL) + if match: + return match.group(1).strip().strip("\"'") + return text.strip() + + +def extract_url(text: str) -> str: + match = re.search(r"https?://\S+", text) + return match.group(0).rstrip(").,]") if match else text.strip() diff --git a/src/free_claude_code/api/web_tools/request.py b/src/free_claude_code/api/web_tools/request.py new file mode 100644 index 0000000..7db4ad6 --- /dev/null +++ b/src/free_claude_code/api/web_tools/request.py @@ -0,0 +1,76 @@ +"""Detect forced Anthropic web server tool requests.""" + +from free_claude_code.core.anthropic import MessagesRequest, Tool + +from .parsers import content_text + + +def forced_tool_turn_text(request: MessagesRequest) -> str: + """Text for parsing forced server-tool inputs: latest user turn only (avoids stale history).""" + if not request.messages: + return "" + + for message in reversed(request.messages): + if message.role == "user": + return content_text(message.content) + return "" + + +def forced_server_tool_name(request: MessagesRequest) -> str | None: + """Return web_search or web_fetch only when tool_choice forces that server tool.""" + tc = request.tool_choice + if not isinstance(tc, dict): + return None + if tc.get("type") != "tool": + return None + name = tc.get("name") + if name in {"web_search", "web_fetch"}: + return str(name) + return None + + +def has_tool_named(request: MessagesRequest, name: str) -> bool: + return any(tool.name == name for tool in request.tools or []) + + +def is_web_server_tool_request(request: MessagesRequest) -> bool: + """True when the client forces a web server tool via tool_choice (not merely listed).""" + forced = forced_server_tool_name(request) + if forced is None: + return False + return has_tool_named(request, forced) + + +def is_anthropic_server_tool_definition(tool: Tool) -> bool: + """Whether ``tool`` refers to an Anthropic server tool (web_search / web_fetch family).""" + name = (tool.name or "").strip() + if name in ("web_search", "web_fetch"): + return True + typ = tool.type + if isinstance(typ, str): + return typ.startswith("web_search") or typ.startswith("web_fetch") + return False + + +def has_listed_anthropic_server_tools(request: MessagesRequest) -> bool: + """True when tools include web_search / web_fetch-style entries (listed, forced or not).""" + return any(is_anthropic_server_tool_definition(t) for t in (request.tools or [])) + + +def unsupported_server_tool_error( + request: MessagesRequest, *, web_tools_enabled: bool +) -> str | None: + """Return the user-facing error when the resolved provider cannot run server tools.""" + forced = forced_server_tool_name(request) + if forced and not web_tools_enabled: + return ( + f"tool_choice forces Anthropic server tool {forced!r}, but local web server tools are " + "disabled (ENABLE_WEB_SERVER_TOOLS=false). Enable them or remove the forced server tool." + ) + if not forced and has_listed_anthropic_server_tools(request): + return ( + "FCC cannot pass listed Anthropic server tools (web_search / web_fetch) " + "to OpenAI Chat upstreams. Set ENABLE_WEB_SERVER_TOOLS=true and force the " + "tool with tool_choice, or remove these tools from the request." + ) + return None diff --git a/src/free_claude_code/api/web_tools/streaming.py b/src/free_claude_code/api/web_tools/streaming.py new file mode 100644 index 0000000..3885e62 --- /dev/null +++ b/src/free_claude_code/api/web_tools/streaming.py @@ -0,0 +1,204 @@ +"""SSE streaming for local web_search / web_fetch server tool results.""" + +import uuid +from collections.abc import AsyncIterator +from datetime import UTC, datetime +from typing import Any + +from free_claude_code.core.anthropic import MessagesRequest +from free_claude_code.core.anthropic.server_tool_sse import ( + SERVER_TOOL_USE, + WEB_FETCH_TOOL_ERROR, + WEB_FETCH_TOOL_RESULT, + WEB_SEARCH_TOOL_RESULT, + WEB_SEARCH_TOOL_RESULT_ERROR, +) +from free_claude_code.core.anthropic.streaming import format_sse_event + +from . import outbound +from .constants import _MAX_FETCH_CHARS +from .egress import WebFetchEgressPolicy +from .parsers import extract_query, extract_url +from .request import ( + forced_server_tool_name, + forced_tool_turn_text, + has_tool_named, +) + + +def _search_summary(query: str, results: list[dict[str, str]]) -> str: + if not results: + return f"No web search results found for: {query}" + lines = [f"Search results for: {query}"] + for index, result in enumerate(results, start=1): + lines.append(f"{index}. {result['title']}\n{result['url']}") + return "\n\n".join(lines) + + +async def stream_web_server_tool_response( + request: MessagesRequest, + input_tokens: int, + *, + web_fetch_egress: WebFetchEgressPolicy, + verbose_client_errors: bool = False, +) -> AsyncIterator[str]: + """Stream a minimal Anthropic-shaped turn for forced `web_search` / `web_fetch` (local fallback). + + When `ENABLE_WEB_SERVER_TOOLS` is on, this is a proxy-side execution path — not a full + hosted Anthropic citation or encrypted-content pipeline. + """ + tool_name = forced_server_tool_name(request) + if tool_name is None or not has_tool_named(request, tool_name): + return + + text = forced_tool_turn_text(request) + message_id = f"msg_{uuid.uuid4()}" + tool_id = f"srvtoolu_{uuid.uuid4().hex}" + usage_key = ( + "web_search_requests" if tool_name == "web_search" else "web_fetch_requests" + ) + tool_input = ( + {"query": extract_query(text)} + if tool_name == "web_search" + else {"url": extract_url(text)} + ) + _result_block_for_tool = { + "web_search": WEB_SEARCH_TOOL_RESULT, + "web_fetch": WEB_FETCH_TOOL_RESULT, + } + _error_payload_type_for_tool = { + "web_search": WEB_SEARCH_TOOL_RESULT_ERROR, + "web_fetch": WEB_FETCH_TOOL_ERROR, + } + + yield format_sse_event( + "message_start", + { + "type": "message_start", + "message": { + "id": message_id, + "type": "message", + "role": "assistant", + "content": [], + "model": request.model, + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": input_tokens, "output_tokens": 1}, + }, + }, + ) + yield format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": { + "type": SERVER_TOOL_USE, + "id": tool_id, + "name": tool_name, + "input": tool_input, + }, + }, + ) + yield format_sse_event( + "content_block_stop", {"type": "content_block_stop", "index": 0} + ) + + try: + if tool_name == "web_search": + query = str(tool_input["query"]) + results = await outbound._run_web_search(query) + result_content: Any = [ + { + "type": "web_search_result", + "title": result["title"], + "url": result["url"], + } + for result in results + ] + summary = _search_summary(query, results) + result_block_type = WEB_SEARCH_TOOL_RESULT + else: + fetched = await outbound._run_web_fetch( + str(tool_input["url"]), web_fetch_egress + ) + result_content = { + "type": "web_fetch_result", + "url": fetched["url"], + "content": { + "type": "document", + "source": { + "type": "text", + "media_type": fetched["media_type"], + "data": fetched["data"], + }, + "title": fetched["title"], + "citations": {"enabled": True}, + }, + "retrieved_at": datetime.now(UTC).isoformat(), + } + summary = fetched["data"][:_MAX_FETCH_CHARS] + result_block_type = WEB_FETCH_TOOL_RESULT + except Exception as error: + fetch_url = str(tool_input["url"]) if tool_name == "web_fetch" else None + outbound._log_web_tool_failure(tool_name, error, fetch_url=fetch_url) + result_block_type = _result_block_for_tool[tool_name] + result_content = { + "type": _error_payload_type_for_tool[tool_name], + "error_code": "unavailable", + } + summary = outbound._web_tool_client_error_summary( + tool_name, error, verbose=verbose_client_errors + ) + + output_tokens = max(1, len(summary) // 4) + + yield format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 1, + "content_block": { + "type": result_block_type, + "tool_use_id": tool_id, + "content": result_content, + }, + }, + ) + yield format_sse_event( + "content_block_stop", {"type": "content_block_stop", "index": 1} + ) + # Model-facing summary: stream as normal text deltas (CLI/transcript code reads `text_delta`, + # not eager `text` on `content_block_start`). + yield format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 2, + "content_block": {"type": "text", "text": ""}, + }, + ) + yield format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 2, + "delta": {"type": "text_delta", "text": summary}, + }, + ) + yield format_sse_event( + "content_block_stop", {"type": "content_block_stop", "index": 2} + ) + yield format_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "server_tool_use": {usage_key: 1}, + }, + }, + ) + yield format_sse_event("message_stop", {"type": "message_stop"}) diff --git a/src/free_claude_code/application/__init__.py b/src/free_claude_code/application/__init__.py new file mode 100644 index 0000000..783af66 --- /dev/null +++ b/src/free_claude_code/application/__init__.py @@ -0,0 +1 @@ +"""Application use cases, values, and consumer-owned ports.""" diff --git a/src/free_claude_code/application/errors.py b/src/free_claude_code/application/errors.py new file mode 100644 index 0000000..f6f348f --- /dev/null +++ b/src/free_claude_code/application/errors.py @@ -0,0 +1,41 @@ +"""Deterministic application and readiness errors.""" + +from collections.abc import Iterable + +from free_claude_code.core.failures import FailureKind + + +class ApplicationError(Exception): + """Base for request/readiness failures, not finalized upstream failures.""" + + kind: FailureKind + status_code: int + + def __init__(self, message: str) -> None: + super().__init__(message) + self.message = message + + +class InvalidRequestError(ApplicationError): + """The accepted request cannot be executed deterministically.""" + + kind = FailureKind.INVALID_REQUEST + status_code = 400 + + +class UnknownProviderError(InvalidRequestError): + """The configured provider identifier is not registered.""" + + @classmethod + def for_provider( + cls, provider_id: str, supported_provider_ids: Iterable[str] + ) -> UnknownProviderError: + supported = "', '".join(supported_provider_ids) + return cls(f"Unknown provider_type: '{provider_id}'. Supported: '{supported}'") + + +class ApplicationUnavailableError(ApplicationError): + """The application cannot currently provide a request runtime.""" + + kind = FailureKind.UNAVAILABLE + status_code = 503 diff --git a/src/free_claude_code/application/execution.py b/src/free_claude_code/application/execution.py new file mode 100644 index 0000000..0e1c3f5 --- /dev/null +++ b/src/free_claude_code/application/execution.py @@ -0,0 +1,147 @@ +"""Provider execution shared by inbound API adapters.""" + +import sys +from collections.abc import AsyncIterator, Callable +from typing import Literal + +from loguru import logger + +from free_claude_code.core.anthropic import ( + Message, + SystemContent, + Tool, + anthropic_request_snapshot, + get_token_count, +) +from free_claude_code.core.trace import ( + close_stream_input, + trace_event, + traced_async_stream, +) + +from .ports import ProviderResolver +from .routing import RoutedMessagesRequest + +TokenCounter = Callable[ + [list[Message], str | list[SystemContent] | None, list[Tool] | None], + int, +] +WireApi = Literal["messages", "responses"] + + +class ProviderExecutor: + """Resolve a provider and execute one routed Anthropic Messages stream.""" + + def __init__( + self, + provider_resolver: ProviderResolver, + *, + token_counter: TokenCounter = get_token_count, + generation_id: int | None = None, + log_raw_payloads: bool = False, + ) -> None: + self._provider_resolver = provider_resolver + self._token_counter = token_counter + self._generation_id = generation_id + self._log_raw_payloads = log_raw_payloads + + def stream( + self, + routed: RoutedMessagesRequest, + *, + wire_api: WireApi, + raw_log_label: str, + raw_log_payload: object, + request_id: str, + ) -> AsyncIterator[str]: + """Preflight synchronously, then return the traced provider stream.""" + provider = self._provider_resolver(routed.resolved.provider_id) + provider.preflight_stream( + routed.request, + thinking_enabled=routed.resolved.thinking_enabled, + ) + + route_trace: dict[str, object] = { + "stage": "routing", + "event": "free_claude_code.api.route.resolved", + "source": "api", + "request_id": request_id, + "provider_id": routed.resolved.provider_id, + "provider_model": routed.resolved.provider_model, + "provider_model_ref": routed.resolved.provider_model_ref, + "gateway_model": routed.request.model, + "thinking_enabled": routed.resolved.thinking_enabled, + } + if wire_api == "responses": + route_trace["wire_api"] = "responses" + if self._generation_id is not None: + route_trace["generation_id"] = self._generation_id + trace_event(**route_trace) + + trace_event( + stage="ingress", + event=( + "free_claude_code.api.responses.request.received" + if wire_api == "responses" + else "free_claude_code.api.request.received" + ), + source="api", + message_count=len(routed.request.messages), + snapshot=anthropic_request_snapshot(routed.request), + request_id=request_id, + ) + + if self._log_raw_payloads: + logger.debug(f"{raw_log_label} [{{}}]: {{}}", request_id, raw_log_payload) + + input_tokens = self._token_counter( + routed.request.messages, + routed.request.system, + routed.request.tools, + ) + + async def provider_body() -> AsyncIterator[str]: + provider_stream: AsyncIterator[str] | None = None + try: + provider_stream = provider.stream_response( + routed.request, + input_tokens=input_tokens, + request_id=request_id, + thinking_enabled=routed.resolved.thinking_enabled, + ) + async for chunk in provider_stream: + yield chunk + finally: + if provider_stream is not None: + await close_stream_input( + provider_stream, + owner="provider_executor", + source="api", + preserved_error=sys.exception(), + ) + + stream_trace: dict[str, object] = { + "request_id": request_id, + "provider_id": routed.resolved.provider_id, + "gateway_model": routed.request.model, + } + if self._generation_id is not None: + stream_trace["generation_id"] = self._generation_id + + return traced_async_stream( + provider_body(), + stage="egress", + source="api", + complete_event=( + "free_claude_code.api.responses.stream_completed" + if wire_api == "responses" + else "free_claude_code.api.response.stream_completed" + ), + interrupted_event=( + "free_claude_code.api.responses.stream_interrupted" + if wire_api == "responses" + else "free_claude_code.api.response.stream_interrupted" + ), + chunk_event=None, + extra=stream_trace, + ) diff --git a/src/free_claude_code/application/model_metadata.py b/src/free_claude_code/application/model_metadata.py new file mode 100644 index 0000000..f9433e9 --- /dev/null +++ b/src/free_claude_code/application/model_metadata.py @@ -0,0 +1,11 @@ +"""Application-owned model metadata.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class ProviderModelInfo: + """Provider model metadata used to shape the application model catalog.""" + + model_id: str + supports_thinking: bool | None = None diff --git a/src/free_claude_code/application/ports.py b/src/free_claude_code/application/ports.py new file mode 100644 index 0000000..0f83cee --- /dev/null +++ b/src/free_claude_code/application/ports.py @@ -0,0 +1,77 @@ +"""Typed capabilities consumed by application use cases.""" + +from collections.abc import AsyncIterator, Callable +from dataclasses import dataclass +from typing import Protocol + +from free_claude_code.config.settings import Settings +from free_claude_code.core.anthropic import MessagesRequest + +from .model_metadata import ProviderModelInfo + + +class ProviderPort(Protocol): + """Minimal provider capability required to execute one request.""" + + def preflight_stream( + self, + request: MessagesRequest, + *, + thinking_enabled: bool, + ) -> None: ... + + def stream_response( + self, + request: MessagesRequest, + *, + input_tokens: int, + request_id: str, + thinking_enabled: bool, + ) -> AsyncIterator[str]: ... + + +ProviderResolver = Callable[[str], ProviderPort] + + +class RequestRuntimeLease(Protocol): + """One provider generation retained for a complete API response.""" + + @property + def generation_id(self) -> int: ... + + @property + def settings(self) -> Settings: ... + + def is_provider_cached(self, provider_id: str) -> bool: ... + + def resolve_provider(self, provider_id: str) -> ProviderPort: ... + + async def release(self) -> None: ... + + +class RequestRuntimePort(Protocol): + """Provider generation and model metadata required by application requests.""" + + async def acquire(self) -> RequestRuntimeLease: ... + + def current_settings(self) -> Settings: ... + + def cached_model_supports_thinking( + self, provider_id: str, model_id: str + ) -> bool | None: ... + + def cached_prefixed_model_infos(self) -> tuple[ProviderModelInfo, ...]: ... + + +@dataclass(frozen=True, slots=True) +class StopResult: + """Implementation-neutral result retaining the existing ``/stop`` variants.""" + + cancelled_count: int | None = None + source: str | None = None + + +class TaskController(Protocol): + """Stop managed work without exposing messaging or CLI resources.""" + + async def stop_all(self) -> StopResult | None: ... diff --git a/src/free_claude_code/application/routing.py b/src/free_claude_code/application/routing.py new file mode 100644 index 0000000..e1267f5 --- /dev/null +++ b/src/free_claude_code/application/routing.py @@ -0,0 +1,157 @@ +"""Model routing for Claude-compatible requests.""" + +from dataclasses import dataclass + +from loguru import logger + +from free_claude_code.application.errors import UnknownProviderError +from free_claude_code.config.model_refs import parse_model_name, parse_provider_type +from free_claude_code.config.provider_catalog import ( + PROVIDER_CATALOG, + SUPPORTED_PROVIDER_IDS, +) +from free_claude_code.config.settings import Settings +from free_claude_code.core.anthropic import MessagesRequest, TokenCountRequest +from free_claude_code.core.gateway_model_ids import decode_gateway_model_id + + +@dataclass(frozen=True, slots=True) +class ResolvedModel: + original_model: str + provider_id: str + provider_model: str + provider_model_ref: str + thinking_enabled: bool + + +@dataclass(frozen=True, slots=True) +class RoutedMessagesRequest: + request: MessagesRequest + resolved: ResolvedModel + + +@dataclass(frozen=True, slots=True) +class RoutedTokenCountRequest: + request: TokenCountRequest + resolved: ResolvedModel + + +class ModelRouter: + """Resolve incoming Claude model names to configured provider/model pairs.""" + + def __init__(self, settings: Settings): + self._settings = settings + + def resolve(self, claude_model_name: str) -> ResolvedModel: + ( + direct_provider_id, + direct_provider_model, + force_thinking_enabled, + ) = self._direct_provider_model(claude_model_name) + if direct_provider_id is not None and direct_provider_model is not None: + thinking_enabled = ( + force_thinking_enabled + if force_thinking_enabled is not None + else self._resolve_thinking(direct_provider_model) + ) + logger.debug( + "MODEL DIRECT: '{}' -> provider='{}' model='{}' thinking={}", + claude_model_name, + direct_provider_id, + direct_provider_model, + thinking_enabled, + ) + return ResolvedModel( + original_model=claude_model_name, + provider_id=direct_provider_id, + provider_model=direct_provider_model, + provider_model_ref=claude_model_name, + thinking_enabled=thinking_enabled, + ) + + provider_model_ref = self._resolve_model_ref(claude_model_name) + thinking_enabled = self._resolve_thinking(claude_model_name) + provider_id = parse_provider_type(provider_model_ref) + self._validate_provider_id(provider_id) + provider_model = parse_model_name(provider_model_ref) + if provider_model != claude_model_name: + logger.debug( + "MODEL MAPPING: '{}' -> '{}'", claude_model_name, provider_model + ) + return ResolvedModel( + original_model=claude_model_name, + provider_id=provider_id, + provider_model=provider_model, + provider_model_ref=provider_model_ref, + thinking_enabled=thinking_enabled, + ) + + @staticmethod + def _validate_provider_id(provider_id: str) -> None: + if provider_id not in PROVIDER_CATALOG: + raise UnknownProviderError.for_provider(provider_id, PROVIDER_CATALOG) + + def _direct_provider_model( + self, model_name: str + ) -> tuple[str | None, str | None, bool | None]: + decoded = decode_gateway_model_id(model_name) + if decoded is not None: + if decoded.provider_id not in SUPPORTED_PROVIDER_IDS: + return None, None, None + return ( + decoded.provider_id, + decoded.provider_model, + decoded.force_thinking_enabled, + ) + + provider_id, separator, provider_model = model_name.partition("/") + if not separator: + return None, None, None + if provider_id not in SUPPORTED_PROVIDER_IDS: + return None, None, None + if not provider_model: + return None, None, None + return provider_id, provider_model, None + + def _resolve_model_ref(self, claude_model_name: str) -> str: + """Resolve a Claude model name to the configured provider/model ref.""" + + name_lower = claude_model_name.lower() + if "opus" in name_lower and self._settings.model_opus is not None: + return self._settings.model_opus + if "haiku" in name_lower and self._settings.model_haiku is not None: + return self._settings.model_haiku + if "sonnet" in name_lower and self._settings.model_sonnet is not None: + return self._settings.model_sonnet + return self._settings.model + + def _resolve_thinking(self, claude_model_name: str) -> bool: + """Resolve whether thinking is enabled for an incoming Claude model name.""" + + name_lower = claude_model_name.lower() + if "opus" in name_lower and self._settings.enable_opus_thinking is not None: + return self._settings.enable_opus_thinking + if "haiku" in name_lower and self._settings.enable_haiku_thinking is not None: + return self._settings.enable_haiku_thinking + if "sonnet" in name_lower and self._settings.enable_sonnet_thinking is not None: + return self._settings.enable_sonnet_thinking + return self._settings.enable_model_thinking + + def resolve_messages_request( + self, request: MessagesRequest + ) -> RoutedMessagesRequest: + """Return an internal routed request context.""" + resolved = self.resolve(request.model) + routed = request.model_copy(deep=True) + routed.model = resolved.provider_model + return RoutedMessagesRequest(request=routed, resolved=resolved) + + def resolve_token_count_request( + self, request: TokenCountRequest + ) -> RoutedTokenCountRequest: + """Return an internal token-count request context.""" + resolved = self.resolve(request.model) + routed = request.model_copy( + update={"model": resolved.provider_model}, deep=True + ) + return RoutedTokenCountRequest(request=routed, resolved=resolved) diff --git a/src/free_claude_code/cli/__init__.py b/src/free_claude_code/cli/__init__.py new file mode 100644 index 0000000..aeec27b --- /dev/null +++ b/src/free_claude_code/cli/__init__.py @@ -0,0 +1,5 @@ +"""CLI integration for installed launchers and managed Claude Code.""" + +from .managed import ManagedClaudeSession, ManagedClaudeSessionManager + +__all__ = ["ManagedClaudeSession", "ManagedClaudeSessionManager"] diff --git a/src/free_claude_code/cli/claude_env.py b/src/free_claude_code/cli/claude_env.py new file mode 100644 index 0000000..42c942b --- /dev/null +++ b/src/free_claude_code/cli/claude_env.py @@ -0,0 +1,32 @@ +"""Shared Claude Code environment policy for FCC client surfaces.""" + +from collections.abc import Mapping + +from free_claude_code.cli.proxy_auth import proxy_auth_token + +CLAUDE_CODE_AUTO_COMPACT_WINDOW = "190000" +CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1" +CLAUDE_BINARY_NAME = "claude" + + +def build_claude_proxy_env( + *, + proxy_root_url: str, + auth_token: str, + base_env: Mapping[str, str], +) -> dict[str, str]: + """Return the canonical environment for Claude Code proxy sessions.""" + + env = { + key: value + for key, value in base_env.items() + if not key.startswith("ANTHROPIC_") + } + env["ANTHROPIC_BASE_URL"] = proxy_root_url + env["ANTHROPIC_AUTH_TOKEN"] = proxy_auth_token(auth_token) + env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] = "1" + env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = CLAUDE_CODE_AUTO_COMPACT_WINDOW + env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = ( + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC + ) + return env diff --git a/src/free_claude_code/cli/entrypoints.py b/src/free_claude_code/cli/entrypoints.py new file mode 100644 index 0000000..fe4a028 --- /dev/null +++ b/src/free_claude_code/cli/entrypoints.py @@ -0,0 +1,176 @@ +"""CLI entry points for the installed package.""" + +import os +import shutil +import sys +import threading +import time +import webbrowser +from collections.abc import Sequence +from pathlib import Path + +import uvicorn + +from free_claude_code.cli.launchers.common import preflight_proxy +from free_claude_code.cli.process_registry import ( + kill_all_best_effort, +) +from free_claude_code.config.env_migrations import ( + explicit_env_file_huggingface_warning, + migrate_owned_env_files, +) +from free_claude_code.config.env_template import load_env_template +from free_claude_code.config.paths import ( + config_dir_path, + legacy_env_paths, + managed_env_path, +) +from free_claude_code.config.server_urls import local_admin_url, local_proxy_root_url +from free_claude_code.config.settings import Settings, get_settings +from free_claude_code.core.version import package_version +from free_claude_code.runtime.bootstrap import build_asgi_app + +SERVER_GRACEFUL_SHUTDOWN_SECONDS = 5 + + +def serve(argv: Sequence[str] | None = None) -> None: + """Start the FastAPI server (registered as `fcc-server` script).""" + if _print_version_if_requested(argv): + return + opened_admin_browser = False + try: + try: + while True: + _migrate_legacy_env_if_missing() + _migrate_config_env_keys() + settings = get_settings() + if not _run_supervised_server( + settings, open_admin_browser=not opened_admin_browser + ): + return + opened_admin_browser = True + get_settings.cache_clear() + except KeyboardInterrupt: + return + finally: + kill_all_best_effort() + + +def _admin_browser_open_enabled() -> bool: + """Whether to open /admin when the server becomes reachable (FCC_OPEN_BROWSER).""" + + raw = os.environ.get("FCC_OPEN_BROWSER", "true").strip().lower() + return raw not in {"", "0", "false", "no"} + + +def _schedule_open_admin_browser(settings: Settings) -> None: + """After /health succeeds, open the admin UI in the default browser (daemon thread).""" + + if not _admin_browser_open_enabled(): + return + + admin_url = local_admin_url(settings) + proxy_root_url = local_proxy_root_url(settings) + + def open_when_ready() -> None: + deadline = time.monotonic() + 30.0 + while time.monotonic() < deadline: + if preflight_proxy(proxy_root_url) is None: + webbrowser.open(admin_url) + return + time.sleep(0.15) + + threading.Thread( + target=open_when_ready, name="fcc-open-admin-browser", daemon=True + ).start() + + +def _run_supervised_server(settings: Settings, *, open_admin_browser: bool) -> bool: + """Run once; restart only after the old ownership graph fully closes.""" + + restart_requested = False + server_holder: dict[str, uvicorn.Server] = {} + + def request_restart() -> None: + nonlocal restart_requested + restart_requested = True + if server := server_holder.get("server"): + server.should_exit = True + + asgi_app = build_asgi_app(settings, restart_callback=request_restart) + config = uvicorn.Config( + asgi_app, + host=settings.host, + port=settings.port, + log_level="debug", + timeout_graceful_shutdown=SERVER_GRACEFUL_SHUTDOWN_SECONDS, + ) + server = uvicorn.Server(config) + server_holder["server"] = server + if open_admin_browser: + _schedule_open_admin_browser(settings) + server.run() + return restart_requested and asgi_app.runtime.is_closed + + +def init(argv: Sequence[str] | None = None) -> None: + """Scaffold config at ~/.fcc/.env (registered as `fcc-init`).""" + if _print_version_if_requested(argv): + return + config_dir = config_dir_path() + env_file = managed_env_path() + + migrated_from = _migrate_legacy_env_if_missing() + _migrate_config_env_keys() + if migrated_from is not None: + print(f"Config migrated from {migrated_from} to {env_file}") + print( + "Edit it to set your API keys and model preferences, then run: fcc-server" + ) + return + + 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 = load_env_template() + 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: fcc-server") + + +def _print_version_if_requested(argv: Sequence[str] | None) -> bool: + args = sys.argv[1:] if argv is None else argv + if "--version" not in args: + return False + print(f"free-claude-code {package_version()}") + return True + + +def _migrate_legacy_env_if_missing() -> Path | None: + """Copy a legacy user env into the managed config path when absent.""" + + env_file = managed_env_path() + if env_file.exists(): + return None + + # TODO: Remove after the ~/.fcc/.env migration has had a release cycle. + for legacy_env in legacy_env_paths(): + if not legacy_env.is_file(): + continue + env_file.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(legacy_env, env_file) + return legacy_env + + return None + + +def _migrate_config_env_keys() -> tuple[Path, ...]: + """Apply dotenv key migrations before Settings loads config.""" + + migrated = migrate_owned_env_files() + if warning := explicit_env_file_huggingface_warning(os.environ): + print(warning, file=sys.stderr) + return migrated diff --git a/src/free_claude_code/cli/launchers/__init__.py b/src/free_claude_code/cli/launchers/__init__.py new file mode 100644 index 0000000..a78dba7 --- /dev/null +++ b/src/free_claude_code/cli/launchers/__init__.py @@ -0,0 +1 @@ +"""Installed FCC client CLI launchers.""" diff --git a/src/free_claude_code/cli/launchers/claude.py b/src/free_claude_code/cli/launchers/claude.py new file mode 100644 index 0000000..8dbc988 --- /dev/null +++ b/src/free_claude_code/cli/launchers/claude.py @@ -0,0 +1,64 @@ +"""Installed `fcc-claude` launcher.""" + +import os +import sys +from collections.abc import Sequence + +from free_claude_code.cli.claude_env import ( + CLAUDE_BINARY_NAME, + build_claude_proxy_env, +) +from free_claude_code.config.server_urls import local_proxy_root_url +from free_claude_code.config.settings import get_settings + +from .common import preflight_proxy, resolve_client_binary, run_client_process + +_DISPLAY_NAME = "Claude Code" +_INSTALL_HINT = "Install Claude Code with: npm install -g @anthropic-ai/claude-code" + + +def launch(argv: Sequence[str] | None = None) -> None: + """Launch Claude Code with Free Claude Code proxy environment variables.""" + + settings = get_settings() + proxy_root_url = local_proxy_root_url(settings) + if error := preflight_proxy(proxy_root_url): + print( + f"Free Claude Code proxy is not reachable at {proxy_root_url}: {error}", + file=sys.stderr, + ) + print("Start it in another terminal with: fcc-server", file=sys.stderr) + raise SystemExit(1) + + binary_name = claude_binary_name() + binary_path = resolve_client_binary( + binary_name=binary_name, + display_name=_DISPLAY_NAME, + install_hint=_INSTALL_HINT, + ) + args = list(sys.argv[1:] if argv is None else argv) + run_client_process( + command=build_claude_launcher_command(binary_path=binary_path, argv=args), + env=build_claude_proxy_env( + proxy_root_url=proxy_root_url, + auth_token=settings.anthropic_auth_token, + base_env=os.environ, + ), + binary_name=binary_name, + display_name=_DISPLAY_NAME, + install_hint=_INSTALL_HINT, + ) + + +def claude_binary_name() -> str: + """Return the Claude Code binary name.""" + + return CLAUDE_BINARY_NAME + + +def build_claude_launcher_command( + *, binary_path: str, argv: Sequence[str] +) -> list[str]: + """Return the Claude wrapper command without changing user arguments.""" + + return [binary_path, *argv] diff --git a/src/free_claude_code/cli/launchers/codex.py b/src/free_claude_code/cli/launchers/codex.py new file mode 100644 index 0000000..3d195d4 --- /dev/null +++ b/src/free_claude_code/cli/launchers/codex.py @@ -0,0 +1,208 @@ +"""Installed `fcc-codex` launcher.""" + +import json +import os +import sys +from collections.abc import Mapping, Sequence +from urllib.request import Request, urlopen + +from free_claude_code.cli.proxy_auth import proxy_auth_token +from free_claude_code.config.paths import codex_model_catalog_path +from free_claude_code.config.server_urls import local_proxy_root_url +from free_claude_code.config.settings import Settings, get_settings + +from .codex_model_catalog import build_codex_model_catalog, write_codex_model_catalog +from .common import ( + PROXY_PREFLIGHT_TIMEOUT_SECONDS, + preflight_proxy, + resolve_client_binary, + run_client_process, +) + +_CODEX_AUTH_ENV_KEY = "FCC_CODEX_API_KEY" +_DISPLAY_NAME = "Codex CLI" +_DEFAULT_BINARY = "codex" +_INSTALL_HINT = "Install Codex with: npm install -g @openai/codex" +# Preserve CODEX_HOME: it owns durable user configuration, not parent-task identity. +_STRIPPED_CODEX_ENV_KEYS = frozenset( + { + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "OPENAI_API_BASE", + "OPENAI_ORG_ID", + "OPENAI_ORGANIZATION", + "CODEX_API_KEY", + "CODEX_INTERNAL_ORIGINATOR_OVERRIDE", + "CODEX_PERMISSION_PROFILE", + "CODEX_SHELL", + "CODEX_THREAD_ID", + _CODEX_AUTH_ENV_KEY, + } +) + + +def launch(argv: Sequence[str] | None = None) -> None: + """Launch Codex CLI with Free Claude Code proxy configuration.""" + + settings = get_settings() + proxy_root_url = local_proxy_root_url(settings) + if error := preflight_proxy(proxy_root_url): + print( + f"Free Claude Code proxy is not reachable at {proxy_root_url}: {error}", + file=sys.stderr, + ) + print("Start it in another terminal with: fcc-server", file=sys.stderr) + raise SystemExit(1) + + binary_name = codex_binary_name() + binary_path = resolve_client_binary( + binary_name=binary_name, + display_name=_DISPLAY_NAME, + install_hint=_INSTALL_HINT, + ) + catalog_args = codex_model_catalog_config_args(proxy_root_url, settings) + args = list(sys.argv[1:] if argv is None else argv) + run_client_process( + command=build_codex_launcher_command( + binary_path=binary_path, + argv=args, + settings=settings, + proxy_root_url=proxy_root_url, + catalog_config_args=catalog_args, + ), + env=build_codex_launcher_env( + auth_token=settings.anthropic_auth_token, + base_env=os.environ, + ), + binary_name=binary_name, + display_name=_DISPLAY_NAME, + install_hint=_INSTALL_HINT, + ) + + +def codex_binary_name() -> str: + """Return the Codex CLI binary name.""" + + return _DEFAULT_BINARY + + +def build_codex_launcher_command( + *, + binary_path: str, + argv: Sequence[str], + settings: Settings, + proxy_root_url: str, + catalog_config_args: Sequence[str] = (), +) -> list[str]: + """Return a Codex command with ephemeral FCC provider config.""" + + return [ + binary_path, + *catalog_config_args, + *codex_config_args( + api_url=_ensure_v1_url(proxy_root_url), + model=getattr(settings, "model", None), + ), + *argv, + ] + + +def build_codex_launcher_env( + *, + auth_token: str, + base_env: Mapping[str, str], +) -> dict[str, str]: + """Return a Codex environment that targets the local proxy provider.""" + + env = { + key: value + for key, value in base_env.items() + if key not in _STRIPPED_CODEX_ENV_KEYS and not key.startswith("OPENAI_") + } + env[_CODEX_AUTH_ENV_KEY] = proxy_auth_token(auth_token) + return env + + +def codex_model_catalog_config_args( + proxy_root_url: str, settings: Settings +) -> list[str]: + """Prepare the generated Codex model catalog and return its config args.""" + + try: + models_response = fetch_proxy_models_response( + proxy_root_url, settings.anthropic_auth_token + ) + catalog = build_codex_model_catalog(models_response) + models = catalog.get("models") + if not isinstance(models, list) or not models: + print( + "Free Claude Code warning: Codex model catalog is empty; " + "launching without model picker catalog.", + file=sys.stderr, + ) + return [] + catalog_path = codex_model_catalog_path() + write_codex_model_catalog(catalog_path, catalog) + except Exception as exc: + print( + "Free Claude Code warning: could not prepare Codex model catalog " + f"({exc}); launching without model picker catalog.", + file=sys.stderr, + ) + return [] + + return build_model_catalog_config_args(str(catalog_path)) + + +def fetch_proxy_models_response( + proxy_root_url: str, auth_token: str +) -> dict[str, object]: + """Fetch the local proxy `/v1/models` response for Codex catalog generation.""" + + url = f"{proxy_root_url.rstrip('/')}/v1/models" + headers: dict[str, str] = {} + if token := auth_token.strip(): + headers["X-API-Key"] = token + + request = Request(url, headers=headers, method="GET") + with urlopen(request, timeout=PROXY_PREFLIGHT_TIMEOUT_SECONDS) as response: + payload = json.loads(response.read().decode("utf-8")) + + if not isinstance(payload, dict): + raise ValueError("model list response was not a JSON object") + return payload + + +def build_model_catalog_config_args(catalog_path: str) -> list[str]: + """Return Codex config args for a generated model catalog.""" + + return ["-c", _toml_assignment("model_catalog_json", catalog_path)] + + +def codex_config_args(*, api_url: str, model: str | None = None) -> list[str]: + """Return Codex `-c` assignments for the ephemeral FCC provider.""" + + args = [ + "-c", + _toml_assignment("model_provider", "fcc"), + "-c", + _toml_assignment("model_providers.fcc.name", "Free Claude Code"), + "-c", + _toml_assignment("model_providers.fcc.base_url", _ensure_v1_url(api_url)), + "-c", + _toml_assignment("model_providers.fcc.env_key", _CODEX_AUTH_ENV_KEY), + "-c", + _toml_assignment("model_providers.fcc.wire_api", "responses"), + ] + if model: + args.extend(["-c", _toml_assignment("model", model)]) + return args + + +def _ensure_v1_url(url: str) -> str: + stripped = url.rstrip("/") + return stripped if stripped.endswith("/v1") else f"{stripped}/v1" + + +def _toml_assignment(key: str, value: str) -> str: + return f"{key}={json.dumps(value)}" diff --git a/src/free_claude_code/cli/launchers/codex_model_catalog.py b/src/free_claude_code/cli/launchers/codex_model_catalog.py new file mode 100644 index 0000000..32504d8 --- /dev/null +++ b/src/free_claude_code/cli/launchers/codex_model_catalog.py @@ -0,0 +1,184 @@ +"""Build Codex model catalogs from the FCC model-list route.""" + +import json +import uuid +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from free_claude_code.config.provider_catalog import SUPPORTED_PROVIDER_IDS +from free_claude_code.core.gateway_model_ids import ( + GATEWAY_MODEL_ID_PREFIX, + NO_THINKING_GATEWAY_MODEL_ID_PREFIX, +) + +SUPPORTED_REASONING_LEVELS = [ + {"effort": "low", "description": "Fast responses with lighter reasoning"}, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks", + }, + {"effort": "high", "description": "Greater reasoning depth for complex problems"}, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems", + }, +] + +CODEX_BASE_INSTRUCTIONS = ( + "You are Codex, a coding agent. Help the user understand, modify, test, " + "and review code in their workspace. Follow the user's instructions, use " + "tools when needed, and communicate concise progress and verification." +) + + +@dataclass(frozen=True, slots=True) +class _CatalogCandidate: + slug: str + provider_model_ref: str + display_name: str + force_no_thinking: bool + + +def build_codex_model_catalog(models_response: Mapping[str, Any]) -> dict[str, Any]: + """Convert FCC `/v1/models` data into Codex `model_catalog_json` payload.""" + + candidates = list(_catalog_candidates(models_response)) + normal_provider_refs = { + candidate.provider_model_ref + for candidate in candidates + if not candidate.force_no_thinking + } + models: list[dict[str, Any]] = [] + seen_slugs: set[str] = set() + + for candidate in candidates: + if ( + candidate.force_no_thinking + and candidate.provider_model_ref in normal_provider_refs + ): + continue + if candidate.slug in seen_slugs: + continue + seen_slugs.add(candidate.slug) + models.append(_codex_catalog_entry(candidate, priority=len(models))) + + return {"models": models} + + +def write_codex_model_catalog(catalog_path: Path, catalog: Mapping[str, Any]) -> None: + """Atomically write a Codex model catalog JSON file.""" + + catalog_path.parent.mkdir(parents=True, exist_ok=True) + temp_path = catalog_path.with_name(f".{catalog_path.name}.{uuid.uuid4().hex}.tmp") + temp_path.write_text( + json.dumps(catalog, ensure_ascii=True, indent=2) + "\n", + encoding="utf-8", + ) + temp_path.replace(catalog_path) + + +def _catalog_candidates( + models_response: Mapping[str, Any], +) -> list[_CatalogCandidate]: + data = models_response.get("data") + if not isinstance(data, list): + return [] + + candidates: list[_CatalogCandidate] = [] + for item in data: + if not isinstance(item, Mapping): + continue + model_id = _string_value(item.get("id")) + if model_id is None: + continue + candidate = _candidate_from_model_id( + model_id, + display_name=_string_value(item.get("display_name")) or model_id, + ) + if candidate is not None: + candidates.append(candidate) + return candidates + + +def _candidate_from_model_id( + model_id: str, *, display_name: str +) -> _CatalogCandidate | None: + prefix, separator, remainder = model_id.partition("/") + if not separator: + return None + + if prefix == GATEWAY_MODEL_ID_PREFIX: + if not _is_provider_model_ref(remainder): + return None + return _CatalogCandidate( + slug=remainder, + provider_model_ref=remainder, + display_name=display_name, + force_no_thinking=False, + ) + + if prefix == NO_THINKING_GATEWAY_MODEL_ID_PREFIX: + if not _is_provider_model_ref(remainder): + return None + return _CatalogCandidate( + slug=model_id, + provider_model_ref=remainder, + display_name=display_name, + force_no_thinking=True, + ) + + if prefix in SUPPORTED_PROVIDER_IDS and remainder: + return _CatalogCandidate( + slug=model_id, + provider_model_ref=model_id, + display_name=display_name, + force_no_thinking=False, + ) + + return None + + +def _codex_catalog_entry( + candidate: _CatalogCandidate, *, priority: int +) -> dict[str, Any]: + return { + "slug": candidate.slug, + "display_name": candidate.display_name, + "description": "Free Claude Code provider model", + "default_reasoning_level": "medium", + "supported_reasoning_levels": SUPPORTED_REASONING_LEVELS, + "shell_type": "shell_command", + "visibility": "list", + "supported_in_api": True, + "priority": priority, + "additional_speed_tiers": [], + "service_tiers": [], + "base_instructions": CODEX_BASE_INSTRUCTIONS, + "supports_reasoning_summaries": True, + "default_reasoning_summary": "none", + "support_verbosity": True, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "truncation_policy": {"mode": "tokens", "limit": 10000}, + "supports_parallel_tool_calls": True, + "supports_image_detail_original": True, + "context_window": 200000, + "max_context_window": 200000, + "effective_context_window_percent": 95, + "experimental_supported_tools": [], + "input_modalities": ["text"], + "supports_search_tool": True, + "use_responses_lite": False, + } + + +def _is_provider_model_ref(value: str) -> bool: + provider_id, separator, provider_model = value.partition("/") + return bool(separator and provider_model and provider_id in SUPPORTED_PROVIDER_IDS) + + +def _string_value(value: Any) -> str | None: + return value if isinstance(value, str) else None diff --git a/src/free_claude_code/cli/launchers/common.py b/src/free_claude_code/cli/launchers/common.py new file mode 100644 index 0000000..a4674cf --- /dev/null +++ b/src/free_claude_code/cli/launchers/common.py @@ -0,0 +1,91 @@ +"""Shared process helpers for installed client CLI launchers.""" + +import shutil +import subprocess +import sys +from collections.abc import Mapping +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from free_claude_code.cli.process_registry import ( + kill_pid_tree_best_effort, + register_pid, + unregister_pid, +) + +PROXY_PREFLIGHT_PATH = "/health" +PROXY_PREFLIGHT_TIMEOUT_SECONDS = 1.5 + + +def preflight_proxy(proxy_root_url: str) -> str | None: + """Return an error message when the local proxy health check is unreachable.""" + + url = f"{proxy_root_url.rstrip('/')}{PROXY_PREFLIGHT_PATH}" + request = Request(url, method="GET") + try: + with urlopen(request, timeout=PROXY_PREFLIGHT_TIMEOUT_SECONDS) as response: + status_code = response.getcode() + except HTTPError as exc: + return f"returned HTTP {exc.code}" + except URLError as exc: + return str(exc.reason) + except OSError as exc: + return str(exc) + + if not 200 <= status_code < 300: + return f"returned HTTP {status_code}" + return None + + +def resolve_client_binary( + *, + binary_name: str, + display_name: str, + install_hint: str, +) -> str: + """Resolve an installed client binary or exit with a user-facing hint.""" + + client_command = shutil.which(binary_name) + if client_command is None: + print( + f"Could not find {display_name} command: {binary_name}", + file=sys.stderr, + ) + print(install_hint, file=sys.stderr) + raise SystemExit(127) + return client_command + + +def run_client_process( + *, + command: list[str], + env: Mapping[str, str], + binary_name: str, + display_name: str, + install_hint: str, +) -> None: + """Run a client CLI command and mirror its exit code.""" + + process: subprocess.Popen[bytes] | None = None + try: + process = subprocess.Popen(command, env=dict(env)) + if process.pid: + register_pid(process.pid) + return_code = process.wait() + except FileNotFoundError: + print( + f"Could not find {display_name} command: {binary_name}", + file=sys.stderr, + ) + print(install_hint, file=sys.stderr) + raise SystemExit(127) from None + except KeyboardInterrupt: + if process is not None and process.pid: + kill_pid_tree_best_effort(process.pid) + process.wait() + raise + finally: + if process is not None and process.pid: + unregister_pid(process.pid) + + raise SystemExit(return_code) diff --git a/src/free_claude_code/cli/launchers/pi.py b/src/free_claude_code/cli/launchers/pi.py new file mode 100644 index 0000000..59e2553 --- /dev/null +++ b/src/free_claude_code/cli/launchers/pi.py @@ -0,0 +1,163 @@ +"""Installed `fcc-pi` launcher.""" + +import os +import subprocess +import sys +from collections.abc import Mapping, Sequence +from pathlib import Path + +from free_claude_code.cli.proxy_auth import proxy_auth_token +from free_claude_code.config.server_urls import local_proxy_root_url +from free_claude_code.config.settings import get_settings + +from .common import preflight_proxy, resolve_client_binary, run_client_process + +_API_KEY_ENV = "FCC_PI_API_KEY" +_BASE_URL_ENV = "FCC_PI_BASE_URL" +_BINARY_NAME = "pi" +_DISPLAY_NAME = "Pi" +_HELP_TIMEOUT_SECONDS = 5.0 +_MODEL_SCOPE = "free-claude-code/**" +_REQUIRED_HELP_MARKERS = ("--extension", "--models") +_PASSTHROUGH_COMMANDS = frozenset( + {"config", "install", "list", "remove", "uninstall", "update"} +) +_PASSTHROUGH_FLAGS = frozenset({"--help", "-h", "--version", "-v"}) + + +def launch(argv: Sequence[str] | None = None) -> None: + """Launch Pi with a process-local Free Claude Code provider.""" + + args = list(sys.argv[1:] if argv is None else argv) + install_hint = pi_install_hint() + binary_path = resolve_client_binary( + binary_name=_BINARY_NAME, + display_name=_DISPLAY_NAME, + install_hint=install_hint, + ) + if not pi_binary_is_compatible(binary_path): + print( + f"The 'pi' command at {binary_path} is not a compatible Pi Coding Agent.", + file=sys.stderr, + ) + print(install_hint, file=sys.stderr) + raise SystemExit(126) + + if is_pi_passthrough(args): + run_client_process( + command=[binary_path, *args], + env=os.environ, + binary_name=_BINARY_NAME, + display_name=_DISPLAY_NAME, + install_hint=install_hint, + ) + return + + settings = get_settings() + proxy_root_url = local_proxy_root_url(settings) + if error := preflight_proxy(proxy_root_url): + print( + f"Free Claude Code proxy is not reachable at {proxy_root_url}: {error}", + file=sys.stderr, + ) + print("Start it in another terminal with: fcc-server", file=sys.stderr) + raise SystemExit(1) + + extension_path = pi_extension_path() + if not extension_path.is_file(): + print( + "Free Claude Code's bundled Pi extension is missing. Reinstall FCC.", + file=sys.stderr, + ) + raise SystemExit(1) + + run_client_process( + command=build_pi_launcher_command( + binary_path=binary_path, + extension_path=extension_path, + argv=args, + ), + env=build_pi_launcher_env( + proxy_root_url=proxy_root_url, + auth_token=settings.anthropic_auth_token, + base_env=os.environ, + ), + binary_name=_BINARY_NAME, + display_name=_DISPLAY_NAME, + install_hint=install_hint, + ) + + +def build_pi_launcher_command( + *, + binary_path: str, + extension_path: Path, + argv: Sequence[str], +) -> list[str]: + """Return a Pi session command with ephemeral FCC provider registration.""" + + return [ + binary_path, + "-e", + str(extension_path), + "--models", + _MODEL_SCOPE, + *argv, + ] + + +def build_pi_launcher_env( + *, + proxy_root_url: str, + auth_token: str, + base_env: Mapping[str, str], +) -> dict[str, str]: + """Return a Pi environment containing only FCC-owned proxy variables.""" + + env = { + key: value for key, value in base_env.items() if not key.startswith("FCC_PI_") + } + env[_BASE_URL_ENV] = proxy_root_url.rstrip("/") + env[_API_KEY_ENV] = proxy_auth_token(auth_token) + return env + + +def is_pi_passthrough(argv: Sequence[str]) -> bool: + """Return whether Pi must receive argv unchanged as a non-session command.""" + + return bool(argv) and ( + argv[0] in _PASSTHROUGH_COMMANDS or argv[0] in _PASSTHROUGH_FLAGS + ) + + +def pi_extension_path() -> Path: + """Return the absolute installed path to the bundled Pi extension.""" + + return Path(__file__).with_name("pi_extension.ts").resolve() + + +def pi_binary_is_compatible(binary_path: str) -> bool: + """Return whether an executable exposes the Pi features FCC requires.""" + + try: + result = subprocess.run( + [binary_path, "--help"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=_HELP_TIMEOUT_SECONDS, + ) + except OSError, subprocess.TimeoutExpired: + return False + return result.returncode == 0 and all( + marker in result.stdout for marker in _REQUIRED_HELP_MARKERS + ) + + +def pi_install_hint(platform: str | None = None) -> str: + """Return Pi's official installer command for the current platform.""" + + if (platform or sys.platform) == "win32": + return 'Install Pi with: powershell -c "irm https://pi.dev/install.ps1 | iex"' + return "Install Pi with: curl -fsSL https://pi.dev/install.sh | sh" diff --git a/src/free_claude_code/cli/launchers/pi_extension.ts b/src/free_claude_code/cli/launchers/pi_extension.ts new file mode 100644 index 0000000..f738a71 --- /dev/null +++ b/src/free_claude_code/cli/launchers/pi_extension.ts @@ -0,0 +1,157 @@ +import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent"; + +const API_KEY_ENV = "FCC_PI_API_KEY"; +const BASE_URL_ENV = "FCC_PI_BASE_URL"; +const CATALOG_TIMEOUT_MS = 3000; +const DEFAULT_CONTEXT_WINDOW = 128000; +const DEFAULT_MAX_TOKENS = 16384; +const NORMAL_MODEL_PREFIX = "anthropic/"; +const NO_THINKING_MODEL_PREFIX = "claude-3-freecc-no-thinking/"; + +function requireEnvironment(name: string): string { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`Missing required ${name} environment variable.`); + } + return value; +} + +function normalizeBaseUrl(value: string): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error(`${BASE_URL_ENV} is not a valid URL.`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`${BASE_URL_ENV} must use http or https.`); + } + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/+$/, ""); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function catalogModelIds(payload: unknown): string[] { + if (!isRecord(payload) || payload.object !== "list" || !Array.isArray(payload.data)) { + throw new Error("FCC model catalog returned an invalid response shape."); + } + + const ids: string[] = []; + for (const entry of payload.data) { + if (!isRecord(entry) || typeof entry.id !== "string") continue; + const id = entry.id.trim(); + if (id) ids.push(id); + } + return ids; +} + +function providerModelRef(id: string, prefix: string): string | undefined { + if (!id.startsWith(prefix)) return undefined; + const parts = id.slice(prefix.length).split("/"); + if (parts.length < 2 || parts.some((part) => !part)) return undefined; + return parts.join("/"); +} + +function modelDefinition(providerModel: string, reasoning: boolean): ProviderModelConfig { + return { + id: providerModel, + name: providerModel, + reasoning, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, + }; +} + +export function projectFccModels(payload: unknown): ProviderModelConfig[] { + const ids = catalogModelIds(payload); + const normalModels = new Set(); + for (const id of ids) { + const providerModel = providerModelRef(id, NORMAL_MODEL_PREFIX); + if (providerModel) normalModels.add(providerModel); + } + + const models: ProviderModelConfig[] = []; + const seen = new Set(); + for (const id of ids) { + const normalModel = providerModelRef(id, NORMAL_MODEL_PREFIX); + if (normalModel) { + if (!seen.has(normalModel)) { + seen.add(normalModel); + models.push(modelDefinition(normalModel, true)); + } + continue; + } + + const noThinkingModel = providerModelRef(id, NO_THINKING_MODEL_PREFIX); + if (!noThinkingModel || normalModels.has(noThinkingModel) || seen.has(noThinkingModel)) continue; + seen.add(noThinkingModel); + models.push(modelDefinition(noThinkingModel, false)); + } + + if (models.length === 0) { + throw new Error("FCC model catalog contains no routable provider models."); + } + return models; +} + +function requestIdSuffix(response: Response): string { + const requestId = response.headers.get("request-id") ?? response.headers.get("x-request-id"); + return requestId ? ` (request ${requestId})` : ""; +} + +async function fetchFccModels(baseUrl: string, apiKey: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), CATALOG_TIMEOUT_MS); + try { + let response: Response; + try { + response = await fetch(`${baseUrl}/v1/models`, { + headers: { "X-API-Key": apiKey }, + signal: controller.signal, + }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error(`FCC model catalog timed out after ${CATALOG_TIMEOUT_MS}ms.`); + } + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Could not reach the FCC model catalog: ${message}`); + } + + if (!response.ok) { + throw new Error(`FCC model catalog returned HTTP ${response.status}${requestIdSuffix(response)}.`); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error(`FCC model catalog timed out after ${CATALOG_TIMEOUT_MS}ms.`); + } + throw new Error(`FCC model catalog returned invalid JSON${requestIdSuffix(response)}.`); + } + return projectFccModels(payload); + } finally { + clearTimeout(timeout); + } +} + +export default async function freeClaudeCode(pi: ExtensionAPI): Promise { + const baseUrl = normalizeBaseUrl(requireEnvironment(BASE_URL_ENV)); + const apiKey = requireEnvironment(API_KEY_ENV); + const models = await fetchFccModels(baseUrl, apiKey); + + pi.registerProvider("free-claude-code", { + name: "Free Claude Code", + baseUrl, + apiKey: `$${API_KEY_ENV}`, + api: "anthropic-messages", + models, + }); +} diff --git a/src/free_claude_code/cli/managed/__init__.py b/src/free_claude_code/cli/managed/__init__.py new file mode 100644 index 0000000..92c7911 --- /dev/null +++ b/src/free_claude_code/cli/managed/__init__.py @@ -0,0 +1,6 @@ +"""Managed Claude Code sessions used by messaging.""" + +from .manager import ManagedClaudeSessionManager +from .session import ManagedClaudeSession + +__all__ = ["ManagedClaudeSession", "ManagedClaudeSessionManager"] diff --git a/src/free_claude_code/cli/managed/claude.py b/src/free_claude_code/cli/managed/claude.py new file mode 100644 index 0000000..bdd6934 --- /dev/null +++ b/src/free_claude_code/cli/managed/claude.py @@ -0,0 +1,215 @@ +"""Managed Claude Code task command, environment, and stdout parsing.""" + +import json +from collections.abc import Iterable, Mapping +from dataclasses import dataclass, field +from typing import Any + +from loguru import logger + +from free_claude_code.cli.claude_env import ( + CLAUDE_BINARY_NAME, + build_claude_proxy_env, +) + +MANAGED_CLAUDE_MODEL_TIER = "opus" + + +@dataclass(frozen=True, slots=True) +class ManagedClaudeTaskRequest: + """One prompt execution request for a managed Claude Code subprocess.""" + + prompt: str + session_id: str | None = None + fork_session: bool = False + + +@dataclass(frozen=True, slots=True) +class ManagedClaudeInvocation: + """Concrete subprocess invocation assembled for a managed Claude task.""" + + argv: tuple[str, ...] + env: dict[str, str] + cwd: str + trace_metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class ManagedClaudeConfig: + """Configuration for a managed Claude Code subprocess.""" + + workspace_path: str + proxy_root_url: str + allowed_dirs: list[str] = field(default_factory=list) + claude_bin: str = CLAUDE_BINARY_NAME + auth_token: str = "" + + +@dataclass(slots=True) +class ManagedClaudeParseState: + """Mutable stdout parser state for one managed Claude Code task run.""" + + log_raw_cli_diagnostics: bool = False + session_id_extracted: bool = False + + +def build_managed_claude_invocation( + *, + config: ManagedClaudeConfig, + request: ManagedClaudeTaskRequest, + base_env: Mapping[str, str], +) -> ManagedClaudeInvocation: + """Build a Claude Code stream-json subprocess invocation.""" + + cmd = build_managed_claude_command( + claude_bin=config.claude_bin, + prompt=request.prompt, + session_id=request.session_id, + fork_session=request.fork_session, + allowed_dirs=config.allowed_dirs, + ) + resume_session_id = ( + request.session_id + if request.session_id and not request.session_id.startswith("pending_") + else None + ) + return ManagedClaudeInvocation( + argv=tuple(cmd), + env=build_managed_claude_env( + proxy_root_url=config.proxy_root_url, + auth_token=config.auth_token, + base_env=base_env, + ), + cwd=config.workspace_path, + trace_metadata={ + "client_cli_id": "claude", + "resume_session_id": resume_session_id, + "fork_session": request.fork_session, + "prompt": request.prompt, + "cwd": config.workspace_path, + "claude_binary": config.claude_bin, + "managed_model_tier": MANAGED_CLAUDE_MODEL_TIER, + "cli_argv": cmd, + }, + ) + + +def build_managed_claude_env( + *, + proxy_root_url: str, + auth_token: str, + base_env: Mapping[str, str], +) -> dict[str, str]: + """Return a Claude Code task environment that targets the local proxy.""" + + env = build_claude_proxy_env( + proxy_root_url=proxy_root_url, + auth_token=auth_token, + base_env=base_env, + ) + env["TERM"] = "dumb" + env["PYTHONIOENCODING"] = "utf-8" + return env + + +def build_managed_claude_command( + *, + claude_bin: str, + prompt: str, + session_id: str | None, + fork_session: bool, + allowed_dirs: list[str], +) -> list[str]: + """Return the Claude Code stream-json command for a managed task.""" + + if session_id and not session_id.startswith("pending_"): + cmd = [ + claude_bin, + "--resume", + session_id, + ] + if fork_session: + cmd.append("--fork-session") + cmd += [ + "--model", + MANAGED_CLAUDE_MODEL_TIER, + "-p", + prompt, + "--output-format", + "stream-json", + "--dangerously-skip-permissions", + "--verbose", + ] + else: + cmd = [ + claude_bin, + "--model", + MANAGED_CLAUDE_MODEL_TIER, + "-p", + prompt, + "--output-format", + "stream-json", + "--dangerously-skip-permissions", + "--verbose", + ] + + for directory in allowed_dirs: + cmd.extend(["--add-dir", directory]) + + return cmd + + +def parse_managed_claude_stdout_line( + line: str, state: ManagedClaudeParseState +) -> Iterable[Any]: + """Parse one Claude Code stream-json stdout line.""" + + try: + event = json.loads(line) + except json.JSONDecodeError: + if state.log_raw_cli_diagnostics: + logger.debug("Non-JSON output: {}", line) + else: + logger.debug("Non-JSON CLI line: char_len={}", len(line)) + yield {"type": "raw", "content": line} + return + + if not state.session_id_extracted: + extracted_id = extract_managed_claude_session_id(event) + if extracted_id: + state.session_id_extracted = True + logger.info("Extracted session ID: {}", extracted_id) + yield {"type": "session_info", "session_id": extracted_id} + + yield event + + +def extract_managed_claude_session_id(event: Any) -> str | None: + """Extract a Claude Code session ID from supported stream-json event shapes.""" + + if not isinstance(event, dict): + return None + + if session_id := _string_value(event.get("session_id")): + return session_id + if session_id := _string_value(event.get("sessionId")): + return session_id + + for key in ("init", "system", "result", "metadata"): + nested = event.get(key) + if not isinstance(nested, dict): + continue + if session_id := _string_value(nested.get("session_id")): + return session_id + if session_id := _string_value(nested.get("sessionId")): + return session_id + + conversation = event.get("conversation") + if isinstance(conversation, dict): + return _string_value(conversation.get("id")) + + return None + + +def _string_value(value: Any) -> str | None: + return value if isinstance(value, str) else None diff --git a/src/free_claude_code/cli/managed/diagnostics.py b/src/free_claude_code/cli/managed/diagnostics.py new file mode 100644 index 0000000..85c6063 --- /dev/null +++ b/src/free_claude_code/cli/managed/diagnostics.py @@ -0,0 +1,47 @@ +"""Managed Claude Code diagnostic classification.""" + +from dataclasses import dataclass + +_BENIGN_STDERR_MARKERS = ("claude.ai connectors are disabled",) + + +@dataclass(frozen=True, slots=True) +class ManagedClaudeStderrDiagnostics: + """Classified stderr lines from one managed Claude Code invocation.""" + + benign_lines: tuple[str, ...] + fatal_lines: tuple[str, ...] + + @property + def fatal_text(self) -> str | None: + text = "\n".join(self.fatal_lines).strip() + return text or None + + @property + def has_benign(self) -> bool: + return bool(self.benign_lines) + + +def classify_managed_claude_stderr( + stderr_text: str, +) -> ManagedClaudeStderrDiagnostics: + """Classify known benign Claude Code diagnostics separately from failures.""" + benign_lines: list[str] = [] + fatal_lines: list[str] = [] + for line in _stderr_lines(stderr_text): + lowered = line.lower() + if any(marker in lowered for marker in _BENIGN_STDERR_MARKERS): + benign_lines.append(line) + else: + fatal_lines.append(line) + return ManagedClaudeStderrDiagnostics( + benign_lines=tuple(benign_lines), + fatal_lines=tuple(fatal_lines), + ) + + +def _stderr_lines(stderr_text: str) -> tuple[str, ...]: + stripped = stderr_text.strip() + if not stripped: + return () + return tuple(line.strip() for line in stripped.splitlines() if line.strip()) diff --git a/src/free_claude_code/cli/managed/manager.py b/src/free_claude_code/cli/managed/manager.py new file mode 100644 index 0000000..30bd603 --- /dev/null +++ b/src/free_claude_code/cli/managed/manager.py @@ -0,0 +1,207 @@ +"""Managed Claude Code session pool for messaging.""" + +import asyncio +import uuid + +from loguru import logger + +from free_claude_code.cli.claude_env import CLAUDE_BINARY_NAME + +from .session import ManagedClaudeSession + + +class ManagedClaudeSessionManager: + """ + Manages multiple Claude Code sessions for parallel conversation processing. + + Each new conversation gets its own subprocess. Replies to existing + conversations reuse the same session instance. + """ + + def __init__( + self, + workspace_path: str, + proxy_root_url: str, + allowed_dirs: list[str] | None = None, + claude_bin: str = CLAUDE_BINARY_NAME, + auth_token: str = "", + *, + log_raw_cli_diagnostics: bool = False, + log_messaging_error_details: bool = False, + ): + """ + Initialize the session manager. + + Args: + workspace_path: Working directory for CLI processes + proxy_root_url: Root URL for the local proxy + allowed_dirs: Directories the CLI is allowed to access + """ + self.workspace = workspace_path + self.proxy_root_url = proxy_root_url + self.allowed_dirs = allowed_dirs or [] + self.claude_bin = claude_bin + self.auth_token = auth_token + self._log_raw_cli_diagnostics = log_raw_cli_diagnostics + self._log_messaging_error_details = log_messaging_error_details + + self._sessions: dict[str, ManagedClaudeSession] = {} + self._pending_sessions: dict[str, ManagedClaudeSession] = {} + self._temp_to_real: dict[str, str] = {} + self._real_to_temp: dict[str, str] = {} + self._closing_sessions: set[ManagedClaudeSession] = set() + self._lock = asyncio.Lock() + + def _session_for_id(self, session_id: str) -> ManagedClaudeSession | None: + lookup_id = self._temp_to_real.get(session_id, session_id) + session = self._sessions.get(lookup_id) + if session is not None: + return session + return self._pending_sessions.get(lookup_id) + + def _forget_session(self, session: ManagedClaudeSession) -> None: + pending_ids = [ + session_id + for session_id, owned in self._pending_sessions.items() + if owned is session + ] + real_ids = [ + session_id + for session_id, owned in self._sessions.items() + if owned is session + ] + for session_id in pending_ids: + self._pending_sessions.pop(session_id, None) + for real_id in real_ids: + self._sessions.pop(real_id, None) + temp_id = self._real_to_temp.pop(real_id, None) + if temp_id is not None: + self._temp_to_real.pop(temp_id, None) + self._closing_sessions.discard(session) + + async def get_or_create_session( + self, session_id: str | None = None + ) -> tuple[ManagedClaudeSession, str, bool]: + """ + Get an existing session or create a new one. + + Returns: + Tuple of (session 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: + session = self._sessions[lookup_id] + if session in self._closing_sessions: + raise RuntimeError("Managed Claude session is closing.") + return session, lookup_id, False + if lookup_id in self._pending_sessions: + session = self._pending_sessions[lookup_id] + if session in self._closing_sessions: + raise RuntimeError("Managed Claude session is closing.") + return session, lookup_id, False + + temp_id = session_id if session_id else f"pending_{uuid.uuid4().hex[:8]}" + + new_session = ManagedClaudeSession( + workspace_path=self.workspace, + proxy_root_url=self.proxy_root_url, + allowed_dirs=self.allowed_dirs, + claude_bin=self.claude_bin, + auth_token=self.auth_token, + log_raw_cli_diagnostics=self._log_raw_cli_diagnostics, + ) + self._pending_sessions[temp_id] = new_session + + 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: + session = self._pending_sessions.get(temp_id) + if session is None: + logger.warning(f"Temp session {temp_id} not found") + return False + if session in self._closing_sessions: + logger.warning("Cannot register a closing managed Claude session") + return False + existing = self._session_for_id(real_session_id) + if existing is not None and existing is not session: + logger.warning( + "Cannot register managed Claude session: real ID is already owned" + ) + return False + + 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: + session = self._session_for_id(session_id) + if session is None: + return False + self._closing_sessions.add(session) + stopped = await session.stop() + if not stopped: + return False + self._forget_session(session) + return True + + async def stop_all(self) -> None: + """Stop all sessions.""" + async with self._lock: + all_sessions = list( + dict.fromkeys( + [ + *self._sessions.values(), + *self._pending_sessions.values(), + *self._closing_sessions, + ] + ) + ) + self._closing_sessions.update(all_sessions) + failures = 0 + for session in all_sessions: + try: + stopped = await session.stop() + except Exception as e: + stopped = False + if self._log_messaging_error_details: + logger.error( + "Error stopping session: {}: {}", + type(e).__name__, + e, + ) + else: + logger.error( + "Error stopping session: exc_type={}", + type(e).__name__, + ) + if stopped: + self._forget_session(session) + else: + failures += 1 + + if failures: + raise RuntimeError( + f"Managed Claude session shutdown failures: {failures}." + ) + 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/src/free_claude_code/cli/managed/session.py b/src/free_claude_code/cli/managed/session.py new file mode 100644 index 0000000..af0016e --- /dev/null +++ b/src/free_claude_code/cli/managed/session.py @@ -0,0 +1,293 @@ +"""Managed Claude Code subprocess session.""" + +import asyncio +import os +from collections.abc import AsyncGenerator + +from loguru import logger + +from free_claude_code.cli.process_registry import ( + kill_pid_tree_best_effort, + register_pid, + unregister_pid, +) +from free_claude_code.core.trace import trace_event + +from .claude import ( + ManagedClaudeConfig, + ManagedClaudeParseState, + ManagedClaudeTaskRequest, + build_managed_claude_invocation, + parse_managed_claude_stdout_line, +) +from .diagnostics import classify_managed_claude_stderr + +# Cap stderr capture so a runaway child cannot exhaust memory; pipe is still drained. +_MAX_STDERR_CAPTURE_BYTES = 256 * 1024 + + +class ManagedClaudeSession: + """Manages a single persistent Claude Code subprocess.""" + + def __init__( + self, + workspace_path: str, + proxy_root_url: str, + allowed_dirs: list[str] | None = None, + claude_bin: str = "claude", + auth_token: str = "", + *, + log_raw_cli_diagnostics: bool = False, + ): + self.config = ManagedClaudeConfig( + workspace_path=os.path.normpath(os.path.abspath(workspace_path)), + proxy_root_url=proxy_root_url, + allowed_dirs=[os.path.normpath(d) for d in (allowed_dirs or [])], + claude_bin=claude_bin, + auth_token=auth_token, + ) + self.workspace = self.config.workspace_path + self.proxy_root_url = self.config.proxy_root_url + self.allowed_dirs = self.config.allowed_dirs + self.claude_bin = self.config.claude_bin + self.auth_token = self.config.auth_token + self._log_raw_cli_diagnostics = log_raw_cli_diagnostics + self.process: asyncio.subprocess.Process | None = None + self.current_session_id: str | None = None + self._is_busy = False + self._cli_lock = asyncio.Lock() + self._lifecycle_lock = asyncio.Lock() + self._closed = False + + @staticmethod + async def _drain_stderr_bounded( + process: asyncio.subprocess.Process, + *, + max_bytes: int = _MAX_STDERR_CAPTURE_BYTES, + ) -> bytes: + """Read stderr concurrently with stdout to avoid subprocess pipe deadlocks. + + Retains at most ``max_bytes`` for logging; any excess is discarded, but + the pipe is read until EOF so a noisy child cannot fill the buffer and + block forever. + """ + if not process.stderr: + return b"" + parts: list[bytes] = [] + received = 0 + while True: + chunk = await process.stderr.read(65_536) + if not chunk: + break + if received < max_bytes: + take = min(len(chunk), max_bytes - received) + if take: + parts.append(chunk[:take]) + received += take + # If already at cap, keep reading and discarding until EOF. + return b"".join(parts) + + @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: + process: asyncio.subprocess.Process | None = None + termination_confirmed = False + try: + async with self._lifecycle_lock: + if self._closed: + raise RuntimeError("Managed Claude session is closed.") + self._is_busy = True + invocation = build_managed_claude_invocation( + config=self.config, + request=ManagedClaudeTaskRequest( + prompt=prompt, + session_id=session_id, + fork_session=fork_session, + ), + base_env=os.environ, + ) + + trace_event( + stage="claude_cli", + event="claude_cli.process.launch", + source="claude_cli", + **invocation.trace_metadata, + ) + + process = await asyncio.create_subprocess_exec( + *invocation.argv, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=invocation.cwd, + env=invocation.env, + ) + self.process = process + if process.pid: + register_pid(process.pid) + + if not process.stdout: + yield {"type": "exit", "code": 1} + return + + parse_state = ManagedClaudeParseState( + log_raw_cli_diagnostics=self._log_raw_cli_diagnostics + ) + buffer = bytearray() + stderr_task: asyncio.Task[bytes] | None = None + if process.stderr: + stderr_task = asyncio.create_task( + self._drain_stderr_bounded(process) + ) + + try: + while True: + chunk = await 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, parse_state + ): + 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, parse_state + ): + yield event + except asyncio.CancelledError: + # Cancelling the handler task should not leave a Claude CLI + # subprocess running in the background. + await asyncio.shield(self.stop()) + raise + finally: + stderr_bytes = b"" + if stderr_task is not None: + stderr_bytes = await stderr_task + + stderr_text = None + if stderr_bytes: + raw_stderr_text = stderr_bytes.decode( + "utf-8", errors="replace" + ).strip() + if raw_stderr_text: + diagnostics = classify_managed_claude_stderr(raw_stderr_text) + if diagnostics.has_benign: + logger.info( + "Claude CLI benign stderr diagnostics: lines={}", + len(diagnostics.benign_lines), + ) + stderr_text = diagnostics.fatal_text + if stderr_text: + if self._log_raw_cli_diagnostics: + logger.error("Claude CLI stderr: {}", stderr_text) + else: + logger.error( + "Claude CLI stderr: bytes={} text_chars={}", + len(stderr_bytes), + len(stderr_text), + ) + logger.info("CLI_SESSION: Yielding error event from stderr") + yield {"type": "error", "error": {"message": stderr_text}} + + return_code = await process.wait() + termination_confirmed = True + 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 ( + process + and process.pid + and (termination_confirmed or process.returncode is not None) + ): + unregister_pid(process.pid) + + async def _handle_line_gen( + self, line_str: str, parse_state: ManagedClaudeParseState + ) -> AsyncGenerator[dict]: + """Process a single line and yield events.""" + for event in parse_managed_claude_stdout_line(line_str, parse_state): + if isinstance(event, dict) and event.get("type") == "session_info": + session_id = event.get("session_id") + if isinstance(session_id, str): + self.current_session_id = session_id + yield event + + async def stop(self) -> bool: + """Stop the CLI process, retaining PID ownership until exit is confirmed.""" + async with self._lifecycle_lock: + self._closed = True + process = self.process + if process is None: + return True + if process.returncode is not None: + if process.pid: + unregister_pid(process.pid) + return True + + try: + logger.info(f"Stopping Claude CLI process {process.pid}") + kill_pid_tree_best_effort(process.pid) + try: + await asyncio.wait_for(process.wait(), timeout=5.0) + except TimeoutError: + process.kill() + await process.wait() + if process.pid: + unregister_pid(process.pid) + return True + except Exception as e: + if self._log_raw_cli_diagnostics: + logger.error( + "Error stopping process: {}: {}", + type(e).__name__, + e, + ) + else: + logger.error( + "Error stopping process: exc_type={}", + type(e).__name__, + ) + return False diff --git a/src/free_claude_code/cli/process_registry.py b/src/free_claude_code/cli/process_registry.py new file mode 100644 index 0000000..3b28964 --- /dev/null +++ b/src/free_claude_code/cli/process_registry.py @@ -0,0 +1,76 @@ +"""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. +""" + +import atexit +import os +import signal +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_pid_tree_best_effort(pid: int) -> None: + """Kill a tracked process and its children where the platform supports it.""" + if not pid: + return + if os.name == "nt": + 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. + try: + os.kill(pid, signal.SIGTERM) + except Exception as e: + logger.debug("process_registry: terminate failed pid=%s: %s", pid, e) + + +def kill_all_best_effort() -> None: + """Kill any still-running registered pids (best-effort).""" + with _lock: + pids = list(_pids) + _pids.clear() + + for pid in pids: + kill_pid_tree_best_effort(pid) diff --git a/src/free_claude_code/cli/proxy_auth.py b/src/free_claude_code/cli/proxy_auth.py new file mode 100644 index 0000000..7264ab7 --- /dev/null +++ b/src/free_claude_code/cli/proxy_auth.py @@ -0,0 +1,9 @@ +"""Shared proxy-auth policy for FCC client launchers.""" + +PROXY_NO_AUTH_SENTINEL = "fcc-no-auth" + + +def proxy_auth_token(auth_token: str) -> str: + """Return the configured proxy token or the no-auth client marker.""" + + return auth_token.strip() or PROXY_NO_AUTH_SENTINEL diff --git a/src/free_claude_code/config/__init__.py b/src/free_claude_code/config/__init__.py new file mode 100644 index 0000000..87b0151 --- /dev/null +++ b/src/free_claude_code/config/__init__.py @@ -0,0 +1,5 @@ +"""Configuration management.""" + +from .settings import Settings, get_settings + +__all__ = ["Settings", "get_settings"] diff --git a/src/free_claude_code/config/admin/__init__.py b/src/free_claude_code/config/admin/__init__.py new file mode 100644 index 0000000..71e00b8 --- /dev/null +++ b/src/free_claude_code/config/admin/__init__.py @@ -0,0 +1 @@ +"""Admin configuration schema, persistence, and presentation metadata.""" diff --git a/src/free_claude_code/config/admin/manifest.py b/src/free_claude_code/config/admin/manifest.py new file mode 100644 index 0000000..1c026a8 --- /dev/null +++ b/src/free_claude_code/config/admin/manifest.py @@ -0,0 +1,698 @@ +"""Admin configuration manifest.""" + +from collections.abc import Iterable +from dataclasses import dataclass +from typing import Literal + +from free_claude_code.config.settings import Settings + +from .provider_manifest import provider_field_specs + +FieldType = Literal[ + "text", + "secret", + "number", + "boolean", + "tri_boolean", + "select", + "textarea", +] + + +@dataclass(frozen=True, slots=True) +class ConfigSectionSpec: + """A group of config fields rendered together in the admin UI.""" + + section_id: str + label: str + description: str + advanced: bool = False + + +@dataclass(frozen=True, slots=True) +class ConfigFieldSpec: + """Typed metadata for one env-backed admin setting.""" + + key: str + label: str + section_id: str + field_type: FieldType = "text" + settings_attr: str | None = None + default: str = "" + options: tuple[str, ...] = () + secret: bool = False + advanced: bool = False + restart_required: bool = False + session_sensitive: bool = False + description: str = "" + + +SECTIONS: tuple[ConfigSectionSpec, ...] = ( + ConfigSectionSpec( + "providers", + "Providers", + "Provider keys, local endpoints, and proxy settings.", + ), + ConfigSectionSpec( + "models", + "Model Routing", + "Provider-prefixed models used for Claude model tiers.", + ), + ConfigSectionSpec( + "thinking", + "Thinking", + "Global and tier-specific thinking behavior.", + ), + ConfigSectionSpec( + "runtime", + "Runtime", + "Server API token, rate limits, timeouts, and process settings.", + ), + ConfigSectionSpec( + "messaging", + "Messaging", + "Discord, Telegram, CLI workspace, and session settings.", + ), + ConfigSectionSpec( + "voice", + "Voice", + "Voice note transcription settings.", + ), + ConfigSectionSpec( + "web_tools", + "Web Tools", + "Local Anthropic web_search and web_fetch behavior.", + ), + ConfigSectionSpec( + "diagnostics", + "Diagnostics", + "Logging and debugging flags.", + advanced=True, + ), + ConfigSectionSpec( + "smoke", + "Smoke Tests", + "Optional live smoke-test model overrides.", + advanced=True, + ), +) + + +_NON_PROVIDER_FIELDS: tuple[ConfigFieldSpec, ...] = ( + ConfigFieldSpec( + "MODEL", + "Default Model", + "models", + settings_attr="model", + default="nvidia_nim/nvidia/nemotron-3-super-120b-a12b", + description="Fallback provider/model route for all Claude model names.", + ), + ConfigFieldSpec( + "MODEL_OPUS", + "Opus Override", + "models", + settings_attr="model_opus", + description="Optional provider/model route for Opus requests.", + ), + ConfigFieldSpec( + "MODEL_SONNET", + "Sonnet Override", + "models", + settings_attr="model_sonnet", + description="Optional provider/model route for Sonnet requests.", + ), + ConfigFieldSpec( + "MODEL_HAIKU", + "Haiku Override", + "models", + settings_attr="model_haiku", + description="Optional provider/model route for Haiku requests.", + ), + ConfigFieldSpec( + "ENABLE_MODEL_THINKING", + "Enable Thinking", + "thinking", + "boolean", + settings_attr="enable_model_thinking", + default="true", + ), + ConfigFieldSpec( + "ENABLE_OPUS_THINKING", + "Opus Thinking", + "thinking", + "tri_boolean", + settings_attr="enable_opus_thinking", + description="Blank inherits Enable Thinking.", + ), + ConfigFieldSpec( + "ENABLE_SONNET_THINKING", + "Sonnet Thinking", + "thinking", + "tri_boolean", + settings_attr="enable_sonnet_thinking", + description="Blank inherits Enable Thinking.", + ), + ConfigFieldSpec( + "ENABLE_HAIKU_THINKING", + "Haiku Thinking", + "thinking", + "tri_boolean", + settings_attr="enable_haiku_thinking", + description="Blank inherits Enable Thinking.", + ), + ConfigFieldSpec( + "ANTHROPIC_AUTH_TOKEN", + "API/CLI Auth Token", + "runtime", + "secret", + settings_attr="anthropic_auth_token", + default="freecc", + secret=True, + restart_required=True, + description="Protects Claude/API access. It is not admin-page login.", + ), + ConfigFieldSpec( + "PROVIDER_RATE_LIMIT", + "Provider Rate Limit", + "runtime", + "number", + settings_attr="provider_rate_limit", + default="1", + ), + ConfigFieldSpec( + "PROVIDER_RATE_WINDOW", + "Provider Rate Window", + "runtime", + "number", + settings_attr="provider_rate_window", + default="3", + ), + ConfigFieldSpec( + "PROVIDER_MAX_CONCURRENCY", + "Provider Max Concurrency", + "runtime", + "number", + settings_attr="provider_max_concurrency", + default="5", + ), + ConfigFieldSpec( + "HTTP_READ_TIMEOUT", + "HTTP Read Timeout", + "runtime", + "number", + settings_attr="http_read_timeout", + default="300", + ), + ConfigFieldSpec( + "HTTP_WRITE_TIMEOUT", + "HTTP Write Timeout", + "runtime", + "number", + settings_attr="http_write_timeout", + default="60", + ), + ConfigFieldSpec( + "HTTP_CONNECT_TIMEOUT", + "HTTP Connect Timeout", + "runtime", + "number", + settings_attr="http_connect_timeout", + default="60", + ), + ConfigFieldSpec( + "HOST", + "Server Host", + "runtime", + settings_attr="host", + default="0.0.0.0", + restart_required=True, + ), + ConfigFieldSpec( + "PORT", + "Server Port", + "runtime", + "number", + settings_attr="port", + default="8082", + restart_required=True, + ), + ConfigFieldSpec( + "MESSAGING_PLATFORM", + "Messaging Platform", + "messaging", + "select", + settings_attr="messaging_platform", + default="discord", + options=("telegram", "discord", "none"), + session_sensitive=True, + ), + ConfigFieldSpec( + "MESSAGING_RATE_LIMIT", + "Messaging Rate Limit", + "messaging", + "number", + settings_attr="messaging_rate_limit", + default="1", + session_sensitive=True, + ), + ConfigFieldSpec( + "MESSAGING_RATE_WINDOW", + "Messaging Rate Window", + "messaging", + "number", + settings_attr="messaging_rate_window", + default="1", + session_sensitive=True, + ), + ConfigFieldSpec( + "TELEGRAM_BOT_TOKEN", + "Telegram Bot Token", + "messaging", + "secret", + settings_attr="telegram_bot_token", + secret=True, + session_sensitive=True, + ), + ConfigFieldSpec( + "ALLOWED_TELEGRAM_USER_ID", + "Allowed Telegram User ID", + "messaging", + settings_attr="allowed_telegram_user_id", + session_sensitive=True, + ), + ConfigFieldSpec( + "TELEGRAM_PROXY_URL", + "Telegram Proxy URL", + "messaging", + "secret", + settings_attr="telegram_proxy_url", + secret=True, + session_sensitive=True, + description="Optional Telegram-only proxy, e.g. socks5://127.0.0.1:1080.", + ), + ConfigFieldSpec( + "DISCORD_BOT_TOKEN", + "Discord Bot Token", + "messaging", + "secret", + settings_attr="discord_bot_token", + secret=True, + session_sensitive=True, + ), + ConfigFieldSpec( + "ALLOWED_DISCORD_CHANNELS", + "Allowed Discord Channels", + "messaging", + settings_attr="allowed_discord_channels", + session_sensitive=True, + ), + ConfigFieldSpec( + "ALLOWED_DIR", + "Allowed Directory", + "messaging", + settings_attr="allowed_dir", + session_sensitive=True, + ), + ConfigFieldSpec( + "MAX_MESSAGE_LOG_ENTRIES_PER_CHAT", + "Max Tracked Messages Per Chat", + "messaging", + "number", + settings_attr="max_message_log_entries_per_chat", + advanced=True, + session_sensitive=True, + ), + ConfigFieldSpec( + "VOICE_NOTE_ENABLED", + "Voice Notes", + "voice", + "boolean", + settings_attr="voice_note_enabled", + default="false", + session_sensitive=True, + ), + ConfigFieldSpec( + "WHISPER_DEVICE", + "Whisper Device", + "voice", + "select", + settings_attr="whisper_device", + default="nvidia_nim", + options=("cpu", "cuda", "nvidia_nim"), + session_sensitive=True, + ), + ConfigFieldSpec( + "WHISPER_MODEL", + "Whisper Model", + "voice", + settings_attr="whisper_model", + default="openai/whisper-large-v3", + session_sensitive=True, + ), + ConfigFieldSpec( + "FAST_PREFIX_DETECTION", + "Fast Prefix Detection", + "runtime", + "boolean", + settings_attr="fast_prefix_detection", + default="true", + advanced=True, + ), + ConfigFieldSpec( + "ENABLE_NETWORK_PROBE_MOCK", + "Network Probe Mock", + "runtime", + "boolean", + settings_attr="enable_network_probe_mock", + default="true", + advanced=True, + ), + ConfigFieldSpec( + "ENABLE_TITLE_GENERATION_SKIP", + "Title Generation Skip", + "runtime", + "boolean", + settings_attr="enable_title_generation_skip", + default="true", + advanced=True, + ), + ConfigFieldSpec( + "ENABLE_SUGGESTION_MODE_SKIP", + "Suggestion Mode Skip", + "runtime", + "boolean", + settings_attr="enable_suggestion_mode_skip", + default="true", + advanced=True, + ), + ConfigFieldSpec( + "ENABLE_FILEPATH_EXTRACTION_MOCK", + "Filepath Extraction Mock", + "runtime", + "boolean", + settings_attr="enable_filepath_extraction_mock", + default="true", + advanced=True, + ), + ConfigFieldSpec( + "ENABLE_WEB_SERVER_TOOLS", + "Web Server Tools", + "web_tools", + "boolean", + settings_attr="enable_web_server_tools", + default="true", + ), + ConfigFieldSpec( + "WEB_FETCH_ALLOWED_SCHEMES", + "Allowed Web Fetch Schemes", + "web_tools", + settings_attr="web_fetch_allowed_schemes", + default="http,https", + ), + ConfigFieldSpec( + "WEB_FETCH_ALLOW_PRIVATE_NETWORKS", + "Allow Private Networks", + "web_tools", + "boolean", + settings_attr="web_fetch_allow_private_networks", + default="false", + ), + ConfigFieldSpec( + "DEBUG_PLATFORM_EDITS", + "Debug Platform Edits", + "diagnostics", + "boolean", + settings_attr="debug_platform_edits", + default="false", + advanced=True, + restart_required=True, + ), + ConfigFieldSpec( + "DEBUG_SUBAGENT_STACK", + "Debug Subagent Stack", + "diagnostics", + "boolean", + settings_attr="debug_subagent_stack", + default="false", + advanced=True, + restart_required=True, + ), + ConfigFieldSpec( + "LOG_RAW_API_PAYLOADS", + "Log Raw API Payloads", + "diagnostics", + "boolean", + settings_attr="log_raw_api_payloads", + default="false", + advanced=True, + restart_required=True, + ), + ConfigFieldSpec( + "LOG_RAW_SSE_EVENTS", + "Log Raw SSE Events", + "diagnostics", + "boolean", + settings_attr="log_raw_sse_events", + default="false", + advanced=True, + ), + ConfigFieldSpec( + "LOG_API_ERROR_TRACEBACKS", + "Log API Error Tracebacks", + "diagnostics", + "boolean", + settings_attr="log_api_error_tracebacks", + default="false", + advanced=True, + restart_required=True, + ), + ConfigFieldSpec( + "LOG_RAW_MESSAGING_CONTENT", + "Log Raw Messaging Content", + "diagnostics", + "boolean", + settings_attr="log_raw_messaging_content", + default="false", + advanced=True, + restart_required=True, + ), + ConfigFieldSpec( + "LOG_RAW_CLI_DIAGNOSTICS", + "Log Raw CLI Diagnostics", + "diagnostics", + "boolean", + settings_attr="log_raw_cli_diagnostics", + default="false", + advanced=True, + restart_required=True, + ), + ConfigFieldSpec( + "LOG_MESSAGING_ERROR_DETAILS", + "Log Messaging Error Details", + "diagnostics", + "boolean", + settings_attr="log_messaging_error_details", + default="false", + advanced=True, + restart_required=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_NVIDIA_NIM", + "Smoke NVIDIA NIM Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_OPEN_ROUTER", + "Smoke OpenRouter Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_MISTRAL", + "Smoke Mistral Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_MISTRAL_CODESTRAL", + "Smoke Mistral Codestral Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_DEEPSEEK", + "Smoke DeepSeek Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_LMSTUDIO", + "Smoke LM Studio Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_LLAMACPP", + "Smoke llama.cpp Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_OLLAMA", + "Smoke Ollama Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_KIMI", + "Smoke Kimi Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_MINIMAX", + "Smoke MiniMax Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_WAFER", + "Smoke Wafer Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_OPENCODE", + "Smoke OpenCode Zen Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_OPENCODE_GO", + "Smoke OpenCode Go Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_VERCEL", + "Smoke Vercel AI Gateway Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_HUGGINGFACE", + "Smoke Hugging Face Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_COHERE", + "Smoke Cohere Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_GITHUB_MODELS", + "Smoke GitHub Models Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_ZAI", + "Smoke Z.ai Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_FIREWORKS", + "Smoke Fireworks Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_CLOUDFLARE", + "Smoke Cloudflare Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_GEMINI", + "Smoke Gemini Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_GROQ", + "Smoke Groq Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_SAMBANOVA", + "Smoke SambaNova Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_CEREBRAS", + "Smoke Cerebras Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_NIM_MODELS", + "Smoke NIM Models", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_NIM_EXTRA_MODELS", + "Smoke NIM Extra Models", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_OPENROUTER_FREE_MODELS", + "Smoke OpenRouter Free Models", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_OPENROUTER_FREE_EXTRA_MODELS", + "Smoke OpenRouter Free Extra Models", + "smoke", + advanced=True, + ), +) + + +FIELDS: tuple[ConfigFieldSpec, ...] = ( + *(ConfigFieldSpec(**spec) for spec in provider_field_specs()), + *_NON_PROVIDER_FIELDS, +) +FIELD_BY_KEY = {field.key: field for field in FIELDS} + + +def field_input_key(field: ConfigFieldSpec) -> str | None: + """Return the Settings input key used for a manifest field.""" + + if field.settings_attr is None: + return None + model_field = Settings.model_fields[field.settings_attr] + alias = model_field.validation_alias + if alias is None: + return field.settings_attr + return str(alias) + + +def env_keys() -> frozenset[str]: + """Return env keys owned by the admin manifest.""" + + return frozenset(field.key for field in FIELDS) + + +def fields_with_attrs() -> Iterable[ConfigFieldSpec]: + """Yield fields that validate through Settings.""" + + return (field for field in FIELDS if field.settings_attr is not None) diff --git a/src/free_claude_code/config/admin/persistence.py b/src/free_claude_code/config/admin/persistence.py new file mode 100644 index 0000000..0e1320d --- /dev/null +++ b/src/free_claude_code/config/admin/persistence.py @@ -0,0 +1,218 @@ +"""Managed env persistence, validation preview, and rendering.""" + +import os +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from free_claude_code.config.paths import managed_env_path +from free_claude_code.config.settings import Settings + +from .manifest import FIELD_BY_KEY, FIELDS, SECTIONS, ConfigFieldSpec +from .sources import dotenv_values_from_file, is_locked_source, template_values +from .validation import settings_from_values +from .values import MASKED_SECRET, load_value_state, normalize_for_env + + +@dataclass(frozen=True, slots=True) +class PreparedAdminUpdate: + """Validated Admin update ready for an atomic managed-file commit.""" + + target_values: dict[str, str] + settings: Settings | None + errors: tuple[str, ...] + pending_fields: tuple[str, ...] + path: Path + + @property + def valid(self) -> bool: + return self.settings is not None + + def validation_response(self) -> dict[str, Any]: + return { + "valid": self.valid, + "errors": list(self.errors), + "env_preview": render_env_file(self.target_values, mask_secrets=True), + } + + def applied_response(self) -> dict[str, Any]: + if not self.valid: + return self.validation_response() | { + "applied": False, + "pending_fields": [], + } + return { + "applied": True, + "valid": True, + "errors": [], + "env_preview": render_env_file( + self.target_values, + mask_secrets=True, + ), + "path": str(self.path), + "pending_fields": list(self.pending_fields), + } + + +def target_values_with_updates(updates: Mapping[str, Any]) -> dict[str, str]: + """Return managed env values after applying admin updates.""" + + state = load_value_state() + values = template_values() + + # Preserve existing managed values when present. If no managed config exists, + # seed the first write from effective repo values to migrate legacy setups. + managed_values = dotenv_values_from_file(managed_env_path()) + if managed_values: + values.update( + {key: val for key, val in managed_values.items() if key in values} + ) + else: + for key, entry in state.items(): + if entry["source"] in {"repo_env", "template", "default"}: + values[key] = str(entry["value"]) + + for key, value in updates.items(): + field = FIELD_BY_KEY.get(key) + if field is None: + continue + if is_locked_source(state[key]["source"]): + continue + if field.secret and value == MASKED_SECRET: + continue + values[key] = normalize_for_env(value) + + for field in FIELDS: + values.setdefault(field.key, field.default) + return values + + +def effective_values_for_validation( + target_values: Mapping[str, str], +) -> dict[str, str]: + """Return values validated after preserving locked external sources.""" + + values = dict(target_values) + for key, entry in load_value_state().items(): + if is_locked_source(entry["source"]): + values[key] = str(entry["value"]) + return values + + +def validate_updates(updates: Mapping[str, Any]) -> dict[str, Any]: + """Validate partial admin updates and return a masked generated env preview.""" + + return prepare_admin_update(updates).validation_response() + + +def changed_pending_fields( + updates: Mapping[str, Any], + *, + settings: Settings, +) -> list[str]: + """Return changed fields that require manual runtime action.""" + + state = load_value_state() + pending: list[str] = [] + for key, value in updates.items(): + field = FIELD_BY_KEY.get(key) + if field is None or is_locked_source(state[key]["source"]): + continue + if field.secret and value == MASKED_SECRET: + continue + requires_restart = field.restart_required or field.session_sensitive + if not requires_restart: + requires_restart = _active_voice_credential(settings) == key + if not requires_restart: + continue + if normalize_for_env(value) == str(state[key]["value"]): + continue + pending.append(key) + return pending + + +def _active_voice_credential(settings: Settings) -> str | None: + if not settings.voice_note_enabled: + return None + if settings.whisper_device == "nvidia_nim": + return "NVIDIA_NIM_API_KEY" + return "HUGGINGFACE_API_KEY" + + +def prepare_admin_update(updates: Mapping[str, Any]) -> PreparedAdminUpdate: + """Validate an update and construct its prospective Settings snapshot.""" + + target_values = target_values_with_updates(updates) + effective_values = effective_values_for_validation(target_values) + settings, errors = settings_from_values(effective_values) + pending_fields = ( + tuple(changed_pending_fields(updates, settings=settings)) + if settings is not None + else () + ) + return PreparedAdminUpdate( + target_values=target_values, + settings=settings, + errors=tuple(errors), + pending_fields=pending_fields, + path=managed_env_path(), + ) + + +def commit_prepared_admin_update(prepared: PreparedAdminUpdate) -> dict[str, Any]: + """Atomically persist a previously validated Admin update.""" + + if not prepared.valid: + raise ValueError("Cannot commit an invalid Admin update") + + path = prepared.path + path.parent.mkdir(parents=True, exist_ok=True) + temp_path = path.with_suffix(path.suffix + ".tmp") + try: + temp_path.write_text( + render_env_file(prepared.target_values), + encoding="utf-8", + ) + os.replace(temp_path, path) + finally: + temp_path.unlink(missing_ok=True) + return prepared.applied_response() + + +def quote_env_value(value: str) -> str: + """Quote a value when dotenv syntax requires it.""" + + if value == "": + return "" + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + if any(char.isspace() for char in value) or any( + char in value for char in ('"', "#", "=", "$") + ): + return f'"{escaped}"' + return value + + +def render_env_file(values: Mapping[str, str], *, mask_secrets: bool = False) -> str: + """Render a complete grouped env file.""" + + lines: list[str] = [ + "# Managed by Free Claude Code /admin.", + "# Edit in the server UI when possible.", + "", + ] + fields_by_section: dict[str, list[ConfigFieldSpec]] = { + section.section_id: [] for section in SECTIONS + } + for field in FIELDS: + fields_by_section.setdefault(field.section_id, []).append(field) + + for section in SECTIONS: + lines.append(f"# {section.label}") + for field in fields_by_section.get(section.section_id, []): + value = values.get(field.key, field.default) + if mask_secrets and field.secret and value: + value = MASKED_SECRET + lines.append(f"{field.key}={quote_env_value(value)}") + lines.append("") + return "\n".join(lines).rstrip() + "\n" diff --git a/src/free_claude_code/config/admin/provider_manifest.py b/src/free_claude_code/config/admin/provider_manifest.py new file mode 100644 index 0000000..0e8f739 --- /dev/null +++ b/src/free_claude_code/config/admin/provider_manifest.py @@ -0,0 +1,201 @@ +"""Catalog-derived Admin provider fields.""" + +from typing import Any + +from free_claude_code.config.provider_catalog import PROVIDER_CATALOG +from free_claude_code.config.settings import Settings + +_PROVIDER_FIELD_OVERRIDES: dict[str, dict[str, Any]] = { + "NVIDIA_NIM_API_KEY": { + "label": "NVIDIA NIM API Key", + "description": "Used by NVIDIA NIM chat and optional NIM voice transcription.", + }, + "MISTRAL_API_KEY": { + "label": "Mistral API Key", + "description": ( + "Mistral La Plateforme (api.mistral.ai); Experiment plan is free tier with rate limits." + ), + }, + "CODESTRAL_API_KEY": { + "label": "Codestral API Key", + "description": ( + "Mistral Codestral endpoint (codestral.mistral.ai); distinct from Mistral " + "La Plateforme ``MISTRAL_API_KEY``. See Mistral docs for coding/FIM domains." + ), + }, + "OPENCODE_API_KEY": { + "label": "OpenCode API Key", + "description": ( + "OpenCode Zen curated gateway (opencode.ai/zen/v1) and OpenCode Go subscription " + "gateway (opencode.ai/zen/go/v1); single key from opencode.ai/auth." + ), + }, + "AI_GATEWAY_API_KEY": { + "label": "Vercel AI Gateway API Key", + "description": ( + "Vercel AI Gateway API key for the OpenAI-compatible endpoint at " + "ai-gateway.vercel.sh/v1." + ), + }, + "HUGGINGFACE_API_KEY": { + "label": "Hugging Face API Key", + "description": ( + "Hugging Face token with Inference Providers permission; also used " + "for local Whisper model downloads when voice notes need gated models." + ), + }, + "COHERE_API_KEY": { + "label": "Cohere API Key", + "description": "Cohere API key for the OpenAI-compatible Compatibility API.", + }, + "GITHUB_MODELS_TOKEN": { + "label": "GitHub Models Token", + "description": ( + "GitHub token with Models access for the OpenAI-compatible inference API " + "at models.github.ai." + ), + }, + "ZAI_API_KEY": { + "label": "Z.ai API Key", + "description": "Z.ai Coding Plan API key.", + }, + "FIREWORKS_API_KEY": { + "label": "Fireworks API Key", + "description": "Fireworks AI inference API key.", + }, + "MINIMAX_API_KEY": { + "label": "MiniMax API Key", + "description": ( + "MiniMax API key for the OpenAI-compatible Chat Completions API at " + "free_claude_code.api.minimax.io/v1." + ), + }, + "CLOUDFLARE_API_TOKEN": { + "label": "Cloudflare API Token", + "description": ( + "Cloudflare API token for account-scoped AI REST requests. " + "Use with CLOUDFLARE_ACCOUNT_ID." + ), + }, + "GEMINI_API_KEY": { + "label": "Gemini API Key", + "description": ( + "Google AI Studio Gemini API key (Google AI Studio / Gemini API " + "[OpenAI-compatible](https://ai.google.dev/gemini-api/docs/openai)); " + "free tier has per-model rate limits and data may be used for improvement " + "outside the UK/CH/EEA/EU." + ), + }, + "GROQ_API_KEY": { + "label": "Groq API Key", + "description": ( + "GroqCloud OpenAI-compatible API key ([console.groq.com/keys](" + "https://console.groq.com/keys)); see Groq " + "[OpenAI compatibility docs](https://console.groq.com/docs/openai)." + ), + }, + "SAMBANOVA_API_KEY": { + "label": "SambaNova API Key", + "description": ( + "SambaNova Cloud OpenAI-compatible API key (create at " + "[cloud.sambanova.ai/apis](https://cloud.sambanova.ai/apis))." + ), + }, + "CEREBRAS_API_KEY": { + "label": "Cerebras API Key", + "description": ( + "Cerebras Inference API key (create in [Cloud Console](https://cloud.cerebras.ai)); " + "see [Quickstart](https://inference-docs.cerebras.ai/quickstart) and " + "[OpenAI compatibility](https://inference-docs.cerebras.ai/resources/openai)." + ), + }, +} + + +def provider_field_specs() -> tuple[dict[str, Any], ...]: + """Return provider fields generated from the provider catalog.""" + + return ( + *_credential_field_specs(), + *_cloudflare_account_field_specs(), + *_local_base_url_field_specs(), + *_proxy_field_specs(), + ) + + +def _credential_field_specs() -> tuple[dict[str, Any], ...]: + specs: list[dict[str, Any]] = [] + seen_env_keys: set[str] = set() + for descriptor in PROVIDER_CATALOG.values(): + if descriptor.credential_env is None: + continue + if descriptor.credential_env in seen_env_keys: + continue + seen_env_keys.add(descriptor.credential_env) + spec = { + "key": descriptor.credential_env, + "label": f"{descriptor.display_name} API Key", + "section_id": "providers", + "field_type": "secret", + "settings_attr": descriptor.credential_attr, + "secret": True, + } + spec.update(_PROVIDER_FIELD_OVERRIDES.get(descriptor.credential_env, {})) + specs.append(spec) + return tuple(specs) + + +def _local_base_url_field_specs() -> tuple[dict[str, Any], ...]: + specs: list[dict[str, Any]] = [] + for descriptor in PROVIDER_CATALOG.values(): + if descriptor.base_url_attr is None: + continue + specs.append( + { + "key": _settings_env_key(descriptor.base_url_attr), + "label": f"{descriptor.display_name} Base URL", + "section_id": "providers", + "settings_attr": descriptor.base_url_attr, + "default": descriptor.default_base_url or "", + } + ) + return tuple(specs) + + +def _cloudflare_account_field_specs() -> tuple[dict[str, Any], ...]: + return ( + { + "key": "CLOUDFLARE_ACCOUNT_ID", + "label": "Cloudflare Account ID", + "section_id": "providers", + "settings_attr": "cloudflare_account_id", + "description": ( + "Cloudflare account ID used to build the /accounts/{id}/ai/v1 endpoint." + ), + }, + ) + + +def _proxy_field_specs() -> tuple[dict[str, Any], ...]: + specs: list[dict[str, Any]] = [] + for descriptor in PROVIDER_CATALOG.values(): + if descriptor.proxy_attr is None: + continue + specs.append( + { + "key": _settings_env_key(descriptor.proxy_attr), + "label": f"{descriptor.display_name} Proxy", + "section_id": "providers", + "field_type": "secret", + "settings_attr": descriptor.proxy_attr, + "secret": True, + "advanced": True, + } + ) + return tuple(specs) + + +def _settings_env_key(settings_attr: str) -> str: + model_field = Settings.model_fields[settings_attr] + alias = model_field.validation_alias + return str(alias) if alias is not None else settings_attr diff --git a/src/free_claude_code/config/admin/sources.py b/src/free_claude_code/config/admin/sources.py new file mode 100644 index 0000000..854538d --- /dev/null +++ b/src/free_claude_code/config/admin/sources.py @@ -0,0 +1,84 @@ +"""Admin config source loading and source precedence.""" + +import os +from io import StringIO +from pathlib import Path +from typing import Literal + +from dotenv import dotenv_values + +from free_claude_code.config.env_files import ( + explicit_env_path as configured_explicit_env_path, +) +from free_claude_code.config.env_files import ( + repo_env_path as configured_repo_env_path, +) +from free_claude_code.config.env_files import ( + settings_env_files, +) +from free_claude_code.config.env_template import load_env_template_or_empty + +from .manifest import FIELDS + +SourceType = Literal[ + "default", + "template", + "repo_env", + "managed_env", + "explicit_env_file", + "process", +] + + +def repo_env_path() -> Path: + """Return the repo-local env path.""" + + return configured_repo_env_path() + + +def explicit_env_path() -> Path | None: + """Return the explicit FCC_ENV_FILE path, when configured.""" + + return configured_explicit_env_path(os.environ) + + +def configured_env_files() -> tuple[tuple[SourceType, Path], ...]: + """Return dotenv files in low-to-high precedence order.""" + + source_names: tuple[SourceType, ...] = ( + "repo_env", + "managed_env", + "explicit_env_file", + ) + return tuple(zip(source_names, settings_env_files(), strict=False)) + + +def dotenv_values_from_text(text: str) -> dict[str, str]: + """Parse dotenv text into string values.""" + + values = dotenv_values(stream=StringIO(text)) + return {key: "" if value is None else value for key, value in values.items()} + + +def template_values() -> dict[str, str]: + """Return .env.example values plus manifest defaults for newer fields.""" + + values = dotenv_values_from_text(load_env_template_or_empty()) + for field in FIELDS: + values.setdefault(field.key, field.default) + return values + + +def dotenv_values_from_file(path: Path) -> dict[str, str]: + """Return dotenv values from a file, or an empty mapping when absent.""" + + if not path.is_file(): + return {} + values = dotenv_values(path) + return {key: "" if value is None else value for key, value in values.items()} + + +def is_locked_source(source: SourceType) -> bool: + """Return whether an admin value source must not be overwritten.""" + + return source in {"process", "explicit_env_file"} diff --git a/src/free_claude_code/config/admin/status.py b/src/free_claude_code/config/admin/status.py new file mode 100644 index 0000000..dbb0fd6 --- /dev/null +++ b/src/free_claude_code/config/admin/status.py @@ -0,0 +1,54 @@ +"""Provider configuration status for the Admin UI.""" + +from collections.abc import Mapping +from typing import Any + +from free_claude_code.config.provider_catalog import PROVIDER_CATALOG + +from .manifest import FIELDS + + +def provider_config_status( + state: Mapping[str, Mapping[str, Any]], +) -> list[dict[str, Any]]: + """Return provider configuration status without making network calls.""" + statuses: list[dict[str, Any]] = [] + for provider_id, descriptor in PROVIDER_CATALOG.items(): + if descriptor.local: + base_url = "" + if descriptor.base_url_attr is not None: + base_url = _value_for_settings_attr(state, descriptor.base_url_attr) + statuses.append( + { + "provider_id": provider_id, + "display_name": descriptor.display_name, + "kind": "local", + "status": "missing_url" if not base_url.strip() else "unknown", + "label": "Missing URL" if not base_url.strip() else "Not checked", + "base_url": base_url or descriptor.default_base_url or "", + } + ) + continue + + value = str(state.get(descriptor.credential_env, {}).get("value", "")) + configured = bool(value.strip()) + statuses.append( + { + "provider_id": provider_id, + "display_name": descriptor.display_name, + "kind": "remote", + "status": "configured" if configured else "missing_key", + "label": "Configured" if configured else "Missing key", + "credential_env": descriptor.credential_env, + } + ) + return statuses + + +def _value_for_settings_attr( + state: Mapping[str, Mapping[str, Any]], settings_attr: str +) -> str: + for field in FIELDS: + if field.settings_attr == settings_attr: + return str(state.get(field.key, {}).get("value", field.default)) + return "" diff --git a/src/free_claude_code/config/admin/validation.py b/src/free_claude_code/config/admin/validation.py new file mode 100644 index 0000000..c37c5d5 --- /dev/null +++ b/src/free_claude_code/config/admin/validation.py @@ -0,0 +1,46 @@ +"""Settings-backed Admin config validation.""" + +from collections.abc import Mapping +from typing import Any + +from pydantic import ValidationError + +from free_claude_code.config.settings import Settings + +from .manifest import FIELDS, field_input_key + + +def validate_values(values: Mapping[str, str]) -> tuple[bool, list[str]]: + """Validate proposed env values against the Settings model.""" + + settings, errors = settings_from_values(values) + return settings is not None, errors + + +def settings_from_values( + values: Mapping[str, str], +) -> tuple[Settings | None, list[str]]: + """Build the prospective Settings snapshot without reading dotenv files.""" + + kwargs: dict[str, Any] = {"_env_file": None} + for field in FIELDS: + input_key = field_input_key(field) + if input_key is None: + continue + kwargs[input_key] = values.get(field.key, "") + + try: + return Settings(**kwargs), [] + except ValidationError as exc: + return None, format_validation_errors(exc) + + +def format_validation_errors(exc: ValidationError) -> list[str]: + """Return user-readable validation errors from a Pydantic exception.""" + + errors: list[str] = [] + for error in exc.errors(): + loc = ".".join(str(part) for part in error.get("loc", ())) + message = str(error.get("msg", "Invalid value")) + errors.append(f"{loc}: {message}" if loc else message) + return errors diff --git a/src/free_claude_code/config/admin/values.py b/src/free_claude_code/config/admin/values.py new file mode 100644 index 0000000..2781894 --- /dev/null +++ b/src/free_claude_code/config/admin/values.py @@ -0,0 +1,113 @@ +"""Admin config value state and API response assembly.""" + +import os +from typing import Any + +from free_claude_code.config.paths import managed_env_path + +from .manifest import FIELD_BY_KEY, FIELDS, SECTIONS, ConfigFieldSpec +from .sources import ( + configured_env_files, + dotenv_values_from_file, + explicit_env_path, + is_locked_source, + repo_env_path, + template_values, +) +from .status import provider_config_status + +MASKED_SECRET = "********" +ValueState = dict[str, dict[str, Any]] + + +def normalize_for_env(value: Any) -> str: + """Normalize a submitted admin value for dotenv persistence.""" + + if value is None: + return "" + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + +def display_value(field: ConfigFieldSpec, value: str) -> str: + """Return the Admin UI display value for a raw config value.""" + + if field.secret and value: + return MASKED_SECRET + return value + + +def load_value_state() -> ValueState: + """Load effective admin field values and their sources.""" + + values = template_values() + sources = {key: "template" if key in values else "default" for key in FIELD_BY_KEY} + + for source, path in configured_env_files(): + file_values = dotenv_values_from_file(path) + for key, value in file_values.items(): + if key in FIELD_BY_KEY: + values[key] = value + sources[key] = source + + for key in FIELD_BY_KEY: + if key in os.environ: + values[key] = os.environ[key] + sources[key] = "process" + + return { + key: { + "value": values.get(key, ""), + "source": sources.get(key, "default"), + } + for key in FIELD_BY_KEY + } + + +def load_config_response() -> dict[str, Any]: + """Return manifest and current config values for the admin UI.""" + + state = load_value_state() + fields: list[dict[str, Any]] = [] + for field in FIELDS: + entry = state[field.key] + source = entry["source"] + raw_value = entry["value"] + fields.append( + { + "key": field.key, + "label": field.label, + "section": field.section_id, + "type": field.field_type, + "value": display_value(field, raw_value), + "configured": bool(str(raw_value).strip()), + "source": source, + "locked": is_locked_source(source), + "secret": field.secret, + "advanced": field.advanced, + "restart_required": field.restart_required, + "session_sensitive": field.session_sensitive, + "options": list(field.options), + "description": field.description, + } + ) + + return { + "sections": [ + { + "id": section.section_id, + "label": section.label, + "description": section.description, + "advanced": section.advanced, + } + for section in SECTIONS + ], + "fields": fields, + "paths": { + "managed": str(managed_env_path()), + "repo": str(repo_env_path()), + "explicit": str(explicit_env_path()) if explicit_env_path() else None, + }, + "provider_status": provider_config_status(state), + } diff --git a/src/free_claude_code/config/constants.py b/src/free_claude_code/config/constants.py new file mode 100644 index 0000000..c4ea854 --- /dev/null +++ b/src/free_claude_code/config/constants.py @@ -0,0 +1,7 @@ +"""Shared defaults used by config models and provider adapters.""" + +# HTTP client connect timeout (seconds). Keep aligned with README.md and .env.example. +HTTP_CONNECT_TIMEOUT_DEFAULT = 10.0 + +# Anthropic Messages API default when the client omits max_tokens. +ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS = 81920 diff --git a/src/free_claude_code/config/env_files.py b/src/free_claude_code/config/env_files.py new file mode 100644 index 0000000..c683021 --- /dev/null +++ b/src/free_claude_code/config/env_files.py @@ -0,0 +1,91 @@ +"""Dotenv file discovery and explicit dotenv override helpers.""" + +import os +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from dotenv import dotenv_values + +from .paths import managed_env_path + +ANTHROPIC_AUTH_TOKEN_ENV = "ANTHROPIC_AUTH_TOKEN" + + +def repo_env_path() -> Path: + """Return the repo-local env path.""" + + return Path(".env") + + +def explicit_env_path(env: Mapping[str, str] | None = None) -> Path | None: + """Return the explicit FCC_ENV_FILE path, when configured.""" + + source = env if env is not None else os.environ + if explicit := source.get("FCC_ENV_FILE"): + return Path(explicit) + return None + + +def settings_env_files(env: Mapping[str, str] | None = None) -> tuple[Path, ...]: + """Return Settings dotenv files in low-to-high precedence order.""" + + files: list[Path] = [ + repo_env_path(), + managed_env_path(), + ] + if explicit := explicit_env_path(env): + files.append(explicit) + return tuple(files) + + +def configured_env_files(model_config: Mapping[str, Any]) -> tuple[Path, ...]: + """Return the env files currently configured for a Settings model.""" + + configured = model_config.get("env_file") + if configured is None: + return () + if isinstance(configured, (str, Path)): + return (Path(configured),) + return tuple(Path(item) for item in configured) + + +def env_file_value(path: Path, key: str) -> str | None: + """Return a dotenv value when the file explicitly defines the key.""" + + if not path.is_file(): + return None + + try: + values = dotenv_values(path) + except OSError: + return None + + if key not in values: + return None + value = values[key] + return "" if value is None else value + + +def env_file_override(model_config: Mapping[str, Any], key: str) -> str | None: + """Return the last configured dotenv value that explicitly defines a key.""" + + configured_value: str | None = None + for env_file in configured_env_files(model_config): + value = env_file_value(env_file, key) + if value is not None: + configured_value = value + return configured_value + + +def process_env_key_is_effective( + model_config: Mapping[str, Any], + key: str, + env: Mapping[str, str] | None = None, +) -> bool: + """Return whether a key is coming from process env instead of configured dotenv.""" + + source = env if env is not None else os.environ + if env_file_override(model_config, key) is not None: + return False + return key in source diff --git a/src/free_claude_code/config/env_migrations.py b/src/free_claude_code/config/env_migrations.py new file mode 100644 index 0000000..91279b9 --- /dev/null +++ b/src/free_claude_code/config/env_migrations.py @@ -0,0 +1,129 @@ +"""One-time dotenv key migrations for FCC-owned config files.""" + +import re +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path + +from .env_files import explicit_env_path, repo_env_path +from .paths import managed_env_path + +LEGACY_HUGGINGFACE_TOKEN_ENV = "HF_TOKEN" +HUGGINGFACE_API_KEY_ENV = "HUGGINGFACE_API_KEY" + +_DOTENV_ASSIGNMENT_RE = re.compile( + r"^(?P\s*(?:export\s+)?)(?P[A-Za-z_][A-Za-z0-9_]*)(?P\s*(?:=|$))" +) + + +@dataclass(frozen=True, slots=True) +class EnvKeyMigration: + """A dotenv key rename migration.""" + + old_key: str + new_key: str + + +HUGGINGFACE_TOKEN_MIGRATION = EnvKeyMigration( + old_key=LEGACY_HUGGINGFACE_TOKEN_ENV, + new_key=HUGGINGFACE_API_KEY_ENV, +) + + +def migrate_owned_env_files() -> tuple[Path, ...]: + """Apply key migrations to repo and managed dotenv files.""" + + return tuple( + path.resolve() + for path in _unique_paths((repo_env_path(), managed_env_path())) + if migrate_env_key_in_file(path, HUGGINGFACE_TOKEN_MIGRATION) + ) + + +def explicit_env_file_huggingface_warning( + env: Mapping[str, str] | None = None, +) -> str | None: + """Return a warning when an explicit env file still uses ``HF_TOKEN``.""" + + path = explicit_env_path(env) + if path is None or not path.is_file(): + return None + text = path.read_text(encoding="utf-8") + if not env_text_needs_migration(text, HUGGINGFACE_TOKEN_MIGRATION): + return None + return ( + f"{LEGACY_HUGGINGFACE_TOKEN_ENV} is set in explicit FCC_ENV_FILE {path}. " + f"Rename it to {HUGGINGFACE_API_KEY_ENV}; explicit env files are not " + "rewritten automatically." + ) + + +def migrate_env_key_in_file(path: Path, migration: EnvKeyMigration) -> bool: + """Rename a dotenv key in ``path`` when the new key is absent.""" + + if not path.is_file(): + return False + original = path.read_text(encoding="utf-8") + migrated, changed = migrate_env_key_in_text(original, migration) + if not changed: + return False + path.write_text(migrated, encoding="utf-8") + return True + + +def migrate_env_key_in_text( + text: str, + migration: EnvKeyMigration, +) -> tuple[str, bool]: + """Return text with ``old_key`` renamed to ``new_key`` when safe.""" + + if _defines_key(text, migration.new_key): + return text, False + + lines = text.splitlines(keepends=True) + changed = False + for index, line in enumerate(lines): + match = _DOTENV_ASSIGNMENT_RE.match(line) + if match is None or match.group("key") != migration.old_key: + continue + lines[index] = ( + f"{match.group('prefix')}{migration.new_key}{match.group('suffix')}" + f"{line[match.end() :]}" + ) + changed = True + if not changed: + return text, False + return "".join(lines), True + + +def env_text_needs_migration(text: str, migration: EnvKeyMigration) -> bool: + """Return whether text defines old key without new key.""" + + return _defines_key(text, migration.old_key) and not _defines_key( + text, migration.new_key + ) + + +def _defines_key(text: str, key: str) -> bool: + for line in text.splitlines(): + if line.lstrip().startswith("#"): + continue + match = _DOTENV_ASSIGNMENT_RE.match(line) + if match is not None and match.group("key") == key: + return True + return False + + +def _unique_paths(paths: tuple[Path, ...]) -> tuple[Path, ...]: + seen: set[Path] = set() + unique: list[Path] = [] + for path in paths: + try: + resolved = path.resolve() + except OSError: + resolved = path + if resolved in seen: + continue + seen.add(resolved) + unique.append(path) + return tuple(unique) diff --git a/src/free_claude_code/config/env_template.py b/src/free_claude_code/config/env_template.py new file mode 100644 index 0000000..77845e9 --- /dev/null +++ b/src/free_claude_code/config/env_template.py @@ -0,0 +1,29 @@ +"""Canonical env template loading for init and Admin UI defaults.""" + +import importlib.resources +from pathlib import Path + + +def load_env_template() -> str: + """Load the root ``.env.example`` template from wheel resources or checkout.""" + + packaged = importlib.resources.files("free_claude_code.config").joinpath( + "env.example" + ) + if packaged.is_file(): + return packaged.read_text("utf-8") + + source_template = Path(__file__).resolve().parents[3] / ".env.example" + if source_template.is_file(): + return source_template.read_text(encoding="utf-8") + + raise FileNotFoundError("Could not find bundled or source .env.example template.") + + +def load_env_template_or_empty() -> str: + """Return the env template, or an empty template when unavailable.""" + + try: + return load_env_template() + except FileNotFoundError: + return "" diff --git a/src/free_claude_code/config/logging_config.py b/src/free_claude_code/config/logging_config.py new file mode 100644 index 0000000..dd100fa --- /dev/null +++ b/src/free_claude_code/config/logging_config.py @@ -0,0 +1,159 @@ +"""Loguru-based structured logging configuration. + +Structured logs are written as JSON lines to a configurable path (default +``logs/server.log``). 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 +import re +import threading +from pathlib import Path + +from loguru import logger + +_configured = False + +# Loguru ``logger.bind()`` key used by structured TRACE payloads; ``core/trace.py`` +# uses the identical string constant ``TRACE_PAYLOAD_BINDING``. +_TRACE_PAYLOAD_BINDING = "trace_payload" + +# Context keys we promote to top-level JSON for traceability / grep +_CONTEXT_KEYS = ( + "request_id", + "node_id", + "chat_id", + "claude_session_id", + "http_method", + "http_path", +) + +_TELEGRAM_BOT_RE = re.compile( + r"(https?://api\.telegram\.org/)bot([0-9]+:[A-Za-z0-9_-]+)(/?)", + re.IGNORECASE, +) +# Authorization: Bearer (HTTP client / proxy debug lines) +_AUTH_BEARER_RE = re.compile( + r"(\bAuthorization\s*:\s*Bearer\s+)([^\s'\"]+)", + re.IGNORECASE, +) + + +def _redact_sensitive_substrings(message: str) -> str: + """Remove obvious API tokens and secrets before JSON log line emission.""" + text = _TELEGRAM_BOT_RE.sub(r"\1bot\3", message) + return _AUTH_BEARER_RE.sub(r"\1", text) + + +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": _redact_sensitive_substrings(str(record["message"])), + "module": record["name"], + "function": record["function"], + "line": record["line"], + } + trace_payload = extra.get(_TRACE_PAYLOAD_BINDING) + for key in _CONTEXT_KEYS: + if key in extra and extra[key] is not None: + out[key] = extra[key] + if isinstance(trace_payload, dict): + for tk, tv in trace_payload.items(): + if tk in out: + continue + out[tk] = tv + out["trace"] = True + record["_json"] = json.dumps(out, default=str) + return "{_json}\n" + + +class InterceptHandler(logging.Handler): + """Redirect stdlib logging to loguru.""" + + def __init__(self) -> None: + super().__init__() + self._local = threading.local() + + def emit(self, record: logging.LogRecord) -> None: + if getattr(self._local, "active", False): + # Avoid deadlock when nested stdlib records fire during a loguru emit. + return + self._local.active = True + try: + 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() + ) + finally: + self._local.active = False + + +def configure_logging( + log_file: str | Path, *, force: bool = False, verbose_third_party: 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). + + When ``verbose_third_party`` is false, noisy HTTP and Telegram loggers are capped + at WARNING unless explicitly configured otherwise. + """ + global _configured + if _configured and not force: + return + _configured = True + + # Remove default loguru handler (writes to stderr) + logger.remove() + + log_path = Path(log_file) + log_path.parent.mkdir(parents=True, exist_ok=True) + + # Truncate log file on fresh start for clean debugging + log_path.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", + enqueue=True, + ) + + # Intercept stdlib logging: route all root logger output to loguru + intercept = InterceptHandler() + logging.root.handlers = [intercept] + logging.root.setLevel(logging.DEBUG) + + third_party = ( + "httpx", + "httpcore", + "httpcore.http11", + "httpcore.connection", + "telegram", + "telegram.ext", + ) + for name in third_party: + logging.getLogger(name).setLevel( + logging.WARNING if not verbose_third_party else logging.NOTSET + ) diff --git a/src/free_claude_code/config/model_refs.py b/src/free_claude_code/config/model_refs.py new file mode 100644 index 0000000..57dc269 --- /dev/null +++ b/src/free_claude_code/config/model_refs.py @@ -0,0 +1,61 @@ +"""Provider-prefixed model reference helpers.""" + +from dataclasses import dataclass +from typing import Protocol + + +@dataclass(frozen=True, slots=True) +class ConfiguredChatModelRef: + """A unique configured chat model reference and the env keys that set it.""" + + model_ref: str + provider_id: str + model_id: str + sources: tuple[str, ...] + + +class ChatModelConfig(Protocol): + model: str + model_opus: str | None + model_sonnet: str | None + model_haiku: str | None + + +def parse_provider_type(model_ref: str) -> str: + """Extract provider type from any 'provider/model' string.""" + + return model_ref.split("/", 1)[0] + + +def parse_model_name(model_ref: str) -> str: + """Extract model name from any 'provider/model' string.""" + + return model_ref.split("/", 1)[1] + + +def configured_chat_model_refs( + settings: ChatModelConfig, +) -> tuple[ConfiguredChatModelRef, ...]: + """Return unique configured chat provider/model refs with source env keys.""" + + candidates = ( + ("MODEL", settings.model), + ("MODEL_OPUS", settings.model_opus), + ("MODEL_SONNET", settings.model_sonnet), + ("MODEL_HAIKU", settings.model_haiku), + ) + sources_by_ref: dict[str, list[str]] = {} + for source, model_ref in candidates: + if model_ref is None: + continue + sources_by_ref.setdefault(model_ref, []).append(source) + + return tuple( + ConfiguredChatModelRef( + model_ref=model_ref, + provider_id=parse_provider_type(model_ref), + model_id=parse_model_name(model_ref), + sources=tuple(sources), + ) + for model_ref, sources in sources_by_ref.items() + ) diff --git a/src/free_claude_code/config/nim.py b/src/free_claude_code/config/nim.py new file mode 100644 index 0000000..fd2fdf3 --- /dev/null +++ b/src/free_claude_code/config/nim.py @@ -0,0 +1,118 @@ +"""NVIDIA NIM settings (fixed values, no env config).""" + +from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator + +from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS + + +class NimSettings(BaseModel): + """Fixed NVIDIA NIM settings (not configurable via env).""" + + temperature: float = Field( + 1.0, ge=0.0, le=2.0, description="Sampling temperature, must be >=0 and <=2." + ) + top_p: float = Field( + 1.0, ge=0.0, le=1.0, description="Nucleus sampling probability. [0,1]" + ) + top_k: int = -1 + max_tokens: int = Field( + ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + ge=1, + description="Maximum number of tokens in output.", + ) + 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, description="Minimum probability threshold [0,1]." + ) + repetition_penalty: float = Field( + 1.0, ge=0.0, description="Penalty for repeated tokens. Must be >=0." + ) + seed: int | None = None + stop: str | None = None + parallel_tool_calls: bool = True + ignore_eos: bool = False + min_tokens: int = Field(0, ge=0, description="Minimum tokens in the response.") + chat_template: str | None = None + request_id: str | None = None + + model_config = ConfigDict(extra="forbid") + + @field_validator("top_k", mode="before") + @classmethod + def validate_top_k(cls, v, info: ValidationInfo): + if v is None or v == "": + return -1 + int_v = int(v) + if int_v < -1: + raise ValueError(f"{info.field_name} must be -1 or >= 0") + return int_v + + @field_validator( + "temperature", + "top_p", + "min_p", + "presence_penalty", + "frequency_penalty", + "repetition_penalty", + mode="before", + ) + @classmethod + def validate_float_fields(cls, v, info: ValidationInfo): + field_defaults = { + "temperature": 1.0, + "top_p": 1.0, + "min_p": 0.0, + "presence_penalty": 0.0, + "frequency_penalty": 0.0, + "repetition_penalty": 1.0, + } + if v is None or v == "": + key = info.field_name or "temperature" + return field_defaults.get(key, 1.0) + try: + val = float(v) + except (TypeError, ValueError) as err: + raise ValueError( + f"{info.field_name} must be a float. Got {type(v).__name__}." + ) from err + return val + + @field_validator("max_tokens", "min_tokens", mode="before") + @classmethod + def validate_int_fields(cls, v, info: ValidationInfo): + field_defaults = { + "max_tokens": ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + "min_tokens": 0, + } + if v is None or v == "": + key = info.field_name or "max_tokens" + return field_defaults.get(key, ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS) + try: + val = int(v) + except (TypeError, ValueError) as err: + raise ValueError( + f"{info.field_name} must be an int. Got {type(v).__name__}." + ) from err + return val + + @field_validator("seed", mode="before") + @classmethod + def parse_optional_int(cls, v, info: ValidationInfo): + if v == "" or v is None: + return None + try: + return int(v) + except (TypeError, ValueError) as err: + raise ValueError( + f"{info.field_name} must be an int or empty/None." + ) from err + + @field_validator("stop", "chat_template", "request_id", mode="before") + @classmethod + def parse_optional_str(cls, v, info: ValidationInfo): + if v == "": + return None + if v is not None and not isinstance(v, str): + return str(v) + return v diff --git a/src/free_claude_code/config/paths.py b/src/free_claude_code/config/paths.py new file mode 100644 index 0000000..980a4c0 --- /dev/null +++ b/src/free_claude_code/config/paths.py @@ -0,0 +1,52 @@ +"""Shared filesystem paths for Free Claude Code configuration.""" + +from pathlib import Path + +FCC_CONFIG_DIRNAME = ".fcc" +FCC_ENV_FILENAME = ".env" +LEGACY_REPO_DIRNAME = "free-claude-code" +LEGACY_XDG_CONFIG_DIRNAME = ".config" +MESSAGING_STATE_DIRNAME = "agent_workspace" +FCC_LOGS_DIRNAME = "logs" +SERVER_LOG_FILENAME = "server.log" +CODEX_MODEL_CATALOG_FILENAME = "codex-model-catalog.json" + + +def config_dir_path() -> Path: + """Return the default user config directory.""" + + return Path.home() / FCC_CONFIG_DIRNAME + + +def managed_env_path() -> Path: + """Return the default user-managed env file path.""" + + return config_dir_path() / FCC_ENV_FILENAME + + +def legacy_env_paths() -> tuple[Path, ...]: + """Return legacy user env paths that can be migrated to ~/.fcc/.env.""" + + home = Path.home() + return ( + home / LEGACY_REPO_DIRNAME / FCC_ENV_FILENAME, + home / LEGACY_XDG_CONFIG_DIRNAME / LEGACY_REPO_DIRNAME / FCC_ENV_FILENAME, + ) + + +def messaging_state_dir_path() -> Path: + """Return the managed messaging state directory.""" + + return config_dir_path() / MESSAGING_STATE_DIRNAME + + +def server_log_path() -> Path: + """Return the canonical server log path.""" + + return config_dir_path() / FCC_LOGS_DIRNAME / SERVER_LOG_FILENAME + + +def codex_model_catalog_path() -> Path: + """Return the generated Codex model catalog path.""" + + return config_dir_path() / CODEX_MODEL_CATALOG_FILENAME diff --git a/src/free_claude_code/config/provider_catalog.py b/src/free_claude_code/config/provider_catalog.py new file mode 100644 index 0000000..bb4f927 --- /dev/null +++ b/src/free_claude_code/config/provider_catalog.py @@ -0,0 +1,284 @@ +"""Neutral provider catalog: IDs, credentials, defaults, proxy and capability metadata. + +Adapter factories live in :mod:`providers.runtime.factory`; this module stays free of +provider implementation imports (see contract tests). +""" + +from dataclasses import dataclass + +# Default upstream base URLs are owned here with the provider catalog. +NVIDIA_NIM_DEFAULT_BASE = "https://integrate.api.nvidia.com/v1" +# Moonshot Kimi OpenAI-compatible Chat Completions API. +KIMI_DEFAULT_BASE = "https://api.moonshot.ai/v1" +WAFER_DEFAULT_BASE = "https://pass.wafer.ai/v1" +MINIMAX_DEFAULT_BASE = "https://api.minimax.io/v1" +# DeepSeek Chat Completions API; cache usage is reported on this endpoint. +DEEPSEEK_DEFAULT_BASE = "https://api.deepseek.com" +FIREWORKS_DEFAULT_BASE = "https://api.fireworks.ai/inference/v1" +# Cloudflare account-scoped AI REST root; provider appends /accounts/{id}/ai/v1. +CLOUDFLARE_AI_REST_ROOT = "https://api.cloudflare.com/client/v4" +OPENROUTER_DEFAULT_BASE = "https://openrouter.ai/api/v1" +MISTRAL_DEFAULT_BASE = "https://api.mistral.ai/v1" +# Codestral IDE/personal endpoint (distinct from La Plateforme ``api.mistral.ai`` keys). +CODESTRAL_DEFAULT_BASE = "https://codestral.mistral.ai/v1" +LMSTUDIO_DEFAULT_BASE = "http://localhost:1234/v1" +LLAMACPP_DEFAULT_BASE = "http://localhost:8080/v1" +OLLAMA_DEFAULT_BASE = "http://localhost:11434" +OPENCODE_DEFAULT_BASE = "https://opencode.ai/zen/v1" +OPENCODE_GO_DEFAULT_BASE = "https://opencode.ai/zen/go/v1" +VERCEL_AI_GATEWAY_DEFAULT_BASE = "https://ai-gateway.vercel.sh/v1" +HUGGINGFACE_DEFAULT_BASE = "https://router.huggingface.co/v1" +COHERE_DEFAULT_BASE = "https://api.cohere.ai/compatibility/v1" +GITHUB_MODELS_DEFAULT_BASE = "https://models.github.ai/inference" +# Z.ai GLM Coding Plan OpenAI-compatible Chat Completions API. +ZAI_DEFAULT_BASE = "https://api.z.ai/api/coding/paas/v4" +# Google AI Studio Gemini API OpenAI-compat layer (not Vertex AI). +GEMINI_DEFAULT_BASE = "https://generativelanguage.googleapis.com/v1beta/openai/" +GROQ_DEFAULT_BASE = "https://api.groq.com/openai/v1" +CEREBRAS_DEFAULT_BASE = "https://api.cerebras.ai/v1" +SAMBANOVA_DEFAULT_BASE = "https://api.sambanova.ai/v1" + + +@dataclass(frozen=True, slots=True) +class ProviderDescriptor: + """Metadata for building :class:`~providers.base.ProviderConfig` and factory wiring.""" + + provider_id: str + display_name: str + local: bool = False + credential_env: str | None = None + credential_url: str | None = None + credential_attr: str | None = None + static_credential: str | None = None + default_base_url: str | None = None + base_url_attr: str | None = None + proxy_attr: str | None = None + + +PROVIDER_CATALOG: dict[str, ProviderDescriptor] = { + "nvidia_nim": ProviderDescriptor( + provider_id="nvidia_nim", + display_name="NVIDIA NIM", + credential_env="NVIDIA_NIM_API_KEY", + credential_url="https://build.nvidia.com/settings/api-keys", + credential_attr="nvidia_nim_api_key", + default_base_url=NVIDIA_NIM_DEFAULT_BASE, + proxy_attr="nvidia_nim_proxy", + ), + "open_router": ProviderDescriptor( + provider_id="open_router", + display_name="OpenRouter", + credential_env="OPENROUTER_API_KEY", + credential_url="https://openrouter.ai/keys", + credential_attr="open_router_api_key", + default_base_url=OPENROUTER_DEFAULT_BASE, + proxy_attr="open_router_proxy", + ), + "gemini": ProviderDescriptor( + provider_id="gemini", + display_name="Gemini", + credential_env="GEMINI_API_KEY", + credential_url="https://aistudio.google.com/apikey", + credential_attr="gemini_api_key", + default_base_url=GEMINI_DEFAULT_BASE, + proxy_attr="gemini_proxy", + ), + "deepseek": ProviderDescriptor( + provider_id="deepseek", + display_name="DeepSeek", + credential_env="DEEPSEEK_API_KEY", + credential_url="https://platform.deepseek.com/api_keys", + credential_attr="deepseek_api_key", + default_base_url=DEEPSEEK_DEFAULT_BASE, + ), + "mistral": ProviderDescriptor( + provider_id="mistral", + display_name="Mistral", + credential_env="MISTRAL_API_KEY", + credential_url="https://console.mistral.ai/", + credential_attr="mistral_api_key", + default_base_url=MISTRAL_DEFAULT_BASE, + proxy_attr="mistral_proxy", + ), + "mistral_codestral": ProviderDescriptor( + provider_id="mistral_codestral", + display_name="Mistral Codestral", + credential_env="CODESTRAL_API_KEY", + credential_url="https://console.mistral.ai/", + credential_attr="codestral_api_key", + default_base_url=CODESTRAL_DEFAULT_BASE, + proxy_attr="codestral_proxy", + ), + "opencode": ProviderDescriptor( + provider_id="opencode", + display_name="OpenCode Zen", + credential_env="OPENCODE_API_KEY", + credential_url="https://opencode.ai/auth", + credential_attr="opencode_api_key", + default_base_url=OPENCODE_DEFAULT_BASE, + proxy_attr="opencode_proxy", + ), + "opencode_go": ProviderDescriptor( + provider_id="opencode_go", + display_name="OpenCode Go", + credential_env="OPENCODE_API_KEY", + credential_url="https://opencode.ai/auth", + credential_attr="opencode_api_key", + default_base_url=OPENCODE_GO_DEFAULT_BASE, + proxy_attr="opencode_go_proxy", + ), + "vercel": ProviderDescriptor( + provider_id="vercel", + display_name="Vercel AI Gateway", + credential_env="AI_GATEWAY_API_KEY", + credential_url="https://vercel.com/docs/ai-gateway", + credential_attr="vercel_ai_gateway_api_key", + default_base_url=VERCEL_AI_GATEWAY_DEFAULT_BASE, + proxy_attr="vercel_ai_gateway_proxy", + ), + "huggingface": ProviderDescriptor( + provider_id="huggingface", + display_name="Hugging Face", + credential_env="HUGGINGFACE_API_KEY", + credential_url="https://huggingface.co/settings/tokens", + credential_attr="huggingface_api_key", + default_base_url=HUGGINGFACE_DEFAULT_BASE, + proxy_attr="huggingface_proxy", + ), + "cohere": ProviderDescriptor( + provider_id="cohere", + display_name="Cohere", + credential_env="COHERE_API_KEY", + credential_url="https://dashboard.cohere.com/api-keys", + credential_attr="cohere_api_key", + default_base_url=COHERE_DEFAULT_BASE, + proxy_attr="cohere_proxy", + ), + "github_models": ProviderDescriptor( + provider_id="github_models", + display_name="GitHub Models", + credential_env="GITHUB_MODELS_TOKEN", + credential_url="https://github.com/settings/tokens", + credential_attr="github_models_token", + default_base_url=GITHUB_MODELS_DEFAULT_BASE, + proxy_attr="github_models_proxy", + ), + "wafer": ProviderDescriptor( + provider_id="wafer", + display_name="Wafer", + credential_env="WAFER_API_KEY", + credential_url="https://www.wafer.ai/pass", + credential_attr="wafer_api_key", + default_base_url=WAFER_DEFAULT_BASE, + proxy_attr="wafer_proxy", + ), + "kimi": ProviderDescriptor( + provider_id="kimi", + display_name="Kimi", + credential_env="KIMI_API_KEY", + credential_url="https://platform.moonshot.cn/console/api-keys", + credential_attr="kimi_api_key", + default_base_url=KIMI_DEFAULT_BASE, + proxy_attr="kimi_proxy", + ), + "minimax": ProviderDescriptor( + provider_id="minimax", + display_name="MiniMax", + credential_env="MINIMAX_API_KEY", + credential_url="https://platform.minimax.io/user-center/basic-information/interface-key", + credential_attr="minimax_api_key", + default_base_url=MINIMAX_DEFAULT_BASE, + proxy_attr="minimax_proxy", + ), + "cerebras": ProviderDescriptor( + provider_id="cerebras", + display_name="Cerebras", + credential_env="CEREBRAS_API_KEY", + credential_url="https://cloud.cerebras.ai", + credential_attr="cerebras_api_key", + default_base_url=CEREBRAS_DEFAULT_BASE, + proxy_attr="cerebras_proxy", + ), + "groq": ProviderDescriptor( + provider_id="groq", + display_name="Groq", + credential_env="GROQ_API_KEY", + credential_url="https://console.groq.com/keys", + credential_attr="groq_api_key", + default_base_url=GROQ_DEFAULT_BASE, + proxy_attr="groq_proxy", + ), + "sambanova": ProviderDescriptor( + provider_id="sambanova", + display_name="SambaNova", + credential_env="SAMBANOVA_API_KEY", + credential_url="https://cloud.sambanova.ai/apis", + credential_attr="sambanova_api_key", + default_base_url=SAMBANOVA_DEFAULT_BASE, + proxy_attr="sambanova_proxy", + ), + "fireworks": ProviderDescriptor( + provider_id="fireworks", + display_name="Fireworks", + credential_env="FIREWORKS_API_KEY", + credential_url="https://fireworks.ai/account/api-keys", + credential_attr="fireworks_api_key", + default_base_url=FIREWORKS_DEFAULT_BASE, + proxy_attr="fireworks_proxy", + ), + "cloudflare": ProviderDescriptor( + provider_id="cloudflare", + display_name="Cloudflare", + credential_env="CLOUDFLARE_API_TOKEN", + credential_url="https://dash.cloudflare.com/profile/api-tokens", + credential_attr="cloudflare_api_token", + default_base_url=CLOUDFLARE_AI_REST_ROOT, + proxy_attr="cloudflare_proxy", + ), + "zai": ProviderDescriptor( + provider_id="zai", + display_name="Z.ai", + credential_env="ZAI_API_KEY", + credential_attr="zai_api_key", + default_base_url=ZAI_DEFAULT_BASE, + proxy_attr="zai_proxy", + ), + "lmstudio": ProviderDescriptor( + provider_id="lmstudio", + display_name="LM Studio", + static_credential="lm-studio", + default_base_url=LMSTUDIO_DEFAULT_BASE, + base_url_attr="lm_studio_base_url", + proxy_attr="lmstudio_proxy", + local=True, + ), + "llamacpp": ProviderDescriptor( + provider_id="llamacpp", + display_name="llama.cpp", + static_credential="llamacpp", + default_base_url=LLAMACPP_DEFAULT_BASE, + base_url_attr="llamacpp_base_url", + proxy_attr="llamacpp_proxy", + local=True, + ), + "ollama": ProviderDescriptor( + provider_id="ollama", + display_name="Ollama", + static_credential="ollama", + default_base_url=OLLAMA_DEFAULT_BASE, + base_url_attr="ollama_base_url", + local=True, + ), +} + +# Key order: +# NVIDIA NIM first (README default), DeepSeek fourth, OpenCode gateways adjacent, +# Vercel / Hugging Face / Cohere / GitHub Models follow gateway-style remotes, +# then cloud gateways and local providers per project plan +# (github.com/cheahjs/free-llm-api-resources Free Providers TOC as rough guide +# beyond fixed slots). +# ``SUPPORTED_PROVIDER_IDS`` inherits this insertion order for UI and error-message listing. +SUPPORTED_PROVIDER_IDS: tuple[str, ...] = tuple(PROVIDER_CATALOG.keys()) + +if len(set(SUPPORTED_PROVIDER_IDS)) != len(SUPPORTED_PROVIDER_IDS): + raise AssertionError("Duplicate provider ids in PROVIDER_CATALOG key order") diff --git a/src/free_claude_code/config/server_urls.py b/src/free_claude_code/config/server_urls.py new file mode 100644 index 0000000..c541438 --- /dev/null +++ b/src/free_claude_code/config/server_urls.py @@ -0,0 +1,26 @@ +"""Browser-friendly local server URLs shared by runtime and launchers.""" + +from free_claude_code.config.settings import Settings + + +def _browser_host_for_local_urls(settings: Settings) -> str: + """Host fragment for URLs shown to humans on the same machine as the server.""" + + host = settings.host.strip() if settings.host else "127.0.0.1" + if host in {"0.0.0.0", "::", "[::]"}: + host = "127.0.0.1" + if ":" in host and not host.startswith("["): + host = f"[{host}]" + return host + + +def local_proxy_root_url(settings: Settings) -> str: + """Return the proxy root URL (no path) for clients on the same machine.""" + + return f"http://{_browser_host_for_local_urls(settings)}:{settings.port}" + + +def local_admin_url(settings: Settings) -> str: + """Return a browser-friendly URL for the localhost-only admin UI.""" + + return f"{local_proxy_root_url(settings)}/admin" diff --git a/src/free_claude_code/config/settings.py b/src/free_claude_code/config/settings.py new file mode 100644 index 0000000..718349d --- /dev/null +++ b/src/free_claude_code/config/settings.py @@ -0,0 +1,403 @@ +"""Flat application settings schema loaded by Pydantic Settings.""" + +from functools import lru_cache +from typing import Any + +from pydantic import Field, field_validator, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +from .constants import HTTP_CONNECT_TIMEOUT_DEFAULT +from .env_files import ( + ANTHROPIC_AUTH_TOKEN_ENV, + env_file_override, + settings_env_files, +) +from .nim import NimSettings +from .provider_catalog import SUPPORTED_PROVIDER_IDS + + +class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + + # ==================== OpenRouter Config ==================== + open_router_api_key: str = Field(default="", validation_alias="OPENROUTER_API_KEY") + + # ==================== Mistral La Plateforme ==================== + mistral_api_key: str = Field(default="", validation_alias="MISTRAL_API_KEY") + + # ==================== Mistral Codestral (codestral.mistral.ai) ==================== + codestral_api_key: str = Field(default="", validation_alias="CODESTRAL_API_KEY") + + # ==================== DeepSeek Config ==================== + deepseek_api_key: str = Field(default="", validation_alias="DEEPSEEK_API_KEY") + + # ==================== Kimi Config ==================== + kimi_api_key: str = Field(default="", validation_alias="KIMI_API_KEY") + + # ==================== Wafer Config ==================== + wafer_api_key: str = Field(default="", validation_alias="WAFER_API_KEY") + + # ==================== MiniMax Config ==================== + minimax_api_key: str = Field(default="", validation_alias="MINIMAX_API_KEY") + + # ==================== OpenCode Zen / OpenCode Go ==================== + # Same key from opencode.ai/auth; zen uses prefix ``opencode/``, Go uses ``opencode_go/``. + opencode_api_key: str = Field(default="", validation_alias="OPENCODE_API_KEY") + + # ==================== Vercel AI Gateway ==================== + vercel_ai_gateway_api_key: str = Field( + default="", validation_alias="AI_GATEWAY_API_KEY" + ) + + # ==================== Hugging Face Inference Providers ==================== + huggingface_api_key: str = Field(default="", validation_alias="HUGGINGFACE_API_KEY") + + # ==================== Cohere Compatibility API ==================== + cohere_api_key: str = Field(default="", validation_alias="COHERE_API_KEY") + + # ==================== GitHub Models ==================== + github_models_token: str = Field(default="", validation_alias="GITHUB_MODELS_TOKEN") + + # ==================== SambaNova Cloud ==================== + sambanova_api_key: str = Field(default="", validation_alias="SAMBANOVA_API_KEY") + + # ==================== Z.ai Config ==================== + zai_api_key: str = Field(default="", validation_alias="ZAI_API_KEY") + + # ==================== Fireworks AI Config ==================== + fireworks_api_key: str = Field(default="", validation_alias="FIREWORKS_API_KEY") + + # ==================== Cloudflare Workers AI Config ==================== + cloudflare_api_token: str = Field( + default="", validation_alias="CLOUDFLARE_API_TOKEN" + ) + cloudflare_account_id: str = Field( + default="", validation_alias="CLOUDFLARE_ACCOUNT_ID" + ) + + # ==================== Google Gemini (Google AI Studio) ==================== + gemini_api_key: str = Field(default="", validation_alias="GEMINI_API_KEY") + + # ==================== Groq (OpenAI-compatible) ==================== + groq_api_key: str = Field(default="", validation_alias="GROQ_API_KEY") + + # ==================== Cerebras Inference (OpenAI-compatible) ==================== + cerebras_api_key: str = Field(default="", validation_alias="CEREBRAS_API_KEY") + + # ==================== Messaging Platform Selection ==================== + # Valid: "telegram" | "discord" | "none" + messaging_platform: str = Field( + default="discord", validation_alias="MESSAGING_PLATFORM" + ) + messaging_rate_limit: int = Field( + default=1, validation_alias="MESSAGING_RATE_LIMIT" + ) + messaging_rate_window: float = Field( + default=1.0, validation_alias="MESSAGING_RATE_WINDOW" + ) + + # ==================== 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", + ) + + # ==================== Ollama Config ==================== + ollama_base_url: str = Field( + default="http://localhost:11434", + validation_alias="OLLAMA_BASE_URL", + ) + + # ==================== Model ==================== + # All Claude model requests are mapped to this single model (fallback) + # Format: provider_type/model/name + model: str = "nvidia_nim/nvidia/nemotron-3-super-120b-a12b" + + # 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") + + # ==================== Per-Provider Proxy ==================== + nvidia_nim_proxy: str = Field(default="", validation_alias="NVIDIA_NIM_PROXY") + open_router_proxy: str = Field(default="", validation_alias="OPENROUTER_PROXY") + mistral_proxy: str = Field(default="", validation_alias="MISTRAL_PROXY") + codestral_proxy: str = Field(default="", validation_alias="CODESTRAL_PROXY") + lmstudio_proxy: str = Field(default="", validation_alias="LMSTUDIO_PROXY") + llamacpp_proxy: str = Field(default="", validation_alias="LLAMACPP_PROXY") + kimi_proxy: str = Field(default="", validation_alias="KIMI_PROXY") + wafer_proxy: str = Field(default="", validation_alias="WAFER_PROXY") + minimax_proxy: str = Field(default="", validation_alias="MINIMAX_PROXY") + opencode_proxy: str = Field(default="", validation_alias="OPENCODE_PROXY") + opencode_go_proxy: str = Field(default="", validation_alias="OPENCODE_GO_PROXY") + vercel_ai_gateway_proxy: str = Field( + default="", validation_alias="VERCEL_AI_GATEWAY_PROXY" + ) + huggingface_proxy: str = Field(default="", validation_alias="HUGGINGFACE_PROXY") + cohere_proxy: str = Field(default="", validation_alias="COHERE_PROXY") + github_models_proxy: str = Field(default="", validation_alias="GITHUB_MODELS_PROXY") + sambanova_proxy: str = Field(default="", validation_alias="SAMBANOVA_PROXY") + zai_proxy: str = Field(default="", validation_alias="ZAI_PROXY") + fireworks_proxy: str = Field(default="", validation_alias="FIREWORKS_PROXY") + cloudflare_proxy: str = Field(default="", validation_alias="CLOUDFLARE_PROXY") + gemini_proxy: str = Field(default="", validation_alias="GEMINI_PROXY") + groq_proxy: str = Field(default="", validation_alias="GROQ_PROXY") + cerebras_proxy: str = Field(default="", validation_alias="CEREBRAS_PROXY") + + # ==================== 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" + ) + enable_model_thinking: bool = Field( + default=True, validation_alias="ENABLE_MODEL_THINKING" + ) + enable_opus_thinking: bool | None = Field( + default=None, validation_alias="ENABLE_OPUS_THINKING" + ) + enable_sonnet_thinking: bool | None = Field( + default=None, validation_alias="ENABLE_SONNET_THINKING" + ) + enable_haiku_thinking: bool | None = Field( + default=None, validation_alias="ENABLE_HAIKU_THINKING" + ) + + # ==================== HTTP Client Timeouts ==================== + http_read_timeout: float = Field( + default=120.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=HTTP_CONNECT_TIMEOUT_DEFAULT, + 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 + + # ==================== Local web server tools (web_search / web_fetch) ==================== + # Off by default: these tools perform outbound HTTP from the proxy (SSRF risk). + enable_web_server_tools: bool = Field( + default=False, validation_alias="ENABLE_WEB_SERVER_TOOLS" + ) + # Comma-separated URL schemes allowed for web_fetch (default: http,https). + web_fetch_allowed_schemes: str = Field( + default="http,https", validation_alias="WEB_FETCH_ALLOWED_SCHEMES" + ) + # When true, skip private/loopback/link-local IP blocking for web_fetch (lab only). + web_fetch_allow_private_networks: bool = Field( + default=False, validation_alias="WEB_FETCH_ALLOW_PRIVATE_NETWORKS" + ) + + # ==================== Debug / diagnostic logging (avoid sensitive content) ==================== + # When false (default), API and SSE helpers log only metadata (counts, lengths, ids). + log_raw_api_payloads: bool = Field( + default=False, validation_alias="LOG_RAW_API_PAYLOADS" + ) + log_raw_sse_events: bool = Field( + default=False, validation_alias="LOG_RAW_SSE_EVENTS" + ) + # When false (default), unhandled exceptions log only type + route metadata (no message/traceback). + log_api_error_tracebacks: bool = Field( + default=False, validation_alias="LOG_API_ERROR_TRACEBACKS" + ) + # When false (default), messaging logs omit text/transcription previews (metadata only). + log_raw_messaging_content: bool = Field( + default=False, validation_alias="LOG_RAW_MESSAGING_CONTENT" + ) + # When true, log full Claude CLI stderr, non-JSON lines, and parser error text. + log_raw_cli_diagnostics: bool = Field( + default=False, validation_alias="LOG_RAW_CLI_DIAGNOSTICS" + ) + # When true, log exception text / CLI error strings in messaging (may leak user content). + log_messaging_error_details: bool = Field( + default=False, validation_alias="LOG_MESSAGING_ERROR_DETAILS" + ) + debug_platform_edits: bool = Field( + default=False, validation_alias="DEBUG_PLATFORM_EDITS" + ) + debug_subagent_stack: bool = Field( + default=False, validation_alias="DEBUG_SUBAGENT_STACK" + ) + + # ==================== NIM Settings ==================== + nim: NimSettings = Field(default_factory=NimSettings) + + # ==================== 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") + # ==================== Bot Wrapper Config ==================== + telegram_bot_token: str | None = None + allowed_telegram_user_id: str | None = None + telegram_proxy_url: str = Field(default="", validation_alias="TELEGRAM_PROXY_URL") + 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" + ) + allowed_dir: str = "" + max_message_log_entries_per_chat: int | None = Field( + default=None, validation_alias="MAX_MESSAGE_LOG_ENTRIES_PER_CHAT" + ) + + # ==================== Server ==================== + host: str = "0.0.0.0" + port: int = 8082 + # 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", + "model_opus", + "model_sonnet", + "model_haiku", + "enable_opus_thinking", + "enable_sonnet_thinking", + "enable_haiku_thinking", + mode="before", + ) + @classmethod + def parse_optional_str(cls, v: Any) -> Any: + if v == "": + return None + return v + + @field_validator("max_message_log_entries_per_chat", mode="before") + @classmethod + def parse_optional_log_cap(cls, v: Any) -> Any: + if v == "" or v is None: + 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("messaging_platform") + @classmethod + def validate_messaging_platform(cls, v: str) -> str: + if v not in ("telegram", "discord", "none"): + raise ValueError( + f"messaging_platform must be 'telegram', 'discord', or 'none', got {v!r}" + ) + return v + + @field_validator("messaging_rate_limit") + @classmethod + def validate_messaging_rate_limit(cls, v: int) -> int: + if v <= 0: + raise ValueError("messaging_rate_limit must be > 0") + return v + + @field_validator("messaging_rate_window") + @classmethod + def validate_messaging_rate_window(cls, v: float) -> float: + if v <= 0: + raise ValueError("messaging_rate_window must be > 0") + return float(v) + + @field_validator("web_fetch_allowed_schemes") + @classmethod + def validate_web_fetch_allowed_schemes(cls, v: str) -> str: + schemes = [part.strip().lower() for part in v.split(",") if part.strip()] + if not schemes: + raise ValueError("web_fetch_allowed_schemes must list at least one scheme") + for scheme in schemes: + if not scheme.isascii() or not scheme.isalpha(): + raise ValueError( + f"Invalid URL scheme in web_fetch_allowed_schemes: {scheme!r}" + ) + return ",".join(schemes) + + @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 + if "/" not in v: + raise ValueError( + f"Model must be prefixed with provider type. " + f"Valid providers: {', '.join(SUPPORTED_PROVIDER_IDS)}. " + f"Format: provider_type/model/name" + ) + provider = v.split("/", 1)[0] + if provider not in SUPPORTED_PROVIDER_IDS: + supported = ", ".join(f"'{p}'" for p in SUPPORTED_PROVIDER_IDS) + raise ValueError(f"Invalid provider: '{provider}'. Supported: {supported}") + return v + + @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 + + @model_validator(mode="after") + def prefer_dotenv_anthropic_auth_token(self) -> Settings: + """Let explicit .env auth config override stale shell/client tokens.""" + dotenv_value = env_file_override(self.model_config, ANTHROPIC_AUTH_TOKEN_ENV) + if dotenv_value is not None: + self.anthropic_auth_token = dotenv_value + return self + + model_config = SettingsConfigDict( + env_file=settings_env_files(), + env_file_encoding="utf-8", + extra="ignore", + ) + + +@lru_cache +def get_settings() -> Settings: + """Get cached settings instance.""" + return Settings() diff --git a/src/free_claude_code/core/__init__.py b/src/free_claude_code/core/__init__.py new file mode 100644 index 0000000..716557d --- /dev/null +++ b/src/free_claude_code/core/__init__.py @@ -0,0 +1 @@ +"""Neutral shared application core.""" diff --git a/src/free_claude_code/core/anthropic/__init__.py b/src/free_claude_code/core/anthropic/__init__.py new file mode 100644 index 0000000..699254a --- /dev/null +++ b/src/free_claude_code/core/anthropic/__init__.py @@ -0,0 +1,100 @@ +"""Anthropic protocol helpers shared across API, providers, and integrations.""" + +from .content import extract_text_from_content, get_block_attr, get_block_type +from .conversion import ( + AnthropicToOpenAIConverter, + OpenAIConversionError, + ReasoningReplayMode, + build_base_request_body, +) +from .errors import ( + anthropic_error_payload, + anthropic_error_type_for_failure, + anthropic_failure_payload, + anthropic_status_for_error_type, +) +from .models import ( + ContentBlockDocument, + ContentBlockImage, + ContentBlockRedactedThinking, + ContentBlockServerToolUse, + ContentBlockText, + ContentBlockThinking, + ContentBlockToolResult, + ContentBlockToolUse, + ContentBlockWebFetchToolResult, + ContentBlockWebSearchToolResult, + Message, + MessagesRequest, + MessagesResponse, + Role, + SystemContent, + ThinkingConfig, + TokenCountRequest, + TokenCountResponse, + Tool, + Usage, +) +from .request_serialization import dump_messages_request, serialize_tool_result_content +from .request_snapshot import anthropic_request_snapshot +from .sse_aggregation import aggregate_anthropic_sse_to_message +from .streaming import ( + AnthropicStreamLedger, + StreamBlockLedger, + ToolBlockState, + format_sse_event, + map_stop_reason, +) +from .thinking import ContentChunk, ContentType, ThinkTagParser +from .tokens import get_token_count +from .tools import HeuristicToolParser +from .utils import set_if_not_none + +__all__ = [ + "AnthropicStreamLedger", + "AnthropicToOpenAIConverter", + "ContentBlockDocument", + "ContentBlockImage", + "ContentBlockRedactedThinking", + "ContentBlockServerToolUse", + "ContentBlockText", + "ContentBlockThinking", + "ContentBlockToolResult", + "ContentBlockToolUse", + "ContentBlockWebFetchToolResult", + "ContentBlockWebSearchToolResult", + "ContentChunk", + "ContentType", + "HeuristicToolParser", + "Message", + "MessagesRequest", + "MessagesResponse", + "OpenAIConversionError", + "ReasoningReplayMode", + "Role", + "StreamBlockLedger", + "SystemContent", + "ThinkTagParser", + "ThinkingConfig", + "TokenCountRequest", + "TokenCountResponse", + "Tool", + "ToolBlockState", + "Usage", + "aggregate_anthropic_sse_to_message", + "anthropic_error_payload", + "anthropic_error_type_for_failure", + "anthropic_failure_payload", + "anthropic_request_snapshot", + "anthropic_status_for_error_type", + "build_base_request_body", + "dump_messages_request", + "extract_text_from_content", + "format_sse_event", + "get_block_attr", + "get_block_type", + "get_token_count", + "map_stop_reason", + "serialize_tool_result_content", + "set_if_not_none", +] diff --git a/src/free_claude_code/core/anthropic/content.py b/src/free_claude_code/core/anthropic/content.py new file mode 100644 index 0000000..e16aaaf --- /dev/null +++ b/src/free_claude_code/core/anthropic/content.py @@ -0,0 +1,31 @@ +"""Content block helpers for Anthropic-compatible payloads.""" + +from typing import Any + + +def get_block_attr(block: Any, attr: str, default: Any = None) -> Any: + """Get an attribute from a Pydantic model, lightweight 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: + """Return a content block type when present.""" + return get_block_attr(block, "type") + + +def extract_text_from_content(content: Any) -> str: + """Extract concatenated text from message content.""" + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for block in content: + text = get_block_attr(block, "text", "") + if isinstance(text, str) and text: + parts.append(text) + return "".join(parts) + return "" diff --git a/src/free_claude_code/core/anthropic/conversion.py b/src/free_claude_code/core/anthropic/conversion.py new file mode 100644 index 0000000..53ad699 --- /dev/null +++ b/src/free_claude_code/core/anthropic/conversion.py @@ -0,0 +1,604 @@ +"""Message and tool format converters.""" + +import json +from copy import deepcopy +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any + +from .content import get_block_attr, get_block_type +from .models import MessagesRequest +from .request_serialization import serialize_tool_result_content +from .utils import set_if_not_none + + +class OpenAIConversionError(Exception): + """Raised when Anthropic content cannot be converted to OpenAI chat without data loss.""" + + +class ReasoningReplayMode(StrEnum): + """How assistant reasoning history is replayed to OpenAI-compatible providers.""" + + DISABLED = "disabled" + THINK_TAGS = "think_tags" + REASONING_CONTENT = "reasoning_content" + + +def _openai_reject_native_only_top_level_fields( + request_data: MessagesRequest, +) -> None: + """OpenAI chat providers may only convert known top-level request fields. + + First-class model fields (e.g. ``context_management``) are not forwarded to + the OpenAI API but are allowed so clients do not hit spurious 400s. + Unknown extra keys (``__pydantic_extra__``) are still rejected. + """ + extra = request_data.model_extra + if not extra: + return + raise OpenAIConversionError( + "OpenAI chat conversion does not support these top-level request fields: " + f"{sorted(str(k) for k in extra)}. Remove the unsupported fields." + ) + + +def _tool_input_schema(tool: Any) -> dict[str, Any]: + schema = getattr(tool, "input_schema", None) + if isinstance(schema, dict): + return schema + return {"type": "object", "properties": {}} + + +def _clean_reasoning_content(value: Any) -> str | None: + if not isinstance(value, str): + return None + return value + + +def _think_tag_content(reasoning: str) -> str: + return f"\n{reasoning}\n" + + +def _tool_call_from_tool_use(block: Any) -> dict[str, Any]: + tool_input = get_block_attr(block, "input", {}) + tool_call: dict[str, Any] = { + "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), + }, + } + extra_content = get_block_attr(block, "extra_content", None) + if isinstance(extra_content, dict) and extra_content: + tool_call["extra_content"] = deepcopy(extra_content) + return tool_call + + +@dataclass +class _PlainSegment: + messages: list[dict[str, Any]] + + +@dataclass +class _ToolTurnSegment: + assistant_message: dict[str, Any] + required_tool_ids: list[str] + deferred_blocks: list[Any] = field(default_factory=list) + top_level_reasoning: str | None = None + reasoning_replay: ReasoningReplayMode = ReasoningReplayMode.THINK_TAGS + assistant_emitted: bool = False + + +_TranscriptSegment = _PlainSegment | _ToolTurnSegment + + +def _tool_call_ids(tool_calls: list[dict[str, Any]]) -> list[str]: + ids: list[str] = [] + for tool_call in tool_calls: + tool_id = tool_call.get("id") + if tool_id is not None and str(tool_id).strip() != "": + ids.append(str(tool_id)) + return ids + + +def _index_first_tool_use(blocks: list[Any]) -> int | None: + for i, block in enumerate(blocks): + if get_block_type(block) == "tool_use": + return i + return None + + +def _iter_tool_uses_in_order(blocks: list[Any]) -> list[dict[str, Any]]: + return [ + _tool_call_from_tool_use(block) + for block in blocks + if get_block_type(block) == "tool_use" + ] + + +def _deferred_post_tool_blocks( + content: list[Any], *, first_tool_index: int +) -> list[Any]: + return [ + b + for i, b in enumerate(content) + if i > first_tool_index and get_block_type(b) != "tool_use" + ] + + +def _assert_no_forbidden_assistant_block(block: Any) -> None: + block_type = get_block_type(block) + if block_type == "image": + raise OpenAIConversionError( + "Assistant image blocks are not supported for OpenAI chat conversion." + ) + if block_type in ( + "server_tool_use", + "web_search_tool_result", + "web_fetch_tool_result", + ): + raise OpenAIConversionError( + "OpenAI chat conversion does not support Anthropic server tool blocks " + f"({block_type!r} in an assistant message). Remove the unsupported block." + ) + + +class _OpenAIChatHistoryLedger: + """Assemble OpenAI chat history while respecting tool-result dependencies.""" + + def __init__(self) -> None: + self._output: list[dict[str, Any]] = [] + self._segments: list[_TranscriptSegment] = [] + self._tool_results: dict[str, dict[str, Any]] = {} + + def add_plain(self, messages: list[dict[str, Any]]) -> None: + if messages: + self._segments.append(_PlainSegment(messages)) + self._drain_ready_segments() + + def add_tool_turn(self, segment: _ToolTurnSegment) -> None: + self._segments.append(segment) + self._drain_ready_segments() + + def add_user_blocks(self, blocks: list[Any]) -> None: + text_blocks: list[Any] = [] + for block in blocks: + block_type = get_block_type(block) + if block_type == "tool_result": + self._add_text_blocks(text_blocks) + self._record_tool_result(block) + else: + text_blocks.append(block) + self._add_text_blocks(text_blocks) + self._drain_ready_segments() + + def finish(self) -> list[dict[str, Any]]: + self._drain_ready_segments() + missing = self._missing_required_tool_ids() + if missing: + raise OpenAIConversionError( + "OpenAI chat conversion cannot replay incomplete tool history; " + f"missing tool_result blocks for tool_use ids: {missing}" + ) + while self._segments: + segment = self._segments.pop(0) + if isinstance(segment, _PlainSegment): + self._output.extend(segment.messages) + continue + self._emit_tool_turn(segment) + return self._output + + def _add_text_blocks(self, blocks: list[Any]) -> None: + if not blocks: + return + self.add_plain(AnthropicToOpenAIConverter._convert_user_message(blocks)) + blocks.clear() + + def _record_tool_result(self, block: Any) -> None: + tuid = get_block_attr(block, "tool_use_id") + tuid_s = str(tuid) if tuid is not None else "" + if not tuid_s: + self.add_plain(AnthropicToOpenAIConverter._convert_user_message([block])) + return + tool_content = get_block_attr(block, "content", "") + serialized = serialize_tool_result_content(tool_content) + tool_message = { + "role": "tool", + "tool_call_id": tuid, + "content": serialized if serialized else "", + } + if self._has_pending_tool_id(tuid_s): + self._tool_results[tuid_s] = tool_message + else: + self.add_plain([tool_message]) + + def _drain_ready_segments(self) -> None: + while self._segments: + segment = self._segments[0] + if isinstance(segment, _PlainSegment): + self._output.extend(segment.messages) + self._segments.pop(0) + continue + + if not segment.assistant_emitted: + self._output.append(segment.assistant_message) + segment.assistant_emitted = True + + missing = [ + tool_id + for tool_id in segment.required_tool_ids + if tool_id not in self._tool_results + ] + if missing: + break + + self._segments.pop(0) + for tool_id in segment.required_tool_ids: + self._output.append(self._tool_results.pop(tool_id)) + deferred_messages = ( + AnthropicToOpenAIConverter._deferred_post_tool_to_messages(segment) + ) + self._output.extend(deferred_messages) + + def _emit_tool_turn(self, segment: _ToolTurnSegment) -> None: + if not segment.assistant_emitted: + self._output.append(segment.assistant_message) + segment.assistant_emitted = True + for tool_id in segment.required_tool_ids: + tool_result = self._tool_results.pop(tool_id, None) + if tool_result is not None: + self._output.append(tool_result) + self._output.extend( + AnthropicToOpenAIConverter._deferred_post_tool_to_messages(segment) + ) + + def _missing_required_tool_ids(self) -> list[str]: + missing: list[str] = [] + for segment in self._segments: + if not isinstance(segment, _ToolTurnSegment): + continue + missing.extend( + tool_id + for tool_id in segment.required_tool_ids + if tool_id not in self._tool_results + ) + return missing + + def _has_pending_tool_id(self, tool_id: str) -> bool: + return any( + isinstance(segment, _ToolTurnSegment) + and tool_id in segment.required_tool_ids + for segment in self._segments + ) + + +class AnthropicToOpenAIConverter: + """Convert Anthropic message format to OpenAI-compatible format.""" + + @staticmethod + def convert_messages( + messages: list[Any], + *, + reasoning_replay: ReasoningReplayMode = ReasoningReplayMode.THINK_TAGS, + ) -> list[dict[str, Any]]: + ledger = _OpenAIChatHistoryLedger() + + for msg in messages: + role = msg.role + content = msg.content + reasoning_content = _clean_reasoning_content( + getattr(msg, "reasoning_content", None) + ) + + if role == "user" and isinstance(content, list): + ledger.add_user_blocks(content) + continue + + segments = AnthropicToOpenAIConverter._convert_message_to_segments( + role, + content, + reasoning_content=reasoning_content, + reasoning_replay=reasoning_replay, + ) + for segment in segments: + if isinstance(segment, _PlainSegment): + ledger.add_plain(segment.messages) + else: + ledger.add_tool_turn(segment) + + return ledger.finish() + + @staticmethod + def _convert_message_to_segments( + role: str, + content: Any, + *, + reasoning_content: str | None, + reasoning_replay: ReasoningReplayMode, + ) -> list[_TranscriptSegment]: + if role == "assistant" and isinstance(content, list): + if (first_i := _index_first_tool_use(content)) is not None: + for block in content: + if get_block_type(block) == "tool_use": + continue + _assert_no_forbidden_assistant_block(block) + return [ + AnthropicToOpenAIConverter._convert_assistant_message_with_split( + content, + first_tool_index=first_i, + reasoning_content=reasoning_content, + reasoning_replay=reasoning_replay, + ) + ] + for block in content: + _assert_no_forbidden_assistant_block(block) + return [ + _PlainSegment( + AnthropicToOpenAIConverter._convert_assistant_message( + content, + reasoning_content=reasoning_content, + reasoning_replay=reasoning_replay, + ) + ) + ] + if role == "user" and isinstance(content, list): + return [ + _PlainSegment(AnthropicToOpenAIConverter._convert_user_message(content)) + ] + if isinstance(content, str): + converted = {"role": role, "content": content} + if role == "assistant" and reasoning_content is not None: + if reasoning_replay == ReasoningReplayMode.REASONING_CONTENT: + converted["reasoning_content"] = reasoning_content + elif ( + reasoning_replay == ReasoningReplayMode.THINK_TAGS + and reasoning_content + ): + content_parts = [_think_tag_content(reasoning_content)] + if content: + content_parts.append(content) + converted["content"] = "\n\n".join(content_parts) + return [_PlainSegment([converted])] + if isinstance(content, list): + return [] + return [_PlainSegment([{"role": role, "content": str(content)}])] + + @staticmethod + def _convert_assistant_message_with_split( + content: list[Any], + *, + first_tool_index: int, + reasoning_content: str | None, + reasoning_replay: ReasoningReplayMode, + ) -> _ToolTurnSegment: + pre = content[:first_tool_index] + tool_calls = _iter_tool_uses_in_order(content) + if not tool_calls: + return _ToolTurnSegment( + assistant_message=AnthropicToOpenAIConverter._convert_assistant_message( + content, + reasoning_content=reasoning_content, + reasoning_replay=reasoning_replay, + )[0], + required_tool_ids=[], + ) + deferred_blocks = _deferred_post_tool_blocks( + content, first_tool_index=first_tool_index + ) + + pre_msg: dict[str, Any] + if not pre: + pre_msg = { + "role": "assistant", + "content": "", + } + if reasoning_replay == ReasoningReplayMode.REASONING_CONTENT: + replay = reasoning_content + if replay is not None: + pre_msg["reasoning_content"] = replay + else: + pre_msg = AnthropicToOpenAIConverter._convert_assistant_message( + pre, + reasoning_content=reasoning_content, + reasoning_replay=reasoning_replay, + )[0] + pre_msg["tool_calls"] = tool_calls + if tool_calls and pre_msg.get("content") == " ": + pre_msg["content"] = "" + return _ToolTurnSegment( + assistant_message=pre_msg, + required_tool_ids=_tool_call_ids(tool_calls), + deferred_blocks=deferred_blocks, + top_level_reasoning=reasoning_content, + reasoning_replay=reasoning_replay, + ) + + @staticmethod + def _convert_assistant_message( + content: list[Any], + *, + reasoning_content: str | None = None, + reasoning_replay: ReasoningReplayMode = ReasoningReplayMode.THINK_TAGS, + ) -> list[dict[str, Any]]: + content_parts: list[str] = [] + thinking_parts: list[str] = [] + thinking_seen = False + 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": + if reasoning_replay == ReasoningReplayMode.DISABLED: + continue + thinking = get_block_attr(block, "thinking", "") + if reasoning_replay == ReasoningReplayMode.THINK_TAGS: + content_parts.append(_think_tag_content(thinking)) + elif reasoning_content is None: + thinking_seen = True + thinking_parts.append(thinking) + elif block_type == "redacted_thinking": + # Opaque provider continuation data; do not materialize as model-visible text + # or reasoning_content for OpenAI chat upstreams. + continue + elif block_type == "tool_use": + tool_calls.append(_tool_call_from_tool_use(block)) + else: + _assert_no_forbidden_assistant_block(block) + + content_str = "\n\n".join(content_parts) + 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 reasoning_replay == ReasoningReplayMode.REASONING_CONTENT: + if reasoning_content is not None: + msg["reasoning_content"] = reasoning_content + elif thinking_seen: + msg["reasoning_content"] = "\n".join(thinking_parts) + + return [msg] + + @staticmethod + def _deferred_post_tool_to_messages( + pending: _ToolTurnSegment, + ) -> list[dict[str, Any]]: + if not pending.deferred_blocks: + return [] + return AnthropicToOpenAIConverter._convert_assistant_message( + pending.deferred_blocks, + reasoning_content=pending.top_level_reasoning, + reasoning_replay=pending.reasoning_replay, + ) + + @staticmethod + def _convert_user_message(content: list[Any]) -> list[dict[str, Any]]: + 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 == "image": + raise OpenAIConversionError( + "User message image blocks are not supported for OpenAI chat " + "conversion; remove the image blocks or extend the converter." + ) + elif block_type == "tool_result": + flush_text() + tool_content = get_block_attr(block, "content", "") + serialized = serialize_tool_result_content(tool_content) + result.append( + { + "role": "tool", + "tool_call_id": get_block_attr(block, "tool_use_id"), + "content": serialized if serialized else "", + } + ) + + flush_text() + return result + + @staticmethod + def convert_tools(tools: list[Any]) -> list[dict[str, Any]]: + return [ + { + "type": "function", + "function": { + "name": tool.name, + "description": tool.description or "", + "parameters": _tool_input_schema(tool), + }, + } + for tool in tools + ] + + @staticmethod + def convert_tool_choice(tool_choice: Any) -> Any: + if not isinstance(tool_choice, dict): + return tool_choice + + choice_type = tool_choice.get("type") + if choice_type == "tool": + name = tool_choice.get("name") + if name: + return {"type": "function", "function": {"name": name}} + if choice_type == "any": + return "required" + if choice_type in {"auto", "none", "required"}: + return choice_type + if choice_type == "function" and isinstance(tool_choice.get("function"), dict): + return tool_choice + + return tool_choice + + @staticmethod + def convert_system_prompt(system: Any) -> dict[str, str] | None: + if isinstance(system, str): + return {"role": "system", "content": system} + if 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: MessagesRequest, + *, + default_max_tokens: int | None = None, + reasoning_replay: ReasoningReplayMode = ReasoningReplayMode.THINK_TAGS, +) -> dict[str, Any]: + """Build the common parts of an OpenAI-format request body.""" + _openai_reject_native_only_top_level_fields(request_data) + messages = AnthropicToOpenAIConverter.convert_messages( + request_data.messages, + reasoning_replay=reasoning_replay, + ) + + system = request_data.system + 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 = request_data.max_tokens + set_if_not_none(body, "max_tokens", max_tokens or default_max_tokens) + set_if_not_none(body, "temperature", request_data.temperature) + set_if_not_none(body, "top_p", request_data.top_p) + + stop_sequences = request_data.stop_sequences + if stop_sequences: + body["stop"] = stop_sequences + + tools = request_data.tools + if tools: + body["tools"] = AnthropicToOpenAIConverter.convert_tools(tools) + tool_choice = request_data.tool_choice + if tool_choice: + body["tool_choice"] = AnthropicToOpenAIConverter.convert_tool_choice( + tool_choice + ) + + return body diff --git a/src/free_claude_code/core/anthropic/errors.py b/src/free_claude_code/core/anthropic/errors.py new file mode 100644 index 0000000..200b111 --- /dev/null +++ b/src/free_claude_code/core/anthropic/errors.py @@ -0,0 +1,87 @@ +"""Anthropic error types and envelopes.""" + +from typing import Any + +from free_claude_code.core.diagnostics import redact_sensitive_error_text +from free_claude_code.core.failures import ExecutionFailure, FailureKind + +_ANTHROPIC_ERROR_STATUS_CODES = { + "invalid_request_error": 400, + "authentication_error": 401, + "billing_error": 402, + "permission_error": 403, + "not_found_error": 404, + "request_too_large": 413, + "rate_limit_error": 429, + "api_error": 500, + "timeout_error": 504, + "overloaded_error": 529, +} + +_FAILURE_ERROR_TYPES = { + FailureKind.INVALID_REQUEST: "invalid_request_error", + FailureKind.AUTHENTICATION: "authentication_error", + FailureKind.PERMISSION: "permission_error", + FailureKind.RATE_LIMIT: "rate_limit_error", + FailureKind.OVERLOADED: "overloaded_error", + FailureKind.TIMEOUT: "api_error", + FailureKind.UPSTREAM: "api_error", + FailureKind.UNAVAILABLE: "api_error", +} + + +def anthropic_error_type_for_failure( + failure: FailureKind | ExecutionFailure, +) -> str: + """Map neutral failure semantics to an Anthropic wire type.""" + if isinstance(failure, ExecutionFailure): + if failure.kind == FailureKind.PERMISSION and failure.status_code == 402: + return "billing_error" + if failure.kind == FailureKind.INVALID_REQUEST: + if failure.status_code == 404: + return "not_found_error" + if failure.status_code == 413: + return "request_too_large" + if failure.kind == FailureKind.TIMEOUT and failure.status_code == 504: + return "timeout_error" + kind = failure.kind + else: + kind = failure + return _FAILURE_ERROR_TYPES[kind] + + +def anthropic_error_payload( + *, + error_type: str, + message: str, + request_id: str | None = None, +) -> dict[str, Any]: + """Return one Anthropic-compatible JSON error envelope.""" + payload: dict[str, Any] = { + "type": "error", + "error": { + "type": error_type, + "message": redact_sensitive_error_text(message), + }, + } + if request_id: + payload["request_id"] = request_id + return payload + + +def anthropic_failure_payload( + failure: ExecutionFailure, + *, + request_id: str | None = None, +) -> dict[str, Any]: + """Serialize a canonical execution failure as an Anthropic JSON error.""" + return anthropic_error_payload( + error_type=anthropic_error_type_for_failure(failure), + message=failure.message, + request_id=request_id, + ) + + +def anthropic_status_for_error_type(error_type: str) -> int: + """Return the standard HTTP status for an Anthropic error type.""" + return _ANTHROPIC_ERROR_STATUS_CODES.get(error_type, 500) diff --git a/src/free_claude_code/core/anthropic/models.py b/src/free_claude_code/core/anthropic/models.py new file mode 100644 index 0000000..105c12a --- /dev/null +++ b/src/free_claude_code/core/anthropic/models.py @@ -0,0 +1,250 @@ +"""Pydantic models for the Anthropic Messages protocol.""" + +from enum import StrEnum +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class Role(StrEnum): + user = "user" + assistant = "assistant" + system = "system" + + +class _AnthropicBlockBase(BaseModel): + """Pass through protocol extensions such as ``cache_control``.""" + + model_config = ConfigDict(extra="allow") + + +class ContentBlockText(_AnthropicBlockBase): + type: Literal["text"] + text: str + + +class ContentBlockImage(_AnthropicBlockBase): + type: Literal["image"] + source: dict[str, Any] + + +class ContentBlockDocument(_AnthropicBlockBase): + """Anthropic document block (e.g. PDF files via the Files API).""" + + type: Literal["document"] + source: dict[str, Any] + + +class ContentBlockToolUse(_AnthropicBlockBase): + type: Literal["tool_use"] + id: str + name: str + input: dict[str, Any] + + +class ContentBlockToolResult(_AnthropicBlockBase): + type: Literal["tool_result"] + tool_use_id: str + content: str | list[Any] | dict[str, Any] + + +class ContentBlockThinking(_AnthropicBlockBase): + type: Literal["thinking"] + thinking: str + signature: str | None = None + + +class ContentBlockRedactedThinking(_AnthropicBlockBase): + type: Literal["redacted_thinking"] + data: str + + +class ContentBlockServerToolUse(_AnthropicBlockBase): + """Anthropic server-side tool invocation (e.g. ``web_search``, ``web_fetch``).""" + + type: Literal["server_tool_use"] + id: str + name: str + input: dict[str, Any] + + +class ContentBlockWebSearchToolResult(_AnthropicBlockBase): + type: Literal["web_search_tool_result"] + tool_use_id: str + content: Any + + +class ContentBlockWebFetchToolResult(_AnthropicBlockBase): + type: Literal["web_fetch_tool_result"] + tool_use_id: str + content: Any + + +class SystemContent(_AnthropicBlockBase): + type: Literal["text"] + text: str + + +def _text_system_block(text: str) -> dict[str, str]: + return {"type": "text", "text": text} + + +def _merge_system_values(existing: Any, additions: list[Any]) -> Any: + values = [existing] if existing is not None else [] + values.extend(additions) + + if all(isinstance(value, str) for value in values): + return "\n\n".join(value for value in values if value) + + blocks: list[Any] = [] + for value in values: + if isinstance(value, str): + blocks.append(_text_system_block(value)) + elif isinstance(value, list): + blocks.extend(value) + else: + blocks.append(value) + return blocks + + +def _normalize_system_role_messages(data: Any) -> Any: + if not isinstance(data, dict): + return data + + messages = data.get("messages") + if not isinstance(messages, list): + return data + + system_contents: list[Any] = [] + normalized_messages: list[Any] = [] + for message in messages: + role = message.get("role") if isinstance(message, dict) else None + if role == Role.system: + system_contents.append(message.get("content", "")) + continue + normalized_messages.append(message) + + if not system_contents: + return data + + normalized = dict(data) + normalized["messages"] = normalized_messages + normalized["system"] = _merge_system_values( + normalized.get("system"), system_contents + ) + return normalized + + +class Message(BaseModel): + role: Literal["user", "assistant"] + content: ( + str + | list[ + ContentBlockText + | ContentBlockImage + | ContentBlockDocument + | ContentBlockToolUse + | ContentBlockToolResult + | ContentBlockThinking + | ContentBlockRedactedThinking + | ContentBlockServerToolUse + | ContentBlockWebSearchToolResult + | ContentBlockWebFetchToolResult + ] + ) + reasoning_content: str | None = None + + +class Tool(_AnthropicBlockBase): + name: str + type: str | None = None + description: str | None = None + input_schema: dict[str, Any] | None = None + + +class ThinkingConfig(BaseModel): + enabled: bool | None = True + type: str | None = None + budget_tokens: int | None = None + + +class MessagesRequest(BaseModel): + model_config = ConfigDict(extra="allow") + + @model_validator(mode="before") + @classmethod + def normalize_system_role_messages(cls, data: Any) -> Any: + return _normalize_system_role_messages(data) + + model: str + original_model: str | None = Field(default=None, exclude=True) + resolved_provider_model: str | None = Field(default=None, exclude=True) + 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 + context_management: dict[str, Any] | None = None + output_config: dict[str, Any] | None = None + mcp_servers: list[dict[str, Any]] | None = None + extra_body: dict[str, Any] | None = None + betas: list[str] | None = Field(default=None, exclude=True) + + +class TokenCountRequest(BaseModel): + model_config = ConfigDict(extra="allow") + + @model_validator(mode="before") + @classmethod + def normalize_system_role_messages(cls, data: Any) -> Any: + return _normalize_system_role_messages(data) + + model: str + original_model: str | None = Field(default=None, exclude=True) + resolved_provider_model: str | None = Field(default=None, exclude=True) + 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 + context_management: dict[str, Any] | None = None + output_config: dict[str, Any] | None = None + mcp_servers: list[dict[str, Any]] | None = None + betas: list[str] | None = Field(default=None, exclude=True) + + +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 + | ContentBlockRedactedThinking + | 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/src/free_claude_code/core/anthropic/request_serialization.py b/src/free_claude_code/core/anthropic/request_serialization.py new file mode 100644 index 0000000..7b3aba6 --- /dev/null +++ b/src/free_claude_code/core/anthropic/request_serialization.py @@ -0,0 +1,57 @@ +"""Shared Anthropic request serialization helpers.""" + +import json +from typing import Any + +from .models import MessagesRequest + +_MESSAGES_REQUEST_FIELDS = ( + "model", + "messages", + "system", + "max_tokens", + "stop_sequences", + "stream", + "temperature", + "top_p", + "top_k", + "metadata", + "tools", + "tool_choice", + "thinking", + "context_management", + "output_config", + "mcp_servers", + "extra_body", +) + + +def dump_messages_request(request: MessagesRequest) -> dict[str, Any]: + """Return JSON-ready public Messages fields without FCC routing state.""" + raw = request.model_dump(exclude_none=True) + return { + field: raw[field] + for field in _MESSAGES_REQUEST_FIELDS + if field in raw and raw[field] is not None + } + + +def serialize_tool_result_content(content: Any) -> str: + """Serialize Anthropic ``tool_result.content`` into provider-safe text.""" + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, dict): + return json.dumps(content, ensure_ascii=False) + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + parts.append(str(item.get("text", ""))) + elif isinstance(item, dict): + parts.append(json.dumps(item, ensure_ascii=False)) + else: + parts.append(str(item)) + return "\n".join(parts) + return str(content) diff --git a/src/free_claude_code/core/anthropic/request_snapshot.py b/src/free_claude_code/core/anthropic/request_snapshot.py new file mode 100644 index 0000000..75c1d5b --- /dev/null +++ b/src/free_claude_code/core/anthropic/request_snapshot.py @@ -0,0 +1,36 @@ +"""Trace-safe snapshots of Anthropic protocol requests.""" + +from typing import Any + +from free_claude_code.core.trace import sanitize_trace_value + +from .models import MessagesRequest, TokenCountRequest + + +def anthropic_request_snapshot( + request: MessagesRequest | TokenCountRequest, +) -> dict[str, Any]: + """Return the traceable public fields of an Anthropic request.""" + data = request.model_dump(mode="python") + snapshot = { + key: data[key] + for key in ( + "model", + "messages", + "system", + "tools", + "tool_choice", + "max_tokens", + "thinking", + "temperature", + "top_p", + "top_k", + "stop_sequences", + "metadata", + "stream", + "thinking_enabled", + ) + if key in data and data[key] is not None + } + sanitized = sanitize_trace_value(snapshot) + return sanitized if isinstance(sanitized, dict) else {} diff --git a/src/free_claude_code/core/anthropic/server_tool_sse.py b/src/free_claude_code/core/anthropic/server_tool_sse.py new file mode 100644 index 0000000..2de1a82 --- /dev/null +++ b/src/free_claude_code/core/anthropic/server_tool_sse.py @@ -0,0 +1,12 @@ +"""SSE content_block ``type`` values for Anthropic web server tools (local handlers). + +Shared by :mod:`api.web_tools` and stream contract tests to avoid drift. +""" + +from typing import Final + +SERVER_TOOL_USE: Final = "server_tool_use" +WEB_SEARCH_TOOL_RESULT: Final = "web_search_tool_result" +WEB_FETCH_TOOL_RESULT: Final = "web_fetch_tool_result" +WEB_SEARCH_TOOL_RESULT_ERROR: Final = "web_search_tool_result_error" +WEB_FETCH_TOOL_ERROR: Final = "web_fetch_tool_error" diff --git a/src/free_claude_code/core/anthropic/sse_aggregation.py b/src/free_claude_code/core/anthropic/sse_aggregation.py new file mode 100644 index 0000000..0827380 --- /dev/null +++ b/src/free_claude_code/core/anthropic/sse_aggregation.py @@ -0,0 +1,128 @@ +"""Fold an Anthropic Messages SSE stream into a single JSON Message body. + +Used to honor client requests with ``stream: false``. The internal pipeline +is always SSE (provider execution always yields a stream), so +callers that need a non-streaming response consume the stream here and get +back the same shape the real Anthropic API returns for a non-streaming +``messages.create()`` call. +""" + +import json +import uuid +from collections.abc import AsyncIterator +from typing import Any + +from .stream_contracts import parse_sse_text + +__all__ = ["aggregate_anthropic_sse_to_message"] + + +async def aggregate_anthropic_sse_to_message( + stream: AsyncIterator[str], +) -> tuple[dict[str, Any], dict[str, Any] | None]: + """Assemble a complete Messages JSON body from an Anthropic SSE stream. + + Returns ``(message_body, error)`` where ``error`` is the payload of a + top-level ``event: error`` if one arrived, else ``None``. + """ + buffer = "" + message: dict[str, Any] = {} + blocks: dict[int, dict[str, Any]] = {} + parts: dict[int, list[str]] = {} + error: dict[str, Any] | None = None + + def handle_payload(payload: dict[str, Any]) -> None: + nonlocal message, error + ptype = payload.get("type") + if ptype == "message_start": + started = payload.get("message") + if isinstance(started, dict): + message = dict(started) + elif ptype == "content_block_start": + idx = payload.get("index") + block = payload.get("content_block") + if isinstance(idx, int) and isinstance(block, dict): + blocks[idx] = dict(block) + parts.setdefault(idx, []) + elif ptype == "content_block_delta": + idx = payload.get("index") + delta = payload.get("delta") + if not isinstance(idx, int) or not isinstance(delta, dict): + return + if idx not in blocks: + blocks[idx] = {"type": "text", "text": ""} + parts[idx] = [] + dtype = delta.get("type") + if dtype == "text_delta": + parts[idx].append(str(delta.get("text", ""))) + elif dtype == "thinking_delta": + parts[idx].append(str(delta.get("thinking", ""))) + elif dtype == "input_json_delta": + parts[idx].append(str(delta.get("partial_json", ""))) + elif dtype == "signature_delta": + blocks[idx]["signature"] = str(delta.get("signature", "")) + elif ptype == "message_delta": + delta = payload.get("delta") + if isinstance(delta, dict): + if delta.get("stop_reason"): + message["stop_reason"] = delta["stop_reason"] + if "stop_sequence" in delta: + message["stop_sequence"] = delta["stop_sequence"] + usage = payload.get("usage") + if isinstance(usage, dict): + merged = ( + dict(message["usage"]) + if isinstance(message.get("usage"), dict) + else {} + ) + merged.update( + {k: v for k, v in usage.items() if isinstance(v, int | dict)} + ) + message["usage"] = merged + elif ptype == "error": + err = payload.get("error") + error = ( + err + if isinstance(err, dict) + else {"type": "api_error", "message": "provider error"} + ) + + async for chunk in stream: + buffer += chunk + while "\n\n" in buffer: + raw_event, buffer = buffer.split("\n\n", 1) + for event in parse_sse_text(raw_event + "\n\n"): + handle_payload(event.data) + + content: list[dict[str, Any]] = [] + for idx in sorted(blocks): + block = blocks[idx] + accumulated = "".join(parts.get(idx, [])) + btype = block.get("type") + if btype == "text": + block["text"] = str(block.get("text", "")) + accumulated + elif btype == "thinking": + block["thinking"] = str(block.get("thinking", "")) + accumulated + block.setdefault("signature", "") + elif btype == "tool_use": + if accumulated.strip(): + try: + block["input"] = json.loads(accumulated) + except json.JSONDecodeError: + block["input"] = block.get("input") or {} + elif not isinstance(block.get("input"), dict): + block["input"] = {} + content.append(block) + + message["content"] = content + message.setdefault("id", f"msg_{uuid.uuid4()}") + message.setdefault("type", "message") + message.setdefault("role", "assistant") + message.setdefault("model", "unknown") + message.setdefault("stop_reason", "end_turn") + message.setdefault("stop_sequence", None) + usage = dict(message["usage"]) if isinstance(message.get("usage"), dict) else {} + usage.setdefault("input_tokens", 0) + usage.setdefault("output_tokens", 0) + message["usage"] = usage + return message, error diff --git a/src/free_claude_code/core/anthropic/stream_contracts.py b/src/free_claude_code/core/anthropic/stream_contracts.py new file mode 100644 index 0000000..d635f4e --- /dev/null +++ b/src/free_claude_code/core/anthropic/stream_contracts.py @@ -0,0 +1,206 @@ +"""Neutral SSE parsing and Anthropic stream shape assertions. + +Used by default CI contract tests and by opt-in live smoke scenarios. +""" + +import json +from collections.abc import Iterable +from dataclasses import dataclass +from typing import Any + +from .server_tool_sse import ( + SERVER_TOOL_USE, + WEB_FETCH_TOOL_RESULT, + WEB_SEARCH_TOOL_RESULT, +) + +# Content blocks that only use content_block_start/stop (no deltas), including +# Anthropic server tools and eager text emitted in a single start event. +_NO_DELTA_BLOCK_KINDS = frozenset( + { + SERVER_TOOL_USE, + WEB_SEARCH_TOOL_RESULT, + WEB_FETCH_TOOL_RESULT, + "text_eager", + "redacted_thinking", + } +) + +_ALLOWED_BLOCK_START_TYPES = frozenset( + { + "text", + "thinking", + "tool_use", + "redacted_thinking", + SERVER_TOOL_USE, + WEB_SEARCH_TOOL_RESULT, + WEB_FETCH_TOOL_RESULT, + } +) + + +@dataclass(frozen=True, slots=True) +class SSEEvent: + event: str + data: dict[str, Any] + raw: str + + +def parse_sse_lines(lines: Iterable[str]) -> list[SSEEvent]: + events: list[SSEEvent] = [] + current_event = "" + data_parts: list[str] = [] + raw_parts: list[str] = [] + + for line in lines: + stripped = line.rstrip("\r\n") + if stripped == "": + _append_event(events, current_event, data_parts, raw_parts) + current_event = "" + data_parts = [] + raw_parts = [] + continue + raw_parts.append(stripped) + if stripped.startswith("event:"): + current_event = stripped.split(":", 1)[1].strip() + elif stripped.startswith("data:"): + data_parts.append(stripped.split(":", 1)[1].strip()) + + _append_event(events, current_event, data_parts, raw_parts) + return events + + +def parse_sse_text(text: str) -> list[SSEEvent]: + return parse_sse_lines(text.splitlines()) + + +def _append_event( + events: list[SSEEvent], + current_event: str, + data_parts: list[str], + raw_parts: list[str], +) -> None: + if not current_event and not data_parts: + return + data_text = "\n".join(data_parts) + data: dict[str, Any] + try: + parsed = json.loads(data_text) if data_text else {} + data = parsed if isinstance(parsed, dict) else {"value": parsed} + except json.JSONDecodeError: + data = {"raw": data_text} + events.append(SSEEvent(current_event, data, "\n".join(raw_parts))) + + +def assert_anthropic_stream_contract( + events: list[SSEEvent], *, allow_error: bool = False +) -> None: + """Check minimal Anthropic-style SSE invariants and block nesting. + + Does *not* assert strict event ordering (e.g. :class:`message_delta` vs + content blocks). Successful streams end in ``message_stop``; when explicitly + allowed, failed streams may instead end in a protocol-native ``error``. + """ + assert events, "stream produced no SSE events" + event_names = [event.event for event in events] + assert "message_start" in event_names, event_names + allowed_terminal_events = ( + {"message_stop", "error"} if allow_error else {"message_stop"} + ) + assert event_names[-1] in allowed_terminal_events, event_names + + open_blocks: dict[int, str] = {} + seen_blocks: set[int] = set() + for event in events: + if event.event == "error" and not allow_error: + raise AssertionError(f"unexpected SSE error event: {event.data}") + + if event.event == "content_block_start": + index = event_index(event) + block = event.data.get("content_block", {}) + assert isinstance(block, dict), event.data + block_type = str(block.get("type", "")) + assert block_type in _ALLOWED_BLOCK_START_TYPES, event.data + assert index not in open_blocks, f"block {index} started twice" + assert index not in seen_blocks, f"block {index} reused after stop" + if block_type == "text" and str(block.get("text", "")).strip(): + storage = "text_eager" + else: + storage = block_type + open_blocks[index] = storage + seen_blocks.add(index) + continue + + if event.event == "content_block_delta": + index = event_index(event) + assert index in open_blocks, f"delta for unopened block {index}" + kind = open_blocks[index] + assert kind not in _NO_DELTA_BLOCK_KINDS, ( + f"unexpected delta for start/stop-only block {kind} at index {index}" + ) + delta = event.data.get("delta", {}) + assert isinstance(delta, dict), event.data + delta_type = str(delta.get("type", "")) + if kind == "thinking": + assert delta_type in ( + "thinking_delta", + "signature_delta", + ), f"block {index} is {kind}, got {delta_type}" + continue + expected = { + "text": "text_delta", + "tool_use": "input_json_delta", + }[kind] + assert delta_type == expected, f"block {index} is {kind}, got {delta_type}" + continue + + if event.event == "content_block_stop": + index = event_index(event) + assert index in open_blocks, f"stop for unopened block {index}" + open_blocks.pop(index) + + assert not open_blocks, f"unclosed blocks: {open_blocks}" + assert seen_blocks, "stream did not emit any content blocks" + + +def event_names(events: list[SSEEvent]) -> list[str]: + return [event.event for event in events] + + +def text_content(events: list[SSEEvent]) -> str: + parts: list[str] = [] + for event in events: + if event.event == "content_block_start": + block = event.data.get("content_block", {}) + if isinstance(block, dict) and block.get("type") == "text": + eager = str(block.get("text", "")) + if eager: + parts.append(eager) + delta = event.data.get("delta", {}) + if isinstance(delta, dict) and delta.get("type") == "text_delta": + parts.append(str(delta.get("text", ""))) + return "".join(parts) + + +def thinking_content(events: list[SSEEvent]) -> str: + parts: list[str] = [] + for event in events: + delta = event.data.get("delta", {}) + if isinstance(delta, dict) and delta.get("type") == "thinking_delta": + parts.append(str(delta.get("thinking", ""))) + return "".join(parts) + + +def has_tool_use(events: list[SSEEvent]) -> bool: + for event in events: + block = event.data.get("content_block", {}) + if isinstance(block, dict) and block.get("type") == "tool_use": + return True + return False + + +def event_index(event: SSEEvent) -> int: + """Return the content block ``index`` field from an SSE payload (strict).""" + value = event.data.get("index") + assert isinstance(value, int), event.data + return value diff --git a/src/free_claude_code/core/anthropic/streaming/__init__.py b/src/free_claude_code/core/anthropic/streaming/__init__.py new file mode 100644 index 0000000..280ddab --- /dev/null +++ b/src/free_claude_code/core/anthropic/streaming/__init__.py @@ -0,0 +1,39 @@ +"""Shared Anthropic streaming engine.""" + +from .emitter import ( + ANTHROPIC_SSE_RESPONSE_HEADERS, + AnthropicSseEmitter, + anthropic_terminal_error_frame, + anthropic_terminal_failure_frame, + format_sse_event, + map_stop_reason, +) +from .ledger import AnthropicStreamLedger, StreamBlockLedger, ToolBlockState +from .recovery import ( + ToolSchema, + accept_tool_json_repair, + continuation_suffix, + make_text_recovery_body, + make_tool_repair_body, + parse_complete_tool_input, + tool_schemas_by_name, +) + +__all__ = [ + "ANTHROPIC_SSE_RESPONSE_HEADERS", + "AnthropicSseEmitter", + "AnthropicStreamLedger", + "StreamBlockLedger", + "ToolBlockState", + "ToolSchema", + "accept_tool_json_repair", + "anthropic_terminal_error_frame", + "anthropic_terminal_failure_frame", + "continuation_suffix", + "format_sse_event", + "make_text_recovery_body", + "make_tool_repair_body", + "map_stop_reason", + "parse_complete_tool_input", + "tool_schemas_by_name", +] diff --git a/src/free_claude_code/core/anthropic/streaming/emitter.py b/src/free_claude_code/core/anthropic/streaming/emitter.py new file mode 100644 index 0000000..3fab009 --- /dev/null +++ b/src/free_claude_code/core/anthropic/streaming/emitter.py @@ -0,0 +1,62 @@ +"""Anthropic SSE serialization helpers.""" + +import json +from typing import Any + +from loguru import logger + +from free_claude_code.core.failures import ExecutionFailure + +from ..errors import anthropic_error_payload, anthropic_failure_payload + +ANTHROPIC_SSE_RESPONSE_HEADERS: dict[str, str] = { + "X-Accel-Buffering": "no", + "Cache-Control": "no-cache", + "Connection": "keep-alive", +} + +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`` values to Anthropic ``stop_reason`` values.""" + return ( + STOP_REASON_MAP.get(openai_reason, "end_turn") if openai_reason else "end_turn" + ) + + +def format_sse_event(event_type: str, data: dict[str, Any]) -> str: + """Format one Anthropic-style SSE event.""" + return f"event: {event_type}\ndata: {json.dumps(data)}\n\n" + + +def anthropic_terminal_error_frame( + message: str, *, error_type: str = "api_error" +) -> str: + """Serialize a terminal Anthropic SSE error event for egress failures.""" + return format_sse_event( + "error", anthropic_error_payload(error_type=error_type, message=message) + ) + + +def anthropic_terminal_failure_frame(failure: ExecutionFailure) -> str: + """Serialize a canonical execution failure as a terminal SSE event.""" + return format_sse_event("error", anthropic_failure_payload(failure)) + + +class AnthropicSseEmitter: + """Serialize Anthropic SSE events and optionally log raw event bodies.""" + + def __init__(self, *, log_raw_events: bool = False) -> None: + self._log_raw_events = log_raw_events + + def event(self, event_type: str, data: dict[str, Any]) -> str: + event = format_sse_event(event_type, data) + if self._log_raw_events: + logger.debug("SSE_EVENT: {} - {}", event_type, event.strip()) + return event diff --git a/src/free_claude_code/core/anthropic/streaming/ledger.py b/src/free_claude_code/core/anthropic/streaming/ledger.py new file mode 100644 index 0000000..5625945 --- /dev/null +++ b/src/free_claude_code/core/anthropic/streaming/ledger.py @@ -0,0 +1,554 @@ +"""Anthropic stream state ledger.""" + +import hashlib +import json +import uuid +from collections.abc import Iterator, Mapping +from contextlib import suppress +from dataclasses import dataclass, field +from typing import Any + +from loguru import logger + +from .emitter import AnthropicSseEmitter +from .recovery import ( + ToolSchema, + parse_complete_tool_input, +) + +try: + import tiktoken + + ENCODER = tiktoken.get_encoding("cl100k_base") +except Exception: + ENCODER = None + + +def _safe_usage_int(value: object) -> int: + return value if isinstance(value, int) else 0 + + +@dataclass +class ToolBlockState: + """State for one streamed tool call.""" + + block_index: int + tool_id: str + name: str + extra_content: dict[str, Any] | None = None + started: bool = False + task_arg_buffer: str = "" + task_args_emitted: bool = False + pre_start_args: str = "" + + +@dataclass +class StreamBlockState: + """Tracked downstream Anthropic content block.""" + + index: int + block_type: str + open: bool = True + tool_id: str = "" + name: str = "" + parts: list[str] = field(default_factory=list) + extra_content: dict[str, Any] | None = None + + @property + def content(self) -> str: + return "".join(self.parts) + + +@dataclass +class StreamBlockLedger: + """Allocate and track Anthropic content block indexes.""" + + next_index: int = 0 + thinking_index: int = -1 + text_index: int = -1 + thinking_started: bool = False + text_started: bool = False + tool_states: dict[int, ToolBlockState] = field(default_factory=dict) + + def allocate_index(self) -> int: + idx = self.next_index + self.next_index += 1 + return idx + + def reserve_index(self, index: int) -> None: + self.next_index = max(self.next_index, index + 1) + + def ensure_tool_state(self, index: int) -> ToolBlockState: + if index not in self.tool_states: + self.tool_states[index] = ToolBlockState( + block_index=-1, tool_id="", name="" + ) + return self.tool_states[index] + + def set_stream_tool_id(self, index: int, tool_id: str | None) -> None: + if not tool_id: + return + self.ensure_tool_state(index).tool_id = str(tool_id) + + def set_tool_extra_content( + self, index: int, extra_content: dict[str, Any] | None + ) -> None: + if extra_content: + self.ensure_tool_state(index).extra_content = extra_content + + def register_tool_name(self, index: int, name: str) -> None: + if index not in self.tool_states: + self.tool_states[index] = ToolBlockState( + 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[str, Any] | None: + 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 not isinstance(args_json, dict): + return None + + _normalize_task_run_in_background(args_json) + state.task_args_emitted = True + state.task_arg_buffer = "" + return args_json + + def flush_task_arg_buffers(self) -> list[tuple[int, str]]: + 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 isinstance(args_json, dict): + _normalize_task_run_in_background(args_json) + out = json.dumps(args_json) + except (json.JSONDecodeError, TypeError, ValueError) as exc: + digest = hashlib.sha256( + state.task_arg_buffer.encode("utf-8", errors="replace") + ).hexdigest()[:16] + logger.warning( + "Task args invalid JSON (id={} len={} buffer_sha256_prefix={}): {}", + state.tool_id or "unknown", + len(state.task_arg_buffer), + digest, + exc, + ) + + state.task_args_emitted = True + state.task_arg_buffer = "" + results.append((tool_index, out)) + return results + + +class AnthropicStreamLedger: + """Own mutable Anthropic stream state and produce serialized SSE events.""" + + def __init__( + self, + message_id: str | None, + model: str, + input_tokens: int = 0, + *, + log_raw_events: bool = False, + ) -> None: + self.message_id = message_id or f"msg_{uuid.uuid4()}" + self.model = model + self.input_tokens = input_tokens + self.blocks = StreamBlockLedger() + self._emitter = AnthropicSseEmitter(log_raw_events=log_raw_events) + self._text_parts: list[str] = [] + self._thinking_parts: list[str] = [] + self._open_stack: list[int] = [] + self._content_blocks: dict[int, StreamBlockState] = {} + self.message_started = False + self.message_stopped = False + self.stop_reason: str | None = None + + def message_start(self) -> str: + self.message_started = True + safe_input = _safe_usage_int(self.input_tokens) + return self._emitter.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": {"input_tokens": safe_input, "output_tokens": 1}, + }, + }, + ) + + def message_delta( + self, + stop_reason: str, + output_tokens: int | None, + *, + input_tokens: int | None = None, + usage_fields: Mapping[str, int] | None = None, + ) -> str: + self.stop_reason = stop_reason + safe_in = _safe_usage_int( + self.input_tokens if input_tokens is None else input_tokens + ) + safe_out = output_tokens if isinstance(output_tokens, int) else 0 + usage = {"input_tokens": safe_in, "output_tokens": safe_out} + if usage_fields: + usage.update( + { + key: value + for key, value in usage_fields.items() + if isinstance(value, int) + } + ) + return self._emitter.event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": stop_reason, "stop_sequence": None}, + "usage": usage, + }, + ) + + def message_stop(self) -> str: + self.message_stopped = True + return self._emitter.event("message_stop", {"type": "message_stop"}) + + def content_block_start(self, index: int, block_type: str, **kwargs: Any) -> str: + 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", {}) + extra_content = kwargs.get("extra_content") + if isinstance(extra_content, dict) and extra_content: + content_block["extra_content"] = extra_content + elif block_type == "redacted_thinking": + data = kwargs.get("data") + if isinstance(data, str): + content_block["data"] = data + + self._record_block_start(index, content_block) + return self._emitter.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: + delta: dict[str, Any] = {"type": delta_type} + if delta_type == "thinking_delta": + delta["thinking"] = content + elif delta_type == "signature_delta": + delta["signature"] = content + elif delta_type == "text_delta": + delta["text"] = content + elif delta_type == "input_json_delta": + delta["partial_json"] = content + + self._record_block_delta(index, delta) + return self._emitter.event( + "content_block_delta", + { + "type": "content_block_delta", + "index": index, + "delta": delta, + }, + ) + + def content_block_stop(self, index: int) -> str: + self._record_block_stop(index) + return self._emitter.event( + "content_block_stop", + {"type": "content_block_stop", "index": index}, + ) + + def start_thinking_block(self) -> str: + 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: + return self.content_block_delta( + self.blocks.thinking_index, "thinking_delta", content + ) + + def stop_thinking_block(self) -> str: + self.blocks.thinking_started = False + return self.content_block_stop(self.blocks.thinking_index) + + def start_text_block(self) -> str: + 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: + return self.content_block_delta(self.blocks.text_index, "text_delta", content) + + def stop_text_block(self) -> str: + self.blocks.text_started = False + return self.content_block_stop(self.blocks.text_index) + + def start_tool_block( + self, + tool_index: int, + tool_id: str, + name: str, + *, + extra_content: dict[str, Any] | None = None, + ) -> str: + 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.name = name + if extra_content: + state.extra_content = extra_content + state.started = True + else: + self.blocks.tool_states[tool_index] = ToolBlockState( + block_index=block_idx, + tool_id=tool_id, + name=name, + extra_content=extra_content, + started=True, + ) + return self.content_block_start( + block_idx, + "tool_use", + id=tool_id, + name=name, + extra_content=extra_content, + ) + + def emit_tool_delta(self, tool_index: int, partial_json: str) -> str: + state = self.blocks.tool_states[tool_index] + return self.content_block_delta( + state.block_index, "input_json_delta", partial_json + ) + + def stop_tool_block(self, tool_index: int) -> str: + return self.content_block_stop(self.blocks.tool_states[tool_index].block_index) + + def ensure_thinking_block(self) -> Iterator[str]: + 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]: + 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]: + 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]: + yield from self.close_content_blocks() + for tool_index, state in list(self.blocks.tool_states.items()): + if state.started: + yield self.stop_tool_block(tool_index) + + def close_unclosed_blocks(self) -> Iterator[str]: + while self._open_stack: + idx = self._open_stack.pop() + state = self._content_blocks.get(idx) + if state is not None: + state.open = False + self._clear_active_content_block(state) + yield self._emitter.event( + "content_block_stop", + {"type": "content_block_stop", "index": idx}, + ) + + def can_salvage_tool_use(self, schemas: dict[str, ToolSchema]) -> bool: + tool_blocks = self.tool_blocks() + if not tool_blocks: + return False + for block in tool_blocks: + if not block.tool_id or not block.name: + return False + if parse_complete_tool_input(block.content, block.name, schemas) is None: + return False + return True + + def tool_blocks(self) -> list[StreamBlockState]: + return [ + block + for block in self._content_blocks.values() + if block.block_type == "tool_use" + ] + + def tool_block_for_tool_index(self, tool_index: int) -> StreamBlockState | None: + state = self.blocks.tool_states.get(tool_index) + if state is None or state.block_index < 0: + return None + block = self._content_blocks.get(state.block_index) + if block is None or block.block_type != "tool_use": + return None + return block + + def has_emitted_tool_block(self) -> bool: + return bool(self.tool_blocks()) + + def has_content_block(self) -> bool: + return bool(self._content_blocks) + + def final_stop_reason(self, fallback: str) -> str: + if self.has_emitted_tool_block(): + return "tool_use" + return fallback + + def has_terminal_message(self) -> bool: + return self.message_stopped + + @property + def accumulated_text(self) -> str: + return "".join(self._text_parts) + + @property + def accumulated_reasoning(self) -> str: + return "".join(self._thinking_parts) + + def estimate_output_tokens(self) -> int: + if ENCODER: + text_tokens = len(ENCODER.encode(self.accumulated_text)) + reasoning_tokens = len(ENCODER.encode(self.accumulated_reasoning)) + tool_tokens = 0 + tool_count = 0 + for name, content in self._iter_tool_token_payloads(): + tool_tokens += len(ENCODER.encode(name)) + tool_tokens += len(ENCODER.encode(content)) + tool_tokens += 15 + tool_count += 1 + + block_count = ( + (1 if self.accumulated_reasoning else 0) + + (1 if self.accumulated_text else 0) + + tool_count + ) + return text_tokens + reasoning_tokens + tool_tokens + (block_count * 4) + + text_tokens = len(self.accumulated_text) // 4 + reasoning_tokens = len(self.accumulated_reasoning) // 4 + tool_tokens = sum(1 for _ in self._iter_tool_token_payloads()) * 50 + return text_tokens + reasoning_tokens + tool_tokens + + def _iter_tool_token_payloads(self) -> Iterator[tuple[str, str]]: + for block in self.tool_blocks(): + yield block.name, block.content + + def _record_block_start(self, index: int, block: dict[str, Any]) -> None: + self.blocks.reserve_index(index) + block_type = str(block.get("type", "")) + state = StreamBlockState(index=index, block_type=block_type) + if block_type == "tool_use": + tool_id = block.get("id") + name = block.get("name") + state.tool_id = tool_id if isinstance(tool_id, str) else "" + state.name = name if isinstance(name, str) else "" + extra_content = block.get("extra_content") + state.extra_content = ( + extra_content if isinstance(extra_content, dict) else None + ) + elif block_type == "text": + self.blocks.text_index = index + self.blocks.text_started = True + text = block.get("text") + if isinstance(text, str) and text: + state.parts.append(text) + self._text_parts.append(text) + elif block_type == "thinking": + self.blocks.thinking_index = index + self.blocks.thinking_started = True + thinking = block.get("thinking") + if isinstance(thinking, str) and thinking: + state.parts.append(thinking) + self._thinking_parts.append(thinking) + self._content_blocks[index] = state + self._open_stack.append(index) + + def _record_block_delta(self, index: int, delta: dict[str, Any]) -> None: + state = self._content_blocks.get(index) + if state is None: + return + if state.block_type == "text": + text = delta.get("text") + if isinstance(text, str): + state.parts.append(text) + self._text_parts.append(text) + elif state.block_type == "thinking": + thinking = delta.get("thinking") + if isinstance(thinking, str): + state.parts.append(thinking) + self._thinking_parts.append(thinking) + elif state.block_type == "tool_use": + partial = delta.get("partial_json") + if isinstance(partial, str): + state.parts.append(partial) + + def _record_block_stop(self, index: int) -> None: + if self._open_stack and self._open_stack[-1] == index: + self._open_stack.pop() + else: + with suppress(ValueError): + self._open_stack.remove(index) + state = self._content_blocks.get(index) + if state is not None: + state.open = False + self._clear_active_content_block(state) + + def _last_open_block(self, block_type: str) -> StreamBlockState | None: + for index in reversed(self._open_stack): + block = self._content_blocks.get(index) + if block is not None and block.block_type == block_type and block.open: + return block + return None + + def _clear_active_content_block(self, state: StreamBlockState) -> None: + if state.block_type == "text" and self.blocks.text_index == state.index: + self.blocks.text_started = False + elif ( + state.block_type == "thinking" and self.blocks.thinking_index == state.index + ): + self.blocks.thinking_started = False + + +def _normalize_task_run_in_background(args_json: dict[str, Any]) -> None: + if args_json.get("run_in_background") is not False: + args_json["run_in_background"] = False diff --git a/src/free_claude_code/core/anthropic/streaming/recovery.py b/src/free_claude_code/core/anthropic/streaming/recovery.py new file mode 100644 index 0000000..e488edc --- /dev/null +++ b/src/free_claude_code/core/anthropic/streaming/recovery.py @@ -0,0 +1,203 @@ +"""Pure Anthropic continuation and tool-repair transformations.""" + +import json +from copy import deepcopy +from dataclasses import dataclass +from typing import Any + +import jsonschema +from loguru import logger + +from ..models import MessagesRequest + +_RECOVERY_USER_PREFIX = ( + "The previous provider stream was interrupted. Continue the assistant response " + "exactly where it stopped. Do not repeat text already written." +) +_RECOVERY_THINKING_PREFIX = ( + "The assistant had already emitted this hidden thinking before the interruption:\n" +) + + +@dataclass(frozen=True, slots=True) +class ToolSchema: + """Tool schema resolved from the original Anthropic request.""" + + name: str + input_schema: dict[str, Any] + + +@dataclass(frozen=True, slots=True) +class ToolRepair: + """Accepted append-only tool JSON repair.""" + + suffix: str + parsed_input: dict[str, Any] + + +def tool_schemas_by_name(request: MessagesRequest) -> dict[str, ToolSchema]: + """Return Anthropic tool input schemas keyed by tool name.""" + schemas: dict[str, ToolSchema] = {} + tools = request.tools + if not tools: + return schemas + + for tool in tools: + name = tool.name + if not name: + continue + schema = tool.input_schema + if schema is None: + schema = {"type": "object"} + schemas[name] = ToolSchema(name=name, input_schema=deepcopy(schema)) + return schemas + + +def validate_tool_input( + tool_name: str, parsed_input: dict[str, Any], schemas: dict[str, ToolSchema] +) -> bool: + tool_schema = schemas.get(tool_name) + if tool_schema is None: + return True + try: + validator_cls = jsonschema.validators.validator_for(tool_schema.input_schema) + validator_cls.check_schema(tool_schema.input_schema) + validator_cls(tool_schema.input_schema).validate(parsed_input) + except jsonschema.exceptions.SchemaError as exc: + logger.warning("Skipping invalid tool schema for {}: {}", tool_name, exc) + return True + except jsonschema.exceptions.ValidationError: + return False + return True + + +def parse_complete_tool_input( + raw_json: str, tool_name: str, schemas: dict[str, ToolSchema] +) -> dict[str, Any] | None: + try: + parsed = json.loads(raw_json) + except json.JSONDecodeError: + return None + if not isinstance(parsed, dict): + return None + if not validate_tool_input(tool_name, parsed, schemas): + return None + return parsed + + +def accept_tool_json_repair( + prefix: str, + candidate: str, + *, + tool_name: str, + schemas: dict[str, ToolSchema], +) -> ToolRepair | None: + for suffix in _repair_suffix_candidates(prefix, candidate): + combined = prefix + suffix + parsed = parse_complete_tool_input(combined, tool_name, schemas) + if parsed is not None: + return ToolRepair(suffix=suffix, parsed_input=parsed) + return None + + +def continuation_suffix(existing: str, candidate: str) -> str | None: + existing = existing or "" + candidate = candidate or "" + if not candidate: + return "" + if not existing: + return candidate + if candidate.startswith(existing): + return candidate[len(existing) :] + + max_overlap = min(len(existing), len(candidate)) + for size in range(max_overlap, 0, -1): + if existing.endswith(candidate[:size]): + return candidate[size:] + + if len(candidate) < max(200, len(existing) // 2): + return candidate + return None + + +def make_text_recovery_body( + body: dict[str, Any], + partial_text: str, + partial_thinking: str = "", +) -> dict[str, Any]: + """Build a text-only continuation request for an OpenAI-chat upstream.""" + recovery = deepcopy(body) + recovery.pop("tools", None) + recovery.pop("tool_choice", None) + recovery["stream"] = True + messages = _copied_messages(recovery) + if partial_text: + messages.append({"role": "assistant", "content": partial_text}) + prompt = _RECOVERY_USER_PREFIX + if partial_thinking: + prompt = f"{_RECOVERY_THINKING_PREFIX}{partial_thinking}\n\n{prompt}" + messages.append({"role": "user", "content": prompt}) + recovery["messages"] = messages + return recovery + + +def make_tool_repair_body( + body: dict[str, Any], + *, + tool_name: str, + prefix: str, + input_schema: dict[str, Any] | None, +) -> dict[str, Any]: + """Build a text-only request asking for a JSON suffix.""" + recovery = deepcopy(body) + recovery.pop("tools", None) + recovery.pop("tool_choice", None) + recovery["stream"] = True + messages = _copied_messages(recovery) + messages.append( + { + "role": "user", + "content": _tool_repair_prompt( + tool_name=tool_name, prefix=prefix, input_schema=input_schema + ), + } + ) + recovery["messages"] = messages + return recovery + + +def _copied_messages(body: dict[str, Any]) -> list[Any]: + messages = body.get("messages") + return deepcopy(messages) if isinstance(messages, list) else [] + + +def _repair_suffix_candidates(prefix: str, candidate: str) -> list[str]: + raw = candidate.strip() + if not raw: + return [] + candidates: list[str] = [] + if raw.startswith("```"): + lines = raw.splitlines() + if lines and lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip() == "```": + lines = lines[:-1] + raw = "\n".join(lines).strip() + candidates.append(raw) + if raw.startswith(prefix): + candidates.append(raw[len(prefix) :]) + return list(dict.fromkeys(candidates)) + + +def _tool_repair_prompt( + *, tool_name: str, prefix: str, input_schema: dict[str, Any] | None +) -> str: + schema_text = json.dumps(input_schema or {"type": "object"}, separators=(",", ":")) + return ( + "A streamed tool call was interrupted while writing JSON arguments.\n" + f"Tool name: {tool_name}\n" + f"JSON schema: {schema_text}\n" + f"Already emitted JSON prefix: {prefix}\n\n" + "Return only the exact missing JSON suffix needed to complete the same object. " + "Do not repeat the prefix. Do not include markdown or explanation." + ) diff --git a/src/free_claude_code/core/anthropic/thinking.py b/src/free_claude_code/core/anthropic/thinking.py new file mode 100644 index 0000000..fa793d1 --- /dev/null +++ b/src/free_claude_code/core/anthropic/thinking.py @@ -0,0 +1,140 @@ +"""Streaming parser for provider-emitted thinking tags.""" + +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 = "" + + 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.""" + 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: + 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) + + 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 + len(self.CLOSE_TAG) :] + if pre_orphan: + return ContentChunk(ContentType.TEXT, pre_orphan) + return None + + if think_start == -1: + last_bracket = self._buffer.rfind("<") + if last_bracket != -1: + potential_tag = self._buffer[last_bracket:] + tag_len = len(potential_tag) + if ( + tag_len < len(self.OPEN_TAG) + and self.OPEN_TAG.startswith(potential_tag) + ) or ( + tag_len < len(self.CLOSE_TAG) + 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 + + emit = self._buffer + self._buffer = "" + if emit: + return ContentChunk(ContentType.TEXT, emit) + return None + + pre_think = self._buffer[:think_start] + self._buffer = self._buffer[think_start + len(self.OPEN_TAG) :] + self._in_think_tag = True + if pre_think: + return ContentChunk(ContentType.TEXT, pre_think) + 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: + last_bracket = self._buffer.rfind("<") + if last_bracket != -1 and len(self._buffer) - last_bracket < len( + self.CLOSE_TAG + ): + 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 + + thinking_content = self._buffer[:think_end] + self._buffer = self._buffer[think_end + len(self.CLOSE_TAG) :] + self._in_think_tag = False + if thinking_content: + return ContentChunk(ContentType.THINKING, thinking_content) + 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/src/free_claude_code/core/anthropic/tokens.py b/src/free_claude_code/core/anthropic/tokens.py new file mode 100644 index 0000000..5fc1750 --- /dev/null +++ b/src/free_claude_code/core/anthropic/tokens.py @@ -0,0 +1,118 @@ +"""Token estimation for Anthropic-compatible requests.""" + +import json + +import tiktoken +from loguru import logger + +from .content import get_block_attr +from .models import Message, SystemContent, Tool + +ENCODER = tiktoken.get_encoding("cl100k_base") + +_DISALLOWED_SPECIAL: tuple[str, ...] = () + + +def _count_text_tokens(text: str) -> int: + return len(ENCODER.encode(text, disallowed_special=_DISALLOWED_SPECIAL)) + + +def get_token_count( + messages: list[Message], + system: str | list[SystemContent] | None = None, + tools: list[Tool] | None = None, +) -> int: + """Estimate token count for a request.""" + total_tokens = 0 + + if system: + if isinstance(system, str): + total_tokens += _count_text_tokens(system) + elif isinstance(system, list): + for block in system: + text = get_block_attr(block, "text", "") + if text: + total_tokens += _count_text_tokens(str(text)) + total_tokens += 4 + + for msg in messages: + if isinstance(msg.content, str): + total_tokens += _count_text_tokens(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 += _count_text_tokens(str(text)) + elif b_type == "thinking": + thinking = get_block_attr(block, "thinking", "") + total_tokens += _count_text_tokens(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 += _count_text_tokens(str(name)) + total_tokens += _count_text_tokens(json.dumps(inp)) + total_tokens += _count_text_tokens(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 += _count_text_tokens(content) + else: + total_tokens += _count_text_tokens(json.dumps(content)) + total_tokens += _count_text_tokens(str(tool_use_id)) + total_tokens += 8 + elif b_type in ( + "server_tool_use", + "web_search_tool_result", + "web_fetch_tool_result", + ): + if hasattr(block, "model_dump"): + blob: object = block.model_dump() + else: + blob = block + try: + total_tokens += _count_text_tokens( + json.dumps(blob, default=str, ensure_ascii=False) + ) + except (TypeError, ValueError, OverflowError) as e: + logger.debug( + "Block encode fallback b_type={} err={}", b_type, e + ) + total_tokens += _count_text_tokens(str(blob)) + total_tokens += 12 + else: + logger.debug( + "Unexpected block type %r, falling back to json/str encoding", + b_type, + ) + try: + total_tokens += _count_text_tokens(json.dumps(block)) + except TypeError, ValueError: + total_tokens += _count_text_tokens(str(block)) + + if tools: + for tool in tools: + tool_str = ( + tool.name + (tool.description or "") + json.dumps(tool.input_schema) + ) + total_tokens += _count_text_tokens(tool_str) + + total_tokens += len(messages) * 4 + if tools: + total_tokens += len(tools) * 5 + + return max(1, total_tokens) diff --git a/src/free_claude_code/core/anthropic/tools.py b/src/free_claude_code/core/anthropic/tools.py new file mode 100644 index 0000000..c09beb5 --- /dev/null +++ b/src/free_claude_code/core/anthropic/tools.py @@ -0,0 +1,212 @@ +"""Heuristic parser for text-emitted tool calls.""" + +import json +import re +import uuid +from enum import Enum +from typing import Any + +from loguru import logger + +_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 for raw text tool calls. + + Some OpenAI-compatible models emit tool calls as text rather than structured + chunks. This parser converts the common ``● `` form into + Anthropic-style ``tool_use`` blocks. + """ + + _FUNC_START_PATTERN = re.compile(r"●\s*]+)>") + _PARAM_PATTERN = re.compile( + r"]+)>(.*?)(?:|$)", re.DOTALL + ) + _WEB_TOOL_JSON_PATTERN = re.compile( + r"(?is)\b(?:use\s+)?(?PWebFetch|WebSearch)\b.*?(?P\{.*?\})" + ) + + def __init__(self): + self._state = ParserState.TEXT + self._buffer = "" + self._current_tool_id = None + self._current_function_name = None + self._current_parameters = {} + + def _extract_web_tool_json_calls(self) -> tuple[str, list[dict[str, Any]]]: + detected_tools: list[dict[str, Any]] = [] + + for match in self._WEB_TOOL_JSON_PATTERN.finditer(self._buffer): + try: + tool_input = json.loads(match.group("json")) + except json.JSONDecodeError: + continue + if not isinstance(tool_input, dict): + continue + + tool_name = match.group("tool") + if tool_name == "WebFetch" and "url" not in tool_input: + continue + if tool_name == "WebSearch" and "query" not in tool_input: + continue + + detected_tools.append( + { + "type": "tool_use", + "id": f"toolu_heuristic_{uuid.uuid4().hex[:8]}", + "name": tool_name, + "input": tool_input, + } + ) + logger.debug( + "Heuristic bypass: Detected JSON-style tool call '{}'", + tool_name, + ) + + if not detected_tools: + return self._buffer, [] + + return "", detected_tools + + def _strip_control_tokens(self, text: str) -> str: + return _CONTROL_TOKEN_RE.sub("", text) + + def _split_incomplete_control_token_tail(self) -> str: + 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 and return safe text plus detected tool calls.""" + self._buffer += text + self._buffer = self._strip_control_tokens(self._buffer) + self._buffer, detected_tools = self._extract_web_tool_json_calls() + filtered_output_parts: list[str] = [] + + while True: + if self._state == ParserState.TEXT: + 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: + 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: + 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 = {} + self._buffer = self._buffer[match.end() :] + self._state = ParserState.PARSING_PARAMETERS + logger.debug( + "Heuristic bypass: Detected start of tool call '{}'", + self._current_function_name, + ) + elif len(self._buffer) > 100: + filtered_output_parts.append(self._buffer[0]) + self._buffer = self._buffer[1:] + self._state = ParserState.TEXT + else: + break + + if self._state == ParserState.PARSING_PARAMETERS: + finished_tool_call = False + + while True: + param_match = self._PARAM_PATTERN.search(self._buffer) + if param_match and "" in param_match.group(0): + 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 + + if "●" in self._buffer: + 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("<"): + if " list[dict[str, Any]]: + """Flush any remaining tool call in the buffer.""" + self._buffer = self._strip_control_tokens(self._buffer) + detected_tools = [] + if self._state == ParserState.PARSING_PARAMETERS: + partial_matches = re.finditer( + r"]+)>(.*)$", self._buffer, re.DOTALL + ) + for match in partial_matches: + key = match.group(1).strip() + val = match.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/src/free_claude_code/core/anthropic/utils.py b/src/free_claude_code/core/anthropic/utils.py new file mode 100644 index 0000000..84d33de --- /dev/null +++ b/src/free_claude_code/core/anthropic/utils.py @@ -0,0 +1,9 @@ +"""Small shared protocol utility helpers.""" + +from typing import Any + + +def set_if_not_none(body: dict[str, Any], key: str, value: Any) -> None: + """Set ``body[key]`` only when value is not None.""" + if value is not None: + body[key] = value diff --git a/src/free_claude_code/core/async_iterators.py b/src/free_claude_code/core/async_iterators.py new file mode 100644 index 0000000..589ac26 --- /dev/null +++ b/src/free_claude_code/core/async_iterators.py @@ -0,0 +1,26 @@ +"""Minimal lifecycle helpers for composed asynchronous iterators.""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class AsyncCloseable(Protocol): + """An object whose asynchronous iteration resources can be released.""" + + async def aclose(self) -> None: ... + + +async def try_close_async_iterator(value: object) -> Exception | None: + """Close ``value`` when supported, returning ordinary cleanup failures. + + Cancellation remains control flow and propagates to the caller. Returning + ordinary exceptions lets an owner observe cleanup failure without replacing + the stream outcome that was already established. + """ + if not isinstance(value, AsyncCloseable): + return None + try: + await value.aclose() + except Exception as exc: + return exc + return None diff --git a/src/free_claude_code/core/diagnostics.py b/src/free_claude_code/core/diagnostics.py new file mode 100644 index 0000000..1335dd7 --- /dev/null +++ b/src/free_claude_code/core/diagnostics.py @@ -0,0 +1,301 @@ +"""Credential-safe diagnostics shared across product boundaries.""" + +import json +import re +import traceback +from dataclasses import dataclass +from typing import Any + +from .failures import ExecutionFailure + +ERROR_DETAIL_DISPLAY_CAP_BYTES = 16_384 +_MAX_CAUSE_CHAIN_DEPTH = 4 +_UPSTREAM_BODY_ATTR = "_fcc_upstream_error_body" +_UPSTREAM_BODY_TRUNCATED_ATTR = "_fcc_upstream_error_body_truncated" + +_SECRET_TEXT_REPLACEMENTS = ( + ( + re.compile( + r"(?i)(?P[\"']?authorization[\"']?\s*[:=]\s*)" + r"(?P[\"']?)(?:(?:bearer|basic)\s+)?" + r"[^\"'\s,;&}\]]+(?P=quote)" + ), + r"\g\g\g", + ), + ( + re.compile( + r"(?i)(?P[\"']?(?:api[_-]?key|access[_-]?token|" + r"refresh[_-]?token|token|client[_-]?secret|secret|password)" + r"[\"']?\s*[:=]\s*)(?P[\"']?)" + r"[^\"'\s,;&}\]]+(?P=quote)" + ), + r"\g\g\g", + ), + (re.compile(r"(?i)(bearer\s+)[^\s,;]+"), r"\1"), + ( + re.compile( + r"(?i)(?", + ), +) + + +@dataclass(frozen=True, slots=True) +class UpstreamErrorDetail: + """Sanitized diagnostic detail extracted from an upstream exception.""" + + status_code: int | None = None + body_text: str | None = None + exception_text: str | None = None + cause_chain_text: str | None = None + category_hint: str | None = None + body_truncated: bool = False + + +def redact_sensitive_error_text(text: str) -> str: + """Redact recognizable credentials while preserving diagnostic context.""" + sanitized = text + for pattern, replacement in _SECRET_TEXT_REPLACEMENTS: + sanitized = pattern.sub(replacement, sanitized) + return sanitized + + +def safe_exception_message( + exc: BaseException, + *, + fallback: str = "Provider request failed unexpectedly.", +) -> str: + """Return a redacted, non-empty exception message.""" + message = redact_sensitive_error_text(str(exc).strip()) + return message or fallback + + +def format_user_error_preview(exc: BaseException, *, max_len: int = 200) -> str: + """Return a short redacted exception preview for chat surfaces.""" + return safe_exception_message(exc)[:max_len] + + +def attach_upstream_error_body( + exc: Exception, + body: bytes | str, + *, + truncated: bool = False, +) -> None: + """Attach a bounded streamed response body for later safe formatting.""" + setattr(exc, _UPSTREAM_BODY_ATTR, body) + setattr(exc, _UPSTREAM_BODY_TRUNCATED_ATTR, truncated) + + +def exception_cause_types(exc: BaseException) -> tuple[str, ...]: + """Return exception cause type names without logging their contents.""" + return tuple(type(cause).__name__ for cause in _exception_causes(exc)) + + +def redacted_exception_traceback(exc: BaseException) -> str: + """Format a traceback while redacting recognizable credentials.""" + return redact_sensitive_error_text("".join(traceback.format_exception(exc))) + + +def extract_upstream_error_detail(exc: Exception) -> UpstreamErrorDetail: + """Extract bounded, redacted body, exception, and cause-chain details.""" + raw_body = getattr(exc, _UPSTREAM_BODY_ATTR, None) + body_truncated = bool(getattr(exc, _UPSTREAM_BODY_TRUNCATED_ATTR, False)) + if raw_body is None: + raw_body = getattr(exc, "body", None) + if raw_body is None: + raw_body = _body_from_response(exc) + + body_text = _normalize_body_text(raw_body) + if body_text is not None: + body_text = redact_sensitive_error_text(body_text) + body_text, capped = _cap_text_bytes(body_text) + body_truncated = body_truncated or capped + + exception_text = str(exc).strip() or None + if exception_text is not None: + exception_text = redact_sensitive_error_text(exception_text) + exception_text, _ = _cap_text_bytes(exception_text) + + return UpstreamErrorDetail( + status_code=_status_code_from_exception(exc), + body_text=body_text, + exception_text=exception_text, + cause_chain_text=_exception_cause_chain_text(exc), + category_hint=_category_hint_from_body(raw_body, body_text), + body_truncated=body_truncated, + ) + + +def format_execution_failure_message( + failure: ExecutionFailure, + detail: UpstreamErrorDetail, + *, + upstream_name: str, + request_id: str | None = None, +) -> str: + """Build a copyable, redacted diagnostic for a finalized execution failure.""" + stable_message = failure.message + has_upstream_detail = detail.status_code is not None or detail.body_text is not None + if not has_upstream_detail: + lines = [stable_message] + if detail.exception_text and detail.exception_text != stable_message: + lines.extend(("", "Provider exception:", detail.exception_text)) + if detail.cause_chain_text: + lines.extend(("", "Caused by:", detail.cause_chain_text)) + _append_request_id_lines(lines, request_id) + return "\n".join(lines) + + if detail.status_code == 405: + lines = [ + f"Upstream provider {upstream_name} rejected the request method " + "or endpoint (HTTP 405)." + ] + elif detail.status_code is not None: + lines = [ + f"Upstream provider {upstream_name} returned HTTP {detail.status_code}." + ] + else: + lines = [f"Upstream provider {upstream_name} returned an error."] + + lines.append(f"Category: {detail.category_hint or failure.kind.value}") + if stable_message and stable_message != lines[0]: + lines.append(f"Mapped message: {stable_message}") + lines.extend(("", "Upstream error:")) + lines.append(detail.body_text or "(empty upstream error body)") + if _body_truncation_line_needed(detail): + lines.append(f"... [truncated after {ERROR_DETAIL_DISPLAY_CAP_BYTES} bytes]") + _append_request_id_lines(lines, request_id) + return "\n".join(lines) + + +def _body_truncation_line_needed(detail: UpstreamErrorDetail) -> bool: + """Return whether a separate truncation marker is needed.""" + return detail.body_truncated and ( + detail.body_text is None + or f"truncated after {ERROR_DETAIL_DISPLAY_CAP_BYTES} bytes" + not in detail.body_text + ) + + +def _status_code_from_exception(exc: Exception) -> int | None: + status = getattr(exc, "status_code", None) + if isinstance(status, int): + return status + response = getattr(exc, "response", None) + response_status = getattr(response, "status_code", None) + return response_status if isinstance(response_status, int) else None + + +def _body_from_response(exc: Exception) -> Any: + response = getattr(exc, "response", None) + if response is None: + return None + try: + return response.json() + except Exception: + pass + try: + return response.text + except Exception: + return None + + +def _normalize_body_text(body: Any) -> str | None: + if body is None: + return None + if isinstance(body, bytes): + text = body.decode("utf-8", errors="replace") + elif isinstance(body, str): + text = body + else: + try: + return json.dumps(body, ensure_ascii=False, separators=(",", ":")) + except TypeError: + text = str(body) + stripped = text.strip() + if not stripped: + return None + try: + parsed = json.loads(stripped) + except ValueError: + return stripped + return json.dumps(parsed, ensure_ascii=False, separators=(",", ":")) + + +def _cap_text_bytes(text: str) -> tuple[str, bool]: + encoded = text.encode("utf-8", errors="replace") + if len(encoded) <= ERROR_DETAIL_DISPLAY_CAP_BYTES: + return text, False + capped = encoded[:ERROR_DETAIL_DISPLAY_CAP_BYTES].decode("utf-8", errors="replace") + return ( + f"{capped}\n... [truncated after {ERROR_DETAIL_DISPLAY_CAP_BYTES} bytes]", + True, + ) + + +def _exception_causes(exc: BaseException) -> tuple[BaseException, ...]: + causes: list[BaseException] = [] + seen = {id(exc)} + current: BaseException | None = exc + while current is not None and len(causes) < _MAX_CAUSE_CHAIN_DEPTH: + next_exc = current.__cause__ or current.__context__ + if next_exc is None or id(next_exc) in seen: + break + seen.add(id(next_exc)) + causes.append(next_exc) + current = next_exc + return tuple(causes) + + +def _exception_cause_chain_text(exc: BaseException) -> str | None: + lines: list[str] = [] + for cause in _exception_causes(exc): + raw_text = str(cause).strip() + lines.append( + f"{type(cause).__name__}: {redact_sensitive_error_text(raw_text)}" + if raw_text + else type(cause).__name__ + ) + if not lines: + return None + text, _ = _cap_text_bytes("\n".join(lines)) + return text + + +def _category_hint_from_body(body: Any, body_text: str | None) -> str | None: + parsed = body + if isinstance(parsed, bytes): + parsed = parsed.decode("utf-8", errors="replace") + if isinstance(parsed, str): + try: + parsed = json.loads(parsed) + except ValueError: + parsed = None + if isinstance(parsed, dict): + error = parsed.get("error") + if isinstance(error, dict): + for key in ("type", "code"): + value = error.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + for key in ("type", "code"): + value = parsed.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + if ( + body_text + and "model" in body_text.lower() + and "unsupported" in body_text.lower() + ): + return "upstream_model_error" + return None + + +def _append_request_id_lines(lines: list[str], request_id: str | None) -> None: + if request_id: + lines.extend(("", f"Request ID: {request_id}")) diff --git a/src/free_claude_code/core/failures.py b/src/free_claude_code/core/failures.py new file mode 100644 index 0000000..c2dacbf --- /dev/null +++ b/src/free_claude_code/core/failures.py @@ -0,0 +1,49 @@ +"""Protocol-neutral execution failure semantics.""" + +from dataclasses import FrozenInstanceError, dataclass +from enum import StrEnum + + +class FailureKind(StrEnum): + """Stable failure categories shared across execution and wire adapters.""" + + INVALID_REQUEST = "invalid_request" + AUTHENTICATION = "authentication" + PERMISSION = "permission" + RATE_LIMIT = "rate_limit" + OVERLOADED = "overloaded" + TIMEOUT = "timeout" + UPSTREAM = "upstream" + UNAVAILABLE = "unavailable" + + +@dataclass(slots=True, eq=False) +class ExecutionFailure(Exception): + """A finalized provider-execution failure independent of any wire protocol.""" + + kind: FailureKind + status_code: int + message: str + retryable: bool + + def __post_init__(self) -> None: + Exception.__init__(self, self.message) + + def __setattr__(self, name: str, value: object) -> None: + # Exception machinery must be able to update __traceback__, __cause__, + # and __context__ while semantic failure fields remain immutable. + if name in self.__slots__ and hasattr(self, name): + raise FrozenInstanceError(f"cannot assign to field {name!r}") + super().__setattr__(name, value) + + +def find_execution_failure(exc: BaseException) -> ExecutionFailure | None: + """Return the first canonical failure in an exception or nested group.""" + pending = [exc] + while pending: + current = pending.pop() + if isinstance(current, ExecutionFailure): + return current + if isinstance(current, BaseExceptionGroup): + pending.extend(reversed(current.exceptions)) + return None diff --git a/src/free_claude_code/core/gateway_model_ids.py b/src/free_claude_code/core/gateway_model_ids.py new file mode 100644 index 0000000..c7db23e --- /dev/null +++ b/src/free_claude_code/core/gateway_model_ids.py @@ -0,0 +1,52 @@ +"""Gateway-safe model ID encoding shared by API and CLI adapters.""" + +from dataclasses import dataclass + +GATEWAY_MODEL_ID_PREFIX = "anthropic" + +# Claude Code currently treats any model id containing ``claude-3-`` as not +# supporting thinking. This intentionally uses that client-side capability +# heuristic while keeping the real provider/model ref reversible for routing. +NO_THINKING_GATEWAY_MODEL_ID_PREFIX = "claude-3-freecc-no-thinking" + + +@dataclass(frozen=True, slots=True) +class DecodedGatewayModelId: + provider_id: str + provider_model: str + force_thinking_enabled: bool | None = None + + +def gateway_model_id(provider_model_ref: str) -> str: + """Return the normal Claude Code-discoverable id for a provider/model ref.""" + return f"{GATEWAY_MODEL_ID_PREFIX}/{provider_model_ref}" + + +def no_thinking_gateway_model_id(provider_model_ref: str) -> str: + """Return a Claude Code-discoverable id that disables client thinking.""" + return f"{NO_THINKING_GATEWAY_MODEL_ID_PREFIX}/{provider_model_ref}" + + +def decode_gateway_model_id(model_name: str) -> DecodedGatewayModelId | None: + """Decode a model id advertised by this gateway, if it is one.""" + prefix, separator, remainder = model_name.partition("/") + if not separator: + return None + + force_thinking_enabled: bool | None + if prefix == GATEWAY_MODEL_ID_PREFIX: + force_thinking_enabled = None + elif prefix == NO_THINKING_GATEWAY_MODEL_ID_PREFIX: + force_thinking_enabled = False + else: + return None + + provider_id, provider_separator, provider_model = remainder.partition("/") + if not provider_separator or not provider_model: + return None + + return DecodedGatewayModelId( + provider_id=provider_id, + provider_model=provider_model, + force_thinking_enabled=force_thinking_enabled, + ) diff --git a/src/free_claude_code/core/openai_responses/__init__.py b/src/free_claude_code/core/openai_responses/__init__.py new file mode 100644 index 0000000..3835f8c --- /dev/null +++ b/src/free_claude_code/core/openai_responses/__init__.py @@ -0,0 +1,17 @@ +"""OpenAI Responses protocol adapter.""" + +from .adapter import OpenAIResponsesAdapter +from .errors import ( + openai_error_payload, + openai_error_type_for_failure, + openai_failure_payload, +) +from .models import OpenAIResponsesRequest + +__all__ = [ + "OpenAIResponsesAdapter", + "OpenAIResponsesRequest", + "openai_error_payload", + "openai_error_type_for_failure", + "openai_failure_payload", +] diff --git a/src/free_claude_code/core/openai_responses/adapter.py b/src/free_claude_code/core/openai_responses/adapter.py new file mode 100644 index 0000000..bb0e2ac --- /dev/null +++ b/src/free_claude_code/core/openai_responses/adapter.py @@ -0,0 +1,39 @@ +"""Facade for OpenAI Responses protocol adaptation.""" + +from collections.abc import AsyncIterable, AsyncIterator +from typing import Any, ClassVar + +from .errors import ResponsesConversionError, openai_error_payload +from .events import OPENAI_RESPONSES_SSE_HEADERS +from .input import convert_request_to_anthropic_payload +from .models import OpenAIResponsesRequest +from .stream import ( + PostStartTerminalFailureObserver, + iter_responses_sse_from_anthropic, +) + + +class OpenAIResponsesAdapter: + """Convert between OpenAI Responses and the proxy's Anthropic core path.""" + + ConversionError: ClassVar[type[ResponsesConversionError]] = ResponsesConversionError + sse_headers: ClassVar[dict[str, str]] = OPENAI_RESPONSES_SSE_HEADERS + + def to_anthropic_payload(self, request: OpenAIResponsesRequest) -> dict[str, Any]: + return convert_request_to_anthropic_payload(request) + + def iter_sse_from_anthropic( + self, + chunks: AsyncIterable[Any], + request: OpenAIResponsesRequest, + *, + on_post_start_terminal_failure: PostStartTerminalFailureObserver | None = None, + ) -> AsyncIterator[str]: + return iter_responses_sse_from_anthropic( + chunks, + request, + on_post_start_terminal_failure=on_post_start_terminal_failure, + ) + + def error_payload(self, *, message: str, error_type: str) -> dict[str, Any]: + return openai_error_payload(message=message, error_type=error_type) diff --git a/src/free_claude_code/core/openai_responses/anthropic_sse.py b/src/free_claude_code/core/openai_responses/anthropic_sse.py new file mode 100644 index 0000000..10f4794 --- /dev/null +++ b/src/free_claude_code/core/openai_responses/anthropic_sse.py @@ -0,0 +1,69 @@ +"""Anthropic SSE parsing used by the Responses stream adapter.""" + +import json +import sys +from collections.abc import AsyncIterable, AsyncIterator +from dataclasses import dataclass +from typing import Any + +from free_claude_code.core.trace import close_stream_input + + +@dataclass(slots=True) +class AnthropicSseEvent: + event: str + data: dict[str, Any] + + +async def iter_sse_events( + chunks: AsyncIterable[Any], +) -> AsyncIterator[AnthropicSseEvent]: + buffer = "" + iterator = aiter(chunks) + try: + async for chunk in iterator: + if isinstance(chunk, bytes): + buffer += chunk.decode("utf-8", errors="replace") + else: + buffer += str(chunk) + + while "\n\n" in buffer: + raw, buffer = buffer.split("\n\n", 1) + event = parse_sse_event(raw) + if event is not None: + yield event + + if buffer.strip(): + event = parse_sse_event(buffer) + if event is not None: + yield event + finally: + await close_stream_input( + iterator, + owner="openai_responses.anthropic_sse", + source="core", + preserved_error=sys.exception(), + ) + + +def parse_sse_event(raw: str) -> AnthropicSseEvent | None: + event_type = "" + data_parts: list[str] = [] + for line in raw.splitlines(): + stripped = line.rstrip("\r") + if stripped.startswith("event:"): + event_type = stripped.split(":", 1)[1].strip() + elif stripped.startswith("data:"): + data_parts.append(stripped.split(":", 1)[1].strip()) + if not event_type and not data_parts: + return None + data_text = "\n".join(data_parts) + if data_text == "[DONE]": + return None + try: + parsed = json.loads(data_text) if data_text else {} + except json.JSONDecodeError: + parsed = {"raw": data_text} + if not isinstance(parsed, dict): + parsed = {"value": parsed} + return AnthropicSseEvent(event=event_type, data=parsed) diff --git a/src/free_claude_code/core/openai_responses/errors.py b/src/free_claude_code/core/openai_responses/errors.py new file mode 100644 index 0000000..1e28867 --- /dev/null +++ b/src/free_claude_code/core/openai_responses/errors.py @@ -0,0 +1,67 @@ +"""Errors and error envelopes for OpenAI Responses compatibility.""" + +from typing import Any + +from free_claude_code.core.diagnostics import redact_sensitive_error_text +from free_claude_code.core.failures import ExecutionFailure, FailureKind + +_FAILURE_ERROR_TYPES = { + FailureKind.INVALID_REQUEST: "invalid_request_error", + FailureKind.AUTHENTICATION: "authentication_error", + FailureKind.PERMISSION: "permission_error", + FailureKind.RATE_LIMIT: "rate_limit_error", + FailureKind.OVERLOADED: "overloaded_error", + FailureKind.TIMEOUT: "api_error", + FailureKind.UPSTREAM: "api_error", + FailureKind.UNAVAILABLE: "api_error", +} + + +class ResponsesConversionError(ValueError): + """Raised when a Responses request cannot be converted deterministically.""" + + +def openai_error_type_for_failure( + failure: FailureKind | ExecutionFailure, +) -> str: + """Map neutral failure semantics to an OpenAI-compatible error type.""" + if isinstance(failure, ExecutionFailure): + if failure.kind == FailureKind.PERMISSION and failure.status_code == 402: + return "billing_error" + if failure.kind == FailureKind.INVALID_REQUEST: + if failure.status_code == 404: + return "not_found_error" + if failure.status_code == 413: + return "request_too_large" + if failure.kind == FailureKind.TIMEOUT and failure.status_code == 504: + return "timeout_error" + kind = failure.kind + else: + kind = failure + return _FAILURE_ERROR_TYPES[kind] + + +def openai_error_payload(*, message: str, error_type: str) -> dict[str, Any]: + """Return an OpenAI-compatible error envelope.""" + + return { + "error": { + "message": redact_sensitive_error_text(message), + "type": error_type, + "param": None, + "code": None, + } + } + + +def openai_error_from_failure(failure: ExecutionFailure) -> dict[str, Any]: + """Return the inner OpenAI error object for a canonical failure.""" + return openai_error_payload( + message=failure.message, + error_type=openai_error_type_for_failure(failure), + )["error"] + + +def openai_failure_payload(failure: ExecutionFailure) -> dict[str, Any]: + """Return an OpenAI-compatible envelope for a canonical failure.""" + return {"error": openai_error_from_failure(failure)} diff --git a/src/free_claude_code/core/openai_responses/events.py b/src/free_claude_code/core/openai_responses/events.py new file mode 100644 index 0000000..0cbec42 --- /dev/null +++ b/src/free_claude_code/core/openai_responses/events.py @@ -0,0 +1,17 @@ +"""OpenAI Responses SSE event formatting.""" + +import json +from collections.abc import Mapping +from typing import Any + +OPENAI_RESPONSES_SSE_HEADERS: dict[str, str] = { + "X-Accel-Buffering": "no", + "Cache-Control": "no-cache", + "Connection": "keep-alive", +} + + +def format_response_sse_event(event_type: str, data: Mapping[str, Any]) -> str: + """Format one OpenAI Responses SSE event.""" + + return f"event: {event_type}\ndata: {json.dumps(data)}\n\n" diff --git a/src/free_claude_code/core/openai_responses/ids.py b/src/free_claude_code/core/openai_responses/ids.py new file mode 100644 index 0000000..0ef53f0 --- /dev/null +++ b/src/free_claude_code/core/openai_responses/ids.py @@ -0,0 +1,19 @@ +"""Identifier helpers for OpenAI Responses payloads.""" + +import uuid + + +def new_response_id() -> str: + return f"resp_{uuid.uuid4().hex}" + + +def new_message_item_id() -> str: + return f"msg_{uuid.uuid4().hex}" + + +def new_reasoning_item_id() -> str: + return f"rs_{uuid.uuid4().hex}" + + +def new_call_id() -> str: + return f"call_{uuid.uuid4().hex[:24]}" diff --git a/src/free_claude_code/core/openai_responses/input.py b/src/free_claude_code/core/openai_responses/input.py new file mode 100644 index 0000000..3fc2232 --- /dev/null +++ b/src/free_claude_code/core/openai_responses/input.py @@ -0,0 +1,357 @@ +"""Convert OpenAI Responses requests into Anthropic Messages payloads.""" + +from collections.abc import Mapping +from typing import Any + +from free_claude_code.core.trace import trace_event + +from .errors import ResponsesConversionError +from .models import OpenAIResponsesRequest +from .reasoning import ( + combine_reasoning, + reasoning_text_from_item, + responses_reasoning_to_thinking, +) +from .tools import ( + call_id_from_item, + convert_tool_choice, + convert_tools, + custom_tool_input_to_anthropic, + optional_str, + parse_arguments, + required_str, + responses_tool_name_to_anthropic_name, +) + + +def convert_request_to_anthropic_payload( + request: OpenAIResponsesRequest, +) -> dict[str, Any]: + """Convert an OpenAI Responses request into an Anthropic Messages payload.""" + + system_parts: list[str] = [] + if instructions := request.instructions: + system_parts.append(instructions) + + messages: list[dict[str, Any]] = [] + pending_reasoning: str | None = None + quarantined_function_call_ids: set[str] = set() + for item in _iter_input_items(request.input): + pending_reasoning = _append_input_item( + item, + messages=messages, + system_parts=system_parts, + pending_reasoning=pending_reasoning, + quarantined_function_call_ids=quarantined_function_call_ids, + ) + _append_pending_reasoning(messages, pending_reasoning) + + if not messages: + raise ResponsesConversionError("Responses request input must contain a message") + + payload: dict[str, Any] = { + "model": required_str(request.model, "model"), + "messages": messages, + "stream": True, + } + if system_parts: + payload["system"] = "\n\n".join(system_parts) + if request.temperature is not None: + payload["temperature"] = request.temperature + if request.top_p is not None: + payload["top_p"] = request.top_p + if request.max_output_tokens is not None: + payload["max_tokens"] = request.max_output_tokens + if request.metadata is not None: + payload["metadata"] = request.metadata + + if thinking := responses_reasoning_to_thinking(request.reasoning): + payload["thinking"] = thinking + + raw_tool_choice = request.tool_choice + tools = convert_tools(request.tools) + if tools and raw_tool_choice != "none": + payload["tools"] = tools + tool_choice = convert_tool_choice(raw_tool_choice) + if tool_choice is not None: + payload["tool_choice"] = tool_choice + + return payload + + +def _append_input_item( + item: Any, + *, + messages: list[dict[str, Any]], + system_parts: list[str], + pending_reasoning: str | None, + quarantined_function_call_ids: set[str], +) -> str | None: + if isinstance(item, str): + _append_pending_reasoning(messages, pending_reasoning) + messages.append({"role": "user", "content": item}) + return None + if not isinstance(item, dict): + raise ResponsesConversionError( + f"Unsupported Responses input item: {type(item).__name__}" + ) + + item_type = item.get("type") + if item_type in (None, "message") or "role" in item: + role = required_str(item.get("role", "user"), "input.role") + if role == "assistant": + _append_message_item( + role, + item.get("content", ""), + messages, + system_parts, + reasoning_content=pending_reasoning, + ) + return None + _append_pending_reasoning(messages, pending_reasoning) + _append_message_item(role, item.get("content", ""), messages, system_parts) + return None + if item_type in {"function_call", "custom_tool_call"}: + namespace = optional_str(item.get("namespace")) + field_name = f"{item_type}.name" + name = required_str(item.get("name"), field_name) + call_id = call_id_from_item(item) + if item_type == "custom_tool_call": + tool_input = custom_tool_input_to_anthropic(item.get("input")) + else: + try: + tool_input = parse_arguments(item.get("arguments")) + except ResponsesConversionError as exc: + quarantined_function_call_ids.add(call_id) + _trace_quarantined_function_call(call_id, exc) + return pending_reasoning + tool_use = { + "type": "tool_use", + "id": call_id, + "name": responses_tool_name_to_anthropic_name(name, namespace=namespace), + "input": tool_input, + } + _append_tool_use_message( + messages, + tool_use, + reasoning_content=pending_reasoning, + ) + return None + if item_type in {"function_call_output", "custom_tool_call_output"}: + call_id = call_id_from_item(item) + if ( + item_type == "function_call_output" + and call_id in quarantined_function_call_ids + ): + return pending_reasoning + _append_pending_reasoning_before_tool_output(messages, pending_reasoning) + _append_tool_result_message( + messages, + { + "type": "tool_result", + "tool_use_id": call_id, + "content": item.get("output", ""), + }, + ) + return None + if item_type == "reasoning": + return combine_reasoning(pending_reasoning, reasoning_text_from_item(item)) + if item_type in {"input_text", "output_text", "text"}: + _append_pending_reasoning(messages, pending_reasoning) + messages.append({"role": "user", "content": _text_from_part(item)}) + return None + + raise ResponsesConversionError( + f"Unsupported Responses input item type: {item_type!r}" + ) + + +def _trace_quarantined_function_call( + call_id: str, exc: ResponsesConversionError +) -> None: + trace_event( + stage="responses", + event="responses.input.function_call_quarantined", + source="openai_responses", + call_id=call_id, + error_type=type(exc).__name__, + ) + + +def _append_message_item( + role: str, + content: Any, + messages: list[dict[str, Any]], + system_parts: list[str], + *, + reasoning_content: str | None = None, +) -> None: + normalized_role = "system" if role == "developer" else role + if normalized_role == "system": + text = _content_as_text(content) + if text: + system_parts.append(text) + return + if normalized_role not in {"user", "assistant"}: + raise ResponsesConversionError(f"Unsupported Responses message role: {role!r}") + message = { + "role": normalized_role, + "content": _convert_message_content(content), + } + if normalized_role == "assistant" and reasoning_content is not None: + message["reasoning_content"] = reasoning_content + messages.append(message) + + +def _append_pending_reasoning( + messages: list[dict[str, Any]], pending_reasoning: str | None +) -> None: + if pending_reasoning is not None: + messages.append( + { + "role": "assistant", + "content": "", + "reasoning_content": pending_reasoning, + } + ) + + +def _append_pending_reasoning_before_tool_output( + messages: list[dict[str, Any]], pending_reasoning: str | None +) -> None: + if pending_reasoning is None: + return + message = _last_assistant_tool_use_message(messages) + if message is None: + _append_pending_reasoning(messages, pending_reasoning) + return + _merge_message_reasoning(message, pending_reasoning) + + +def _append_tool_use_message( + messages: list[dict[str, Any]], + tool_use: dict[str, Any], + *, + reasoning_content: str | None, +) -> None: + message = _last_assistant_tool_use_message(messages) + if message is None: + message = {"role": "assistant", "content": []} + messages.append(message) + if reasoning_content is not None: + _merge_message_reasoning(message, reasoning_content) + content = message["content"] + if isinstance(content, list): + content.append(tool_use) + + +def _append_tool_result_message( + messages: list[dict[str, Any]], + tool_result: dict[str, Any], +) -> None: + message = _last_user_tool_result_message(messages) + if message is None: + message = {"role": "user", "content": []} + messages.append(message) + content = message["content"] + if isinstance(content, list): + content.append(tool_result) + + +def _last_assistant_tool_use_message( + messages: list[dict[str, Any]], +) -> dict[str, Any] | None: + if not messages: + return None + message = messages[-1] + if message.get("role") != "assistant": + return None + content = message.get("content") + if not isinstance(content, list) or not content: + return None + if all( + isinstance(block, dict) and block.get("type") == "tool_use" for block in content + ): + return message + return None + + +def _last_user_tool_result_message( + messages: list[dict[str, Any]], +) -> dict[str, Any] | None: + if not messages: + return None + message = messages[-1] + if message.get("role") != "user": + return None + content = message.get("content") + if not isinstance(content, list) or not content: + return None + if all( + isinstance(block, dict) and block.get("type") == "tool_result" + for block in content + ): + return message + return None + + +def _merge_message_reasoning(message: dict[str, Any], reasoning: str) -> None: + existing = message.get("reasoning_content") + existing_reasoning = existing if isinstance(existing, str) else None + message["reasoning_content"] = combine_reasoning(existing_reasoning, reasoning) + + +def _iter_input_items(value: Any) -> list[Any]: + if value is None: + return [] + if isinstance(value, list): + return value + return [value] + + +def _convert_message_content(content: Any) -> str | list[dict[str, Any]]: + if isinstance(content, str): + return content + if isinstance(content, list): + blocks: list[dict[str, Any]] = [] + for part in content: + if isinstance(part, str): + blocks.append({"type": "text", "text": part}) + continue + if not isinstance(part, dict): + raise ResponsesConversionError( + f"Unsupported Responses content part: {type(part).__name__}" + ) + part_type = part.get("type") + if part_type in {"input_text", "output_text", "text"} or "text" in part: + blocks.append({"type": "text", "text": _text_from_part(part)}) + continue + if part_type == "refusal": + blocks.append({"type": "text", "text": str(part.get("refusal", ""))}) + continue + raise ResponsesConversionError( + f"Unsupported Responses content part type: {part_type!r}" + ) + return blocks + if isinstance(content, dict): + return [{"type": "text", "text": _text_from_part(content)}] + raise ResponsesConversionError( + f"Unsupported Responses message content: {type(content).__name__}" + ) + + +def _content_as_text(content: Any) -> str: + converted = _convert_message_content(content) + if isinstance(converted, str): + return converted + return "\n".join(str(block.get("text", "")) for block in converted) + + +def _text_from_part(part: Mapping[str, Any]) -> str: + if text := optional_str(part.get("text")): + return text + if text := optional_str(part.get("input_text")): + return text + if text := optional_str(part.get("output_text")): + return text + return "" diff --git a/src/free_claude_code/core/openai_responses/items.py b/src/free_claude_code/core/openai_responses/items.py new file mode 100644 index 0000000..e3f9bd5 --- /dev/null +++ b/src/free_claude_code/core/openai_responses/items.py @@ -0,0 +1,35 @@ +"""Responses object and output item builders.""" + +from typing import Any + + +def message_item(item_id: str, text: str, status: str) -> dict[str, Any]: + return { + "id": item_id, + "type": "message", + "status": status, + "role": "assistant", + "content": [{"type": "output_text", "text": text, "annotations": []}], + } + + +def reasoning_item(item_id: str, text: str, status: str) -> dict[str, Any]: + return { + "id": item_id, + "type": "reasoning", + "status": status, + "summary": [], + "content": [{"type": "reasoning_text", "text": text}], + } + + +def encrypted_reasoning_item( + item_id: str, encrypted_content: str, status: str +) -> dict[str, Any]: + return { + "id": item_id, + "type": "reasoning", + "status": status, + "summary": [], + "encrypted_content": encrypted_content, + } diff --git a/src/free_claude_code/core/openai_responses/models.py b/src/free_claude_code/core/openai_responses/models.py new file mode 100644 index 0000000..ec80b44 --- /dev/null +++ b/src/free_claude_code/core/openai_responses/models.py @@ -0,0 +1,26 @@ +"""Pydantic models for OpenAI Responses-compatible ingress.""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict + + +class OpenAIResponsesRequest(BaseModel): + """Permissive subset of the OpenAI Responses API request shape.""" + + model_config = ConfigDict(extra="allow") + + model: str + input: Any = None + instructions: str | None = None + tools: list[dict[str, Any]] | None = None + tool_choice: Any = None + parallel_tool_calls: bool | None = None + stream: bool | None = True + temperature: float | None = None + top_p: float | None = None + max_output_tokens: int | None = None + metadata: dict[str, Any] | None = None + reasoning: dict[str, Any] | None = None + previous_response_id: str | None = None + store: bool | None = None diff --git a/src/free_claude_code/core/openai_responses/reasoning.py b/src/free_claude_code/core/openai_responses/reasoning.py new file mode 100644 index 0000000..12a732f --- /dev/null +++ b/src/free_claude_code/core/openai_responses/reasoning.py @@ -0,0 +1,54 @@ +"""Reasoning and thinking conversion helpers for OpenAI Responses.""" + +from collections.abc import Mapping +from typing import Any + +from .tools import optional_str + + +def reasoning_text_from_item(item: Mapping[str, Any]) -> str | None: + content_parts = _text_parts_from_items( + item.get("content"), item_type="reasoning_text" + ) + if content_parts: + return "\n".join(content_parts) + summary_parts = _text_parts_from_items( + item.get("summary"), item_type="summary_text" + ) + if summary_parts: + return "\n".join(summary_parts) + return None + + +def combine_reasoning(existing: str | None, addition: str | None) -> str | None: + if addition is None: + return existing + if existing is None: + return addition + if existing == "": + return addition + if addition == "": + return existing + return f"{existing}\n{addition}" + + +def responses_reasoning_to_thinking(value: Any) -> dict[str, Any] | None: + if not isinstance(value, Mapping): + return None + if value.get("effort") == "none": + return {"type": "disabled", "enabled": False} + if any(item is not None for item in value.values()): + return {"type": "enabled", "enabled": True} + return None + + +def _text_parts_from_items(value: Any, *, item_type: str) -> list[str]: + if not isinstance(value, list): + return [] + parts: list[str] = [] + for item in value: + if isinstance(item, dict) and item.get("type") == item_type: + text = optional_str(item.get("text")) + if text is not None: + parts.append(text) + return parts diff --git a/src/free_claude_code/core/openai_responses/stream.py b/src/free_claude_code/core/openai_responses/stream.py new file mode 100644 index 0000000..833c31e --- /dev/null +++ b/src/free_claude_code/core/openai_responses/stream.py @@ -0,0 +1,92 @@ +"""Translate Anthropic SSE streams into OpenAI Responses SSE streams.""" + +import asyncio +import sys +from collections.abc import AsyncIterable, AsyncIterator, Callable +from typing import Any + +from free_claude_code.core.diagnostics import safe_exception_message +from free_claude_code.core.failures import ExecutionFailure, find_execution_failure +from free_claude_code.core.trace import close_stream_input + +from .anthropic_sse import iter_sse_events +from .models import OpenAIResponsesRequest +from .streaming import ResponsesStreamAssembler + +PostStartTerminalFailureObserver = Callable[[BaseException], None] + + +async def iter_responses_sse_from_anthropic( + chunks: AsyncIterable[Any], + request: OpenAIResponsesRequest, + *, + on_post_start_terminal_failure: PostStartTerminalFailureObserver | None = None, +) -> AsyncIterator[str]: + """Yield Responses SSE events translated from an Anthropic SSE stream.""" + assembler = ResponsesStreamAssembler(request) + emitted_any_chunk = False + events = iter_sse_events(chunks) + try: + async for event in events: + for chunk in assembler.process_anthropic_event(event): + yield chunk + emitted_any_chunk = True + if assembler.terminal: + return + for chunk in assembler.finish_if_needed(): + yield chunk + emitted_any_chunk = True + except GeneratorExit: + raise + except asyncio.CancelledError: + raise + except ExecutionFailure as exc: + if not emitted_any_chunk: + raise + _observe_post_start_terminal_failure(on_post_start_terminal_failure, exc) + for chunk in assembler.fail_execution(exc): + yield chunk + except BaseExceptionGroup as exc: + if not emitted_any_chunk: + raise + failure = find_execution_failure(exc) + if failure is not None: + _observe_post_start_terminal_failure( + on_post_start_terminal_failure, failure + ) + for chunk in assembler.fail_execution(failure): + yield chunk + else: + _observe_post_start_terminal_failure(on_post_start_terminal_failure, exc) + for chunk in assembler.fail_response(_unexpected_error_data(exc)): + yield chunk + except Exception as exc: + if not emitted_any_chunk: + raise + _observe_post_start_terminal_failure(on_post_start_terminal_failure, exc) + for chunk in assembler.fail_response(_unexpected_error_data(exc)): + yield chunk + finally: + await close_stream_input( + events, + owner="openai_responses.stream", + source="core", + preserved_error=sys.exception(), + ) + + +def _observe_post_start_terminal_failure( + observer: PostStartTerminalFailureObserver | None, + exc: BaseException, +) -> None: + if observer is not None: + observer(exc) + + +def _unexpected_error_data(exc: BaseException) -> dict[str, dict[str, str]]: + return { + "error": { + "type": "api_error", + "message": safe_exception_message(exc), + } + } diff --git a/src/free_claude_code/core/openai_responses/streaming/__init__.py b/src/free_claude_code/core/openai_responses/streaming/__init__.py new file mode 100644 index 0000000..fc3056a --- /dev/null +++ b/src/free_claude_code/core/openai_responses/streaming/__init__.py @@ -0,0 +1,5 @@ +"""OpenAI Responses streaming assembly internals.""" + +from .assembler import ResponsesStreamAssembler + +__all__ = ["ResponsesStreamAssembler"] diff --git a/src/free_claude_code/core/openai_responses/streaming/assembler.py b/src/free_claude_code/core/openai_responses/streaming/assembler.py new file mode 100644 index 0000000..1511e3f --- /dev/null +++ b/src/free_claude_code/core/openai_responses/streaming/assembler.py @@ -0,0 +1,354 @@ +"""Anthropic SSE to OpenAI Responses stream assembly.""" + +import json +import time +import uuid +from collections.abc import Mapping +from typing import Any + +from free_claude_code.core.failures import ExecutionFailure +from free_claude_code.core.trace import trace_event + +from ..anthropic_sse import AnthropicSseEvent +from ..errors import ResponsesConversionError, openai_error_from_failure +from ..ids import ( + new_call_id, + new_message_item_id, + new_reasoning_item_id, + new_response_id, +) +from ..models import OpenAIResponsesRequest +from ..tools import responses_tool_identity_from_anthropic_name +from . import event_builders as events +from .blocks import ReasoningBlockState, TextBlockState, ToolBlockState +from .completion import ResponseBlockCompleter, reasoning_output_item, tool_item +from .error_mapping import ( + openai_error_from_anthropic_error, + replay_unsafe_function_call_error, +) +from .ledger import ResponsesOutputLedger + + +class ResponsesStreamAssembler: + """Assemble Responses SSE events from indexed Anthropic content blocks.""" + + def __init__(self, request: OpenAIResponsesRequest) -> None: + self._request = request + self._response_id = new_response_id() + self._created_at = int(time.time()) + self._ledger = ResponsesOutputLedger() + self._completer = ResponseBlockCompleter( + self._ledger, + on_invalid_function_call=self._fail_invalid_function_call, + ) + self._started = False + self._provisional_error: dict[str, Any] | None = None + self.terminal = False + self.final_response: dict[str, Any] | None = None + + def process_anthropic_event(self, event: AnthropicSseEvent) -> list[str]: + if self.terminal: + return [] + + chunks = self._ensure_started() + if event.event == "content_block_start": + chunks.extend(self._handle_content_block_start(event.data)) + elif event.event == "content_block_delta": + chunks.extend(self._handle_content_block_delta(event.data)) + elif event.event == "content_block_stop": + chunks.extend(self._handle_content_block_stop(event.data)) + elif event.event == "message_delta": + self._ledger.record_usage_delta(event.data) + elif event.event == "message_stop": + chunks.extend(self.complete_response()) + elif event.event == "error": + chunks.extend(self.fail_response(event.data)) + return chunks + + def finish_if_needed(self) -> list[str]: + if self.terminal: + return [] + chunks = self._ensure_started() + chunks.extend(self.complete_response()) + return chunks + + def response_payload( + self, *, status: str, error: dict[str, Any] | None = None + ) -> dict[str, Any]: + return { + "id": self._response_id, + "object": "response", + "created_at": self._created_at, + "status": status, + "model": self._request.model, + "output": self._ledger.output(), + "parallel_tool_calls": ( + True + if self._request.parallel_tool_calls is None + else self._request.parallel_tool_calls + ), + "tool_choice": ( + "auto" + if self._request.tool_choice is None + else self._request.tool_choice + ), + "temperature": self._request.temperature, + "top_p": self._request.top_p, + "max_output_tokens": self._request.max_output_tokens, + "usage": self._ledger.usage(), + "error": error, + } + + def complete_response(self) -> list[str]: + chunks = self._flush_active_blocks() + if self.terminal: + return chunks + if self._provisional_error is not None: + chunks.extend(self._finish_failed_response(self._provisional_error)) + return chunks + self.final_response = self.response_payload(status="completed") + chunks.append(events.response_completed(self.final_response)) + self.terminal = True + return chunks + + def fail_response(self, data: Mapping[str, Any]) -> list[str]: + chunks = self._flush_active_blocks() + if self.terminal: + return chunks + error = openai_error_from_anthropic_error(data) + chunks.extend(self._finish_failed_response(error)) + return chunks + + def fail_execution(self, failure: ExecutionFailure) -> list[str]: + """Finish the current response with a canonical execution failure.""" + chunks = self._flush_active_blocks() + if self.terminal: + return chunks + chunks.extend(self._finish_failed_response(openai_error_from_failure(failure))) + return chunks + + def _finish_failed_response(self, error: dict[str, Any]) -> list[str]: + self._provisional_error = None + self.final_response = self.response_payload(status="failed", error=error) + self.terminal = True + return [events.response_failed(self.final_response)] + + def _ensure_started(self) -> list[str]: + if self._started: + return [] + self._started = True + return [events.response_created(self.response_payload(status="in_progress"))] + + def _handle_content_block_start(self, data: Mapping[str, Any]) -> list[str]: + block = data.get("content_block") + if not isinstance(block, dict): + return [] + block_type = block.get("type") + index = _event_index(data) + if block_type == "text": + index = self._ledger.safe_text_index(index) + chunks, state = self._start_text_block(index) + if state is None: + return chunks + if text := _string_value(block.get("text")): + chunks.extend(self._emit_text_delta(state, text)) + return chunks + if block_type == "thinking": + if index is None: + return [] + chunks, state = self._start_reasoning_block(index) + if state is None: + return chunks + if text := _string_value(block.get("thinking")): + chunks.extend(self._emit_reasoning_delta(state, text)) + return chunks + if block_type == "redacted_thinking": + if index is None: + return [] + chunks, _state = self._start_reasoning_block( + index, encrypted_content=_string_value(block.get("data")) + ) + return chunks + if block_type == "tool_use": + if index is None: + return [] + return self._start_tool_block(index, block) + return [] + + def _handle_content_block_delta(self, data: Mapping[str, Any]) -> list[str]: + delta = data.get("delta") + if not isinstance(delta, dict): + return [] + delta_type = delta.get("type") + index = _event_index(data) + if delta_type == "text_delta": + index = self._ledger.safe_text_index(index) + state = self._ledger.active_block(index) + chunks: list[str] = [] + if not isinstance(state, TextBlockState): + chunks, state = self._start_text_block(index) + if state is None: + return chunks + chunks.extend( + self._emit_text_delta(state, _string_value(delta.get("text"))) + ) + return chunks + if delta_type == "thinking_delta": + if index is None: + return [] + state = self._ledger.active_block(index) + chunks = [] + if not isinstance(state, ReasoningBlockState): + chunks, state = self._start_reasoning_block(index) + if state is None: + return chunks + chunks.extend( + self._emit_reasoning_delta(state, _string_value(delta.get("thinking"))) + ) + return chunks + if delta_type == "input_json_delta": + state = self._ledger.active_block(index) if index is not None else None + if isinstance(state, ToolBlockState): + state.argument_parts.append(_string_value(delta.get("partial_json"))) + return [] + + def _handle_content_block_stop(self, data: Mapping[str, Any]) -> list[str]: + index = _event_index(data) + if index is None: + return [] + state = self._ledger.pop_active_block(index) + if state is None: + return [] + return self._completer.complete_block(state) + + def _start_text_block(self, index: int) -> tuple[list[str], TextBlockState | None]: + chunks = self._complete_existing_block(index) + if self.terminal: + return chunks, None + output_index = self._ledger.reserve_output_slot() + state = TextBlockState( + index=index, + output_index=output_index, + item_id=new_message_item_id(), + ) + self._ledger.set_active_block(state) + item = { + "id": state.item_id, + "type": "message", + "status": "in_progress", + "role": "assistant", + "content": [], + } + chunks.extend( + [ + events.output_item_added(output_index, item), + events.content_part_added(state.item_id, output_index), + ] + ) + return chunks, state + + def _start_reasoning_block( + self, index: int, *, encrypted_content: str | None = None + ) -> tuple[list[str], ReasoningBlockState | None]: + chunks = self._complete_existing_block(index) + if self.terminal: + return chunks, None + output_index = self._ledger.reserve_output_slot() + state = ReasoningBlockState( + index=index, + output_index=output_index, + item_id=new_reasoning_item_id(), + encrypted_content=encrypted_content, + ) + self._ledger.set_active_block(state) + chunks.append( + events.output_item_added( + output_index, + reasoning_output_item(state, status="in_progress"), + ) + ) + return chunks, state + + def _start_tool_block(self, index: int, block: Mapping[str, Any]) -> list[str]: + chunks = self._complete_existing_block(index) + if self.terminal: + return chunks + identity = responses_tool_identity_from_anthropic_name( + self._request.tools, _string_value(block.get("name")) + ) + state = ToolBlockState( + index=index, + output_index=self._ledger.reserve_output_slot(), + item_id=f"{'ctc' if identity.kind == 'custom' else 'fc'}_" + f"{uuid.uuid4().hex[:24]}", + call_id=_string_value(block.get("id")) or new_call_id(), + kind=identity.kind, + name=identity.name, + namespace=identity.namespace, + ) + initial_input = block.get("input") + if (identity.kind == "custom" and initial_input not in (None, {}, "")) or ( + isinstance(initial_input, dict) and initial_input + ): + state.argument_parts.append(json.dumps(initial_input)) + self._ledger.set_active_block(state) + chunks.append( + events.output_item_added( + state.output_index, + tool_item(state, status="in_progress"), + ) + ) + return chunks + + def _emit_text_delta(self, state: TextBlockState, text: str) -> list[str]: + if not text: + return [] + state.text_parts.append(text) + return [events.output_text_delta(state.item_id, state.output_index, text)] + + def _emit_reasoning_delta(self, state: ReasoningBlockState, text: str) -> list[str]: + if not text: + return [] + state.text_parts.append(text) + return [events.reasoning_text_delta(state.item_id, state.output_index, text)] + + def _complete_existing_block(self, index: int) -> list[str]: + existing = self._ledger.pop_active_block(index) + if existing is None: + return [] + return self._completer.complete_block(existing) + + def _flush_active_blocks(self) -> list[str]: + chunks: list[str] = [] + for state in self._ledger.pop_active_blocks_by_output_order(): + if self.terminal: + break + chunks.extend(self._completer.complete_block(state)) + return chunks + + def _fail_invalid_function_call( + self, state: ToolBlockState, exc: ResponsesConversionError + ) -> list[str]: + trace_event( + stage="responses", + event="responses.output.function_call_invalid_arguments", + source="openai_responses", + call_id=state.call_id, + tool_name=state.name, + error_type=type(exc).__name__, + ) + error = replay_unsafe_function_call_error() + if self._provisional_error is None: + self._provisional_error = error + return [] + + +def _event_index(data: Mapping[str, Any]) -> int | None: + value = data.get("index") + return value if isinstance(value, int) else None + + +def _string_value(value: Any) -> str: + if value is None: + return "" + return value if isinstance(value, str) else str(value) diff --git a/src/free_claude_code/core/openai_responses/streaming/blocks.py b/src/free_claude_code/core/openai_responses/streaming/blocks.py new file mode 100644 index 0000000..581f7b3 --- /dev/null +++ b/src/free_claude_code/core/openai_responses/streaming/blocks.py @@ -0,0 +1,36 @@ +"""Block state for OpenAI Responses streaming assembly.""" + +from dataclasses import dataclass, field +from typing import Literal + + +@dataclass(slots=True) +class TextBlockState: + index: int + output_index: int + item_id: str + text_parts: list[str] = field(default_factory=list) + + +@dataclass(slots=True) +class ReasoningBlockState: + index: int + output_index: int + item_id: str + text_parts: list[str] = field(default_factory=list) + encrypted_content: str | None = None + + +@dataclass(slots=True) +class ToolBlockState: + index: int + output_index: int + item_id: str + call_id: str + kind: Literal["function", "custom"] + name: str + namespace: str | None = None + argument_parts: list[str] = field(default_factory=list) + + +BlockState = TextBlockState | ReasoningBlockState | ToolBlockState diff --git a/src/free_claude_code/core/openai_responses/streaming/completion.py b/src/free_claude_code/core/openai_responses/streaming/completion.py new file mode 100644 index 0000000..f118fa9 --- /dev/null +++ b/src/free_claude_code/core/openai_responses/streaming/completion.py @@ -0,0 +1,154 @@ +"""Block finalization for OpenAI Responses streams.""" + +from collections.abc import Callable + +from ..errors import ResponsesConversionError +from ..items import encrypted_reasoning_item, message_item, reasoning_item +from ..tools import ( + custom_tool_input_text_from_arguments, + normalized_function_call_arguments, +) +from . import event_builders as events +from .blocks import BlockState, ReasoningBlockState, TextBlockState, ToolBlockState +from .ledger import ResponsesOutputLedger + +InvalidFunctionCallHandler = Callable[ + [ToolBlockState, ResponsesConversionError], list[str] +] + + +class ResponseBlockCompleter: + """Finalize active Responses output blocks.""" + + def __init__( + self, + ledger: ResponsesOutputLedger, + *, + on_invalid_function_call: InvalidFunctionCallHandler, + ) -> None: + self._ledger = ledger + self._on_invalid_function_call = on_invalid_function_call + + def complete_block(self, state: BlockState) -> list[str]: + if isinstance(state, TextBlockState): + return self._complete_text_block(state) + if isinstance(state, ReasoningBlockState): + return self._complete_reasoning_block(state) + return self._complete_tool_block(state) + + def _complete_text_block(self, state: TextBlockState) -> list[str]: + text = "".join(state.text_parts) + item = message_item(state.item_id, text, "completed") + self._ledger.commit_output(state.output_index, item) + return [ + events.output_text_done(state.item_id, state.output_index, text), + events.content_part_done(state.item_id, state.output_index, text), + events.output_item_done(state.output_index, item), + ] + + def _complete_reasoning_block(self, state: ReasoningBlockState) -> list[str]: + item = _reasoning_output_item(state, status="completed") + self._ledger.commit_output(state.output_index, item) + chunks: list[str] = [] + text = "".join(state.text_parts) + if text: + self._ledger.add_reasoning_text(text) + chunks.append( + events.reasoning_text_done(state.item_id, state.output_index, text) + ) + chunks.append(events.output_item_done(state.output_index, item)) + return chunks + + def _complete_tool_block(self, state: ToolBlockState) -> list[str]: + if state.kind == "custom": + return self._complete_custom_tool_block(state) + raw_arguments = "".join(state.argument_parts) or "{}" + try: + arguments = normalized_function_call_arguments(raw_arguments) + except ResponsesConversionError as exc: + return self._on_invalid_function_call(state, exc) + item = tool_item(state, status="completed", arguments=arguments) + self._ledger.commit_output(state.output_index, item) + chunks: list[str] = [] + if arguments: + chunks.append( + events.function_call_arguments_delta( + state.item_id, state.output_index, arguments + ) + ) + chunks.extend( + [ + events.function_call_arguments_done( + state.item_id, state.output_index, arguments + ), + events.output_item_done(state.output_index, item), + ] + ) + return chunks + + def _complete_custom_tool_block(self, state: ToolBlockState) -> list[str]: + input_text = custom_tool_input_text_from_arguments( + "".join(state.argument_parts) + ) + item = tool_item(state, status="completed", input_text=input_text) + self._ledger.commit_output(state.output_index, item) + chunks: list[str] = [] + if input_text: + chunks.append( + events.custom_tool_call_input_delta( + state.item_id, state.output_index, input_text + ) + ) + chunks.extend( + [ + events.custom_tool_call_input_done( + state.item_id, state.output_index, input_text + ), + events.output_item_done(state.output_index, item), + ] + ) + return chunks + + +def tool_item( + state: ToolBlockState, + *, + status: str, + arguments: str = "", + input_text: str = "", +) -> dict[str, object]: + if state.kind == "custom": + item: dict[str, object] = { + "id": state.item_id, + "type": "custom_tool_call", + "status": status, + "call_id": state.call_id, + "name": state.name, + "input": input_text, + } + else: + item = { + "id": state.item_id, + "type": "function_call", + "status": status, + "call_id": state.call_id, + "name": state.name, + "arguments": arguments, + } + if state.namespace: + item["namespace"] = state.namespace + return item + + +def reasoning_output_item( + state: ReasoningBlockState, *, status: str +) -> dict[str, object]: + if state.encrypted_content is not None: + return encrypted_reasoning_item(state.item_id, state.encrypted_content, status) + return reasoning_item(state.item_id, "".join(state.text_parts), status) + + +def _reasoning_output_item( + state: ReasoningBlockState, *, status: str +) -> dict[str, object]: + return reasoning_output_item(state, status=status) diff --git a/src/free_claude_code/core/openai_responses/streaming/error_mapping.py b/src/free_claude_code/core/openai_responses/streaming/error_mapping.py new file mode 100644 index 0000000..ad5878b --- /dev/null +++ b/src/free_claude_code/core/openai_responses/streaming/error_mapping.py @@ -0,0 +1,28 @@ +"""Responses stream error mapping.""" + +from collections.abc import Mapping +from typing import Any + + +def openai_error_from_anthropic_error(data: Mapping[str, Any]) -> dict[str, Any]: + error = data.get("error") + if not isinstance(error, dict): + error = {"type": "api_error", "message": str(data)} + return { + "message": str(error.get("message", "")), + "type": str(error.get("type", "api_error")), + "param": None, + "code": None, + } + + +def replay_unsafe_function_call_error() -> dict[str, Any]: + return { + "message": ( + "Upstream function_call arguments were not a valid JSON object; " + "refusing to emit replay-unsafe Responses output." + ), + "type": "api_error", + "param": None, + "code": None, + } diff --git a/src/free_claude_code/core/openai_responses/streaming/event_builders.py b/src/free_claude_code/core/openai_responses/streaming/event_builders.py new file mode 100644 index 0000000..bad9122 --- /dev/null +++ b/src/free_claude_code/core/openai_responses/streaming/event_builders.py @@ -0,0 +1,182 @@ +"""OpenAI Responses SSE event builders.""" + +from typing import Any + +from ..events import format_response_sse_event + + +def response_created(response: dict[str, Any]) -> str: + return format_response_sse_event( + "response.created", + {"type": "response.created", "response": response}, + ) + + +def response_completed(response: dict[str, Any]) -> str: + return format_response_sse_event( + "response.completed", + {"type": "response.completed", "response": response}, + ) + + +def response_failed(response: dict[str, Any]) -> str: + return format_response_sse_event( + "response.failed", + {"type": "response.failed", "response": response}, + ) + + +def output_item_added(output_index: int, item: dict[str, Any]) -> str: + return format_response_sse_event( + "response.output_item.added", + { + "type": "response.output_item.added", + "output_index": output_index, + "item": item, + }, + ) + + +def output_item_done(output_index: int, item: dict[str, Any]) -> str: + return format_response_sse_event( + "response.output_item.done", + { + "type": "response.output_item.done", + "output_index": output_index, + "item": item, + }, + ) + + +def content_part_added(item_id: str, output_index: int) -> str: + return format_response_sse_event( + "response.content_part.added", + { + "type": "response.content_part.added", + "item_id": item_id, + "output_index": output_index, + "content_index": 0, + "part": {"type": "output_text", "text": "", "annotations": []}, + }, + ) + + +def output_text_delta(item_id: str, output_index: int, text: str) -> str: + return format_response_sse_event( + "response.output_text.delta", + { + "type": "response.output_text.delta", + "item_id": item_id, + "output_index": output_index, + "content_index": 0, + "delta": text, + }, + ) + + +def output_text_done(item_id: str, output_index: int, text: str) -> str: + return format_response_sse_event( + "response.output_text.done", + { + "type": "response.output_text.done", + "item_id": item_id, + "output_index": output_index, + "content_index": 0, + "text": text, + }, + ) + + +def content_part_done(item_id: str, output_index: int, text: str) -> str: + return format_response_sse_event( + "response.content_part.done", + { + "type": "response.content_part.done", + "item_id": item_id, + "output_index": output_index, + "content_index": 0, + "part": {"type": "output_text", "text": text, "annotations": []}, + }, + ) + + +def reasoning_text_delta(item_id: str, output_index: int, text: str) -> str: + return format_response_sse_event( + "response.reasoning_text.delta", + { + "type": "response.reasoning_text.delta", + "item_id": item_id, + "output_index": output_index, + "content_index": 0, + "delta": text, + }, + ) + + +def reasoning_text_done(item_id: str, output_index: int, text: str) -> str: + return format_response_sse_event( + "response.reasoning_text.done", + { + "type": "response.reasoning_text.done", + "item_id": item_id, + "output_index": output_index, + "content_index": 0, + "text": text, + }, + ) + + +def function_call_arguments_delta( + item_id: str, output_index: int, arguments: str +) -> str: + return format_response_sse_event( + "response.function_call_arguments.delta", + { + "type": "response.function_call_arguments.delta", + "item_id": item_id, + "output_index": output_index, + "delta": arguments, + }, + ) + + +def function_call_arguments_done( + item_id: str, output_index: int, arguments: str +) -> str: + return format_response_sse_event( + "response.function_call_arguments.done", + { + "type": "response.function_call_arguments.done", + "item_id": item_id, + "output_index": output_index, + "arguments": arguments, + }, + ) + + +def custom_tool_call_input_delta( + item_id: str, output_index: int, input_text: str +) -> str: + return format_response_sse_event( + "response.custom_tool_call_input.delta", + { + "type": "response.custom_tool_call_input.delta", + "item_id": item_id, + "output_index": output_index, + "delta": input_text, + }, + ) + + +def custom_tool_call_input_done( + item_id: str, output_index: int, input_text: str +) -> str: + return format_response_sse_event( + "response.custom_tool_call_input.done", + { + "type": "response.custom_tool_call_input.done", + "item_id": item_id, + "output_index": output_index, + "input": input_text, + }, + ) diff --git a/src/free_claude_code/core/openai_responses/streaming/ledger.py b/src/free_claude_code/core/openai_responses/streaming/ledger.py new file mode 100644 index 0000000..8791bd8 --- /dev/null +++ b/src/free_claude_code/core/openai_responses/streaming/ledger.py @@ -0,0 +1,84 @@ +"""Output ledger for OpenAI Responses streaming assembly.""" + +from collections.abc import Mapping +from typing import Any + +from ..usage import estimate_text_tokens +from .blocks import BlockState + + +class ResponsesOutputLedger: + """Track active blocks, reserved output slots, and accumulated usage.""" + + def __init__(self) -> None: + self._output_slots: list[dict[str, Any] | None] = [] + self._active_blocks: dict[int, BlockState] = {} + self._fallback_text_index = -1 + self._input_tokens: int | None = None + self._output_tokens: int | None = None + self._reasoning_tokens_estimate = 0 + + def active_block(self, index: int) -> BlockState | None: + return self._active_blocks.get(index) + + def set_active_block(self, state: BlockState) -> None: + self._active_blocks[state.index] = state + + def pop_active_block(self, index: int) -> BlockState | None: + return self._active_blocks.pop(index, None) + + def pop_active_blocks_by_output_order(self) -> list[BlockState]: + states = sorted( + self._active_blocks.values(), key=lambda state: state.output_index + ) + self._active_blocks.clear() + return states + + def reserve_output_slot(self) -> int: + output_index = len(self._output_slots) + self._output_slots.append(None) + return output_index + + def commit_output(self, output_index: int, item: dict[str, Any]) -> None: + while output_index >= len(self._output_slots): + self._output_slots.append(None) + self._output_slots[output_index] = item + + def output(self) -> list[dict[str, Any]]: + return [item for item in self._output_slots if item is not None] + + def record_usage_delta(self, data: Mapping[str, Any]) -> None: + usage = data.get("usage") + if not isinstance(usage, dict): + return + if isinstance(usage.get("input_tokens"), int): + self._input_tokens = usage["input_tokens"] + if isinstance(usage.get("output_tokens"), int): + self._output_tokens = usage["output_tokens"] + + def add_reasoning_text(self, text: str) -> None: + self._reasoning_tokens_estimate += estimate_text_tokens(text) + + def usage(self) -> dict[str, Any] | None: + if self._input_tokens is None and self._output_tokens is None: + return None + input_tokens = self._input_tokens or 0 + output_tokens = self._output_tokens or 0 + usage: dict[str, Any] = { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "total_tokens": input_tokens + output_tokens, + } + capped_reasoning_tokens = min(self._reasoning_tokens_estimate, output_tokens) + if capped_reasoning_tokens: + usage["output_tokens_details"] = { + "reasoning_tokens": capped_reasoning_tokens + } + return usage + + def safe_text_index(self, index: int | None) -> int: + if index is not None: + return index + value = self._fallback_text_index + self._fallback_text_index -= 1 + return value diff --git a/src/free_claude_code/core/openai_responses/tools.py b/src/free_claude_code/core/openai_responses/tools.py new file mode 100644 index 0000000..259e75e --- /dev/null +++ b/src/free_claude_code/core/openai_responses/tools.py @@ -0,0 +1,375 @@ +"""Tool conversion helpers for the OpenAI Responses adapter.""" + +import hashlib +import json +import re +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, Literal + +from .errors import ResponsesConversionError +from .ids import new_call_id + +_MAX_ANTHROPIC_TOOL_NAME_LEN = 64 +_NAMESPACE_TOOL_SEPARATOR = "__" +_UNSUPPORTED_PASSIVE_TOOL_TYPES = frozenset( + {"web_search", "image_generation", "tool_search"} +) +_INVALID_TOOL_NAME_CHARS = re.compile(r"[^A-Za-z0-9_-]+") + + +@dataclass(frozen=True, slots=True) +class ResponsesToolIdentity: + kind: Literal["function", "custom"] + name: str + namespace: str | None = None + + +def convert_tools(value: Any) -> list[dict[str, Any]] | None: + if value is None: + return None + if not isinstance(value, list): + raise ResponsesConversionError("Responses tools must be a list") + + tools: list[dict[str, Any]] = [] + for tool in value: + if not isinstance(tool, dict): + raise ResponsesConversionError( + f"Unsupported Responses tool: {type(tool).__name__}" + ) + tool_type = tool.get("type") + if tool_type == "function": + tools.append(_convert_function_tool(tool, namespace=None)) + continue + if tool_type == "custom": + tools.append(_convert_custom_tool(tool, namespace=None)) + continue + if tool_type == "namespace": + tools.extend(_convert_namespace_tool(tool)) + continue + if tool_type in _UNSUPPORTED_PASSIVE_TOOL_TYPES: + continue + if tool_type != "function": + raise ResponsesConversionError( + f"Unsupported Responses tool type: {tool_type!r}" + ) + return tools + + +def convert_tool_choice(value: Any) -> dict[str, Any] | None: + if value is None or value == "auto": + return None + if value == "none": + return None + if value == "required": + return {"type": "any"} + if isinstance(value, dict): + choice_type = value.get("type") + if choice_type == "function": + namespace = optional_str(value.get("namespace")) + name = required_str(value.get("name"), "tool_choice.name") + return { + "type": "tool", + "name": responses_tool_name_to_anthropic_name( + name, namespace=namespace + ), + } + if choice_type == "custom": + source = _custom_source(value) + namespace = optional_str(source.get("namespace")) or optional_str( + value.get("namespace") + ) + name = required_str(source.get("name"), "tool_choice.name") + return { + "type": "tool", + "name": responses_tool_name_to_anthropic_name( + name, namespace=namespace + ), + } + if choice_type == "tool": + namespace = optional_str(value.get("namespace")) + name = optional_str(value.get("name")) + if name: + return { + "type": "tool", + "name": responses_tool_name_to_anthropic_name( + name, namespace=namespace + ), + } + return dict(value) + if choice_type in {"auto", "any"}: + return dict(value) + raise ResponsesConversionError(f"Unsupported Responses tool_choice: {value!r}") + + +def responses_tool_name_to_anthropic_name( + name: str, *, namespace: str | None = None +) -> str: + """Return a deterministic Anthropic tool name for a Responses tool identity.""" + + if not namespace: + return name + combined = ( + f"{_tool_name_part(namespace)}" + f"{_NAMESPACE_TOOL_SEPARATOR}" + f"{_tool_name_part(name)}" + ) + if len(combined) <= _MAX_ANTHROPIC_TOOL_NAME_LEN: + return combined + digest = hashlib.sha1(combined.encode("utf-8")).hexdigest()[:8] + prefix_len = _MAX_ANTHROPIC_TOOL_NAME_LEN - len(digest) - 1 + return f"{combined[:prefix_len]}_{digest}" + + +def responses_tool_identity_from_anthropic_name( + tools: list[dict[str, Any]] | None, anthropic_name: str +) -> ResponsesToolIdentity: + """Return the Responses namespace/name represented by an Anthropic tool name.""" + + if tools is None: + return ResponsesToolIdentity(kind="function", name=anthropic_name) + for tool in tools: + if not isinstance(tool, dict): + continue + tool_type = tool.get("type") + if tool_type == "function": + source = tool.get("function") + function = source if isinstance(source, dict) else tool + if (name := optional_str(function.get("name"))) and ( + responses_tool_name_to_anthropic_name(name) == anthropic_name + ): + return ResponsesToolIdentity(kind="function", name=name) + continue + if tool_type == "custom": + source = _custom_source(tool) + if (name := optional_str(source.get("name"))) and ( + responses_tool_name_to_anthropic_name(name) == anthropic_name + ): + return ResponsesToolIdentity(kind="custom", name=name) + continue + if tool_type != "namespace": + continue + namespace = optional_str(tool.get("name")) + nested_tools = tool.get("tools") + if not namespace or not isinstance(nested_tools, list): + continue + for nested_tool in nested_tools: + if not isinstance(nested_tool, dict): + continue + nested_tool_type = nested_tool.get("type") + if nested_tool_type == "function": + source = nested_tool.get("function") + function = source if isinstance(source, dict) else nested_tool + if (name := optional_str(function.get("name"))) and ( + responses_tool_name_to_anthropic_name(name, namespace=namespace) + == anthropic_name + ): + return ResponsesToolIdentity( + kind="function", name=name, namespace=namespace + ) + continue + if nested_tool_type == "custom": + source = _custom_source(nested_tool) + if (name := optional_str(source.get("name"))) and ( + responses_tool_name_to_anthropic_name(name, namespace=namespace) + == anthropic_name + ): + return ResponsesToolIdentity( + kind="custom", name=name, namespace=namespace + ) + return ResponsesToolIdentity(kind="function", name=anthropic_name) + + +def parse_arguments(value: Any) -> dict[str, Any]: + if value is None or value == "": + return {} + if isinstance(value, dict): + return value + if not isinstance(value, str): + raise ResponsesConversionError("Responses function_call arguments must be JSON") + try: + parsed = json.loads(value) + except json.JSONDecodeError as exc: + raise ResponsesConversionError( + f"Responses function_call arguments are invalid JSON: {exc.msg}" + ) from exc + if not isinstance(parsed, dict): + raise ResponsesConversionError( + "Responses function_call arguments must decode to an object" + ) + return parsed + + +def normalized_function_call_arguments(value: Any) -> str: + return json.dumps(parse_arguments(value), separators=(",", ":")) + + +def custom_tool_input_to_anthropic(value: Any) -> dict[str, str]: + return {"input": custom_tool_input_text(value)} + + +def custom_tool_input_text(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + return _json_dumps(value) + + +def custom_tool_input_text_from_anthropic(value: Any) -> str: + if isinstance(value, Mapping): + raw_input = value.get("input") + if isinstance(raw_input, str): + return raw_input + if raw_input is not None: + return custom_tool_input_text(raw_input) + if not value: + return "" + return _json_dumps(value) + return custom_tool_input_text(value) + + +def custom_tool_input_text_from_arguments(arguments: str) -> str: + if not arguments: + return "" + try: + parsed = json.loads(arguments) + except json.JSONDecodeError: + return arguments + return custom_tool_input_text_from_anthropic(parsed) + + +def call_id_from_item(item: Mapping[str, Any]) -> str: + for key in ("call_id", "id"): + if value := optional_str(item.get(key)): + return value + return new_call_id() + + +def required_str(value: Any, field_name: str) -> str: + if isinstance(value, str) and value: + return value + raise ResponsesConversionError( + f"Responses field {field_name} must be a non-empty string" + ) + + +def optional_str(value: Any) -> str | None: + return value if isinstance(value, str) else None + + +def _convert_namespace_tool(tool: Mapping[str, Any]) -> list[dict[str, Any]]: + namespace = required_str(tool.get("name"), "tool.namespace.name") + nested_tools = tool.get("tools") + if not isinstance(nested_tools, list): + raise ResponsesConversionError( + f"Responses namespace tool {namespace!r} tools must be a list" + ) + + converted_tools: list[dict[str, Any]] = [] + for nested_tool in nested_tools: + if not isinstance(nested_tool, dict): + raise ResponsesConversionError( + f"Unsupported Responses namespace tool: {type(nested_tool).__name__}" + ) + nested_tool_type = nested_tool.get("type") + if nested_tool_type == "function": + converted_tools.append( + _convert_function_tool(nested_tool, namespace=namespace) + ) + continue + if nested_tool_type == "custom": + converted_tools.append( + _convert_custom_tool(nested_tool, namespace=namespace) + ) + continue + raise ResponsesConversionError( + f"Unsupported Responses namespace tool type: {nested_tool_type!r}" + ) + return converted_tools + + +def _convert_function_tool( + tool: Mapping[str, Any], *, namespace: str | None +) -> dict[str, Any]: + function = tool.get("function") + source = function if isinstance(function, dict) else tool + name = required_str(source.get("name"), "tool.name") + schema = source.get("parameters") + if schema is None: + schema = {"type": "object", "properties": {}} + if not isinstance(schema, dict): + raise ResponsesConversionError( + f"Responses tool {name!r} parameters must be an object" + ) + converted: dict[str, Any] = { + "name": responses_tool_name_to_anthropic_name(name, namespace=namespace), + "input_schema": schema, + } + if description := optional_str(source.get("description")): + converted["description"] = description + return converted + + +def _convert_custom_tool( + tool: Mapping[str, Any], *, namespace: str | None +) -> dict[str, Any]: + source = _custom_source(tool) + name = required_str(source.get("name"), "tool.name") + converted: dict[str, Any] = { + "name": responses_tool_name_to_anthropic_name(name, namespace=namespace), + "input_schema": { + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "Free-form input for the custom tool.", + } + }, + "required": ["input"], + }, + } + if description := _custom_tool_description(source): + converted["description"] = description + return converted + + +def _custom_source(tool: Mapping[str, Any]) -> Mapping[str, Any]: + custom = tool.get("custom") + return custom if isinstance(custom, Mapping) else tool + + +def _custom_tool_description(source: Mapping[str, Any]) -> str | None: + parts: list[str] = [] + if description := optional_str(source.get("description")): + parts.append(description) + format_value = source.get("format") + if isinstance(format_value, Mapping): + format_type = optional_str(format_value.get("type")) + if format_type == "text": + parts.append("Custom tool input format: unconstrained text.") + elif format_type == "grammar": + syntax = optional_str(format_value.get("syntax")) + definition = optional_str(format_value.get("definition")) + guidance = "Custom tool input format: grammar" + if syntax: + guidance = f"{guidance} ({syntax})" + guidance = f"{guidance}: {definition}" if definition else f"{guidance}." + parts.append(guidance) + elif format_type: + parts.append(f"Custom tool input format: {format_type}.") + else: + parts.append(f"Custom tool input format: {_json_dumps(format_value)}") + return "\n\n".join(parts) if parts else None + + +def _tool_name_part(value: str) -> str: + normalized = _INVALID_TOOL_NAME_CHARS.sub("_", value).strip("_") + return normalized or "tool" + + +def _json_dumps(value: Any) -> str: + try: + return json.dumps(value) + except TypeError: + return str(value) diff --git a/src/free_claude_code/core/openai_responses/usage.py b/src/free_claude_code/core/openai_responses/usage.py new file mode 100644 index 0000000..a6a2e28 --- /dev/null +++ b/src/free_claude_code/core/openai_responses/usage.py @@ -0,0 +1,35 @@ +"""Usage helpers for OpenAI Responses payloads.""" + +from typing import Protocol + +_DISALLOWED_SPECIAL: tuple[str, ...] = () + + +class _TokenEncoder(Protocol): + def encode( + self, text: str, *, disallowed_special: tuple[str, ...] + ) -> list[int]: ... + + +def _load_encoder() -> _TokenEncoder | None: + try: + import tiktoken + except ImportError: + return None + + try: + return tiktoken.get_encoding("cl100k_base") + except ValueError: + return None + + +_ENCODER = _load_encoder() + + +def estimate_text_tokens(text: str) -> int: + """Return a best-effort token estimate for Responses usage details.""" + if not text: + return 0 + if _ENCODER is not None: + return len(_ENCODER.encode(text, disallowed_special=_DISALLOWED_SPECIAL)) + return max(1, len(text) // 4) diff --git a/src/free_claude_code/core/rate_limit.py b/src/free_claude_code/core/rate_limit.py new file mode 100644 index 0000000..15bd9f4 --- /dev/null +++ b/src/free_claude_code/core/rate_limit.py @@ -0,0 +1,72 @@ +"""Shared strict sliding-window rate limiting primitives.""" + +import asyncio +import time +from collections import deque +from collections.abc import Callable + + +class StrictSlidingWindowLimiter: + """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: + await self._acquire(None) + + async def acquire_if(self, allowed: Callable[[], bool]) -> bool: + """Record an acquisition only if ``allowed`` still holds at admission. + + Capacity is awaited first. The synchronous condition and timestamp write + then run without yielding, so a rejected admission consumes no quota. + """ + return await self._acquire(allowed) + + async def _acquire(self, allowed: Callable[[], bool] | None) -> bool: + 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: + if allowed is not None and not allowed(): + return False + self._times.append(time.monotonic()) + return True + + 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) -> StrictSlidingWindowLimiter: + await self.acquire() + return self + + async def __aexit__(self, exc_type, exc, tb) -> bool: + return False diff --git a/src/free_claude_code/core/trace.py b/src/free_claude_code/core/trace.py new file mode 100644 index 0000000..713e633 --- /dev/null +++ b/src/free_claude_code/core/trace.py @@ -0,0 +1,196 @@ +"""Structured TRACE events for end-to-end request / CLI / provider logging. + +Emitted lines are merged into JSON log rows by ``config.logging_config``. +Conversation and Claude Code prompts are logged verbatim unless values live under +sanitized credential keys (e.g. ``api_key``, ``authorization``). +""" + +import asyncio +import sys +from collections.abc import AsyncGenerator, AsyncIterator, Mapping +from typing import Any + +from loguru import logger + +from free_claude_code.core.async_iterators import try_close_async_iterator + +TRACE_PAYLOAD_BINDING = "trace_payload" + +_SECRET_VALUE_KEYS = frozenset( + k.lower() + for k in ( + "authorization", + "x-api-key", + "anthropic-auth-token", + "api_key", + "password", + "secret", + "token", + "bearer_token", + "openapi_token", + "nvidia-api-key", + ) +) + + +def sanitize_trace_value(obj: Any) -> Any: + """Recursively copy JSON-like structures redacting credential-shaped keys.""" + if isinstance(obj, Mapping): + out: dict[str, Any] = {} + for k, v in obj.items(): + if str(k).lower() in _SECRET_VALUE_KEYS: + out[str(k)] = "" + else: + out[str(k)] = sanitize_trace_value(v) + return out + if isinstance(obj, tuple | list): + return [sanitize_trace_value(x) for x in obj] + return obj + + +def trace_event(*, stage: str, event: str, source: str, **fields: Any) -> None: + """Emit one structured TRACE row (merged into JSON by the log sink).""" + payload = sanitize_trace_value( + { + "stage": stage, + "event": event, + "source": source, + **fields, + }, + ) + logger.bind(trace_payload=payload).info("TRACE {}", event) + + +async def close_stream_input( + iterator: object, + *, + owner: str, + source: str, + preserved_error: BaseException | None, +) -> None: + """Close one transform input and observe cleanup failure without raising it.""" + close_error = await try_close_async_iterator(iterator) + if close_error is None: + return + trace_event( + stage="lifecycle", + event="stream.input.close_failed", + source=source, + owner=owner, + close_exc_type=type(close_error).__name__, + preserved_exc_type=( + type(preserved_error).__name__ if preserved_error is not None else None + ), + ) + + +def extract_claude_session_id_from_headers(headers: Mapping[str, str]) -> str | None: + """Best-effort session id forwarded by Claude Code / SDK via HTTP.""" + lowered = {str(k).lower(): v for k, v in headers.items() if isinstance(v, str)} + for key in ( + "anthropic-session-id", + "x-anthropic-session-id", + "claude-session-id", + "x-claude-session-id", + ): + candidate = lowered.get(key) + if candidate: + return candidate + return None + + +async def traced_async_stream( + agen: AsyncIterator[str], + *, + stage: str, + source: str, + complete_event: str, + interrupted_event: str, + chunk_event: str | None = None, + chunk_interval: int = 250, + extra: Mapping[str, Any] | None = None, +) -> AsyncGenerator[str]: + """Emit TRACE rows when a text stream completes, fails, cancels, or periodically.""" + common = dict(extra or {}) + count = 0 + nbytes = 0 + interrupted = False + try: + async for chunk in agen: + count += 1 + nbytes += len(chunk.encode("utf-8", errors="replace")) + if chunk_event and chunk_interval > 0 and count % chunk_interval == 0: + trace_event( + stage=stage, + event=chunk_event, + source=source, + stream_chunks_so_far=count, + stream_bytes_so_far=nbytes, + **common, + ) + yield chunk + except GeneratorExit: + raise + except asyncio.CancelledError: + interrupted = True + trace_event( + stage=stage, + event=interrupted_event, + source=source, + stream_chunks=count, + stream_bytes=nbytes, + outcome="cancelled", + **common, + ) + raise + except BaseExceptionGroup as grp: + interrupted = True + trace_event( + stage=stage, + event=interrupted_event, + source=source, + stream_chunks=count, + stream_bytes=nbytes, + outcome="exception_group", + note=str(grp), + **common, + ) + raise + except Exception as exc: + interrupted = True + trace_event( + stage=stage, + event=interrupted_event, + source=source, + stream_chunks=count, + stream_bytes=nbytes, + outcome="error", + exc_type=type(exc).__name__, + **common, + ) + raise + finally: + await close_stream_input( + agen, + owner="traced_async_stream", + source=source, + preserved_error=sys.exception(), + ) + + if not interrupted: + trace_event( + stage=stage, + event=complete_event, + source=source, + stream_chunks=count, + stream_bytes=nbytes, + outcome="ok", + **common, + ) + + +def provider_chat_body_snapshot(body: Mapping[str, Any]) -> dict[str, Any]: + """Sanitized OpenAI-compat chat body subset for traces (conversation text verbatim).""" + keys = ("model", "messages", "tools", "tool_choice", "temperature", "max_tokens") + snap = {k: body[k] for k in keys if k in body and body[k] is not None} + return sanitize_trace_value(snap) diff --git a/src/free_claude_code/core/version.py b/src/free_claude_code/core/version.py new file mode 100644 index 0000000..42a122c --- /dev/null +++ b/src/free_claude_code/core/version.py @@ -0,0 +1,15 @@ +"""Canonical installed Free Claude Code package version.""" + +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as distribution_version + +_DISTRIBUTION_NAME = "free-claude-code" +_UNKNOWN_VERSION = "0+unknown" + + +def package_version() -> str: + """Return installed metadata, or an explicit source-only fallback.""" + try: + return distribution_version(_DISTRIBUTION_NAME) + except PackageNotFoundError: + return _UNKNOWN_VERSION diff --git a/src/free_claude_code/messaging/__init__.py b/src/free_claude_code/messaging/__init__.py new file mode 100644 index 0000000..40f0461 --- /dev/null +++ b/src/free_claude_code/messaging/__init__.py @@ -0,0 +1,16 @@ +"""Platform-agnostic messaging layer.""" + +from .managed_protocols import ( + ManagedClaudeSessionManagerProtocol, + ManagedClaudeSessionProtocol, +) +from .models import IncomingMessage, MessageScope +from .platforms.ports import OutboundMessenger + +__all__ = [ + "IncomingMessage", + "ManagedClaudeSessionManagerProtocol", + "ManagedClaudeSessionProtocol", + "MessageScope", + "OutboundMessenger", +] diff --git a/src/free_claude_code/messaging/cli_event_constants.py b/src/free_claude_code/messaging/cli_event_constants.py new file mode 100644 index 0000000..3d531ec --- /dev/null +++ b/src/free_claude_code/messaging/cli_event_constants.py @@ -0,0 +1,67 @@ +"""CLI event types and status-line mapping for transcript / UI updates.""" + +from collections.abc import Callable +from typing import Any + +# 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: dict[str, tuple[str, str]] = { + "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[str, Any], + format_status_fn: Callable[..., str], +) -> 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 diff --git a/src/free_claude_code/messaging/command_context.py b/src/free_claude_code/messaging/command_context.py new file mode 100644 index 0000000..5a46f52 --- /dev/null +++ b/src/free_claude_code/messaging/command_context.py @@ -0,0 +1,99 @@ +"""Typed dependency surface for messaging slash commands.""" + +from dataclasses import dataclass +from typing import Protocol + +from .managed_protocols import ManagedClaudeSessionManagerProtocol +from .models import MessageScope +from .platforms.ports import OutboundMessenger +from .transcript import RenderCtx + + +@dataclass(frozen=True, slots=True) +class ReplyClearResult: + """Customer-facing result of clearing one literal reply subtree.""" + + delete_message_ids: frozenset[str] + tree_matched: bool + + +@dataclass(frozen=True, slots=True) +class StopOutcome: + """Customer-facing stop result after terminal status ownership is assigned.""" + + cancelled_count: int + status_feedback_scopes: frozenset[MessageScope] + fallback_required: bool + + def requires_confirmation(self, scope: MessageScope) -> bool: + """Return whether this scope lacks complete terminal status feedback.""" + return ( + self.cancelled_count == 0 + or self.fallback_required + or self.status_feedback_scopes != frozenset({scope}) + ) + + +class MessagingCommandContext(Protocol): + """Operations commands need from the messaging workflow.""" + + outbound: OutboundMessenger + cli_manager: ManagedClaudeSessionManagerProtocol + + def format_status(self, emoji: str, label: str, suffix: str | None = None) -> str: + """Format a platform-specific status line.""" + ... + + def get_render_ctx(self) -> RenderCtx: + """Return the render context for command output.""" + ... + + async def stop_all_tasks(self) -> StopOutcome: + """Stop every pending or active messaging task.""" + ... + + async def stop_reply( + self, + scope: MessageScope, + reply_id: str, + ) -> StopOutcome: + """Stop the exact voice/tree owner of a replied-to message.""" + ... + + def get_tree_count(self) -> int: + """Return the number of conversation trees.""" + ... + + async def clear_reply( + self, + scope: MessageScope, + reply_id: str, + ) -> ReplyClearResult | None: + """Clear the literal subtree rooted at a replied-to message.""" + ... + + async def clear_chat(self, platform: str, chat_id: str) -> frozenset[str]: + """Clear one chat and return every tracked platform message ID.""" + ... + + def forget_tracked_message_ids( + self, + platform: str, + chat_id: str, + message_ids: set[str], + ) -> None: + """Forget platform message IDs removed from the managed conversation.""" + ... + + def record_outgoing_message( + self, + platform: str, + chat_id: str, + msg_id: str | None, + kind: str, + ) -> bool: + """Record an outgoing platform message ID and report registry ownership.""" + ... + + +__all__ = ["MessagingCommandContext", "ReplyClearResult", "StopOutcome"] diff --git a/src/free_claude_code/messaging/command_dispatcher.py b/src/free_claude_code/messaging/command_dispatcher.py new file mode 100644 index 0000000..31d30b6 --- /dev/null +++ b/src/free_claude_code/messaging/command_dispatcher.py @@ -0,0 +1,31 @@ +"""Command parsing and dispatch for messaging handlers.""" + +from .command_context import MessagingCommandContext +from .commands import handle_clear_command, handle_stats_command, handle_stop_command +from .models import IncomingMessage + +_COMMAND_HANDLERS = { + "/clear": handle_clear_command, + "/stop": handle_stop_command, + "/stats": handle_stats_command, +} + + +def parse_command_base(text: str | None) -> str: + """Return the slash command without bot mention suffix.""" + parts = (text or "").strip().split() + cmd = parts[0] if parts else "" + return cmd.split("@", 1)[0] if cmd else "" + + +async def dispatch_command( + context: MessagingCommandContext, + incoming: IncomingMessage, + command_base: str, +) -> bool: + """Dispatch a known command and return whether it was handled.""" + command = _COMMAND_HANDLERS.get(command_base) + if command is None: + return False + await command(context, incoming) + return True diff --git a/src/free_claude_code/messaging/commands.py b/src/free_claude_code/messaging/commands.py new file mode 100644 index 0000000..fd042ca --- /dev/null +++ b/src/free_claude_code/messaging/commands.py @@ -0,0 +1,182 @@ +"""Command handlers for messaging platform commands (/stop, /stats, /clear). + +Commands depend on MessagingCommandContext instead of the concrete workflow. +""" + +from loguru import logger + +from .command_context import MessagingCommandContext +from .models import IncomingMessage + + +async def _send_stop_feedback( + handler: MessagingCommandContext, + incoming: IncomingMessage, + suffix: str, +) -> None: + """Send stop feedback only when no existing status can represent the result.""" + msg_id = await handler.outbound.queue_send_message( + incoming.chat_id, + handler.format_status("⏹", "Stopped.", suffix), + 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_stop_command( + handler: MessagingCommandContext, 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: + outcome = await handler.stop_reply( + incoming.scope, + incoming.reply_to_message_id, + ) + + if outcome.cancelled_count == 0: + await _send_stop_feedback( + handler, + incoming, + "Nothing to stop for that message.", + ) + return + + if outcome.requires_confirmation(incoming.scope): + noun = "request" if outcome.cancelled_count == 1 else "requests" + await _send_stop_feedback( + handler, + incoming, + f"Cancelled {outcome.cancelled_count} {noun}.", + ) + return + + # Global stop: legacy behavior (stop everything) + outcome = await handler.stop_all_tasks() + if outcome.cancelled_count == 0: + await _send_stop_feedback(handler, incoming, "Nothing to stop.") + elif outcome.requires_confirmation(incoming.scope): + noun = "request" if outcome.cancelled_count == 1 else "requests" + await _send_stop_feedback( + handler, + incoming, + f"Cancelled {outcome.cancelled_count} pending or active {noun}.", + ) + + +async def handle_stats_command( + handler: MessagingCommandContext, incoming: IncomingMessage +) -> None: + """Handle /stats command.""" + stats = handler.cli_manager.get_stats() + tree_count = handler.get_tree_count() + ctx = handler.get_render_ctx() + msg_id = await handler.outbound.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: MessagingCommandContext, 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) + non_numeric.sort(reverse=True) + ordered = [mid for _, mid in numeric] + non_numeric + + failed = 0 + try: + await handler.outbound.queue_delete_messages( + chat_id, + ordered, + fire_and_forget=False, + ) + except Exception as e: + failed = len(ordered) + logger.debug("Message delete failed for chat {}: {}", chat_id, type(e).__name__) + + if ordered: + logger.info( + "Clear delete attempted={} failed={}", + len(ordered), + failed, + ) + + +async def handle_clear_command( + handler: MessagingCommandContext, incoming: IncomingMessage +) -> None: + """ + Handle /clear command. + + Reply-scoped: delete the selected message and its literal reply subtree. + Standalone: reset and delete the invoking chat's managed conversation. + """ + if incoming.is_reply() and incoming.reply_to_message_id: + result = await handler.clear_reply( + incoming.scope, + incoming.reply_to_message_id, + ) + if result is None: + msg_id = await handler.outbound.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 + + delete_message_ids = set(result.delete_message_ids) + if incoming.message_id is not None: + delete_message_ids.add(str(incoming.message_id)) + await _delete_message_ids(handler, incoming.chat_id, delete_message_ids) + handler.forget_tracked_message_ids( + incoming.platform, + incoming.chat_id, + delete_message_ids, + ) + return + + msg_ids = set(await handler.clear_chat(incoming.platform, incoming.chat_id)) + + # 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) diff --git a/src/free_claude_code/messaging/event_parser.py b/src/free_claude_code/messaging/event_parser.py new file mode 100644 index 0000000..14b30e8 --- /dev/null +++ b/src/free_claude_code/messaging/event_parser.py @@ -0,0 +1,184 @@ +"""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, *, log_raw_cli: bool = False) -> list[dict]: + """ + Parse a CLI event and return a structured result. + + Args: + event: Raw event dictionary from CLI + log_raw_cli: When True, log full error text from the CLI. Default is + metadata-only (lengths / exit codes) to avoid leaking user content. + + 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) + if log_raw_cli: + logger.info("CLI_PARSER: Parsed error event: {}", msg) + else: + mlen = len(msg) if isinstance(msg, str) else 0 + logger.info("CLI_PARSER: Parsed error event: message_chars={}", mlen) + 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"}] + + error_msg = stderr if stderr else f"Process exited with code {code}" + if log_raw_cli: + logger.warning( + "CLI_PARSER: Error exit (code={}): {}", + code, + error_msg, + ) + else: + em = error_msg if isinstance(error_msg, str) else str(error_msg) + logger.warning( + "CLI_PARSER: Error exit (code={}): message_chars={}", + code, + len(em), + ) + return [ + { + "type": "error", + "message": error_msg, + "source": "exit", + "exit_code": code, + } + ] + + # Log unrecognized events for debugging + if etype: + logger.debug(f"CLI_PARSER: Unrecognized event type: {etype}") + return [] diff --git a/src/free_claude_code/messaging/limiter.py b/src/free_claude_code/messaging/limiter.py new file mode 100644 index 0000000..584289e --- /dev/null +++ b/src/free_claude_code/messaging/limiter.py @@ -0,0 +1,342 @@ +"""Runtime-owned queued delivery for one messaging platform.""" + +import asyncio +from collections import deque +from collections.abc import Awaitable, Callable +from typing import Any + +from loguru import logger + +from free_claude_code.core.rate_limit import ( + StrictSlidingWindowLimiter as SlidingWindowLimiter, +) + +from .safe_diagnostics import format_exception_for_log + + +class MessagingRateLimiter: + """ + Rate limiter and compacting work queue for one messaging runtime. + + Uses a custom queue with task compaction (deduplication) to ensure + only the latest version of a message update is processed. + """ + + def __init__( + self, + *, + rate_limit: int, + rate_window: float, + log_error_details: bool = False, + ) -> None: + self.limiter = SlidingWindowLimiter(rate_limit, rate_window) + self._log_error_details = log_error_details + # 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 = None + self._background_tasks: set[asyncio.Task[None]] = set() + self._active_futures: list[asyncio.Future[Any]] = [] + self._closed = False + self._paused_until = 0 + + logger.info( + f"MessagingRateLimiter initialized ({rate_limit} req / {rate_window}s with Task Compaction)" + ) + + def start(self) -> None: + """Start the owned worker on the current event loop.""" + if self._closed: + raise RuntimeError("Messaging rate limiter is closed.") + if self._worker_task and not self._worker_task.done(): + return + self._worker_task = asyncio.create_task( + self._worker(), name="msg-limiter-worker" + ) + + async def _worker(self) -> None: + """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) + self._active_futures = futures + + # 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 asyncio.CancelledError: + for f in futures: + if not f.done(): + f.cancel() + worker = asyncio.current_task() + if self._shutdown.is_set() or ( + worker is not None and worker.cancelling() + ): + raise + logger.debug( + "Messaging operation cancelled for key {}; worker remains active", + dedup_key, + ) + 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( + "Error in limiter worker for key {}: {}", + dedup_key, + format_exception_for_log( + e, + log_full_message=self._log_error_details, + ), + ) + finally: + self._active_futures = [] + except asyncio.CancelledError: + for future in self._active_futures: + if not future.done(): + future.cancel() + self._active_futures = [] + if self._shutdown.is_set(): + break + raise + except Exception as e: + if self._log_error_details: + logger.error( + "MessagingRateLimiter worker critical error: {}", + e, + exc_info=True, + ) + else: + logger.error( + "MessagingRateLimiter worker critical error: exc_type={}", + type(e).__name__, + ) + await asyncio.sleep(1) + + async def shutdown(self, timeout: float | None = None) -> None: + """Cancel queued work and stop every task owned by this limiter.""" + self._closed = True + self._shutdown.set() + async with self._condition: + queued_futures = [ + future + for _func, futures in self._queue_map.values() + for future in futures + ] + self._queue_list.clear() + self._queue_map.clear() + for future in queued_futures: + if not future.done(): + future.cancel() + for future in self._active_futures: + if not future.done(): + future.cancel() + self._condition.notify_all() + + cancellation: asyncio.CancelledError | None = None + timeout_error: TimeoutError | None = None + task = self._worker_task + if task and not task.done(): + task.cancel() + try: + drain = asyncio.gather(task, return_exceptions=True) + if timeout is None: + await drain + else: + await asyncio.wait_for(drain, timeout=timeout) + except TimeoutError as exc: + timeout_error = exc + except asyncio.CancelledError as exc: + cancellation = exc + if task is None or task.done(): + self._worker_task = None + + background_tasks = tuple(self._background_tasks) + for background_task in background_tasks: + background_task.cancel() + if background_tasks: + try: + await asyncio.gather(*background_tasks, return_exceptions=True) + except asyncio.CancelledError as exc: + cancellation = exc + self._background_tasks.difference_update( + task for task in background_tasks if task.done() + ) + + if cancellation is not None: + raise cancellation + if timeout_error is not None: + raise TimeoutError( + "MessagingRateLimiter worker did not stop before timeout" + ) from timeout_error + + async def _enqueue_internal( + self, + func: Callable[[], Awaitable[Any]], + future: asyncio.Future[Any], + dedup_key: str, + front: bool = False, + ) -> None: + await self._enqueue_internal_multi(func, [future], dedup_key, front) + + async def _enqueue_internal_multi( + self, + func: Callable[[], Awaitable[Any]], + futures: list[asyncio.Future[Any]], + dedup_key: str, + front: bool = False, + ) -> None: + async with self._condition: + if self._closed: + raise RuntimeError("Messaging rate limiter is closed.") + if self._worker_task is None or self._worker_task.done(): + raise RuntimeError("Messaging rate limiter has not been started.") + 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. + """ + self._require_running() + if dedup_key is None: + # Unique key to avoid deduplication + dedup_key = f"task_{id(func)}_{asyncio.get_running_loop().time()}" + + future = asyncio.get_running_loop().create_future() + try: + await self._enqueue_internal(func, future, dedup_key) + except BaseException: + future.cancel() + raise + return await future + + def fire_and_forget( + self, func: Callable[[], Awaitable[Any]], dedup_key: str | None = None + ) -> None: + """Enqueue a task without waiting for the result.""" + self._require_running() + if dedup_key is None: + dedup_key = f"task_{id(func)}_{asyncio.get_running_loop().time()}" + + async def _wrapped() -> None: + max_retries = 2 + for attempt in range(max_retries + 1): + try: + await self.enqueue(func, dedup_key) + return + 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 + if self._log_error_details: + logger.warning( + "Limiter fire_and_forget transient error (attempt {}): {}. Retrying in {}s...", + attempt + 1, + e, + wait, + ) + else: + logger.warning( + "Limiter fire_and_forget transient error (attempt {}): exc_type={}. Retrying in {}s...", + attempt + 1, + type(e).__name__, + wait, + ) + await asyncio.sleep(wait) + continue + + logger.error( + "Final error in fire_and_forget for key {}: {}", + dedup_key, + format_exception_for_log( + e, + log_full_message=self._log_error_details, + ), + ) + break + + task = asyncio.create_task(_wrapped(), name=f"msg-limiter:{dedup_key}") + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + + def _require_running(self) -> None: + if self._closed: + raise RuntimeError("Messaging rate limiter is closed.") + if self._worker_task is None or self._worker_task.done(): + raise RuntimeError("Messaging rate limiter has not been started.") diff --git a/src/free_claude_code/messaging/managed_protocols.py b/src/free_claude_code/messaging/managed_protocols.py new file mode 100644 index 0000000..e149880 --- /dev/null +++ b/src/free_claude_code/messaging/managed_protocols.py @@ -0,0 +1,35 @@ +"""Protocols for messaging-owned managed Claude sessions.""" + +from collections.abc import AsyncGenerator +from typing import Any, Protocol, runtime_checkable + + +@runtime_checkable +class ManagedClaudeSessionProtocol(Protocol): + """Protocol for managed Claude sessions used by messaging.""" + + def start_task( + self, prompt: str, session_id: str | None = None, fork_session: bool = False + ) -> AsyncGenerator[dict, Any]: ... + + @property + def is_busy(self) -> bool: ... + + +@runtime_checkable +class ManagedClaudeSessionManagerProtocol(Protocol): + """Protocol for the managed Claude session pool used by messaging.""" + + async def get_or_create_session( + self, session_id: str | None = None + ) -> tuple[ManagedClaudeSessionProtocol, str, bool]: ... + + async def register_real_session_id( + self, temp_id: str, real_session_id: str + ) -> bool: ... + + async def stop_all(self) -> None: ... + + async def remove_session(self, session_id: str) -> bool: ... + + def get_stats(self) -> dict: ... diff --git a/src/free_claude_code/messaging/models.py b/src/free_claude_code/messaging/models.py new file mode 100644 index 0000000..07a7541 --- /dev/null +++ b/src/free_claude_code/messaging/models.py @@ -0,0 +1,57 @@ +"""Platform-agnostic message models.""" + +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any + + +@dataclass(frozen=True, slots=True) +class MessageScope: + """Platform chat namespace in which message IDs are unique.""" + + platform: str + chat_id: str + + +@dataclass(frozen=True, slots=True) +class AdmissionToken: + """Generations that must still match when a turn commits.""" + + stop_generation: int + clear_generation: int + + +@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 stays at ingress and is never persisted by trees. + 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 + + @property + def scope(self) -> MessageScope: + """Return the namespace that owns this message's platform IDs.""" + return MessageScope(platform=str(self.platform), chat_id=str(self.chat_id)) diff --git a/src/free_claude_code/messaging/node_event_pipeline.py b/src/free_claude_code/messaging/node_event_pipeline.py new file mode 100644 index 0000000..dc7d4d5 --- /dev/null +++ b/src/free_claude_code/messaging/node_event_pipeline.py @@ -0,0 +1,120 @@ +"""CLI event handling for a single queued node (transcript + session + errors).""" + +from collections.abc import Awaitable, Callable +from typing import Any + +from loguru import logger + +from free_claude_code.core.trace import trace_event + +from .cli_event_constants import TRANSCRIPT_EVENT_TYPES, get_status_for_event +from .managed_protocols import ManagedClaudeSessionManagerProtocol +from .safe_diagnostics import text_len_hint +from .transcript import TranscriptBuffer +from .trees import NodeClaim + +RecordSession = Callable[[str], Awaitable[None]] +CompleteClaim = Callable[[str | None], Awaitable[None]] +FailClaim = Callable[[str, str], Awaitable[None]] + + +async def handle_session_info_event( + event_data: dict[str, Any], + claim: NodeClaim, + captured_session_id: str | None, + temp_session_id: str | None, + *, + cli_manager: ManagedClaudeSessionManagerProtocol, + record_session: RecordSession, +) -> 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 + + registered = await cli_manager.register_real_session_id( + temp_session_id, + real_session_id, + ) + if not registered: + raise RuntimeError("Managed Claude session registration failed.") + trace_event( + stage="claude_cli", + event="claude_cli.session.registered", + source="claude_cli", + node_id=claim.node.node_id, + temp_session_id=temp_session_id, + real_session_id=real_session_id, + tree_root_id=claim.identity.root_id, + ) + await record_session(real_session_id) + + return real_session_id, None + + +async def process_parsed_cli_event( + parsed: dict[str, Any], + transcript: TranscriptBuffer, + update_ui: Callable[..., Awaitable[None]], + last_status: str | None, + had_transcript_events: bool, + claim: NodeClaim, + captured_session_id: str | None, + *, + format_status: Callable[..., str], + complete_claim: CompleteClaim, + fail_claim: FailClaim, + log_messaging_error_details: bool = False, +) -> 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, 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 parsed.get("status") != "success": + return last_status, had_transcript_events + if not had_transcript_events: + transcript.apply({"type": "text_chunk", "text": "Done."}) + trace_event( + stage="claude_cli", + event="turn.completed", + source="cli_event", + node_id=claim.node.node_id, + claude_session_id=captured_session_id, + ) + await update_ui(format_status("✅", "Complete"), force=True) + await complete_claim(captured_session_id) + elif ptype == "error": + error_msg = parsed.get("message", "Unknown error") + em = error_msg if isinstance(error_msg, str) else str(error_msg) + trace_event( + stage="claude_cli", + event="turn.failed", + source="cli_event", + node_id=claim.node.node_id, + claude_session_id=captured_session_id, + cli_error_message=em, + ) + if log_messaging_error_details: + logger.error("HANDLER: Error event received: {}", error_msg) + else: + logger.error( + "HANDLER: Error event received: message_chars={}", + text_len_hint(em), + ) + await update_ui(format_status("❌", "Error"), force=True) + await fail_claim(em, "Parent task failed") + + return last_status, had_transcript_events diff --git a/src/free_claude_code/messaging/node_runner.py b/src/free_claude_code/messaging/node_runner.py new file mode 100644 index 0000000..ad4bfa3 --- /dev/null +++ b/src/free_claude_code/messaging/node_runner.py @@ -0,0 +1,408 @@ +"""Run queued messaging nodes through a managed CLI session.""" + +import asyncio +from collections.abc import Callable + +from loguru import logger + +from free_claude_code.core.diagnostics import ( + format_user_error_preview, + safe_exception_message, +) +from free_claude_code.core.trace import trace_event + +from .event_parser import parse_cli_event +from .managed_protocols import ManagedClaudeSessionManagerProtocol +from .node_event_pipeline import handle_session_info_event, process_parsed_cli_event +from .platforms.ports import OutboundMessenger +from .safe_diagnostics import format_exception_for_log +from .session import SessionStore +from .transcript import RenderCtx, TranscriptBuffer +from .trees import CancellationReason, NodeClaim, TreeQueueManager, TreeSnapshot +from .ui_updates import ThrottledTranscriptEditor + + +class MessagingNodeRunner: + """Owns the lifecycle of one queued messaging node.""" + + def __init__( + self, + *, + platform_name: str, + outbound: OutboundMessenger, + cli_manager: ManagedClaudeSessionManagerProtocol, + session_store: SessionStore, + get_tree_queue: Callable[[], TreeQueueManager], + format_status: Callable[[str, str, str | None], str], + get_parse_mode: Callable[[], str | None], + get_render_ctx: Callable[[], RenderCtx], + get_limit_chars: Callable[[], int], + debug_platform_edits: bool = False, + debug_subagent_stack: bool = False, + log_raw_cli_diagnostics: bool = False, + log_messaging_error_details: bool = False, + ) -> None: + self.platform_name = platform_name + self.outbound = outbound + self.cli_manager = cli_manager + self.session_store = session_store + self._get_tree_queue = get_tree_queue + self._format_status = format_status + self._get_parse_mode = get_parse_mode + self._get_render_ctx = get_render_ctx + self._get_limit_chars = get_limit_chars + self._debug_platform_edits = debug_platform_edits + self._debug_subagent_stack = debug_subagent_stack + self._log_raw_cli_diagnostics = log_raw_cli_diagnostics + self._log_messaging_error_details = log_messaging_error_details + + 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, + debug_subagent_stack=self._debug_subagent_stack, + ) + return transcript, self._get_render_ctx() + + def _save_snapshot(self, snapshot: TreeSnapshot | None) -> None: + """Persist a snapshot returned by the active aggregate manager.""" + if snapshot is None: + return + self.session_store.save_tree_snapshot(snapshot) + + async def _record_session(self, claim: NodeClaim, session_id: str) -> None: + snapshot = await self._get_tree_queue().record_session(claim, session_id) + self._save_snapshot(snapshot) + + async def _complete_claim( + self, + claim: NodeClaim, + session_id: str | None, + ) -> None: + snapshot = await self._get_tree_queue().complete_claim(claim, session_id) + self._save_snapshot(snapshot) + + async def _fail_claim( + self, + claim: NodeClaim, + *, + propagate: bool, + child_status_text: str | None = None, + ) -> None: + result = await self._get_tree_queue().fail_claim( + claim, + propagate=propagate, + ) + self._save_snapshot(result.snapshot) + if child_status_text is None: + return + for child in result.affected: + if child.node_id == claim.node.node_id: + continue + self.outbound.fire_and_forget( + self.outbound.queue_edit_message( + child.scope.chat_id, + child.status_message_id, + self._format_status("❌", "Cancelled:", child_status_text), + parse_mode=self._get_parse_mode(), + ) + ) + + async def process_node( + self, + claim: NodeClaim, + ) -> None: + """Core task processor for a single CLI interaction.""" + node_id = claim.node.node_id + status_msg_id = claim.node.status_message_id + chat_id = claim.node.scope.chat_id + + with logger.contextualize(node_id=node_id, chat_id=chat_id): + await self._process_node_impl(claim, chat_id, status_msg_id) + + async def _process_node_impl( + self, + claim: NodeClaim, + chat_id: str, + status_msg_id: str, + ) -> None: + """Internal implementation of process_node with context bound.""" + node_id = claim.node.node_id + + transcript, render_ctx = self._create_transcript_and_render_ctx() + + had_transcript_events = False + non_exit_error: str | None = None + terminal_seen = False + captured_session_id = None + temp_session_id = None + last_status: str | None = None + + parent_session_id = claim.parent_session_id + platform_nm = self.platform_name + if parent_session_id: + trace_event( + stage="claude_cli", + event="claude_cli.fork.from_parent_session", + source=platform_nm, + chat_id=chat_id, + node_id=node_id, + parent_session_id=parent_session_id, + ) + + editor = ThrottledTranscriptEditor( + outbound=self.outbound, + parse_mode=self._get_parse_mode(), + get_limit_chars=self._get_limit_chars, + transcript=transcript, + render_ctx=render_ctx, + node_id=node_id, + chat_id=chat_id, + status_msg_id=status_msg_id, + debug_platform_edits=self._debug_platform_edits, + log_messaging_error_details=self._log_messaging_error_details, + ) + + async def update_ui(status: str | None = None, force: bool = False) -> None: + await editor.update(status, force=force) + + try: + try: + ( + cli_session, + session_or_temp_id, + is_new, + ) = await self.cli_manager.get_or_create_session( + session_id=parent_session_id + ) + if is_new: + temp_session_id = session_or_temp_id + else: + captured_session_id = session_or_temp_id + + sess_evt = ( + "claude_cli.session.pending_created" + if is_new + else "claude_cli.session.reused" + ) + trace_event( + stage="claude_cli", + event=sess_evt, + source=platform_nm, + chat_id=chat_id, + node_id=node_id, + status_message_id=status_msg_id, + session_handle=str(session_or_temp_id), + parent_resume_session_id=parent_session_id, + fork_requested=bool(parent_session_id), + ) + trace_event( + stage="claude_cli", + event="claude_cli.request.sent", + source=platform_nm, + chat_id=chat_id, + node_id=node_id, + prompt=claim.prompt, + fork_session_arg=bool(parent_session_id), + resume_session_arg=parent_session_id, + ) + except RuntimeError as e: + error_message = safe_exception_message(e) + transcript.apply({"type": "error", "message": error_message}) + await update_ui( + self._format_status("⏳", "Session limit reached", None), + force=True, + ) + await self._fail_claim( + claim, + propagate=False, + ) + trace_event( + stage="claude_cli", + event="claude_cli.session.limit_reached", + source=platform_nm, + chat_id=chat_id, + node_id=node_id, + ) + return + + async for event_data in cli_session.start_task( + claim.prompt, + 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 + + ( + captured_session_id, + temp_session_id, + ) = await handle_session_info_event( + event_data, + claim, + captured_session_id, + temp_session_id, + cli_manager=self.cli_manager, + record_session=lambda session_id: self._record_session( + claim, session_id + ), + ) + if event_data.get("type") == "session_info": + continue + + parsed_list = parse_cli_event( + event_data, log_raw_cli=self._log_raw_cli_diagnostics + ) + + for parsed in parsed_list: + ptype = parsed.get("type") + if ( + ptype == "error" + and parsed.get("source") == "exit" + and non_exit_error is not None + ): + await self._fail_claim( + claim, + propagate=True, + child_status_text="Parent task failed", + ) + terminal_seen = True + continue + + propagate_failure = parsed.get("source") == "exit" + + async def fail_parsed_event( + error_message: str, + child_status: str, + propagate: bool = propagate_failure, + ) -> None: + await self._fail_claim( + claim, + propagate=propagate, + child_status_text=child_status, + ) + + ( + last_status, + had_transcript_events, + ) = await process_parsed_cli_event( + parsed, + transcript, + update_ui, + last_status, + had_transcript_events, + claim, + captured_session_id, + format_status=self._format_status, + complete_claim=lambda session_id: self._complete_claim( + claim, session_id + ), + fail_claim=fail_parsed_event, + log_messaging_error_details=self._log_messaging_error_details, + ) + if ptype == "error" and parsed.get("source") != "exit": + error_message = parsed.get("message", "Unknown error") + non_exit_error = ( + error_message + if isinstance(error_message, str) + else str(error_message) + ) + if (ptype == "error" and parsed.get("source") == "exit") or ( + ptype == "complete" and parsed.get("status") == "success" + ): + terminal_seen = True + + if non_exit_error is not None and not terminal_seen: + await self._fail_claim( + claim, + propagate=True, + child_status_text="Parent task failed", + ) + elif not terminal_seen: + error_message = "Claude CLI ended without a terminal event" + transcript.apply({"type": "error", "message": error_message}) + await update_ui( + self._format_status("💥", "Task Failed", None), + force=True, + ) + await self._fail_claim( + claim, + propagate=True, + child_status_text="Parent task failed", + ) + + except asyncio.CancelledError as exc: + trace_event( + stage="claude_cli", + event="turn.processor.cancelled", + source=platform_nm, + chat_id=chat_id, + node_id=node_id, + ) + logger.warning(f"HANDLER: Task cancelled for node {node_id}") + reason = exc.args[0] if exc.args else None + if reason is CancellationReason.STOP: + await update_ui(self._format_status("⏹", "Stopped.", None), force=True) + elif reason is not CancellationReason.CLEAR: + transcript.apply({"type": "error", "message": "Task was cancelled"}) + await update_ui( + self._format_status("❌", "Cancelled", None), force=True + ) + + await self._fail_claim( + claim, + propagate=False, + ) + except Exception as e: + trace_event( + stage="claude_cli", + event="turn.processor.exception", + source=platform_nm, + chat_id=chat_id, + node_id=node_id, + exc_type=type(e).__name__, + ) + logger.error( + "HANDLER: Task failed with exception: {}", + format_exception_for_log( + e, log_full_message=self._log_messaging_error_details + ), + ) + error_msg = format_user_error_preview(e) + transcript.apply({"type": "error", "message": error_msg}) + await update_ui(self._format_status("💥", "Task Failed", None), force=True) + await self._fail_claim( + claim, + propagate=True, + child_status_text="Parent task failed", + ) + finally: + trace_event( + stage="routing", + event="turn.processor.finished", + source=platform_nm, + chat_id=chat_id, + node_id=node_id, + claude_session_id=captured_session_id or temp_session_id, + ) + 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( + "Failed to remove session for node {}: {}", + node_id, + format_exception_for_log( + e, log_full_message=self._log_messaging_error_details + ), + ) + + +__all__ = ["MessagingNodeRunner"] diff --git a/src/free_claude_code/messaging/platforms/__init__.py b/src/free_claude_code/messaging/platforms/__init__.py new file mode 100644 index 0000000..f7f302f --- /dev/null +++ b/src/free_claude_code/messaging/platforms/__init__.py @@ -0,0 +1,20 @@ +"""Messaging platform runtimes and ports.""" + +from .factory import MessagingPlatformOptions, create_messaging_components +from .ports import ( + MessagingPlatformComponents, + MessagingRuntime, + MessagingStartupNotice, + OutboundMessenger, + VoiceCancellation, +) + +__all__ = [ + "MessagingPlatformComponents", + "MessagingPlatformOptions", + "MessagingRuntime", + "MessagingStartupNotice", + "OutboundMessenger", + "VoiceCancellation", + "create_messaging_components", +] diff --git a/src/free_claude_code/messaging/platforms/discord.py b/src/free_claude_code/messaging/platforms/discord.py new file mode 100644 index 0000000..1a77b5e --- /dev/null +++ b/src/free_claude_code/messaging/platforms/discord.py @@ -0,0 +1,319 @@ +"""Discord messaging runtime.""" + +import asyncio +import contextlib +from collections.abc import Awaitable, Callable +from typing import Any + +from loguru import logger + +from free_claude_code.core.diagnostics import format_user_error_preview + +from ..limiter import MessagingRateLimiter +from ..models import IncomingMessage, MessageScope +from ..rendering.discord_markdown import format_status_discord +from ..voice import Transcriber, VoiceCancellationResult +from .discord_inbound import ( + discord_text_message_from_event, + discord_voice_request_from_event, + get_audio_attachment, + parse_allowed_channels, +) +from .discord_io import DiscordMessenger +from .ports import InboundMessageHandler +from .voice_flow import VoiceNoteFlow + +_discord_module: Any = None +try: + import discord as _discord_import + + _discord_module = _discord_import + DISCORD_AVAILABLE = True +except ImportError: + DISCORD_AVAILABLE = False + + +def _get_discord() -> Any: + """Return the discord module or raise a setup error.""" + if not DISCORD_AVAILABLE or _discord_module is None: + raise ImportError( + "discord.py is required. Install with: pip install discord.py" + ) + return _discord_module + + +if DISCORD_AVAILABLE and _discord_module is not None: + _discord = _discord_module + + class _DiscordClient(_discord.Client): + """Internal Discord client that forwards events to the runtime.""" + + def __init__( + self, + runtime: DiscordRuntime, + intents: _discord.Intents, + ) -> None: + super().__init__(intents=intents) + self._runtime = runtime + + async def on_ready(self) -> None: + self._runtime._mark_connected() + + async def on_message(self, message: Any) -> None: + await self._runtime._handle_client_message(message) +else: + _DiscordClient = None + + +class DiscordRuntime: + """Owns Discord SDK lifecycle and inbound event handoff.""" + + name = "discord" + + def __init__( + self, + bot_token: str | None = None, + allowed_channel_ids: str | None = None, + *, + limiter: MessagingRateLimiter, + transcriber: Transcriber | None, + log_raw_messaging_content: bool = False, + log_api_error_tracebacks: bool = False, + ) -> None: + if not DISCORD_AVAILABLE: + raise ImportError( + "discord.py is required. Install with: pip install discord.py" + ) + + self.bot_token = bot_token + self.allowed_channel_ids = parse_allowed_channels(allowed_channel_ids) + 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: InboundMessageHandler | None = None + self._connected = False + self._accepting_messages = False + self._ready = asyncio.Event() + self._inbound_tasks: set[asyncio.Task[Any]] = set() + self._limiter = limiter + self._start_task: asyncio.Task[None] | None = None + self.outbound = DiscordMessenger( + get_client=lambda: self._client, + get_discord=_get_discord, + limiter=limiter, + ) + self._voice_flow = VoiceNoteFlow( + transcriber=transcriber, + log_raw_messaging_content=log_raw_messaging_content, + log_api_error_tracebacks=log_api_error_tracebacks, + ) + self._log_raw_messaging_content = log_raw_messaging_content + self._log_api_error_tracebacks = log_api_error_tracebacks + + async def _handle_client_message(self, message: Any) -> None: + """Adapter entry point used by the internal Discord client.""" + if not self._accepting_messages: + return + task = asyncio.current_task() + if task is not None: + self._inbound_tasks.add(task) + try: + if self._accepting_messages: + await self._on_discord_message(message) + finally: + if task is not None: + self._inbound_tasks.discard(task) + + def _mark_connected(self) -> None: + """Publish Discord readiness while this runtime accepts ingress.""" + if not self._accepting_messages: + return + self._connected = True + self._ready.set() + logger.info("Discord platform connected") + + async def cancel_pending_voice( + self, scope: MessageScope, reply_id: str + ) -> VoiceCancellationResult | None: + """Cancel a pending voice transcription.""" + return await self._voice_flow.cancel_pending_voice(scope, reply_id) + + async def cancel_all_pending_voices( + self, + ) -> tuple[VoiceCancellationResult, ...]: + """Cancel every pending voice transcription and handoff.""" + return await self._voice_flow.cancel_all_pending_voices() + + async def cancel_pending_voices_in_scope( + self, + scope: MessageScope, + ) -> tuple[VoiceCancellationResult, ...]: + """Cancel pending voice transcriptions belonging to one chat.""" + return await self._voice_flow.cancel_pending_voices_in_scope(scope) + + async def _handle_voice_note( + self, message: Any, attachment: Any, channel_id: str + ) -> bool: + """Handle a Discord audio attachment.""" + return await self._voice_flow.handle( + discord_voice_request_from_event(message, attachment, channel_id), + message_handler=self._message_handler, + queue_send_message=self.outbound.queue_send_message, + queue_delete_messages=self.outbound.queue_delete_messages, + ) + + 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 + + if not message.content: + audio_att = get_audio_attachment(message) + if audio_att: + await self._handle_voice_note(message, audio_att, channel_id) + return + + incoming = discord_text_message_from_event( + message, + log_raw_messaging_content=self._log_raw_messaging_content, + ) + if self._message_handler is None: + return + + try: + await self._message_handler(incoming) + except Exception as e: + if self._log_api_error_tracebacks: + logger.error("Error handling message: {}", e) + else: + logger.error("Error handling message: exc_type={}", type(e).__name__) + with contextlib.suppress(Exception): + await self.outbound.send_message( + channel_id, + format_status_discord("Error:", format_user_error_preview(e)), + reply_to=str(message.id), + ) + + async def start(self) -> None: + """Initialize and connect to Discord.""" + if not self.bot_token: + raise ValueError("DISCORD_BOT_TOKEN is required") + + self._limiter.start() + self._accepting_messages = True + self._ready.clear() + + self._start_task = asyncio.create_task( + self._client.start(self.bot_token), + name="discord-client-start", + ) + self._start_task.add_done_callback(self._observe_client_exit) + ready_task = asyncio.create_task( + self._ready.wait(), + name="discord-client-ready", + ) + try: + done, _pending = await asyncio.wait( + (self._start_task, ready_task), + timeout=30.0, + return_when=asyncio.FIRST_COMPLETED, + ) + if not done: + raise RuntimeError("Discord client failed to connect within timeout") + if self._start_task in done: + await self._start_task + raise RuntimeError("Discord client stopped before becoming ready") + if self._start_task.done(): + await self._start_task + raise RuntimeError("Discord client stopped unexpectedly") + finally: + ready_task.cancel() + await asyncio.gather(ready_task, return_exceptions=True) + + logger.info("Discord platform started") + + def _observe_client_exit(self, task: asyncio.Task[None]) -> None: + """Observe the long-lived Discord client task and publish lost readiness.""" + if task.cancelled(): + exception: BaseException | None = None + else: + exception = task.exception() + + was_connected = self._connected + if not self._accepting_messages: + return + + self._connected = False + self._ready.clear() + if not was_connected: + return + + if exception is None: + logger.error("Discord client stopped unexpectedly") + elif self._log_api_error_tracebacks: + logger.error("Discord client stopped unexpectedly: {}", exception) + else: + logger.error( + "Discord client stopped unexpectedly: exc_type={}", + type(exception).__name__, + ) + + async def quiesce(self) -> None: + """Stop Discord ingress and drain active SDK handlers.""" + self._accepting_messages = False + try: + if not self._client.is_closed(): + await self._client.close() + finally: + try: + await self._drain_start_task() + finally: + try: + await self._drain_inbound_tasks() + finally: + self._connected = False + self._ready.clear() + + async def close(self) -> None: + """Close Discord delivery resources after ingress is quiescent.""" + try: + await self.outbound.close() + finally: + await self._limiter.shutdown() + logger.info("Discord platform closed") + + async def _drain_start_task(self) -> None: + task = self._start_task + if task is None: + return + if not task.done(): + task.cancel() + try: + await asyncio.gather(task, return_exceptions=True) + finally: + if task.done() and self._start_task is task: + self._start_task = None + + async def _drain_inbound_tasks(self) -> None: + tasks = tuple(self._inbound_tasks) + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + + def on_message(self, handler: Callable[[IncomingMessage], Awaitable[None]]) -> None: + """Register the workflow callback for inbound messages.""" + self._message_handler = handler + + @property + def is_connected(self) -> bool: + """Return whether Discord startup completed.""" + return self._connected diff --git a/src/free_claude_code/messaging/platforms/discord_inbound.py b/src/free_claude_code/messaging/platforms/discord_inbound.py new file mode 100644 index 0000000..ba35838 --- /dev/null +++ b/src/free_claude_code/messaging/platforms/discord_inbound.py @@ -0,0 +1,112 @@ +"""Discord inbound event normalization.""" + +from typing import Any + +from loguru import logger + +from ..models import IncomingMessage +from ..rendering.discord_markdown import format_status_discord +from .voice_flow import ( + VoiceNoteRequest, + audio_suffix_from_metadata, + is_audio_metadata, +) + + +def parse_allowed_channels(raw: str | None) -> set[str]: + """Parse comma-separated Discord channel IDs.""" + if not raw or not raw.strip(): + return set() + return {s.strip() for s in raw.split(",") if s.strip()} + + +def get_audio_attachment(message: Any) -> Any | None: + """Return the first audio attachment from a Discord message.""" + for att in message.attachments: + if is_audio_metadata(att.filename, att.content_type): + return att + return None + + +def discord_text_message_from_event( + message: Any, + *, + log_raw_messaging_content: bool, +) -> IncomingMessage: + """Normalize a Discord message into an incoming text message.""" + channel_id = str(message.channel.id) + message_id = str(message.id) + reply_to = ( + str(message.reference.message_id) + if message.reference and message.reference.message_id + else None + ) + raw_content = message.content or "" + if log_raw_messaging_content: + text_preview = raw_content[:80] + if len(raw_content) > 80: + text_preview += "..." + logger.info( + "DISCORD_MSG: chat_id={} message_id={} reply_to={} text_preview={!r}", + channel_id, + message_id, + reply_to, + text_preview, + ) + else: + logger.info( + "DISCORD_MSG: chat_id={} message_id={} reply_to={} text_len={}", + channel_id, + message_id, + reply_to, + len(raw_content), + ) + + return IncomingMessage( + text=message.content, + chat_id=channel_id, + user_id=str(message.author.id), + message_id=message_id, + platform="discord", + reply_to_message_id=reply_to, + username=message.author.display_name, + raw_event=message, + ) + + +def discord_voice_request_from_event( + message: Any, + attachment: Any, + channel_id: str, +) -> VoiceNoteRequest: + """Normalize a Discord voice/audio attachment into a voice-note request.""" + message_id = str(message.id) + reply_to = ( + str(message.reference.message_id) + if message.reference and message.reference.message_id + else None + ) + + async def _download_to(tmp_path) -> None: + await attachment.save(str(tmp_path)) + + async def _reply_text(text: str) -> None: + await message.reply(text) + + return VoiceNoteRequest( + platform="discord", + chat_id=channel_id, + user_id=str(message.author.id), + message_id=message_id, + raw_event=message, + content_type=attachment.content_type or "audio/ogg", + temp_suffix=audio_suffix_from_metadata( + filename=attachment.filename, + content_type=attachment.content_type, + ), + status_text=format_status_discord("Transcribing voice note..."), + reply_to_message_id=reply_to, + username=message.author.display_name, + download_to=_download_to, + reply_text=_reply_text, + ) diff --git a/src/free_claude_code/messaging/platforms/discord_io.py b/src/free_claude_code/messaging/platforms/discord_io.py new file mode 100644 index 0000000..ae8dfd4 --- /dev/null +++ b/src/free_claude_code/messaging/platforms/discord_io.py @@ -0,0 +1,167 @@ +"""Discord outbound delivery.""" + +from collections.abc import Awaitable, Callable +from typing import Any, cast + +from ..limiter import MessagingRateLimiter +from .outbox import PlatformOutbox + +DISCORD_MESSAGE_LIMIT = 2000 + +ClientGetter = Callable[[], Any] +DiscordGetter = Callable[[], Any] + + +def truncate_discord_message(text: str, limit: int = DISCORD_MESSAGE_LIMIT) -> str: + """Return text that fits Discord's message limit.""" + if len(text) <= limit: + return text + return text[: limit - 3] + "..." + + +class DiscordMessenger: + """Owns Discord sends, edits, deletes, and queued delivery.""" + + def __init__( + self, + *, + get_client: ClientGetter, + get_discord: DiscordGetter, + limiter: MessagingRateLimiter, + ) -> None: + self._get_client = get_client + self._get_discord = get_discord + self._outbox = PlatformOutbox( + limiter=limiter, + send=self.send_message, + edit=self.edit_message, + delete_many=self.delete_messages, + ) + + 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 Discord message immediately.""" + client = self._get_client() + channel = client.get_channel(int(chat_id)) + if not channel or not hasattr(channel, "send"): + raise RuntimeError(f"Channel {chat_id} not found") + + text = truncate_discord_message(text) + channel = cast(Any, channel) + + if reply_to: + discord = self._get_discord() + 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 a Discord message immediately.""" + client = self._get_client() + channel = client.get_channel(int(chat_id)) + if not channel or not hasattr(channel, "fetch_message"): + raise RuntimeError(f"Channel {chat_id} not found") + + discord = self._get_discord() + channel = cast(Any, channel) + try: + msg = await channel.fetch_message(int(message_id)) + except discord.NotFound: + return + + await msg.edit(content=truncate_discord_message(text)) + + async def delete_message(self, chat_id: str, message_id: str) -> None: + """Delete a Discord message immediately.""" + client = self._get_client() + channel = client.get_channel(int(chat_id)) + if not channel or not hasattr(channel, "fetch_message"): + return + + discord = self._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 Discord 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: + """Queue a Discord send.""" + return await self._outbox.queue_send_message( + chat_id, + text, + reply_to, + parse_mode, + fire_and_forget, + message_thread_id, + ) + + 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: + """Queue a Discord edit.""" + await self._outbox.queue_edit_message( + chat_id, + message_id, + text, + parse_mode, + fire_and_forget, + ) + + async def queue_delete_messages( + self, + chat_id: str, + message_ids: list[str], + fire_and_forget: bool = True, + ) -> None: + """Queue a Discord bulk delete.""" + await self._outbox.queue_delete_messages( + chat_id, + message_ids, + fire_and_forget, + ) + + def fire_and_forget(self, task: Awaitable[Any]) -> None: + """Execute a coroutine without awaiting it.""" + self._outbox.fire_and_forget(task) + + async def close(self) -> None: + """Cancel outstanding outbound work.""" + await self._outbox.close() diff --git a/src/free_claude_code/messaging/platforms/factory.py b/src/free_claude_code/messaging/platforms/factory.py new file mode 100644 index 0000000..0b678bb --- /dev/null +++ b/src/free_claude_code/messaging/platforms/factory.py @@ -0,0 +1,109 @@ +"""Messaging platform component factory.""" + +from dataclasses import dataclass + +from loguru import logger + +from ..limiter import MessagingRateLimiter +from ..voice import Transcriber +from .ports import MessagingPlatformComponents, MessagingStartupNotice + + +@dataclass(frozen=True, slots=True) +class MessagingPlatformOptions: + """Typed wiring from app settings into messaging platform runtimes.""" + + telegram_bot_token: str | None = None + allowed_telegram_user_id: str | None = None + telegram_proxy_url: str = "" + discord_bot_token: str | None = None + allowed_discord_channels: str | None = None + transcriber: Transcriber | None = None + messaging_rate_limit: int = 1 + messaging_rate_window: float = 1.0 + log_raw_messaging_content: bool = False + log_messaging_error_details: bool = False + log_api_error_tracebacks: bool = False + + +def create_messaging_components( + platform_type: str, + options: MessagingPlatformOptions | None = None, +) -> MessagingPlatformComponents | None: + """Create runtime/outbound components for the configured messaging platform.""" + opts = options or MessagingPlatformOptions() + if platform_type == "none": + logger.info("Messaging platform disabled by configuration") + return None + + if platform_type == "telegram": + bot_token = opts.telegram_bot_token + if not bot_token: + logger.info("No Telegram bot token configured, skipping platform setup") + return None + + from .telegram import TelegramRuntime + + limiter = MessagingRateLimiter( + rate_limit=opts.messaging_rate_limit, + rate_window=opts.messaging_rate_window, + log_error_details=opts.log_messaging_error_details, + ) + runtime = TelegramRuntime( + bot_token=bot_token, + allowed_user_id=opts.allowed_telegram_user_id, + telegram_proxy_url=opts.telegram_proxy_url, + limiter=limiter, + transcriber=opts.transcriber, + log_raw_messaging_content=opts.log_raw_messaging_content, + log_api_error_tracebacks=opts.log_api_error_tracebacks, + ) + startup_notice = ( + MessagingStartupNotice( + chat_id=opts.allowed_telegram_user_id, + transport_label="Bot API", + ) + if opts.allowed_telegram_user_id + else None + ) + return MessagingPlatformComponents( + name=runtime.name, + runtime=runtime, + outbound=runtime.outbound, + voice_cancellation=runtime, + startup_notice=startup_notice, + ) + + if platform_type == "discord": + bot_token = opts.discord_bot_token + if not bot_token: + logger.info("No Discord bot token configured, skipping platform setup") + return None + + from .discord import DiscordRuntime + + limiter = MessagingRateLimiter( + rate_limit=opts.messaging_rate_limit, + rate_window=opts.messaging_rate_window, + log_error_details=opts.log_messaging_error_details, + ) + runtime = DiscordRuntime( + bot_token=bot_token, + allowed_channel_ids=opts.allowed_discord_channels, + limiter=limiter, + transcriber=opts.transcriber, + log_raw_messaging_content=opts.log_raw_messaging_content, + log_api_error_tracebacks=opts.log_api_error_tracebacks, + ) + return MessagingPlatformComponents( + name=runtime.name, + runtime=runtime, + outbound=runtime.outbound, + voice_cancellation=runtime, + ) + + logger.warning( + "Unknown messaging platform: '{}'. Supported: 'none', 'telegram', 'discord'", + platform_type, + ) + return None diff --git a/src/free_claude_code/messaging/platforms/outbox.py b/src/free_claude_code/messaging/platforms/outbox.py new file mode 100644 index 0000000..0b9c51d --- /dev/null +++ b/src/free_claude_code/messaging/platforms/outbox.py @@ -0,0 +1,143 @@ +"""Shared queued delivery helper for messaging platforms.""" + +import asyncio +import hashlib +from collections.abc import Awaitable, Callable +from typing import Any, cast + +from loguru import logger + +from ..limiter import MessagingRateLimiter + +SendOperation = Callable[ + [str, str, str | None, str | None, str | None], + Awaitable[str], +] +EditOperation = Callable[[str, str, str, str | None], Awaitable[None]] +DeleteManyOperation = Callable[[str, list[str]], Awaitable[None]] + + +class PlatformOutbox: + """Own queueing, deduplication, and fire-and-forget delivery policy.""" + + def __init__( + self, + *, + limiter: MessagingRateLimiter, + send: SendOperation, + edit: EditOperation, + delete_many: DeleteManyOperation, + ) -> None: + self._limiter = limiter + self._send = send + self._edit = edit + self._delete_many = delete_many + self._background_tasks: set[asyncio.Future[Any]] = set() + self._closed = False + + 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: + """Queue or immediately send a platform message.""" + self._require_open() + + async def _send() -> str: + return await self._send( + chat_id, + text, + reply_to, + parse_mode, + message_thread_id, + ) + + if fire_and_forget: + self._limiter.fire_and_forget(_send) + return None + return cast(str | None, 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: + """Queue or immediately edit a platform message.""" + self._require_open() + + async def _edit() -> None: + await self._edit(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_messages( + self, + chat_id: str, + message_ids: list[str], + fire_and_forget: bool = True, + ) -> None: + """Queue or immediately bulk-delete platform messages.""" + self._require_open() + ids_snapshot = tuple(str(message_id) for message_id in message_ids) + if not ids_snapshot: + return + + async def _delete_many() -> None: + await self._delete_many(chat_id, list(ids_snapshot)) + + digest = hashlib.sha256("\x1f".join(ids_snapshot).encode()).hexdigest()[:16] + dedup_key = f"del_bulk:{chat_id}:{digest}" + if fire_and_forget: + self._limiter.fire_and_forget(_delete_many, dedup_key=dedup_key) + else: + await self._limiter.enqueue(_delete_many, dedup_key=dedup_key) + + def fire_and_forget(self, task: Awaitable[Any]) -> None: + """Run and retain arbitrary outbound work until completion or shutdown.""" + future = asyncio.ensure_future(task) + if self._closed: + future.cancel() + raise RuntimeError("Platform outbox is closed.") + self._background_tasks.add(future) + future.add_done_callback(self._complete_background_task) + + async def close(self) -> None: + """Cancel and await arbitrary outbound work owned by this outbox.""" + if not self._closed: + self._closed = True + tasks = tuple(self._background_tasks) + for task in tasks: + task.cancel() + try: + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + finally: + self._background_tasks.difference_update( + task for task in tasks if task.done() + ) + + def _complete_background_task(self, task: asyncio.Future[Any]) -> None: + self._background_tasks.discard(task) + if task.cancelled(): + return + error = task.exception() + if error is not None: + logger.error( + "Outbound background task failed: exc_type={}", + type(error).__name__, + ) + + def _require_open(self) -> None: + if self._closed: + raise RuntimeError("Platform outbox is closed.") diff --git a/src/free_claude_code/messaging/platforms/ports.py b/src/free_claude_code/messaging/platforms/ports.py new file mode 100644 index 0000000..4e5c9af --- /dev/null +++ b/src/free_claude_code/messaging/platforms/ports.py @@ -0,0 +1,99 @@ +"""Messaging platform ports used by the customer-facing workflow.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any, Protocol, runtime_checkable + +from ..models import IncomingMessage, MessageScope +from ..voice import VoiceCancellationResult + +InboundMessageHandler = Callable[[IncomingMessage], Awaitable[None]] + + +@runtime_checkable +class MessagingRuntime(Protocol): + """Owns ingress and delivery lifecycle for one messaging platform.""" + + @property + def name(self) -> str: ... + + async def start(self) -> None: ... + + async def quiesce(self) -> None: ... + + async def close(self) -> None: ... + + def on_message(self, handler: InboundMessageHandler) -> None: ... + + @property + def is_connected(self) -> bool: ... + + +@runtime_checkable +class OutboundMessenger(Protocol): + """Owns queued outbound platform delivery.""" + + 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: ... + + 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: ... + + async def queue_delete_messages( + self, + chat_id: str, + message_ids: list[str], + fire_and_forget: bool = True, + ) -> None: ... + + def fire_and_forget(self, task: Awaitable[Any]) -> None: ... + + +@dataclass(frozen=True, slots=True) +class MessagingStartupNotice: + """One clearable customer notice declared by a platform composition.""" + + chat_id: str + transport_label: str + + +@runtime_checkable +class VoiceCancellation(Protocol): + """Optional voice-note cancellation boundary used by stop/clear flows.""" + + async def cancel_pending_voice( + self, scope: MessageScope, reply_id: str + ) -> VoiceCancellationResult | None: ... + + async def cancel_all_pending_voices( + self, + ) -> tuple[VoiceCancellationResult, ...]: ... + + async def cancel_pending_voices_in_scope( + self, + scope: MessageScope, + ) -> tuple[VoiceCancellationResult, ...]: ... + + +@dataclass(frozen=True, slots=True) +class MessagingPlatformComponents: + """Runtime/outbound bundle for one configured messaging platform.""" + + name: str + runtime: MessagingRuntime + outbound: OutboundMessenger + voice_cancellation: VoiceCancellation | None = None + startup_notice: MessagingStartupNotice | None = None diff --git a/src/free_claude_code/messaging/platforms/telegram.py b/src/free_claude_code/messaging/platforms/telegram.py new file mode 100644 index 0000000..4556b7c --- /dev/null +++ b/src/free_claude_code/messaging/platforms/telegram.py @@ -0,0 +1,300 @@ +"""Telegram messaging runtime.""" + +import asyncio +import contextlib +import os +from collections.abc import Awaitable, Callable + +# Opt-in to future behavior for python-telegram-bot (retry_after as timedelta). +os.environ["PTB_TIMEDELTA"] = "1" + +from loguru import logger + +from free_claude_code.core.diagnostics import format_user_error_preview + +from ..limiter import MessagingRateLimiter +from ..models import IncomingMessage, MessageScope +from ..rendering.telegram_markdown import escape_md_v2 +from ..voice import Transcriber, VoiceCancellationResult +from .ports import InboundMessageHandler +from .telegram_inbound import ( + telegram_text_message_from_update, + telegram_voice_request_from_update, +) +from .telegram_io import TelegramMessenger +from .voice_flow import VoiceNoteFlow + +try: + from telegram import Update + from telegram.ext import ( + Application, + CommandHandler, + ContextTypes, + MessageHandler, + filters, + ) + from telegram.request import HTTPXRequest + + TELEGRAM_AVAILABLE = True +except ImportError: + TELEGRAM_AVAILABLE = False + + +class TelegramRuntime: + """Owns Telegram SDK lifecycle and inbound event handoff.""" + + name = "telegram" + + def __init__( + self, + bot_token: str | None = None, + allowed_user_id: str | None = None, + *, + telegram_proxy_url: str = "", + limiter: MessagingRateLimiter, + transcriber: Transcriber | None, + log_raw_messaging_content: bool = False, + log_api_error_tracebacks: bool = False, + ) -> None: + if not TELEGRAM_AVAILABLE: + raise ImportError( + "python-telegram-bot is required. Install with: pip install python-telegram-bot" + ) + + self.bot_token = bot_token + self.allowed_user_id = allowed_user_id + self.telegram_proxy_url = telegram_proxy_url.strip() + if not self.bot_token: + logger.warning("TELEGRAM_BOT_TOKEN not set") + + self._application: Application | None = None + self._message_handler: InboundMessageHandler | None = None + self._connected = False + self._limiter = limiter + self.outbound = TelegramMessenger( + get_application=lambda: self._application, + limiter=limiter, + ) + self._voice_flow = VoiceNoteFlow( + transcriber=transcriber, + log_raw_messaging_content=log_raw_messaging_content, + log_api_error_tracebacks=log_api_error_tracebacks, + ) + self._log_raw_messaging_content = log_raw_messaging_content + self._log_api_error_tracebacks = log_api_error_tracebacks + + async def cancel_pending_voice( + self, scope: MessageScope, reply_id: str + ) -> VoiceCancellationResult | None: + """Cancel a pending voice transcription.""" + return await self._voice_flow.cancel_pending_voice(scope, reply_id) + + async def cancel_all_pending_voices( + self, + ) -> tuple[VoiceCancellationResult, ...]: + """Cancel every pending voice transcription and handoff.""" + return await self._voice_flow.cancel_all_pending_voices() + + async def cancel_pending_voices_in_scope( + self, + scope: MessageScope, + ) -> tuple[VoiceCancellationResult, ...]: + """Cancel pending voice transcriptions belonging to one chat.""" + return await self._voice_flow.cancel_pending_voices_in_scope(scope) + + async def start(self) -> None: + """Initialize and connect to Telegram.""" + if not self.bot_token: + raise ValueError("TELEGRAM_BOT_TOKEN is required") + + if self.telegram_proxy_url: + request = HTTPXRequest( + connection_pool_size=8, + connect_timeout=30.0, + read_timeout=30.0, + proxy=self.telegram_proxy_url, + ) + update_request = HTTPXRequest( + connection_pool_size=8, + connect_timeout=30.0, + read_timeout=30.0, + proxy=self.telegram_proxy_url, + ) + builder = ( + Application.builder() + .token(self.bot_token) + .request(request) + .get_updates_request(update_request) + ) + else: + request = HTTPXRequest( + connection_pool_size=8, connect_timeout=30.0, read_timeout=30.0 + ) + builder = Application.builder().token(self.bot_token).request(request) + application = builder.build() + self._application = application + + application.add_handler( + MessageHandler(filters.TEXT & (~filters.COMMAND), self._on_telegram_message) + ) + application.add_handler(CommandHandler("start", self._on_start_command)) + application.add_handler( + MessageHandler(filters.COMMAND, self._on_telegram_message) + ) + application.add_handler(MessageHandler(filters.VOICE, self._on_telegram_voice)) + + await self._retry_connection_step( + application.initialize, + step="initialization", + ) + await application.start() + self._limiter.start() + updater = application.updater + if updater is not None: + await self._retry_connection_step( + lambda: updater.start_polling(drop_pending_updates=False), + step="polling", + ) + self._connected = True + + logger.info("Telegram platform started (Bot API)") + + async def _retry_connection_step( + self, + operation: Callable[[], Awaitable[object]], + *, + step: str, + ) -> None: + """Retry one independently repeatable Telegram connection step.""" + max_attempts = 3 + for attempt in range(1, max_attempts + 1): + try: + await operation() + return + except Exception as exc: + if attempt == max_attempts: + logger.error( + "Telegram {} failed after {} attempts", + step, + max_attempts, + ) + raise + wait_time = 2 * attempt + if self._log_api_error_tracebacks: + logger.warning( + "Telegram {} failed (attempt {}/{}): {}. Retrying in {}s...", + step, + attempt, + max_attempts, + exc, + wait_time, + ) + else: + logger.warning( + "Telegram {} failed (attempt {}/{}): exc_type={}. Retrying in {}s...", + step, + attempt, + max_attempts, + type(exc).__name__, + wait_time, + ) + await asyncio.sleep(wait_time) + + async def quiesce(self) -> None: + """Stop Telegram ingress after draining active SDK handlers.""" + application = self._application + updater = application.updater if application is not None else None + try: + if updater is not None and updater.running: + await updater.stop() + finally: + try: + if application is not None and application.running: + await application.stop() + finally: + self._connected = False + + async def close(self) -> None: + """Close Telegram delivery and initialized SDK resources.""" + application = self._application + try: + await self.outbound.close() + finally: + try: + await self._limiter.shutdown() + finally: + try: + if application is not None: + await application.shutdown() + finally: + logger.info("Telegram platform closed") + + def on_message(self, handler: Callable[[IncomingMessage], Awaitable[None]]) -> None: + """Register the workflow callback for inbound messages.""" + self._message_handler = handler + + @property + def is_connected(self) -> bool: + """Return whether Telegram startup completed.""" + return self._connected + + async def _on_start_command( + self, update: Update, context: ContextTypes.DEFAULT_TYPE + ) -> None: + if update.message: + await update.message.reply_text("👋 Hello! I am the Claude Code Proxy Bot.") + await self._on_telegram_message(update, context) + + async def _on_telegram_message( + self, update: Update, context: ContextTypes.DEFAULT_TYPE + ) -> None: + incoming = telegram_text_message_from_update( + update, + allowed_user_id=self.allowed_user_id, + log_raw_messaging_content=self._log_raw_messaging_content, + ) + if incoming is None or self._message_handler is None: + return + + try: + await self._message_handler(incoming) + except Exception as e: + if self._log_api_error_tracebacks: + logger.error("Error handling message: {}", e) + else: + logger.error("Error handling message: exc_type={}", type(e).__name__) + with contextlib.suppress(Exception): + await self.outbound.send_message( + incoming.chat_id, + f"❌ *{escape_md_v2('Error:')}* {escape_md_v2(format_user_error_preview(e))}", + reply_to=incoming.message_id, + message_thread_id=incoming.message_thread_id, + parse_mode="MarkdownV2", + ) + + async def _on_telegram_voice( + self, update: Update, context: ContextTypes.DEFAULT_TYPE + ) -> None: + message = update.message + + async def _reply_text(text: str) -> None: + if message is not None: + await message.reply_text(text) + + if await self._voice_flow.reply_if_disabled(_reply_text): + return + + request = telegram_voice_request_from_update( + update, + context, + allowed_user_id=self.allowed_user_id, + ) + if request is None: + return + + await self._voice_flow.handle( + request, + message_handler=self._message_handler, + queue_send_message=self.outbound.queue_send_message, + queue_delete_messages=self.outbound.queue_delete_messages, + ) diff --git a/src/free_claude_code/messaging/platforms/telegram_inbound.py b/src/free_claude_code/messaging/platforms/telegram_inbound.py new file mode 100644 index 0000000..3311d2d --- /dev/null +++ b/src/free_claude_code/messaging/platforms/telegram_inbound.py @@ -0,0 +1,133 @@ +"""Telegram inbound event normalization.""" + +from loguru import logger +from telegram import Update +from telegram.ext import ContextTypes + +from ..models import IncomingMessage +from ..rendering.telegram_markdown import format_status +from .voice_flow import VoiceNoteRequest, audio_suffix_from_metadata + + +def telegram_text_message_from_update( + update: Update, + *, + allowed_user_id: str | None, + log_raw_messaging_content: bool, +) -> IncomingMessage | None: + """Normalize a Telegram text update into an incoming message.""" + if ( + not update.message + or not update.message.text + or not update.effective_user + or not update.effective_chat + ): + return None + + user_id = str(update.effective_user.id) + chat_id = str(update.effective_chat.id) + if allowed_user_id and user_id != str(allowed_user_id).strip(): + logger.warning("Unauthorized access attempt from {}", user_id) + return None + + message = update.message + message_id = str(message.message_id) + reply_to = ( + str(message.reply_to_message.message_id) if message.reply_to_message else None + ) + thread_id = ( + str(message.message_thread_id) + if getattr(message, "message_thread_id", None) is not None + else None + ) + raw_text = message.text or "" + if log_raw_messaging_content: + text_preview = raw_text[:80] + if len(raw_text) > 80: + text_preview += "..." + logger.info( + "TELEGRAM_MSG: chat_id={} message_id={} reply_to={} text_preview={!r}", + chat_id, + message_id, + reply_to, + text_preview, + ) + else: + logger.info( + "TELEGRAM_MSG: chat_id={} message_id={} reply_to={} text_len={}", + chat_id, + message_id, + reply_to, + len(raw_text), + ) + + return IncomingMessage( + text=raw_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, + ) + + +def telegram_voice_request_from_update( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + *, + allowed_user_id: str | None, +) -> VoiceNoteRequest | None: + """Normalize a Telegram voice update into a voice-note request.""" + message = update.message + effective_user = update.effective_user + effective_chat = update.effective_chat + if ( + message is None + or message.voice is None + or effective_user is None + or effective_chat is None + ): + return None + + user_id = str(effective_user.id) + if allowed_user_id and user_id != str(allowed_user_id).strip(): + logger.warning("Unauthorized voice access attempt from {}", user_id) + return None + + voice = message.voice + chat_id = str(effective_chat.id) + message_id = str(message.message_id) + thread_id = ( + str(message.message_thread_id) + if getattr(message, "message_thread_id", None) is not None + else None + ) + reply_to = ( + str(message.reply_to_message.message_id) if message.reply_to_message else None + ) + + async def _download_to(tmp_path) -> None: + tg_file = await context.bot.get_file(voice.file_id) + await tg_file.download_to_drive(custom_path=str(tmp_path)) + + async def _reply_text(text: str) -> None: + await message.reply_text(text) + + return VoiceNoteRequest( + platform="telegram", + chat_id=chat_id, + user_id=user_id, + message_id=message_id, + raw_event=update, + content_type=voice.mime_type or "audio/ogg", + temp_suffix=audio_suffix_from_metadata(content_type=voice.mime_type), + status_text=format_status("⏳", "Transcribing voice note..."), + status_parse_mode="MarkdownV2", + message_thread_id=thread_id, + reply_to_message_id=reply_to, + username=None, + download_to=_download_to, + reply_text=_reply_text, + ) diff --git a/src/free_claude_code/messaging/platforms/telegram_io.py b/src/free_claude_code/messaging/platforms/telegram_io.py new file mode 100644 index 0000000..dff1fbb --- /dev/null +++ b/src/free_claude_code/messaging/platforms/telegram_io.py @@ -0,0 +1,287 @@ +"""Telegram outbound delivery.""" + +import asyncio +from collections.abc import Awaitable, Callable +from datetime import timedelta +from typing import Any + +from loguru import logger + +from ..limiter import MessagingRateLimiter +from .outbox import PlatformOutbox + +TELEGRAM_DELETE_MESSAGES_BATCH_SIZE = 100 + +TelegramNetworkError: type[BaseException] +TelegramRetryAfter: type[BaseException] +TelegramBaseError: type[BaseException] +try: + from telegram.error import ( + NetworkError as _TelegramNetworkError, + ) + from telegram.error import ( + RetryAfter as _TelegramRetryAfter, + ) + from telegram.error import ( + TelegramError as _TelegramBaseError, + ) + + TelegramNetworkError = _TelegramNetworkError + TelegramRetryAfter = _TelegramRetryAfter + TelegramBaseError = _TelegramBaseError +except ImportError: + TelegramNetworkError = TimeoutError + TelegramRetryAfter = TimeoutError + TelegramBaseError = Exception + +ApplicationGetter = Callable[[], Any | None] + + +class TelegramMessenger: + """Owns Telegram sends, edits, deletes, and queued delivery.""" + + def __init__( + self, + *, + get_application: ApplicationGetter, + limiter: MessagingRateLimiter, + ) -> None: + self._get_application = get_application + self._outbox = PlatformOutbox( + limiter=limiter, + send=self.send_message, + edit=self.edit_message, + delete_many=self.delete_messages, + ) + + async def _with_retry( + self, + func: Callable[..., Awaitable[Any]], + *args: Any, + suppress_known_message_errors: bool = True, + **kwargs: Any, + ) -> Any: + """Execute a Telegram API call with the platform retry policy.""" + max_retries = 3 + for attempt in range(max_retries): + try: + return await func(*args, **kwargs) + except (TimeoutError, TelegramNetworkError) as e: + if "Message is not modified" in str(e): + if suppress_known_message_errors: + return None + raise + if attempt < max_retries - 1: + wait_time = 2**attempt + logger.warning( + "Telegram API network error (attempt {}/{}): {}. " + "Retrying in {}s...", + attempt + 1, + max_retries, + e, + wait_time, + ) + await asyncio.sleep(wait_time) + else: + logger.error( + "Telegram API failed after {} attempts: {}", + max_retries, + e, + ) + raise + except TelegramRetryAfter as e: + retry_after = getattr(e, "retry_after", 0) + wait_secs = ( + retry_after.total_seconds() + if isinstance(retry_after, timedelta) + else float(retry_after) + ) + logger.warning("Rate limited by Telegram, waiting {}s...", wait_secs) + await asyncio.sleep(wait_secs) + return await func(*args, **kwargs) + except TelegramBaseError as e: + err_lower = str(e).lower() + if "message is not modified" in err_lower: + if suppress_known_message_errors: + return None + raise + 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", + ] + ): + if suppress_known_message_errors: + return None + raise + 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 + return None + + 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 Telegram message immediately.""" + app = self._get_application() + if not app or not app.bot: + raise RuntimeError("Telegram application or bot not initialized") + + async def _do_send(parse_mode: str | None = parse_mode) -> str: + 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 app.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 a Telegram message immediately.""" + app = self._get_application() + if not app or not app.bot: + raise RuntimeError("Telegram application or bot not initialized") + + async def _do_edit(parse_mode: str | None = parse_mode) -> None: + await app.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 Telegram message immediately.""" + app = self._get_application() + if not app or not app.bot: + raise RuntimeError("Telegram application or bot not initialized") + + async def _do_delete() -> None: + await app.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 Telegram messages best-effort.""" + if not message_ids: + return + app = self._get_application() + if not app or not app.bot: + raise RuntimeError("Telegram application or bot not initialized") + + bot = app.bot + mids: list[int] = [] + for mid in message_ids: + try: + mids.append(int(mid)) + except Exception: + continue + if not mids: + return + + if hasattr(bot, "delete_messages"): + for start in range(0, len(mids), TELEGRAM_DELETE_MESSAGES_BATCH_SIZE): + chunk = mids[start : start + TELEGRAM_DELETE_MESSAGES_BATCH_SIZE] + chunk_snapshot = tuple(chunk) + + async def _do_bulk(ids: tuple[int, ...] = chunk_snapshot) -> None: + await bot.delete_messages(chat_id=chat_id, message_ids=list(ids)) + + try: + await self._with_retry( + _do_bulk, + suppress_known_message_errors=False, + ) + except Exception as e: + logger.debug( + "Telegram bulk delete failed for chat {}: {}; falling back", + chat_id, + type(e).__name__, + ) + for mid in chunk: + await self.delete_message(chat_id, str(mid)) + return + + for mid in mids: + await self.delete_message(chat_id, str(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: + """Queue a Telegram send.""" + return await self._outbox.queue_send_message( + chat_id, + text, + reply_to, + parse_mode, + fire_and_forget, + message_thread_id, + ) + + 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: + """Queue a Telegram edit.""" + await self._outbox.queue_edit_message( + chat_id, + message_id, + text, + parse_mode, + fire_and_forget, + ) + + async def queue_delete_messages( + self, + chat_id: str, + message_ids: list[str], + fire_and_forget: bool = True, + ) -> None: + """Queue a Telegram bulk delete.""" + await self._outbox.queue_delete_messages( + chat_id, + message_ids, + fire_and_forget, + ) + + def fire_and_forget(self, task: Awaitable[Any]) -> None: + """Execute a coroutine without awaiting it.""" + self._outbox.fire_and_forget(task) + + async def close(self) -> None: + """Cancel outstanding outbound work.""" + await self._outbox.close() diff --git a/src/free_claude_code/messaging/platforms/voice_flow.py b/src/free_claude_code/messaging/platforms/voice_flow.py new file mode 100644 index 0000000..c8b4208 --- /dev/null +++ b/src/free_claude_code/messaging/platforms/voice_flow.py @@ -0,0 +1,388 @@ +"""Shared voice-note flow for messaging platform adapters.""" + +import asyncio +import contextlib +import tempfile +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from loguru import logger + +from free_claude_code.core.diagnostics import format_user_error_preview + +from ..models import IncomingMessage, MessageScope +from ..voice import ( + PendingVoiceClaim, + PendingVoiceRegistry, + Transcriber, + VoiceCancellationResult, + VoiceHandoffOutcome, +) + +AUDIO_EXTENSIONS = (".ogg", ".mp4", ".mp3", ".wav", ".m4a") +MAX_AUDIO_SIZE_BYTES = 25 * 1024 * 1024 +VOICE_DISABLED_MESSAGE = "Voice notes are disabled." +VOICE_TRANSCRIPTION_ERROR_MESSAGE = ( + "Could not transcribe voice note. Please try again or send text." +) + +MessageHandler = Callable[[IncomingMessage], Awaitable[None]] +QueueSend = Callable[..., Awaitable[str | None]] +QueueDeleteMany = Callable[..., Awaitable[None]] + + +@dataclass(frozen=True) +class VoiceNoteRequest: + """Platform-normalized voice-note input.""" + + platform: str + chat_id: str + user_id: str + message_id: str + raw_event: Any + content_type: str + temp_suffix: str + status_text: str + download_to: Callable[[Path], Awaitable[None]] + reply_text: Callable[[str], Awaitable[None]] + reply_to_message_id: str | None = None + status_parse_mode: str | None = None + message_thread_id: str | None = None + username: str | None = None + + @property + def scope(self) -> MessageScope: + return MessageScope(platform=self.platform, chat_id=self.chat_id) + + +def is_audio_metadata(filename: str | None, content_type: str | None) -> bool: + """Return whether attachment metadata describes an audio file.""" + normalized_content_type = (content_type or "").lower() + normalized_filename = (filename or "").lower() + return normalized_content_type.startswith("audio/") or any( + normalized_filename.endswith(extension) for extension in AUDIO_EXTENSIONS + ) + + +def audio_suffix_from_metadata( + *, + filename: str | None = None, + content_type: str | None = None, + default: str = ".ogg", +) -> str: + """Choose a temp-file suffix from platform attachment metadata.""" + normalized_filename = (filename or "").lower() + normalized_content_type = (content_type or "").lower() + + if "m4a" in normalized_content_type: + return ".m4a" + if "mp4" in normalized_content_type: + if normalized_filename.endswith(".m4a"): + return ".m4a" + return ".mp4" + if "mpeg" in normalized_content_type or "mp3" in normalized_content_type: + return ".mp3" + if "wav" in normalized_content_type: + return ".wav" + + for extension in AUDIO_EXTENSIONS: + if normalized_filename.endswith(extension): + return extension + return default + + +class VoiceNoteFlow: + """Own common voice transcription state and control flow.""" + + def __init__( + self, + *, + transcriber: Transcriber | None, + log_raw_messaging_content: bool, + log_api_error_tracebacks: bool, + ) -> None: + self._transcriber = transcriber + self._log_raw_messaging_content = log_raw_messaging_content + self._log_api_error_tracebacks = log_api_error_tracebacks + self._pending_voice = PendingVoiceRegistry() + + @property + def is_enabled(self) -> bool: + """Return whether voice-note handling is enabled.""" + return self._transcriber is not None + + async def reply_if_disabled( + self, reply_text: Callable[[str], Awaitable[None]] + ) -> bool: + """Reply with the disabled message when voice-note handling is disabled.""" + if self.is_enabled: + return False + await reply_text(VOICE_DISABLED_MESSAGE) + return True + + async def cancel_pending_voice( + self, scope: MessageScope, reply_id: str + ) -> VoiceCancellationResult | None: + """Cancel a pending voice transcription.""" + return await self._pending_voice.cancel(scope, reply_id) + + async def cancel_all_pending_voices( + self, + ) -> tuple[VoiceCancellationResult, ...]: + """Cancel every pending voice transcription and published handoff.""" + return await self._pending_voice.cancel_all() + + async def cancel_pending_voices_in_scope( + self, + scope: MessageScope, + ) -> tuple[VoiceCancellationResult, ...]: + """Cancel pending voice transcriptions belonging to one chat.""" + return await self._pending_voice.cancel_scope(scope) + + async def handle( + self, + request: VoiceNoteRequest, + *, + message_handler: MessageHandler | None, + queue_send_message: QueueSend, + queue_delete_messages: QueueDeleteMany, + ) -> bool: + """Transcribe a voice note and hand the resulting turn to messaging.""" + if await self.reply_if_disabled(request.reply_text): + return True + + if message_handler is None: + return False + + claim = await self._pending_voice.reserve( + request.scope, + request.message_id, + ) + if claim is None: + return True + + status_msg_id: str | None = None + status_bound = False + tmp_path: Path | None = None + handed_off = False + + try: + delivered_status_id = await queue_send_message( + request.chat_id, + request.status_text, + reply_to=request.message_id, + parse_mode=request.status_parse_mode, + fire_and_forget=False, + message_thread_id=request.message_thread_id, + ) + if delivered_status_id is None: + raise RuntimeError("Voice status delivery returned no message ID.") + status_msg_id = str(delivered_status_id) + status_bound = await self._pending_voice.bind_status(claim, status_msg_id) + if not status_bound: + _, cancellation = await self._finish_failed_pending_voice( + request, + claim, + status_msg_id, + queue_delete_messages, + status_bound=False, + handed_off=False, + ) + if cancellation is not None: + raise cancellation from None + return True + + with tempfile.NamedTemporaryFile( + suffix=request.temp_suffix, delete=False + ) as tmp: + tmp_path = Path(tmp.name) + + await request.download_to(tmp_path) + _validate_audio_file(tmp_path) + + transcriber = self._transcriber + if transcriber is None: + raise RuntimeError("Voice transcription is not configured.") + transcribed = await transcriber.transcribe(tmp_path) + + incoming = IncomingMessage( + text=transcribed, + chat_id=request.chat_id, + user_id=request.user_id, + message_id=request.message_id, + platform=request.platform, + reply_to_message_id=request.reply_to_message_id, + message_thread_id=request.message_thread_id, + username=request.username, + raw_event=request.raw_event, + status_message_id=status_msg_id, + ) + self._log_transcription(request, transcribed) + + async def handle_incoming() -> None: + nonlocal handed_off + handed_off = True + await message_handler(incoming) + + handoff_outcome = await self._pending_voice.handoff( + claim, + handle_incoming, + ) + if handoff_outcome is VoiceHandoffOutcome.REJECTED: + _, cancellation = await self._finish_failed_pending_voice( + request, + claim, + status_msg_id, + queue_delete_messages, + status_bound=status_bound, + handed_off=False, + ) + if cancellation is not None: + raise cancellation from None + return True + return True + except asyncio.CancelledError: + await self._finish_failed_pending_voice( + request, + claim, + status_msg_id, + queue_delete_messages, + status_bound=status_bound, + handed_off=handed_off, + ) + raise + except (ValueError, ImportError) as e: + discarded, cancellation = await self._finish_failed_pending_voice( + request, + claim, + status_msg_id, + queue_delete_messages, + status_bound=status_bound, + handed_off=handed_off, + ) + if cancellation is not None: + raise cancellation from None + if not handed_off and not discarded: + return True + await request.reply_text(format_user_error_preview(e)) + return True + except Exception as e: + discarded, cancellation = await self._finish_failed_pending_voice( + request, + claim, + status_msg_id, + queue_delete_messages, + status_bound=status_bound, + handed_off=handed_off, + ) + if cancellation is not None: + raise cancellation from None + if not handed_off and not discarded: + return True + if self._log_api_error_tracebacks: + logger.error("Voice transcription failed: {}", e) + else: + logger.error( + "Voice transcription failed: exc_type={}", + type(e).__name__, + ) + await request.reply_text(VOICE_TRANSCRIPTION_ERROR_MESSAGE) + return True + except BaseException: + await self._finish_failed_pending_voice( + request, + claim, + status_msg_id, + queue_delete_messages, + status_bound=status_bound, + handed_off=handed_off, + ) + raise + finally: + if tmp_path is not None: + with contextlib.suppress(OSError): + tmp_path.unlink(missing_ok=True) + + async def _finish_failed_pending_voice( + self, + request: VoiceNoteRequest, + claim: PendingVoiceClaim, + status_msg_id: str | None, + queue_delete_messages: QueueDeleteMany, + *, + status_bound: bool, + handed_off: bool, + ) -> tuple[bool, asyncio.CancelledError | None]: + """Finish owned cleanup before propagating any caller cancellation.""" + cleanup_task = asyncio.create_task( + self._clear_failed_pending_voice( + request, + claim, + status_msg_id, + queue_delete_messages, + status_bound=status_bound, + handed_off=handed_off, + ), + name=f"voice-cleanup-{claim.claim_id}", + ) + cancellation: asyncio.CancelledError | None = None + current = asyncio.current_task() + while True: + cancelling_before = current.cancelling() if current is not None else 0 + try: + return await asyncio.shield(cleanup_task), cancellation + except asyncio.CancelledError as error: + if current is None or current.cancelling() <= cancelling_before: + raise + cancellation = cancellation or error + + async def _clear_failed_pending_voice( + self, + request: VoiceNoteRequest, + claim: PendingVoiceClaim, + status_msg_id: str | None, + queue_delete_messages: QueueDeleteMany, + *, + status_bound: bool, + handed_off: bool, + ) -> bool: + discarded = await self._pending_voice.discard(claim) + if ( + not handed_off + and status_msg_id is not None + and (discarded or not status_bound) + ): + with contextlib.suppress(Exception): + await queue_delete_messages(request.chat_id, [status_msg_id]) + return discarded + + def _log_transcription(self, request: VoiceNoteRequest, transcribed: str) -> None: + label = request.platform.upper() + if self._log_raw_messaging_content: + logger.info( + "{}_VOICE: chat_id={} message_id={} transcribed={!r}", + label, + request.chat_id, + request.message_id, + (transcribed[:80] + "..." if len(transcribed) > 80 else transcribed), + ) + else: + logger.info( + "{}_VOICE: chat_id={} message_id={} transcribed_len={}", + label, + request.chat_id, + request.message_id, + len(transcribed), + ) + + +def _validate_audio_file(file_path: Path) -> None: + 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." + ) diff --git a/src/free_claude_code/messaging/rendering/__init__.py b/src/free_claude_code/messaging/rendering/__init__.py new file mode 100644 index 0000000..0c3f2c0 --- /dev/null +++ b/src/free_claude_code/messaging/rendering/__init__.py @@ -0,0 +1,3 @@ +"""Markdown rendering utilities for messaging platforms.""" + +__all__: list[str] = [] diff --git a/src/free_claude_code/messaging/rendering/discord_markdown.py b/src/free_claude_code/messaging/rendering/discord_markdown.py new file mode 100644 index 0000000..ccaf9f4 --- /dev/null +++ b/src/free_claude_code/messaging/rendering/discord_markdown.py @@ -0,0 +1,318 @@ +"""Discord markdown utilities. + +Discord uses standard markdown: **bold**, *italic*, `code`, ```code block```. +Used by the message handler and Discord platform adapter. +""" + +from markdown_it import MarkdownIt + +from .markdown_tables import normalize_gfm_tables + +# Discord escapes: \ * _ ` ~ | > +DISCORD_SPECIAL = set("\\*_`~|>") + +_MD = MarkdownIt("commonmark", {"html": False, "breaks": False}) +_MD.enable("strikethrough") +_MD.enable("table") + + +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/src/free_claude_code/messaging/rendering/markdown_tables.py b/src/free_claude_code/messaging/rendering/markdown_tables.py new file mode 100644 index 0000000..f5d7c95 --- /dev/null +++ b/src/free_claude_code/messaging/rendering/markdown_tables.py @@ -0,0 +1,47 @@ +"""Shared Markdown table pre-normalization for platform renderers.""" + +import re + +_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: + """Return whether a line looks like a GFM table header.""" + if "|" not in line: + return False + if _TABLE_SEP_RE.match(line): + return False + parts = [part.strip() for part in line.strip().strip("|").split("|")] + return len([part for part in parts if part]) >= 2 + + +def normalize_gfm_tables(text: str) -> str: + """Insert blank lines before detected tables 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() != "" + ): + indent_match = re.match(r"^(\s*)", line) + out_lines.append(indent_match.group(1) if indent_match else "") + + out_lines.append(line) + + return "\n".join(out_lines) diff --git a/src/free_claude_code/messaging/rendering/profiles.py b/src/free_claude_code/messaging/rendering/profiles.py new file mode 100644 index 0000000..ae70297 --- /dev/null +++ b/src/free_claude_code/messaging/rendering/profiles.py @@ -0,0 +1,53 @@ +"""Platform rendering profiles for messaging transcripts and status text.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from free_claude_code.messaging.rendering.discord_markdown import ( + discord_bold, + discord_code_inline, + escape_discord, + escape_discord_code, + render_markdown_to_discord, +) +from free_claude_code.messaging.rendering.discord_markdown import ( + format_status as format_status_discord, +) +from free_claude_code.messaging.rendering.telegram_markdown import ( + escape_md_v2, + escape_md_v2_code, + mdv2_bold, + mdv2_code_inline, + render_markdown_to_mdv2, +) +from free_claude_code.messaging.rendering.telegram_markdown import ( + format_status as format_status_telegram, +) +from free_claude_code.messaging.transcript import RenderCtx + + +@dataclass(frozen=True, slots=True) +class RenderingProfile: + format_status: Callable[[str, str, str | None], str] + parse_mode: str | None + render_ctx: RenderCtx + limit_chars: int + + +def build_rendering_profile(platform_name: str) -> RenderingProfile: + """Return rendering rules for a messaging platform.""" + is_discord = platform_name == "discord" + return RenderingProfile( + format_status=format_status_discord if is_discord else format_status_telegram, + parse_mode=None if is_discord else "MarkdownV2", + render_ctx=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, + ), + limit_chars=1900 if is_discord else 3900, + ) diff --git a/src/free_claude_code/messaging/rendering/telegram_markdown.py b/src/free_claude_code/messaging/rendering/telegram_markdown.py new file mode 100644 index 0000000..b0c24c6 --- /dev/null +++ b/src/free_claude_code/messaging/rendering/telegram_markdown.py @@ -0,0 +1,327 @@ +"""Telegram MarkdownV2 utilities. + +Renders common Markdown into Telegram MarkdownV2 format. +Used by the message handler and Telegram platform adapter. +""" + +from markdown_it import MarkdownIt + +from .markdown_tables import normalize_gfm_tables + +MDV2_SPECIAL_CHARS = set("\\_*[]()~`>#+-=|{}.!") +MDV2_LINK_ESCAPE = set("\\)") + +_MD = MarkdownIt("commonmark", {"html": False, "breaks": False}) +_MD.enable("strikethrough") +_MD.enable("table") + + +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/src/free_claude_code/messaging/safe_diagnostics.py b/src/free_claude_code/messaging/safe_diagnostics.py new file mode 100644 index 0000000..edd3d62 --- /dev/null +++ b/src/free_claude_code/messaging/safe_diagnostics.py @@ -0,0 +1,15 @@ +"""Helpers for redacting user-derived content from log lines.""" + + +def format_exception_for_log(exc: BaseException, *, log_full_message: bool) -> str: + """Return exception type and optionally ``str(exc)`` for operator diagnostics.""" + if log_full_message: + return f"{type(exc).__name__}: {exc}" + return type(exc).__name__ + + +def text_len_hint(text: str | None) -> int: + """Length of text for metadata-only logging (0 when missing).""" + if not text: + return 0 + return len(text) diff --git a/src/free_claude_code/messaging/session/__init__.py b/src/free_claude_code/messaging/session/__init__.py new file mode 100644 index 0000000..cdf1f36 --- /dev/null +++ b/src/free_claude_code/messaging/session/__init__.py @@ -0,0 +1,5 @@ +"""Public messaging session persistence API.""" + +from .store import SessionStore + +__all__ = ["SessionStore"] diff --git a/src/free_claude_code/messaging/session/managed_message_log.py b/src/free_claude_code/messaging/session/managed_message_log.py new file mode 100644 index 0000000..c62e2ad --- /dev/null +++ b/src/free_claude_code/messaging/session/managed_message_log.py @@ -0,0 +1,135 @@ +"""Persist platform messages belonging to FCC-managed conversations.""" + +from datetime import UTC, datetime +from typing import Any + + +class ManagedMessageLog: + """Track managed inbound and outbound messages in insertion order.""" + + def __init__(self, *, cap: int | None = None) -> None: + self._items: dict[str, list[dict[str, Any]]] = {} + self._ids: dict[str, set[str]] = {} + self._cap = cap + + @property + def cap(self) -> int | None: + return self._cap + + @classmethod + def from_json(cls, raw_log: Any, *, cap: int | None = None) -> ManagedMessageLog: + """Load current and legacy message-log entries.""" + log = cls(cap=cap) + if not isinstance(raw_log, dict): + return log + for chat_key, items in raw_log.items(): + if not isinstance(chat_key, str) or not isinstance(items, list): + continue + for item in items: + if not isinstance(item, dict): + continue + message_id = item.get("message_id") + direction = str(item.get("direction") or "") + kind = str(item.get("kind") or "") + if message_id is None or direction not in {"in", "out"} or not kind: + continue + log._append( + chat_key, + str(message_id), + ts=str(item.get("ts") or ""), + direction=direction, + kind=kind, + ) + return log + + def to_json(self) -> dict[str, list[dict[str, Any]]]: + return {chat_key: list(items) for chat_key, items in self._items.items()} + + def record( + self, + *, + platform: str, + chat_id: str, + message_id: str, + direction: str, + kind: str, + ) -> bool: + """Record one managed platform message.""" + if direction not in {"in", "out"}: + raise ValueError("Managed message direction must be 'in' or 'out'") + if not kind: + raise ValueError("Managed message kind cannot be empty") + return self._append( + make_chat_key(platform, chat_id), + str(message_id), + ts=datetime.now(UTC).isoformat(), + direction=direction, + kind=str(kind), + ) + + def ids_for_chat(self, platform: str, chat_id: str) -> list[str]: + chat_key = make_chat_key(platform, chat_id) + return [str(item["message_id"]) for item in self._items.get(chat_key, [])] + + def remove_ids(self, platform: str, chat_id: str, message_ids: set[str]) -> bool: + chat_key = make_chat_key(platform, chat_id) + if not message_ids or chat_key not in self._items: + return False + + before_count = len(self._items[chat_key]) + retained = [ + item + for item in self._items[chat_key] + if str(item["message_id"]) not in message_ids + ] + if retained: + self._items[chat_key] = retained + self._ids[chat_key] = {str(item["message_id"]) for item in retained} + else: + self._items.pop(chat_key) + self._ids.pop(chat_key, None) + return len(retained) != before_count + + def clear_chat(self, platform: str, chat_id: str) -> bool: + chat_key = make_chat_key(platform, chat_id) + removed = self._items.pop(chat_key, None) is not None + self._ids.pop(chat_key, None) + return removed + + def _append( + self, + chat_key: str, + message_id: str, + *, + ts: str, + direction: str, + kind: str, + ) -> bool: + seen = self._ids.setdefault(chat_key, set()) + if message_id in seen: + return False + self._items.setdefault(chat_key, []).append( + { + "message_id": message_id, + "ts": ts, + "direction": direction, + "kind": kind, + } + ) + seen.add(message_id) + self._trim(chat_key) + return True + + def _trim(self, chat_key: str) -> None: + if self._cap is None or self._cap <= 0: + return + items = self._items.get(chat_key, []) + if len(items) <= self._cap: + return + retained = items[-self._cap :] + self._items[chat_key] = retained + self._ids[chat_key] = {str(item["message_id"]) for item in retained} + + +def make_chat_key(platform: str, chat_id: str) -> str: + return f"{platform}:{chat_id}" diff --git a/src/free_claude_code/messaging/session/persistence.py b/src/free_claude_code/messaging/session/persistence.py new file mode 100644 index 0000000..7849e62 --- /dev/null +++ b/src/free_claude_code/messaging/session/persistence.py @@ -0,0 +1,161 @@ +"""Atomic JSON persistence for messaging session state.""" + +import contextlib +import json +import os +import tempfile +import threading +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from loguru import logger + + +@dataclass(frozen=True) +class _PendingWrite: + generation: int + snapshot: dict[str, Any] + + +class DebouncedJsonPersistence: + """Thread-safe debounced JSON writer with atomic replace semantics.""" + + def __init__( + self, + storage_path: str, + *, + snapshot: Callable[[], dict[str, Any]], + on_dirty: Callable[[bool], None], + debounce_secs: float = 0.5, + ) -> None: + self.storage_path = storage_path + self._snapshot = snapshot + self._on_dirty = on_dirty + self._debounce_secs = debounce_secs + self._save_timer: threading.Timer | None = None + self._timer_lock = threading.Lock() + self._writer_lock = threading.Lock() + self._save_generation = 0 + + def load_json(self) -> dict[str, Any]: + if not os.path.exists(self.storage_path): + return {} + with open(self.storage_path, encoding="utf-8") as file: + data = json.load(file) + return data if isinstance(data, dict) else {} + + def schedule_save(self) -> None: + self._on_dirty(True) + with self._timer_lock: + if self._save_timer is not None: + self._save_timer.cancel() + self._save_generation += 1 + generation = self._save_generation + timer = threading.Timer( + self._debounce_secs, + self._save_from_timer, + args=(generation,), + ) + timer.daemon = True + self._save_timer = timer + timer.start() + + def flush(self) -> None: + self._on_dirty(True) + pending = self._snapshot_for_write() + if pending is None: + return + self._write_pending(pending) + + def _save_from_timer(self, generation: int) -> None: + try: + pending = self._snapshot_for_write(expected_generation=generation) + if pending is None: + return + self._write_pending(pending) + except Exception as e: + self._on_dirty(True) + logger.error( + "Failed to save sessions: exc_type={}", + type(e).__name__, + ) + + def _write_pending(self, pending: _PendingWrite) -> None: + try: + written = self._write_if_current(pending) + except Exception: + self._on_dirty(True) + raise + if written: + self._mark_clean_if_current(pending.generation) + + def _write_if_current(self, pending: _PendingWrite) -> bool: + """Serialize writers and reject a snapshot superseded before replace.""" + with self._writer_lock: + with self._timer_lock: + if pending.generation != self._save_generation: + return False + self._write_file(pending.snapshot) + return True + + def _snapshot_for_write( + self, *, expected_generation: int | None = None + ) -> _PendingWrite | None: + generation = self._claim_timer(expected_generation) + if generation is None: + return None + snapshot = self._snapshot() + return _PendingWrite(generation=generation, snapshot=snapshot) + + def _claim_timer(self, expected_generation: int | None) -> int | None: + with self._timer_lock: + if expected_generation is not None and ( + expected_generation != self._save_generation or self._save_timer is None + ): + return None + if self._save_timer is not None: + self._save_timer.cancel() + self._save_timer = None + return self._save_generation + + def _mark_clean_if_current(self, generation: int) -> None: + with self._timer_lock: + is_current = ( + self._save_timer is None and generation == self._save_generation + ) + if is_current: + self._on_dirty(False) + + def write_data(self, data: dict[str, Any]) -> None: + """Write authoritative state after invalidating older pending snapshots.""" + self._on_dirty(True) + with self._timer_lock: + if self._save_timer is not None: + self._save_timer.cancel() + self._save_timer = None + self._save_generation += 1 + pending = _PendingWrite( + generation=self._save_generation, + snapshot=data, + ) + self._write_pending(pending) + + def _write_file(self, data: dict[str, Any]) -> None: + abs_target = os.path.abspath(self.storage_path) + dir_name = os.path.dirname(abs_target) or "." + fd, tmp_path = tempfile.mkstemp( + dir=dir_name, + prefix=".sessions.", + suffix=".tmp.json", + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as file: + json.dump(data, file, indent=2) + file.flush() + os.fsync(file.fileno()) + os.replace(tmp_path, abs_target) + except BaseException: + with contextlib.suppress(OSError): + os.unlink(tmp_path) + raise diff --git a/src/free_claude_code/messaging/session/store.py b/src/free_claude_code/messaging/session/store.py new file mode 100644 index 0000000..674aa72 --- /dev/null +++ b/src/free_claude_code/messaging/session/store.py @@ -0,0 +1,162 @@ +"""Persistent messaging conversation state store.""" + +import threading +from copy import deepcopy + +from loguru import logger + +from free_claude_code.messaging.models import MessageScope +from free_claude_code.messaging.trees import ( + ConversationSnapshot, + TreeIdentity, + TreeSnapshot, +) + +from .managed_message_log import ManagedMessageLog +from .persistence import DebouncedJsonPersistence + + +class SessionStore: + """ + Persistent storage for conversation snapshots and managed platform messages. + + The store reads both the old raw ``trees``/``node_to_tree`` shape and the + current typed ``conversation`` snapshot shape. Runtime callers deal in typed + snapshots only. + """ + + def __init__( + self, + storage_path: str = "sessions.json", + *, + managed_message_cap: int | None = None, + ) -> None: + self.storage_path = storage_path + self._lock = threading.RLock() + self._conversation = ConversationSnapshot() + self._managed_messages = ManagedMessageLog(cap=managed_message_cap) + self._dirty = False + self._persistence = DebouncedJsonPersistence( + storage_path, + snapshot=self._snapshot_for_persistence, + on_dirty=self._set_dirty, + ) + self._load() + + @property + def dirty(self) -> bool: + return self._dirty + + def _set_dirty(self, dirty: bool) -> None: + with self._lock: + self._dirty = dirty + + def _load(self) -> None: + try: + data = self._persistence.load_json() + except Exception as e: + logger.error("Failed to load sessions: {}", e) + return + + conversation_data = data.get("conversation") if isinstance(data, dict) else None + if not isinstance(conversation_data, dict): + conversation_data = data + + with self._lock: + self._conversation = ConversationSnapshot.from_json(conversation_data) + raw_messages = {} + if isinstance(data, dict): + raw_messages = data.get("managed_messages", data.get("message_log", {})) + self._managed_messages = ManagedMessageLog.from_json( + raw_messages, + cap=self._managed_messages.cap, + ) + message_count = sum( + len(items) for items in self._managed_messages.to_json().values() + ) + logger.info( + "Loaded {} trees and {} managed message IDs from {}", + len(self._conversation.trees), + message_count, + self.storage_path, + ) + + def _snapshot_for_persistence(self) -> dict: + with self._lock: + return { + "conversation": self._conversation.to_json(), + "managed_messages": self._managed_messages.to_json(), + } + + def load_conversation_snapshot(self) -> ConversationSnapshot: + with self._lock: + return deepcopy(self._conversation) + + def save_conversation_snapshot(self, snapshot: ConversationSnapshot) -> None: + with self._lock: + self._conversation = deepcopy(snapshot) + self._persistence.schedule_save() + + def save_tree_snapshot(self, snapshot: TreeSnapshot) -> None: + with self._lock: + self._conversation = self._conversation.with_tree(deepcopy(snapshot)) + self._persistence.schedule_save() + logger.debug("Saved tree {}", snapshot.root_id) + + def remove_tree_snapshot(self, identity: TreeIdentity) -> None: + with self._lock: + self._conversation = self._conversation.without_tree(identity) + self._persistence.schedule_save() + + def flush_pending_save(self) -> None: + self._persistence.flush() + + def record_message_id( + self, + platform: str, + chat_id: str, + message_id: str, + direction: str, + kind: str, + ) -> None: + if message_id is None: + return + with self._lock: + recorded = self._managed_messages.record( + platform=str(platform), + chat_id=str(chat_id), + message_id=str(message_id), + direction=str(direction), + kind=str(kind), + ) + if recorded: + self._persistence.schedule_save() + + def get_tracked_message_ids_for_chat( + self, platform: str, chat_id: str + ) -> list[str]: + with self._lock: + return self._managed_messages.ids_for_chat(str(platform), str(chat_id)) + + def forget_tracked_message_ids( + self, platform: str, chat_id: str, message_ids: set[str] + ) -> None: + with self._lock: + removed = self._managed_messages.remove_ids( + str(platform), + str(chat_id), + {str(message_id) for message_id in message_ids}, + ) + if removed: + self._persistence.schedule_save() + + def clear_scope(self, scope: MessageScope) -> None: + """Authoritatively clear one platform chat while preserving others.""" + with self._lock: + self._conversation = self._conversation.without_scope(scope) + self._managed_messages.clear_chat(scope.platform, scope.chat_id) + self._write_current_state() + + def _write_current_state(self) -> None: + self._set_dirty(True) + self._persistence.write_data(self._snapshot_for_persistence()) diff --git a/src/free_claude_code/messaging/transcript/__init__.py b/src/free_claude_code/messaging/transcript/__init__.py new file mode 100644 index 0000000..881befc --- /dev/null +++ b/src/free_claude_code/messaging/transcript/__init__.py @@ -0,0 +1,6 @@ +"""Public transcript API for messaging UI rendering.""" + +from .buffer import TranscriptBuffer +from .context import RenderCtx + +__all__ = ["RenderCtx", "TranscriptBuffer"] diff --git a/src/free_claude_code/messaging/transcript/buffer.py b/src/free_claude_code/messaging/transcript/buffer.py new file mode 100644 index 0000000..a99e88b --- /dev/null +++ b/src/free_claude_code/messaging/transcript/buffer.py @@ -0,0 +1,220 @@ +"""Transcript event application and open-block tracking.""" + +from typing import Any + +from .context import RenderCtx +from .renderer import render_segments +from .segments import ( + ErrorSegment, + Segment, + SubagentSegment, + TextSegment, + ThinkingSegment, + ToolCallSegment, + ToolResultSegment, +) +from .subagents import SubagentState, task_heading_from_input + +_SUBAGENT_SUPPRESSED_EVENTS = frozenset( + { + "thinking_start", + "thinking_delta", + "thinking_chunk", + "text_start", + "text_delta", + "text_chunk", + } +) + + +class TranscriptBuffer: + """Maintains an ordered, truncatable transcript of parsed CLI events.""" + + def __init__( + self, + *, + show_tool_results: bool = True, + debug_subagent_stack: bool = False, + ) -> None: + self._segments: list[Segment] = [] + self._open_thinking_by_index: dict[int, ThinkingSegment] = {} + self._open_text_by_index: dict[int, TextSegment] = {} + self._open_tools_by_index: dict[int, ToolCallSegment] = {} + self._tool_name_by_id: dict[str, str] = {} + self._show_tool_results = bool(show_tool_results) + self._subagents = SubagentState(debug=debug_subagent_stack) + + def apply(self, event: dict[str, Any]) -> None: + """Apply a parsed CLI transcript event.""" + event_type = event.get("type") + if self._subagents.in_subagent() and event_type in _SUBAGENT_SUPPRESSED_EVENTS: + return + + if event_type == "thinking_start": + self._start_thinking(_event_index(event)) + return + if event_type in ("thinking_delta", "thinking_chunk"): + self._append_thinking(_event_index(event), str(event.get("text", ""))) + return + if event_type == "thinking_stop": + self._open_thinking_by_index.pop(_event_index(event), None) + return + + if event_type == "text_start": + self._start_text(_event_index(event)) + return + if event_type in ("text_delta", "text_chunk"): + self._append_text(_event_index(event), str(event.get("text", ""))) + return + if event_type == "text_stop": + self._open_text_by_index.pop(_event_index(event), None) + return + + if event_type == "tool_use_start": + self._start_tool_use(event) + return + if event_type == "tool_use_delta": + return + if event_type == "tool_use_stop": + segment = self._open_tools_by_index.pop(_event_index(event), None) + if segment is not None: + segment.closed = True + return + + if event_type == "block_stop": + self._close_block(_event_index(event)) + return + if event_type == "tool_use": + self._append_complete_tool_use(event) + return + if event_type == "tool_result": + self._append_tool_result(event) + return + if event_type == "error": + self._segments.append(ErrorSegment(str(event.get("message", "")))) + + def render(self, ctx: RenderCtx, *, limit_chars: int, status: str | None) -> str: + return render_segments( + self._segments, + ctx, + limit_chars=limit_chars, + status=status, + ) + + def _start_thinking(self, index: int) -> None: + if index >= 0: + self._close_block(index) + segment = ThinkingSegment() + self._segments.append(segment) + if index >= 0: + self._open_thinking_by_index[index] = segment + + def _append_thinking(self, index: int, text: str) -> None: + segment = self._open_thinking_by_index.get(index) + if segment is None: + segment = ThinkingSegment() + self._segments.append(segment) + if index >= 0: + self._open_thinking_by_index[index] = segment + segment.append(text) + + def _start_text(self, index: int) -> None: + if index >= 0: + self._close_block(index) + segment = TextSegment() + self._segments.append(segment) + if index >= 0: + self._open_text_by_index[index] = segment + + def _append_text(self, index: int, text: str) -> None: + segment = self._open_text_by_index.get(index) + if segment is None: + segment = TextSegment() + self._segments.append(segment) + if index >= 0: + self._open_text_by_index[index] = segment + segment.append(text) + + def _start_tool_use(self, event: dict[str, Any]) -> None: + index = _event_index(event) + if index >= 0: + self._close_block(index) + + tool_id = _event_tool_id(event, "id") + name = str(event.get("name", "") or "tool") + if tool_id: + self._tool_name_by_id[tool_id] = name + + if name == "Task": + segment = SubagentSegment(task_heading_from_input(event.get("input"))) + self._segments.append(segment) + self._subagents.push(tool_id, segment) + return + + segment = self._append_tool_call(tool_id, name) + if index >= 0: + self._open_tools_by_index[index] = segment + + def _append_complete_tool_use(self, event: dict[str, Any]) -> None: + tool_id = _event_tool_id(event, "id") + name = str(event.get("name", "") or "tool") + if tool_id: + self._tool_name_by_id[tool_id] = name + + if name == "Task": + segment = SubagentSegment(task_heading_from_input(event.get("input"))) + self._segments.append(segment) + self._subagents.push(tool_id, segment) + return + + segment = self._append_tool_call(tool_id, name) + segment.closed = True + + def _append_tool_call(self, tool_id: str, name: str) -> ToolCallSegment: + if self._subagents.in_subagent(): + parent = self._subagents.current_segment() + if parent is not None: + return parent.set_current_tool_call(tool_id, name) + + segment = ToolCallSegment(tool_id, name) + self._segments.append(segment) + return segment + + def _append_tool_result(self, event: dict[str, Any]) -> None: + tool_id = _event_tool_id(event, "tool_use_id") + name = self._tool_name_by_id.get(tool_id) + + if self._subagents.in_subagent(): + self._subagents.close_for_tool_result(tool_id, tool_name=name) + + if not self._show_tool_results: + return + + self._segments.append( + ToolResultSegment( + tool_id, + event.get("content"), + name=name, + is_error=bool(event.get("is_error", False)), + ) + ) + + def _close_block(self, index: int) -> None: + if index in self._open_tools_by_index: + segment = self._open_tools_by_index.pop(index, None) + if segment is not None: + segment.closed = True + return + if index in self._open_thinking_by_index: + self._open_thinking_by_index.pop(index, None) + return + if index in self._open_text_by_index: + self._open_text_by_index.pop(index, None) + + +def _event_index(event: dict[str, Any]) -> int: + return int(event.get("index", -1)) + + +def _event_tool_id(event: dict[str, Any], key: str) -> str: + return str(event.get(key, "") or "").strip() diff --git a/src/free_claude_code/messaging/transcript/context.py b/src/free_claude_code/messaging/transcript/context.py new file mode 100644 index 0000000..a88d3c3 --- /dev/null +++ b/src/free_claude_code/messaging/transcript/context.py @@ -0,0 +1,18 @@ +"""Rendering context used by transcript segments.""" + +from collections.abc import Callable +from dataclasses import dataclass + + +@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 diff --git a/src/free_claude_code/messaging/transcript/renderer.py b/src/free_claude_code/messaging/transcript/renderer.py new file mode 100644 index 0000000..7a1eccb --- /dev/null +++ b/src/free_claude_code/messaging/transcript/renderer.py @@ -0,0 +1,65 @@ +"""Render and truncate ordered transcript segments.""" + +from collections import deque +from collections.abc import Iterable + +from .context import RenderCtx +from .segments import Segment + + +def render_segments( + segments: Iterable[Segment], + ctx: RenderCtx, + *, + limit_chars: int, + status: str | None, +) -> str: + rendered: list[str] = [] + for segment in segments: + try: + output = segment.render(ctx) + except Exception: + continue + if output: + rendered.append(output) + + 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 + + candidate = _join(rendered, add_marker=False) + if len(candidate) <= limit_chars: + return candidate + + 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 + + if dropped and last_part: + budget = limit_chars - len(prefix_marker) - len(status_text) + if budget > 20: + tail = ( + "..." + last_part[-(budget - 3) :] + if len(last_part) > budget + else last_part + ) + candidate = prefix_marker + tail + status_text + if len(candidate) <= limit_chars: + return candidate + + if dropped: + minimal = prefix_marker + status_text.lstrip("\n") + if len(minimal) <= limit_chars: + return minimal + return status or "" diff --git a/src/free_claude_code/messaging/transcript/segments.py b/src/free_claude_code/messaging/transcript/segments.py new file mode 100644 index 0000000..783ee5a --- /dev/null +++ b/src/free_claude_code/messaging/transcript/segments.py @@ -0,0 +1,176 @@ +"""Transcript segment types for messaging UI output.""" + +import json +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any + +from .context import RenderCtx + + +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, text: str) -> None: + if text: + self._parts.append(text) + + @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, text: str) -> None: + if text: + self._parts.append(text) + + @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.closed = False + self.indent_level = max(0, int(indent_level)) + + def render(self, ctx: RenderCtx) -> str: + name = ctx.code_inline(self.name) + 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) + self.content_text = ( + content if isinstance(content, str) else 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 = [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 "{}" + 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)}" diff --git a/src/free_claude_code/messaging/transcript/subagents.py b/src/free_claude_code/messaging/transcript/subagents.py new file mode 100644 index 0000000..6cbb2c0 --- /dev/null +++ b/src/free_claude_code/messaging/transcript/subagents.py @@ -0,0 +1,108 @@ +"""Task/subagent display state for messaging transcripts.""" + +from typing import Any + +from loguru import logger + +from .segments import SubagentSegment + + +class SubagentState: + """Track active Task tool calls that suppress nested text/thinking output.""" + + def __init__(self, *, debug: bool = False) -> None: + self._stack: list[str] = [] + self._segments: list[SubagentSegment] = [] + self._debug = debug + + @property + def open_ids(self) -> tuple[str, ...]: + return tuple(self._stack) + + def in_subagent(self) -> bool: + return bool(self._stack) + + def current_segment(self) -> SubagentSegment | None: + return self._segments[-1] if self._segments else None + + def push(self, tool_id: str, segment: SubagentSegment) -> None: + marker = str(tool_id or "").strip() or f"__task_{len(self._stack) + 1}" + self._stack.append(marker) + self._segments.append(segment) + if self._debug: + logger.debug( + "SUBAGENT_STACK: push id=%r depth=%d heading=%r", + marker, + len(self._stack), + segment.description, + ) + + def close_for_tool_result(self, tool_id: str, *, tool_name: str | None) -> bool: + tool_id = str(tool_id or "").strip() + popped = self._pop(tool_id) + top = self._stack[-1] if self._stack else "" + looks_like_task_id = "task" in tool_id.lower() + + if ( + not popped + and tool_id + and top.startswith("__task_") + and tool_name in (None, "Task") + and looks_like_task_id + ): + return self._pop("") + return popped + + def _pop(self, tool_id: str) -> bool: + tool_id = str(tool_id or "").strip() + if not self._stack: + return False + + if tool_id: + if _ids_roughly_match(self._stack[-1], tool_id): + self._pop_to_depth(len(self._stack) - 1, tool_id, "LIFO") + return True + + for idx in range(len(self._stack) - 1, -1, -1): + if _ids_roughly_match(self._stack[idx], tool_id): + self._pop_to_depth(idx, tool_id, "matched") + return True + return False + + if self._stack[-1].startswith("__task_"): + self._pop_to_depth(len(self._stack) - 1, self._stack[-1], "synthetic") + return True + return False + + def _pop_to_depth(self, idx: int, requested_id: str, reason: str) -> None: + while len(self._stack) > idx: + popped = self._stack.pop() + if self._segments: + self._segments.pop() + if self._debug: + logger.debug( + "SUBAGENT_STACK: pop id=%r depth=%d (%s=%r)", + popped, + len(self._stack), + reason, + requested_id, + ) + + +def task_heading_from_input(input_value: Any) -> str: + if isinstance(input_value, dict): + for key in ("description", "subagent_type", "type"): + value = str(input_value.get(key, "") or "").strip() + if value: + return value + return "Subagent" + + +def _ids_roughly_match(stack_id: str, result_id: str) -> bool: + if not stack_id or not result_id: + return False + return ( + stack_id == result_id + or stack_id.startswith(result_id) + or result_id.startswith(stack_id) + ) diff --git a/src/free_claude_code/messaging/transcription.py b/src/free_claude_code/messaging/transcription.py new file mode 100644 index 0000000..82eb426 --- /dev/null +++ b/src/free_claude_code/messaging/transcription.py @@ -0,0 +1,132 @@ +"""Instance-owned local Whisper transcription.""" + +import asyncio +from pathlib import Path +from typing import Any + +from loguru import logger + +_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", +} +_WHISPER_SAMPLE_RATE = 16000 + + +class TranscriptionService: + """Own one lazily loaded local Whisper pipeline.""" + + def __init__( + self, + *, + model: str, + device: str, + huggingface_api_key: str = "", + ) -> None: + if device not in {"cpu", "cuda"}: + raise ValueError( + f"Local Whisper device must be 'cpu' or 'cuda', got {device!r}" + ) + self._model_id = _MODEL_MAP.get(model, model) + self._device = device + self._huggingface_api_key = huggingface_api_key + self._pipeline: Any | None = None + self._lock = asyncio.Lock() + self._closed = False + + async def transcribe(self, file_path: Path) -> str: + """Transcribe one audio file without blocking the event loop.""" + async with self._lock: + if self._closed: + raise RuntimeError("Transcription service is closed.") + worker = asyncio.create_task( + asyncio.to_thread(self._transcribe_sync, file_path) + ) + try: + return await asyncio.shield(worker) + except asyncio.CancelledError: + await _wait_for_thread_exit(worker) + raise + + async def close(self) -> None: + """Prevent new work and release the owned model pipeline.""" + self._closed = True + async with self._lock: + self._pipeline = None + self._huggingface_api_key = "" + + def _transcribe_sync(self, file_path: Path) -> str: + pipe = self._get_pipeline() + 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("Local transcription: {} chars", len(result_text)) + return result_text or "(no speech detected)" + + def _get_pipeline(self) -> Any: + if self._pipeline is not None: + return self._pipeline + try: + import torch + from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor, pipeline + except ImportError as exc: + raise ImportError( + "Local Whisper requires the voice_local extra. " + "Install with: uv sync --extra voice_local" + ) from exc + + token = self._huggingface_api_key or None + use_cuda = self._device == "cuda" and torch.cuda.is_available() + pipeline_device = "cuda:0" if use_cuda else "cpu" + model_dtype = torch.float16 if use_cuda else torch.float32 + model = AutoModelForSpeechSeq2Seq.from_pretrained( + self._model_id, + dtype=model_dtype, + low_cpu_mem_usage=True, + attn_implementation="sdpa", + token=token, + ) + model = model.to(pipeline_device) + processor = AutoProcessor.from_pretrained(self._model_id, token=token) + self._pipeline = pipeline( + "automatic-speech-recognition", + model=model, + tokenizer=processor.tokenizer, + feature_extractor=processor.feature_extractor, + device=pipeline_device, + ) + logger.debug( + "Loaded Whisper pipeline: model={} device={}", + self._model_id, + pipeline_device, + ) + return self._pipeline + + +async def _wait_for_thread_exit(worker: asyncio.Task[str]) -> None: + """Wait through repeated caller cancellation without cancelling thread work.""" + while not worker.done(): + try: + await asyncio.shield(asyncio.wait((worker,))) + except asyncio.CancelledError: + continue + if not worker.cancelled(): + worker.exception() + + +def _load_audio(file_path: Path) -> dict[str, Any]: + """Load an audio file into the waveform shape expected by Whisper.""" + import librosa + + waveform, sample_rate = librosa.load( + str(file_path), sr=_WHISPER_SAMPLE_RATE, mono=True + ) + return {"array": waveform, "sampling_rate": sample_rate} diff --git a/src/free_claude_code/messaging/trees/__init__.py b/src/free_claude_code/messaging/trees/__init__.py new file mode 100644 index 0000000..318776f --- /dev/null +++ b/src/free_claude_code/messaging/trees/__init__.py @@ -0,0 +1,41 @@ +"""Internal messaging tree package facade.""" + +from .identity import TreeIdentity +from .manager import TreeQueueManager +from .node import MessageReferenceKind, MessageState +from .snapshot import ConversationSnapshot, TreeSnapshot +from .transitions import ( + AdmissionRejection, + CancellationReason, + CancellationResult, + CancellationUiOwner, + FailureResult, + MessageSubtreeRemovalResult, + NodeClaim, + NodeUiTarget, + NodeView, + QueueDecision, + QueueEntry, + ReplyTarget, +) + +__all__ = [ + "AdmissionRejection", + "CancellationReason", + "CancellationResult", + "CancellationUiOwner", + "ConversationSnapshot", + "FailureResult", + "MessageReferenceKind", + "MessageState", + "MessageSubtreeRemovalResult", + "NodeClaim", + "NodeUiTarget", + "NodeView", + "QueueDecision", + "QueueEntry", + "ReplyTarget", + "TreeIdentity", + "TreeQueueManager", + "TreeSnapshot", +] diff --git a/src/free_claude_code/messaging/trees/graph.py b/src/free_claude_code/messaging/trees/graph.py new file mode 100644 index 0000000..d120df0 --- /dev/null +++ b/src/free_claude_code/messaging/trees/graph.py @@ -0,0 +1,250 @@ +"""In-memory graph for one messaging conversation tree.""" + +from loguru import logger + +from ..models import MessageScope +from .identity import TreeIdentity +from .node import MessageNode, MessageReferenceKind, MessageState +from .snapshot import ( + TreeSnapshot, + node_from_snapshot, + node_scope_from_snapshot, + node_to_snapshot, +) + + +class MessageTreeGraph: + """Own parent/child links, node lookup, and status-message lookup.""" + + def __init__(self, root_node: MessageNode) -> None: + if root_node.status_message_id == root_node.node_id: + raise ValueError("Prompt and status message IDs must be distinct") + self.root_id = root_node.node_id + self.identity = TreeIdentity( + scope=root_node.scope, + root_id=root_node.node_id, + ) + self._nodes: dict[str, MessageNode] = {root_node.node_id: root_node} + self._status_to_node: dict[str, str] = {} + if root_node.status_message_id is not None: + self._status_to_node[root_node.status_message_id] = root_node.node_id + + def add_node( + self, + *, + node_id: str, + scope: MessageScope, + prompt: str, + status_message_id: str, + parent_id: str, + parent_reference_id: str, + ) -> MessageNode: + if scope != self.identity.scope: + raise ValueError("A reply cannot cross platform chat boundaries") + if parent_id not in self._nodes: + raise ValueError(f"Parent node {parent_id} not found in tree") + parent_reference = self.resolve_reference(parent_reference_id) + if parent_reference is None or parent_reference[0].node_id != parent_id: + raise ValueError("Reply reference does not belong to its logical parent") + if node_id in self._nodes: + raise ValueError(f"Node {node_id} already exists in tree") + if status_message_id == node_id: + raise ValueError("Prompt and status message IDs must be distinct") + if node_id in self._status_to_node: + raise ValueError(f"Message reference {node_id} already exists in tree") + if status_message_id in self._status_to_node: + raise ValueError( + f"Status message {status_message_id} already exists in tree" + ) + if status_message_id in self._nodes: + raise ValueError( + f"Message reference {status_message_id} already exists in tree" + ) + + node = MessageNode( + node_id=node_id, + scope=scope, + prompt=prompt, + status_message_id=status_message_id, + parent_id=parent_id, + parent_reference_id=parent_reference_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("Added node {} as child of {}", node_id, parent_id) + return node + + def get_node(self, node_id: str) -> MessageNode | None: + return self._nodes.get(node_id) + + def get_root(self) -> MessageNode: + return self._nodes[self.root_id] + + def get_parent(self, node_id: str) -> MessageNode | None: + 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: + parent = self.get_parent(node_id) + return parent.session_id if parent else None + + def find_node_by_status_message(self, status_msg_id: str) -> MessageNode | None: + node_id = self._status_to_node.get(status_msg_id) + return self._nodes.get(node_id) if node_id else None + + def resolve_reference( + self, reference_id: str + ) -> tuple[MessageNode, MessageReferenceKind] | None: + """Resolve an exact prompt or FCC status reference.""" + node = self._nodes.get(reference_id) + if node is not None: + return node, MessageReferenceKind.PROMPT + node = self.find_node_by_status_message(reference_id) + if node is not None: + return node, MessageReferenceKind.STATUS + return None + + def all_nodes(self) -> list[MessageNode]: + return list(self._nodes.values()) + + def get_descendants(self, node_id: str) -> list[str]: + if node_id not in self._nodes: + return [] + result: list[str] = [] + stack = [node_id] + while stack: + current_id = stack.pop() + result.append(current_id) + node = self._nodes.get(current_id) + if node: + stack.extend(node.children_ids) + return result + + def get_reference_descendants(self, reference_id: str) -> list[str]: + """Return the literal platform reply subtree rooted at a reference.""" + if self.resolve_reference(reference_id) is None: + return [] + + children: dict[str, list[str]] = {} + for node in self._nodes.values(): + if node.status_message_id is not None: + children.setdefault(node.node_id, []).append(node.status_message_id) + if node.parent_reference_id is not None: + children.setdefault(node.parent_reference_id, []).append(node.node_id) + + result: list[str] = [] + stack = [reference_id] + while stack: + current_id = stack.pop() + result.append(current_id) + stack.extend(children.get(current_id, ())) + return result + + def remove_nodes(self, node_ids: set[str]) -> None: + """Remove an exact set of nodes after reference-subtree calculation.""" + for node_id in node_ids: + node = self._nodes.get(node_id) + if node is None: + continue + if node.parent_id is not None: + parent = self._nodes.get(node.parent_id) + if parent is not None: + parent.children_ids = [ + child_id + for child_id in parent.children_ids + if child_id != node_id + ] + if node.status_message_id is not None: + self._status_to_node.pop(node.status_message_id, None) + self._nodes.pop(node_id, None) + + def clear_status(self, node_id: str) -> None: + """Remove one status reference while preserving its prompt node.""" + node = self._nodes.get(node_id) + if node is None or node.status_message_id is None: + return + self._status_to_node.pop(node.status_message_id, None) + node.clear_status() + + def all_reference_ids(self) -> set[str]: + """Return every prompt and live FCC status reference in the tree.""" + references = set(self._nodes) + references.update(self._status_to_node) + return references + + def snapshot(self) -> TreeSnapshot: + return TreeSnapshot( + scope=self.identity.scope, + root_id=self.root_id, + nodes={ + node_id: node_to_snapshot(node) for node_id, node in self._nodes.items() + }, + ) + + @classmethod + def from_snapshot(cls, snapshot: TreeSnapshot) -> MessageTreeGraph: + root_data = snapshot.nodes[snapshot.root_id] + if not isinstance(root_data, dict): + raise ValueError("Tree snapshot contains an invalid root node") + if node_scope_from_snapshot(root_data) not in (None, snapshot.scope): + raise ValueError("Tree snapshot contains a cross-scope node") + root_node = node_from_snapshot(root_data, snapshot.scope) + if root_node.node_id != snapshot.root_id: + raise ValueError("Tree snapshot root key does not match its node ID") + graph = cls(root_node) + reference_owner = {root_node.node_id: root_node.node_id} + if root_node.status_message_id is not None: + reference_owner[root_node.status_message_id] = root_node.node_id + for snapshot_node_id, node_data in snapshot.nodes.items(): + if snapshot_node_id == snapshot.root_id: + continue + if not isinstance(node_data, dict): + raise ValueError("Tree snapshot contains an invalid node") + if node_scope_from_snapshot(node_data) not in (None, snapshot.scope): + raise ValueError("Tree snapshot contains a cross-scope node") + node = node_from_snapshot(node_data, snapshot.scope) + if str(snapshot_node_id) != node.node_id: + raise ValueError("Tree snapshot node key does not match its node ID") + if node.status_message_id == node.node_id: + raise ValueError("Prompt and status message IDs must be distinct") + if node.node_id in graph._nodes: + raise ValueError(f"Duplicate node {node.node_id} in tree snapshot") + references = {node.node_id} + if node.status_message_id is not None: + references.add(node.status_message_id) + for reference in references: + owner = reference_owner.get(reference) + if owner is not None and owner != node.node_id: + raise ValueError( + f"Duplicate message reference {reference} in tree snapshot" + ) + reference_owner[reference] = node.node_id + graph._nodes[node.node_id] = node + if node.status_message_id is not None: + graph._status_to_node[node.status_message_id] = node.node_id + + if root_node.parent_id is not None or root_node.parent_reference_id is not None: + raise ValueError("Tree snapshot root cannot have a parent") + for node in graph._nodes.values(): + if node.node_id == graph.root_id: + continue + if node.parent_id is None or node.parent_id not in graph._nodes: + raise ValueError(f"Node {node.node_id} has no valid parent") + if node.parent_reference_id is None: + raise ValueError(f"Node {node.node_id} has no exact parent reference") + parent_reference = graph.resolve_reference(node.parent_reference_id) + if ( + parent_reference is None + or parent_reference[0].node_id != node.parent_id + ): + raise ValueError( + f"Node {node.node_id} has an invalid exact parent reference" + ) + graph._nodes[node.parent_id].children_ids.append(node.node_id) + if set(graph.get_descendants(graph.root_id)) != set(graph._nodes): + raise ValueError("Tree snapshot contains a disconnected branch") + return graph diff --git a/src/free_claude_code/messaging/trees/identity.py b/src/free_claude_code/messaging/trees/identity.py new file mode 100644 index 0000000..ada6bb3 --- /dev/null +++ b/src/free_claude_code/messaging/trees/identity.py @@ -0,0 +1,16 @@ +"""Stable and runtime identities for messaging conversation trees.""" + +from dataclasses import dataclass + +from ..models import MessageScope + + +@dataclass(frozen=True, slots=True) +class TreeIdentity: + """Customer conversation identity, independent of one runtime generation.""" + + scope: MessageScope + root_id: str + + +__all__ = ["TreeIdentity"] diff --git a/src/free_claude_code/messaging/trees/manager.py b/src/free_claude_code/messaging/trees/manager.py new file mode 100644 index 0000000..472055a --- /dev/null +++ b/src/free_claude_code/messaging/trees/manager.py @@ -0,0 +1,587 @@ +"""Public facade for atomic messaging tree aggregates.""" + +import asyncio +from collections.abc import Callable, Coroutine +from typing import Any + +from loguru import logger + +from ..models import IncomingMessage, MessageScope +from .identity import TreeIdentity +from .node import MessageNode, MessageState +from .processor import ( + CancelledTask, + NodeProcessor, + NodeStartedCallback, + QueueUpdateCallback, + TreeQueueProcessor, +) +from .repository import TreeRepository +from .runtime import MessageTree +from .snapshot import ConversationSnapshot, TreeSnapshot +from .transitions import ( + AdmissionRejection, + CancellationEffect, + CancellationReason, + CancellationResult, + CancellationUiOwner, + FailureResult, + MessageSubtreeRemovalResult, + NodeClaim, + NodeUiTarget, + NodeView, + QueueDecision, + ReplyTarget, + TreeCancellation, +) + +CANCEL_TASK_DRAIN_TIMEOUT_S = 5.0 + + +async def _finish_transition[T](awaitable: Coroutine[Any, Any, T]) -> T: + """Deliver a transition result even if its caller is cancelled mid-commit.""" + task = asyncio.create_task(awaitable) + current = asyncio.current_task() + while True: + try: + return await asyncio.shield(task) + except asyncio.CancelledError: + if task.done(): + return task.result() + if current is not None: + while current.cancelling(): + current.uncancel() + + +async def _drain_cancelled_tasks(tasks: list[asyncio.Task[None]]) -> None: + """Wait briefly for cancelled claims to finish aggregate cleanup.""" + if not tasks: + return + done, pending = await asyncio.wait( + set(tasks), + timeout=CANCEL_TASK_DRAIN_TIMEOUT_S, + ) + if pending: + logger.warning( + "Timed out waiting for {} cancelled messaging task(s) to finish cleanup", + len(pending), + ) + for task in done: + if task.cancelled(): + continue + try: + task.result() + except Exception as exc: + logger.debug( + "Cancelled messaging task finished with {}", type(exc).__name__ + ) + + +class TreeQueueManager: + """Locate aggregates and coordinate tasks without exposing mutable trees.""" + + def __init__( + self, + node_processor: NodeProcessor, + *, + queue_update_callback: QueueUpdateCallback | None = None, + node_started_callback: NodeStartedCallback | None = None, + unexpected_failure_callback: Callable[[FailureResult], None] | None = None, + log_messaging_error_details: bool = False, + _repository: TreeRepository | None = None, + _restored_snapshot: ConversationSnapshot | None = None, + _restored_stale_targets: tuple[NodeUiTarget, ...] = (), + ) -> None: + self._repository = _repository or TreeRepository() + self._lock = asyncio.Lock() + self._processor = TreeQueueProcessor( + node_processor, + claim_failure_callback=self._handle_processor_failure, + claim_finished_callback=self._finish_claim, + queue_update_callback=queue_update_callback, + node_started_callback=node_started_callback, + log_messaging_error_details=log_messaging_error_details, + ) + self._restored_snapshot = _restored_snapshot + self._restored_stale_targets = _restored_stale_targets + self._unexpected_failure_callback = unexpected_failure_callback + logger.info("TreeQueueManager initialized") + + @property + def restored_snapshot(self) -> ConversationSnapshot | None: + return self._restored_snapshot + + @property + def restored_stale_targets(self) -> tuple[NodeUiTarget, ...]: + return self._restored_stale_targets + + async def admit( + self, + incoming: IncomingMessage, + status_message_id: str, + *, + parent_reference_id: str | None = None, + ) -> QueueDecision: + """Publish one admission before its processor can begin.""" + node_id = str(incoming.message_id) + scope = incoming.scope + prompt = incoming.text or "" + async with self._lock: + duplicate_tree = self._repository.get_tree_for_reference(scope, node_id) + if duplicate_tree is None: + duplicate_tree = self._repository.get_tree_for_reference( + scope, + status_message_id, + ) + if duplicate_tree is not None: + return await duplicate_tree.enqueue_or_claim(node_id) + + tree: MessageTree | None = None + resolved_parent: ReplyTarget | None = None + if parent_reference_id is not None: + tree = self._repository.get_tree_for_reference( + scope, parent_reference_id + ) + if tree is not None: + resolved_parent = await tree.resolve_reply(parent_reference_id) + + if tree is None or resolved_parent is None: + return QueueDecision( + claim=None, + position=None, + snapshot=None, + rejection=AdmissionRejection.PARENT_REMOVED, + ) + + if tree is not None and resolved_parent is not None: + decision = await tree.add_and_enqueue( + node_id, + scope, + prompt, + status_message_id, + resolved_parent.node_id, + resolved_parent.reference_id, + ) + self._repository.register_node( + identity=tree.identity, + node_id=node_id, + status_message_id=status_message_id, + ) + logger.info("Added node {} to tree {}", node_id, tree.identity) + else: + root = MessageNode( + node_id=node_id, + scope=scope, + prompt=prompt, + status_message_id=status_message_id, + state=MessageState.PENDING, + ) + tree = MessageTree(root) + decision = await tree.enqueue_or_claim(node_id) + self._repository.add_tree( + tree, + node_id=node_id, + status_message_id=status_message_id, + ) + logger.info("Created new tree {}", tree.identity) + + if decision.claim is not None: + self._processor.launch(tree, decision.claim) + return decision + + async def resolve_reply( + self, + scope: MessageScope, + reference_id: str, + ) -> ReplyTarget | None: + """Resolve a scoped node or status-message reference.""" + async with self._lock: + tree = self._repository.get_tree_for_reference(scope, reference_id) + return await tree.resolve_reply(reference_id) if tree is not None else None + + async def resolve_node_id( + self, + scope: MessageScope, + reference_id: str, + ) -> str | None: + target = await self.resolve_reply(scope, reference_id) + return target.node_id if target is not None else None + + async def get_node( + self, + scope: MessageScope, + reference_id: str, + ) -> NodeView | None: + """Return an immutable node read model.""" + async with self._lock: + tree = self._repository.get_tree_for_reference(scope, reference_id) + if tree is None: + return None + target = await tree.resolve_reply(reference_id) + return await tree.node_view(target.node_id) if target is not None else None + + async def record_session( + self, + claim: NodeClaim, + session_id: str, + ) -> TreeSnapshot | None: + async with self._lock: + tree = self._repository.get_tree(claim.identity) + return ( + await tree.record_session(claim.claim_id, session_id) + if tree is not None + else None + ) + + async def complete_claim( + self, + claim: NodeClaim, + session_id: str | None, + ) -> TreeSnapshot | None: + async with self._lock: + tree = self._repository.get_tree(claim.identity) + return ( + await tree.complete_claim(claim.claim_id, session_id) + if tree is not None + else None + ) + + async def fail_claim( + self, + claim: NodeClaim, + *, + propagate: bool = True, + ) -> FailureResult: + return await self._fail_claim(claim, propagate=propagate) + + async def _fail_claim( + self, + claim: NodeClaim, + *, + propagate: bool, + ) -> FailureResult: + async with self._lock: + tree = self._repository.get_tree(claim.identity) + result = ( + await tree.fail_claim( + claim.claim_id, + propagate=propagate, + ) + if tree is not None + else FailureResult(affected=(), queue_update=None, snapshot=None) + ) + if result.queue_update is not None: + await self._processor.notify_queue_updated(result.queue_update) + return result + + async def _handle_processor_failure( + self, + claim: NodeClaim, + ) -> None: + """Route an escaped runner failure through manager-owned effects.""" + result = await self._fail_claim(claim, propagate=True) + if self._unexpected_failure_callback is None: + return + try: + self._unexpected_failure_callback(result) + except Exception as exc: + logger.warning( + "Unexpected messaging failure callback failed: {}", + type(exc).__name__, + ) + + async def _finish_claim(self, tree: MessageTree, claim: NodeClaim) -> None: + """Serialize successor task publication with aggregate detachment.""" + async with self._lock: + tree_is_published = self._repository.get_tree(claim.identity) is tree + completion = await tree.finish_and_claim_next(claim.claim_id) + if tree_is_published and completion.next_claim is not None: + self._processor.launch( + tree, + completion.next_claim, + announce_started=True, + queue=completion.queue, + ) + + @staticmethod + def _external_effects( + transition: TreeCancellation, + cancelled_task: CancelledTask | None, + ) -> tuple[CancellationEffect, ...]: + active_node_id = ( + transition.active_claim.node.node_id + if transition.active_claim is not None + else None + ) + return tuple( + CancellationEffect( + node=node, + ui_owner=( + CancellationUiOwner.RUNNER + if node.node_id == active_node_id + and cancelled_task is not None + and cancelled_task.runner_started + else CancellationUiOwner.WORKFLOW + ), + ) + for node in transition.nodes + ) + + async def _current_snapshot( + self, + identity: TreeIdentity, + expected_tree: MessageTree, + ) -> TreeSnapshot | None: + async with self._lock: + tree = self._repository.get_tree(identity) + if tree is not expected_tree: + return None + return await tree.snapshot() + + async def cancel_node( + self, + scope: MessageScope, + node_id: str, + *, + reason: CancellationReason | None = None, + ) -> CancellationResult: + return await _finish_transition(self._cancel_node(scope, node_id, reason)) + + async def _cancel_node( + self, + scope: MessageScope, + node_id: str, + reason: CancellationReason | None, + ) -> CancellationResult: + async with self._lock: + tree = self._repository.get_tree_for_reference(scope, node_id) + if tree is None: + return CancellationResult() + target = await tree.resolve_reply(node_id) + if target is None: + return CancellationResult() + transition = await tree.cancel_node(target.node_id) + cancelled_task = ( + self._processor.cancel(transition.active_claim, reason) + if transition.active_claim is not None + else None + ) + + if transition.queue_update is not None: + await self._processor.notify_queue_updated(transition.queue_update) + if cancelled_task is not None: + await _drain_cancelled_tasks([cancelled_task.task]) + snapshot = await self._current_snapshot(tree.identity, tree) + return CancellationResult( + effects=self._external_effects(transition, cancelled_task), + snapshots=(snapshot,) if snapshot is not None else (), + ) + + async def cancel_all( + self, + *, + reason: CancellationReason | None = None, + ) -> CancellationResult: + return await _finish_transition(self._cancel_all(reason)) + + async def _cancel_all( + self, + reason: CancellationReason | None, + ) -> CancellationResult: + transitions: list[ + tuple[MessageTree, TreeCancellation, CancelledTask | None] + ] = [] + async with self._lock: + for tree in self._repository.trees(): + transition = await tree.cancel_all() + cancelled_task = ( + self._processor.cancel(transition.active_claim, reason) + if transition.active_claim is not None + else None + ) + transitions.append((tree, transition, cancelled_task)) + + for _tree, transition, _task in transitions: + if transition.queue_update is not None: + await self._processor.notify_queue_updated(transition.queue_update) + await _drain_cancelled_tasks( + [ + cancelled.task + for _tree, _transition, cancelled in transitions + if cancelled is not None + ] + ) + + effects: list[CancellationEffect] = [] + snapshots: list[TreeSnapshot] = [] + for tree, transition, cancelled_task in transitions: + effects.extend(self._external_effects(transition, cancelled_task)) + snapshot = await self._current_snapshot(tree.identity, tree) + if snapshot is not None: + snapshots.append(snapshot) + return CancellationResult(effects=tuple(effects), snapshots=tuple(snapshots)) + + async def clear_scope( + self, + scope: MessageScope, + *, + reason: CancellationReason | None = None, + ) -> CancellationResult: + """Atomically detach one chat's trees, then drain their active tasks.""" + return await _finish_transition(self._clear_scope(scope, reason)) + + async def _clear_scope( + self, + scope: MessageScope, + reason: CancellationReason | None, + ) -> CancellationResult: + transitions: list[tuple[TreeCancellation, CancelledTask | None]] = [] + async with self._lock: + for tree in self._repository.trees(): + if tree.identity.scope != scope: + continue + transition = await tree.cancel_all() + cancelled_task = ( + self._processor.cancel(transition.active_claim, reason) + if transition.active_claim is not None + else None + ) + transitions.append((transition, cancelled_task)) + self._repository.remove_tree(tree.identity) + + await _drain_cancelled_tasks( + [ + cancelled.task + for _transition, cancelled in transitions + if cancelled is not None + ] + ) + effects: list[CancellationEffect] = [] + for transition, cancelled_task in transitions: + effects.extend(self._external_effects(transition, cancelled_task)) + return CancellationResult(effects=tuple(effects)) + + async def remove_message_subtree( + self, + scope: MessageScope, + reference_id: str, + *, + reason: CancellationReason | None = None, + ) -> MessageSubtreeRemovalResult: + """Atomically cancel, detach, and unindex one literal reply subtree.""" + return await _finish_transition( + self._remove_message_subtree(scope, reference_id, reason) + ) + + async def _remove_message_subtree( + self, + scope: MessageScope, + reference_id: str, + reason: CancellationReason | None, + ) -> MessageSubtreeRemovalResult: + async with self._lock: + tree = self._repository.get_tree_for_reference(scope, reference_id) + if tree is None: + return MessageSubtreeRemovalResult( + cancellation=CancellationResult(), + removed_tree_identity=None, + delete_message_ids=frozenset(), + tree_matched=False, + ) + target = await tree.resolve_reply(reference_id) + if target is None: + return MessageSubtreeRemovalResult( + cancellation=CancellationResult(), + removed_tree_identity=None, + delete_message_ids=frozenset(), + tree_matched=False, + ) + identity = tree.identity + transition = await tree.remove_message_subtree(target.reference_id) + lookup_ids = set(transition.removed_message_ids) + if transition.removed_entire_tree: + self._repository.remove_tree(identity) + else: + self._repository.unregister_references(identity, lookup_ids) + cancelled_task = ( + self._processor.cancel(transition.cancellation.active_claim, reason) + if transition.cancellation.active_claim is not None + else None + ) + + if transition.cancellation.queue_update is not None: + await self._processor.notify_queue_updated( + transition.cancellation.queue_update + ) + if cancelled_task is not None: + await _drain_cancelled_tasks([cancelled_task.task]) + snapshot = ( + None + if transition.removed_entire_tree + else await self._current_snapshot(identity, tree) + ) + cancellation = CancellationResult( + effects=self._external_effects( + transition.cancellation, + cancelled_task, + ), + snapshots=(snapshot,) if snapshot is not None else (), + ) + return MessageSubtreeRemovalResult( + cancellation=cancellation, + removed_tree_identity=( + identity if transition.removed_entire_tree else None + ), + delete_message_ids=transition.removed_message_ids, + tree_matched=True, + ) + + def get_tree_count(self) -> int: + return self._repository.tree_count() + + def task_count(self) -> int: + return self._processor.task_count() + + async def wait_idle(self) -> None: + """Wait until every processor-owned claim has finished cleanup.""" + await self._processor.wait_idle() + + async def get_message_ids_for_chat(self, platform: str, chat_id: str) -> set[str]: + async with self._lock: + message_ids: set[str] = set() + for tree in self._repository.trees(): + message_ids.update(await tree.message_ids_for_chat(platform, chat_id)) + return message_ids + + async def snapshot(self) -> ConversationSnapshot: + async with self._lock: + trees: dict[TreeIdentity, TreeSnapshot] = {} + for tree in self._repository.trees(): + trees[tree.identity] = await tree.snapshot() + return ConversationSnapshot(trees=trees) + + @classmethod + def from_snapshot( + cls, + snapshot: ConversationSnapshot, + node_processor: NodeProcessor, + *, + queue_update_callback: QueueUpdateCallback | None = None, + node_started_callback: NodeStartedCallback | None = None, + unexpected_failure_callback: Callable[[FailureResult], None] | None = None, + log_messaging_error_details: bool = False, + ) -> TreeQueueManager: + repository, normalized, stale_targets = TreeRepository.from_snapshot(snapshot) + return cls( + node_processor, + queue_update_callback=queue_update_callback, + node_started_callback=node_started_callback, + unexpected_failure_callback=unexpected_failure_callback, + log_messaging_error_details=log_messaging_error_details, + _repository=repository, + _restored_snapshot=normalized, + _restored_stale_targets=stale_targets, + ) + + +__all__ = ["TreeQueueManager"] diff --git a/src/free_claude_code/messaging/trees/node.py b/src/free_claude_code/messaging/trees/node.py new file mode 100644 index 0000000..9596883 --- /dev/null +++ b/src/free_claude_code/messaging/trees/node.py @@ -0,0 +1,56 @@ +"""Message tree node model.""" + +from dataclasses import dataclass, field +from enum import Enum + +from ..models import MessageScope + + +class MessageState(Enum): + """State of a message node in the tree.""" + + PENDING = "pending" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + ERROR = "error" + + +class MessageReferenceKind(Enum): + """Kind of platform message represented by a tree reference.""" + + PROMPT = "prompt" + STATUS = "status" + + +@dataclass +class MessageNode: + """A single user prompt/status node in a messaging conversation tree.""" + + node_id: str + scope: MessageScope + prompt: str + status_message_id: str | None + state: MessageState = MessageState.PENDING + parent_id: str | None = None + parent_reference_id: str | None = None + session_id: str | None = None + children_ids: list[str] = field(default_factory=list) + + def update_state( + self, + state: MessageState, + *, + session_id: str | None = None, + ) -> None: + self.state = state + if session_id: + self.session_id = session_id + + def mark_error(self) -> None: + self.update_state(MessageState.ERROR) + + def clear_status(self) -> None: + """Invalidate the response and resume point while retaining its prompt.""" + self.status_message_id = None + self.session_id = None + self.mark_error() diff --git a/src/free_claude_code/messaging/trees/processor.py b/src/free_claude_code/messaging/trees/processor.py new file mode 100644 index 0000000..d13eec2 --- /dev/null +++ b/src/free_claude_code/messaging/trees/processor.py @@ -0,0 +1,268 @@ +"""Task execution for claims returned by messaging tree aggregates.""" + +import asyncio +import contextlib +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from loguru import logger + +from ..safe_diagnostics import format_exception_for_log +from .runtime import MessageTree +from .transitions import CancellationReason, NodeClaim, QueueEntry + +NodeProcessor = Callable[[NodeClaim], Awaitable[None]] +QueueUpdateCallback = Callable[[tuple[QueueEntry, ...]], Awaitable[None]] +NodeStartedCallback = Callable[[NodeClaim], Awaitable[None]] +ClaimFailureCallback = Callable[[NodeClaim], Awaitable[None]] +ClaimFinishedCallback = Callable[[MessageTree, NodeClaim], Awaitable[None]] + + +@dataclass(slots=True) +class _TaskSlot: + tree: MessageTree + claim: NodeClaim + task: asyncio.Task[None] | None = None + runner_started: bool = False + transitioned: bool = False + recovery_task: asyncio.Task[None] | None = None + cancellation_requested: bool = False + cancellation_reason: CancellationReason | None = None + + +@dataclass(frozen=True, slots=True) +class CancelledTask: + """Task handle plus whether the node runner owns cancellation UI.""" + + task: asyncio.Task[None] + runner_started: bool + + +class TreeQueueProcessor: + """Own asyncio tasks while MessageTree owns scheduling state.""" + + def __init__( + self, + node_processor: NodeProcessor, + *, + claim_failure_callback: ClaimFailureCallback, + claim_finished_callback: ClaimFinishedCallback, + queue_update_callback: QueueUpdateCallback | None = None, + node_started_callback: NodeStartedCallback | None = None, + log_messaging_error_details: bool = False, + ) -> None: + self._node_processor = node_processor + self._claim_failure_callback = claim_failure_callback + self._claim_finished_callback = claim_finished_callback + self._queue_update_callback = queue_update_callback + self._node_started_callback = node_started_callback + self._log_messaging_error_details = log_messaging_error_details + self._tasks: dict[str, _TaskSlot] = {} + self._completion_failures: list[Exception] = [] + self._idle = asyncio.Event() + self._idle.set() + + @staticmethod + def _key(claim: NodeClaim) -> str: + return claim.claim_id + + def launch( + self, + tree: MessageTree, + claim: NodeClaim, + *, + announce_started: bool = False, + queue: tuple[QueueEntry, ...] = (), + ) -> None: + """Attach a task synchronously before another coroutine can cancel it.""" + key = self._key(claim) + if key in self._tasks: + raise RuntimeError(f"Claim {key} already has a task") + slot = _TaskSlot(tree=tree, claim=claim) + self._tasks[key] = slot + self._idle.clear() + claim_runner = self._run_claim( + slot, + announce_started=announce_started, + queue=queue, + ) + try: + task = asyncio.create_task( + claim_runner, + name=(f"messaging-claim-{claim.identity.root_id}-{claim.claim_id[:8]}"), + eager_start=False, + ) + except BaseException: + claim_runner.close() + if self._tasks.get(key) is slot: + self._tasks.pop(key) + if not self._tasks: + self._idle.set() + raise + slot.task = task + task.add_done_callback(lambda _task, claim_key=key: self._task_done(claim_key)) + + def _task_done(self, key: str) -> None: + """Recover a claim if its task was cancelled before entering its body.""" + slot = self._tasks.get(key) + if slot is None or slot.transitioned or slot.recovery_task is not None: + return + slot.recovery_task = asyncio.create_task( + self._recover_unentered_task(slot), + name=f"messaging-claim-recovery-{key[:8]}", + ) + + async def _recover_unentered_task(self, slot: _TaskSlot) -> None: + task = slot.task + if task is not None: + with contextlib.suppress(asyncio.CancelledError, Exception): + await task + if not slot.transitioned: + await self._finish_and_continue(slot) + + async def _notify_queue_updated(self, queue: tuple[QueueEntry, ...]) -> None: + if self._queue_update_callback is None: + return + try: + await self._queue_update_callback(queue) + except Exception as exc: + logger.warning( + "Queue update callback failed: {}", + format_exception_for_log( + exc, + log_full_message=self._log_messaging_error_details, + ), + ) + + async def notify_queue_updated(self, queue: tuple[QueueEntry, ...]) -> None: + """Publish a transition-owned queue snapshot.""" + await self._notify_queue_updated(queue) + + async def _notify_node_started(self, claim: NodeClaim) -> None: + if self._node_started_callback is None: + return + try: + await self._node_started_callback(claim) + except Exception as exc: + logger.warning( + "Node started callback failed: {}", + format_exception_for_log( + exc, + log_full_message=self._log_messaging_error_details, + ), + ) + + async def _run_claim( + self, + slot: _TaskSlot, + *, + announce_started: bool, + queue: tuple[QueueEntry, ...], + ) -> None: + claim = slot.claim + try: + if announce_started: + await self._notify_node_started(claim) + await self._notify_queue_updated(queue) + if slot.cancellation_requested: + if slot.cancellation_reason is None: + raise asyncio.CancelledError + raise asyncio.CancelledError(slot.cancellation_reason) + slot.runner_started = True + await self._node_processor(claim) + except asyncio.CancelledError: + logger.info("Task for node {} was cancelled", claim.node.node_id) + raise + except Exception as exc: + logger.error( + "Error processing node {}: {}", + claim.node.node_id, + format_exception_for_log( + exc, + log_full_message=self._log_messaging_error_details, + ), + ) + await self._claim_failure_callback(claim) + finally: + if not slot.transitioned: + await self._finish_and_continue(slot) + + async def _finish_and_continue(self, slot: _TaskSlot) -> None: + current = asyncio.current_task() + if current is not None: + while current.cancelling(): + current.uncancel() + try: + while True: + try: + await self._claim_finished_callback(slot.tree, slot.claim) + slot.transitioned = True + break + except asyncio.CancelledError: + if current is not None: + while current.cancelling(): + current.uncancel() + continue + except Exception as exc: + self._completion_failures.append(exc) + logger.error( + "Claim completion callback failed for node {}: {}", + slot.claim.node.node_id, + format_exception_for_log( + exc, + log_full_message=self._log_messaging_error_details, + ), + ) + finally: + key = self._key(slot.claim) + if self._tasks.get(key) is slot: + self._tasks.pop(key) + if not self._tasks: + self._idle.set() + + def cancel( + self, + claim: NodeClaim, + reason: CancellationReason | None, + ) -> CancelledTask | None: + """Cancel exactly the task bound to one aggregate claim.""" + slot = self._tasks.get(self._key(claim)) + if slot is None: + return None + slot.cancellation_requested = True + slot.cancellation_reason = reason + task = slot.task + if task is None or task.done(): + return None + if reason is None: + task.cancel() + else: + task.cancel(reason) + + if slot.runner_started: + return CancelledTask(task=task, runner_started=True) + + if slot.recovery_task is None: + slot.recovery_task = asyncio.create_task( + self._recover_unentered_task(slot), + name=f"messaging-claim-recovery-{claim.claim_id[:8]}", + ) + return CancelledTask(task=slot.recovery_task, runner_started=False) + + def task_count(self) -> int: + """Return the number of attached claims for observability.""" + return len(self._tasks) + + async def wait_idle(self) -> None: + """Wait for every task and hand completion failures to the caller once.""" + await self._idle.wait() + if not self._completion_failures: + return + failures = self._completion_failures + self._completion_failures = [] + if len(failures) == 1: + raise failures[0] + raise ExceptionGroup("Messaging claim completion failures", failures) + + +__all__ = ["CancelledTask", "TreeQueueProcessor"] diff --git a/src/free_claude_code/messaging/trees/queue.py b/src/free_claude_code/messaging/trees/queue.py new file mode 100644 index 0000000..c7bafdc --- /dev/null +++ b/src/free_claude_code/messaging/trees/queue.py @@ -0,0 +1,47 @@ +"""FIFO queue state for one messaging conversation tree.""" + +from collections import deque + + +class MessageNodeQueue: + """Queue with snapshot/remove helpers, backed by a deque and a set index.""" + + def __init__(self, items: list[str] | None = None) -> None: + self._deque: deque[str] = deque() + self._set: set[str] = set() + for item in items or []: + self.put(item) + + def put(self, item: str) -> bool: + """Append a unique item and report whether the queue changed.""" + if item in self._set: + return False + self._deque.append(item) + self._set.add(item) + return True + + def pop(self) -> str | None: + if not self._deque: + return None + item = self._deque.popleft() + self._set.discard(item) + return item + + def qsize(self) -> int: + return len(self._deque) + + def items(self) -> tuple[str, ...]: + return tuple(self._deque) + + def remove(self, item: str) -> bool: + 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 + + def drain(self) -> tuple[str, ...]: + items = tuple(self._deque) + self._deque.clear() + self._set.clear() + return items diff --git a/src/free_claude_code/messaging/trees/repository.py b/src/free_claude_code/messaging/trees/repository.py new file mode 100644 index 0000000..c7e60ec --- /dev/null +++ b/src/free_claude_code/messaging/trees/repository.py @@ -0,0 +1,135 @@ +"""Manager-owned index of messaging tree aggregates.""" + +from loguru import logger + +from ..models import MessageScope +from .identity import TreeIdentity +from .runtime import MessageTree +from .snapshot import ConversationSnapshot +from .transitions import NodeUiTarget + + +class TreeRepository: + """Store aggregates and map node/status references to their root.""" + + def __init__(self) -> None: + self._trees: dict[TreeIdentity, MessageTree] = {} + self._reference_to_tree: dict[tuple[MessageScope, str], TreeIdentity] = {} + + def get_tree(self, identity: TreeIdentity) -> MessageTree | None: + return self._trees.get(identity) + + def get_tree_for_reference( + self, + scope: MessageScope, + reference_id: str, + ) -> MessageTree | None: + identity = self._reference_to_tree.get((scope, reference_id)) + return self._trees.get(identity) if identity is not None else None + + def has_reference(self, scope: MessageScope, reference_id: str) -> bool: + return (scope, reference_id) in self._reference_to_tree + + def add_tree( + self, + tree: MessageTree, + *, + node_id: str, + status_message_id: str, + ) -> None: + identity = tree.identity + if identity in self._trees: + raise ValueError(f"Tree {identity} already exists") + self.register_node( + identity=identity, + node_id=node_id, + status_message_id=status_message_id, + ) + self._trees[identity] = tree + logger.debug("TREE_REPO: add_tree identity={}", identity) + + def register_node( + self, + *, + identity: TreeIdentity, + node_id: str, + status_message_id: str, + ) -> None: + if self.has_reference(identity.scope, node_id) or self.has_reference( + identity.scope, status_message_id + ): + raise ValueError("Node or status message is already registered") + self._reference_to_tree[(identity.scope, node_id)] = identity + self._reference_to_tree[(identity.scope, status_message_id)] = identity + + def unregister_references( + self, + identity: TreeIdentity, + reference_ids: set[str], + ) -> None: + for reference_id in reference_ids: + key = (identity.scope, reference_id) + if self._reference_to_tree.get(key) == identity: + self._reference_to_tree.pop(key) + + def remove_tree( + self, + identity: TreeIdentity, + ) -> MessageTree | None: + tree = self._trees.pop(identity, None) + if tree is None: + return None + self._reference_to_tree = { + key: owner + for key, owner in self._reference_to_tree.items() + if owner != identity + } + logger.debug("TREE_REPO: remove_tree identity={}", identity) + return tree + + def trees(self) -> tuple[MessageTree, ...]: + return tuple(self._trees.values()) + + def tree_count(self) -> int: + return len(self._trees) + + @classmethod + def from_snapshot( + cls, snapshot: ConversationSnapshot + ) -> tuple[ + TreeRepository, + ConversationSnapshot, + tuple[NodeUiTarget, ...], + ]: + repo = cls() + normalized = ConversationSnapshot() + stale_targets: list[NodeUiTarget] = [] + for tree_snapshot in snapshot.trees.values(): + try: + tree = MessageTree.from_snapshot(tree_snapshot) + except (KeyError, TypeError, ValueError) as exc: + logger.warning( + "Skipping invalid messaging tree snapshot: {}", + type(exc).__name__, + ) + continue + references = tree_snapshot.lookup_ids() + if tree.identity in repo._trees or any( + repo.has_reference(tree.identity.scope, reference) + for reference in references + ): + logger.warning("Skipping duplicate messaging tree {}", tree.identity) + continue + repo._trees[tree.identity] = tree + for reference in references: + repo._reference_to_tree[(tree.identity.scope, reference)] = ( + tree.identity + ) + stale_targets.extend(tree.restored_stale_targets) + normalized_tree = tree.restored_snapshot + if normalized_tree is not None: + normalized = normalized.with_tree(normalized_tree) + return repo, normalized, tuple(stale_targets) + + +__all__ = ["TreeRepository"] diff --git a/src/free_claude_code/messaging/trees/runtime.py b/src/free_claude_code/messaging/trees/runtime.py new file mode 100644 index 0000000..c3d2b32 --- /dev/null +++ b/src/free_claude_code/messaging/trees/runtime.py @@ -0,0 +1,487 @@ +"""Atomic runtime aggregate for one messaging conversation tree.""" + +import asyncio +from dataclasses import dataclass +from uuid import uuid4 + +from loguru import logger + +from ..models import MessageScope +from .graph import MessageTreeGraph +from .identity import TreeIdentity +from .node import MessageNode, MessageReferenceKind, MessageState +from .queue import MessageNodeQueue +from .snapshot import TreeSnapshot +from .transitions import ( + AdmissionRejection, + CompletionResult, + FailureResult, + MessageSubtreeRemoval, + NodeClaim, + NodeUiTarget, + NodeView, + QueueDecision, + QueueEntry, + ReplyTarget, + TreeCancellation, +) + + +@dataclass(slots=True) +class _ActiveClaim: + """Runtime execution identity kept separate from the node's UI state.""" + + claim: NodeClaim + cancellation_requested: bool = False + + +class MessageTree: + """Own graph, queue, claim identity, and every concurrency invariant.""" + + def __init__( + self, + root_node: MessageNode, + *, + graph: MessageTreeGraph | None = None, + ) -> None: + self._graph = graph or MessageTreeGraph(root_node) + self._queue = MessageNodeQueue() + self._lock = asyncio.Lock() + self._active: _ActiveClaim | None = None + self._restored_snapshot: TreeSnapshot | None = None + self._restored_stale_targets: tuple[NodeUiTarget, ...] = () + logger.debug("Created MessageTree with root {}", self.root_id) + + @property + def root_id(self) -> str: + return self._graph.root_id + + @property + def identity(self) -> TreeIdentity: + return self._graph.identity + + @property + def restored_snapshot(self) -> TreeSnapshot | None: + """Normalized startup snapshot captured before the tree is published.""" + return self._restored_snapshot + + @property + def restored_stale_targets(self) -> tuple[NodeUiTarget, ...]: + """UI targets normalized from runnable to interrupted on restore.""" + return self._restored_stale_targets + + def _ui_target(self, node: MessageNode) -> NodeUiTarget: + if node.status_message_id is None: + raise ValueError("Runnable node has no status message") + return NodeUiTarget( + scope=node.scope, + node_id=node.node_id, + status_message_id=node.status_message_id, + ) + + def _queue_entries(self) -> tuple[QueueEntry, ...]: + entries: list[QueueEntry] = [] + for node_id in self._queue.items(): + node = self._graph.get_node(node_id) + if node is None or node.state is not MessageState.PENDING: + continue + entries.append( + QueueEntry(node=self._ui_target(node), position=len(entries) + 1) + ) + return tuple(entries) + + def _claim(self, node: MessageNode) -> NodeClaim: + node.update_state(MessageState.IN_PROGRESS) + claim = NodeClaim( + identity=self.identity, + claim_id=uuid4().hex, + node=self._ui_target(node), + prompt=node.prompt, + parent_session_id=self._graph.get_parent_session_id(node.node_id), + ) + self._active = _ActiveClaim(claim=claim) + return claim + + def _enqueue_or_claim(self, node_id: str) -> QueueDecision: + node = self._graph.get_node(node_id) + if node is None or node.state is not MessageState.PENDING: + return QueueDecision( + claim=None, + position=None, + snapshot=None, + rejection=AdmissionRejection.DUPLICATE, + ) + + if self._active is None: + claim = self._claim(node) + return QueueDecision( + claim=claim, + position=None, + snapshot=self._graph.snapshot(), + ) + + if not self._queue.put(node_id): + return QueueDecision( + claim=None, + position=None, + snapshot=None, + rejection=AdmissionRejection.DUPLICATE, + ) + + position = self._queue.qsize() + logger.info("Queued node {}, position {}", node_id, position) + return QueueDecision( + claim=None, + position=position, + snapshot=self._graph.snapshot(), + ) + + async def enqueue_or_claim(self, node_id: str) -> QueueDecision: + """Atomically reject, queue, or exclusively claim an existing node.""" + async with self._lock: + return self._enqueue_or_claim(node_id) + + async def add_and_enqueue( + self, + node_id: str, + scope: MessageScope, + prompt: str, + status_message_id: str, + parent_id: str, + parent_reference_id: str, + ) -> QueueDecision: + """Atomically add a reply and admit it to this tree.""" + async with self._lock: + self._graph.add_node( + node_id=node_id, + scope=scope, + prompt=prompt, + status_message_id=status_message_id, + parent_id=parent_id, + parent_reference_id=parent_reference_id, + ) + return self._enqueue_or_claim(node_id) + + async def finish_and_claim_next(self, claim_id: str) -> CompletionResult: + """Release only the matching claim and atomically select its successor.""" + async with self._lock: + if self._active is None or self._active.claim.claim_id != claim_id: + return CompletionResult( + next_claim=None, + queue=self._queue_entries(), + ) + + self._active = None + next_claim: NodeClaim | None = None + while node_id := self._queue.pop(): + node = self._graph.get_node(node_id) + if node is not None and node.state is MessageState.PENDING: + next_claim = self._claim(node) + break + + return CompletionResult( + next_claim=next_claim, + queue=self._queue_entries(), + ) + + async def cancel_node( + self, + node_id: str, + ) -> TreeCancellation: + """Atomically cancel one active, queued, or stale runnable node.""" + async with self._lock: + node = self._graph.get_node(node_id) + active_claim = ( + self._active.claim + if self._active is not None + and self._active.claim.node.node_id == node_id + else None + ) + if active_claim is not None: + active = self._active + if active is not None: + active.cancellation_requested = True + if node is None: + return TreeCancellation( + nodes=(), + active_claim=active_claim, + queue_update=None, + ) + + queue_changed = self._queue.remove(node_id) + cancelled_nodes: tuple[NodeUiTarget, ...] = () + if node.state in (MessageState.PENDING, MessageState.IN_PROGRESS): + node.mark_error() + cancelled_nodes = (self._ui_target(node),) + elif node.state is MessageState.ERROR and active_claim is not None: + cancelled_nodes = (self._ui_target(node),) + return TreeCancellation( + nodes=cancelled_nodes, + active_claim=active_claim, + queue_update=self._queue_entries() if queue_changed else None, + ) + + async def cancel_all( + self, + ) -> TreeCancellation: + """Atomically cancel every runnable node present at the transition.""" + async with self._lock: + cancelled_nodes: list[NodeUiTarget] = [] + seen: set[str] = set() + active_claim: NodeClaim | None = None + + if self._active is not None: + active_claim = self._active.claim + self._active.cancellation_requested = True + active_node = self._graph.get_node(active_claim.node.node_id) + if active_node is not None and active_node.state in ( + MessageState.PENDING, + MessageState.IN_PROGRESS, + ): + active_node.mark_error() + seen.add(active_node.node_id) + cancelled_nodes.append(self._ui_target(active_node)) + elif active_node is not None: + seen.add(active_node.node_id) + if active_node.state is MessageState.ERROR: + cancelled_nodes.append(self._ui_target(active_node)) + + queued_ids = self._queue.drain() + for node_id in queued_ids: + node = self._graph.get_node(node_id) + if node is None or node.state not in ( + MessageState.PENDING, + MessageState.IN_PROGRESS, + ): + continue + node.mark_error() + seen.add(node_id) + cancelled_nodes.append(self._ui_target(node)) + + for node in self._graph.all_nodes(): + if node.node_id in seen or node.state not in ( + MessageState.PENDING, + MessageState.IN_PROGRESS, + ): + continue + node.mark_error() + cancelled_nodes.append(self._ui_target(node)) + + return TreeCancellation( + nodes=tuple(cancelled_nodes), + active_claim=active_claim, + queue_update=() if queued_ids else None, + ) + + async def remove_message_subtree( + self, + reference_id: str, + ) -> MessageSubtreeRemoval: + """Atomically cancel and detach one literal platform reply subtree.""" + async with self._lock: + resolved = self._graph.resolve_reference(reference_id) + reference_ids = tuple(self._graph.get_reference_descendants(reference_id)) + if resolved is None or not reference_ids: + empty = TreeCancellation( + nodes=(), + active_claim=None, + queue_update=None, + ) + return MessageSubtreeRemoval( + cancellation=empty, + removed_message_ids=frozenset(), + removed_entire_tree=False, + ) + + owner, reference_kind = resolved + removed_node_ids = { + candidate + for candidate in reference_ids + if self._graph.get_node(candidate) is not None + } + affected_node_ids = set(removed_node_ids) + if reference_kind is MessageReferenceKind.STATUS: + affected_node_ids.add(owner.node_id) + active_claim = ( + self._active.claim + if self._active is not None + and self._active.claim.node.node_id in affected_node_ids + else None + ) + if active_claim is not None: + active = self._active + if active is not None: + active.cancellation_requested = True + cancelled_nodes: list[NodeUiTarget] = [] + queue_changed = False + + for node_id in affected_node_ids: + node = self._graph.get_node(node_id) + if node is None: + continue + queue_changed = self._queue.remove(node_id) or queue_changed + if node.state in (MessageState.PENDING, MessageState.IN_PROGRESS): + target = self._ui_target(node) + node.mark_error() + cancelled_nodes.append(target) + elif ( + node.state is MessageState.ERROR + and active_claim is not None + and active_claim.node.node_id == node_id + ): + cancelled_nodes.append(self._ui_target(node)) + + if reference_kind is MessageReferenceKind.STATUS: + self._graph.clear_status(owner.node_id) + removed_entire_tree = self.root_id in removed_node_ids + self._graph.remove_nodes(removed_node_ids) + cancellation = TreeCancellation( + nodes=tuple(cancelled_nodes), + active_claim=active_claim, + queue_update=self._queue_entries() if queue_changed else None, + ) + return MessageSubtreeRemoval( + cancellation=cancellation, + removed_message_ids=frozenset(reference_ids), + removed_entire_tree=removed_entire_tree, + ) + + async def record_session( + self, claim_id: str, session_id: str + ) -> TreeSnapshot | None: + """Record a real CLI session only for the currently active claim.""" + async with self._lock: + if ( + self._active is None + or self._active.claim.claim_id != claim_id + or self._active.cancellation_requested + ): + return None + node = self._graph.get_node(self._active.claim.node.node_id) + if node is None or node.state is not MessageState.IN_PROGRESS: + return None + node.update_state(MessageState.IN_PROGRESS, session_id=session_id) + return self._graph.snapshot() + + async def complete_claim( + self, claim_id: str, session_id: str | None + ) -> TreeSnapshot | None: + """Mark the currently active claim complete.""" + async with self._lock: + if ( + self._active is None + or self._active.claim.claim_id != claim_id + or self._active.cancellation_requested + ): + return None + node = self._graph.get_node(self._active.claim.node.node_id) + if node is None or node.state not in ( + MessageState.IN_PROGRESS, + MessageState.ERROR, + ): + return None + node.update_state(MessageState.COMPLETED, session_id=session_id) + return self._graph.snapshot() + + async def fail_claim( + self, + claim_id: str, + *, + propagate: bool, + ) -> FailureResult: + """Atomically fail the active claim and its pending descendants.""" + async with self._lock: + if ( + self._active is None + or self._active.claim.claim_id != claim_id + or self._active.cancellation_requested + ): + return FailureResult(affected=(), queue_update=None, snapshot=None) + node = self._graph.get_node(self._active.claim.node.node_id) + if node is None: + return FailureResult(affected=(), queue_update=None, snapshot=None) + + affected: list[NodeUiTarget] = [] + queue_changed = False + if node.state is not MessageState.COMPLETED: + if node.state is not MessageState.ERROR: + node.mark_error() + affected.append(self._ui_target(node)) + + if propagate: + for descendant_id in self._graph.get_descendants(node.node_id)[1:]: + child = self._graph.get_node(descendant_id) + if child is None or child.state is not MessageState.PENDING: + continue + child.mark_error() + queue_changed = ( + self._queue.remove(child.node_id) or queue_changed + ) + affected.append(self._ui_target(child)) + + return FailureResult( + affected=tuple(affected), + queue_update=self._queue_entries() if queue_changed else None, + snapshot=self._graph.snapshot(), + ) + + async def resolve_reply(self, reference_id: str) -> ReplyTarget | None: + """Resolve a node/status reference without exposing the mutable graph.""" + async with self._lock: + resolved = self._graph.resolve_reference(reference_id) + if resolved is None: + return None + node, reference_kind = resolved + return ReplyTarget( + node_id=node.node_id, + reference_id=reference_id, + reference_kind=reference_kind, + queue_position=(self._queue.qsize() + 1) + if self._active is not None + else None, + ) + + async def node_view(self, node_id: str) -> NodeView | None: + """Return a copied node read model.""" + async with self._lock: + node = self._graph.get_node(node_id) + if node is None: + return None + return NodeView( + identity=self.identity, + node_id=node.node_id, + state=node.state, + parent_id=node.parent_id, + session_id=node.session_id, + ) + + async def snapshot(self) -> TreeSnapshot: + """Capture a detached persistence snapshot under the aggregate lock.""" + async with self._lock: + return self._graph.snapshot() + + async def message_ids_for_chat(self, platform: str, chat_id: str) -> set[str]: + """Copy every prompt and FCC status belonging to one platform chat.""" + async with self._lock: + if self.identity.scope.platform != str(platform) or ( + self.identity.scope.chat_id != str(chat_id) + ): + return set() + return self._graph.all_reference_ids() + + @classmethod + def from_snapshot(cls, snapshot: TreeSnapshot) -> MessageTree: + """Restore and reconcile interrupted nodes before publishing the tree.""" + graph = MessageTreeGraph.from_snapshot(snapshot) + tree = cls(graph.get_root(), graph=graph) + stale_targets: list[NodeUiTarget] = [] + for node in graph.all_nodes(): + if node.state in (MessageState.PENDING, MessageState.IN_PROGRESS): + stale_targets.append(tree._ui_target(node)) + node.mark_error() + tree._restored_stale_targets = tuple(stale_targets) + tree._restored_snapshot = graph.snapshot() + return tree + + +__all__ = ["MessageTree"] diff --git a/src/free_claude_code/messaging/trees/snapshot.py b/src/free_claude_code/messaging/trees/snapshot.py new file mode 100644 index 0000000..83ec27a --- /dev/null +++ b/src/free_claude_code/messaging/trees/snapshot.py @@ -0,0 +1,221 @@ +"""Serializable messaging conversation snapshots.""" + +from dataclasses import dataclass, field +from typing import Any + +from loguru import logger + +from ..models import MessageScope +from .identity import TreeIdentity +from .node import MessageNode, MessageState + + +@dataclass(frozen=True) +class TreeSnapshot: + """Detached persisted representation of one conversation tree.""" + + scope: MessageScope + root_id: str + nodes: dict[str, dict[str, Any]] + + @property + def identity(self) -> TreeIdentity: + return TreeIdentity(scope=self.scope, root_id=self.root_id) + + def to_json(self) -> dict[str, Any]: + return { + "scope": { + "platform": self.scope.platform, + "chat_id": self.scope.chat_id, + }, + "root_id": self.root_id, + "nodes": dict(self.nodes), + } + + @classmethod + def from_json(cls, data: Any) -> TreeSnapshot | None: + if not isinstance(data, dict): + return None + root_id = data.get("root_id") + nodes = data.get("nodes") + if root_id is None or not isinstance(nodes, dict): + return None + normalized_root_id = str(root_id) + scope_data = data.get("scope") + scope: MessageScope | None = None + if isinstance(scope_data, dict): + platform = scope_data.get("platform") + chat_id = scope_data.get("chat_id") + if platform is not None and chat_id is not None: + scope = MessageScope(platform=str(platform), chat_id=str(chat_id)) + if scope is None: + scope = _legacy_scope(normalized_root_id, nodes) + if scope is None: + logger.warning( + "Skipping messaging tree snapshot without recoverable scope: " + "root_id={}", + normalized_root_id, + ) + return None + return cls(scope=scope, root_id=normalized_root_id, nodes=dict(nodes)) + + def lookup_ids(self) -> set[str]: + lookup_ids: set[str] = set() + for node_key, node_data in self.nodes.items(): + lookup_ids.add(str(node_key)) + if not isinstance(node_data, dict): + continue + node_id = node_data.get("node_id") + if node_id is not None: + lookup_ids.add(str(node_id)) + status_message_id = node_data.get("status_message_id") + if status_message_id is not None: + lookup_ids.add(str(status_message_id)) + return lookup_ids + + +@dataclass(frozen=True) +class ConversationSnapshot: + """Detached persisted conversation trees keyed by customer identity.""" + + trees: dict[TreeIdentity, TreeSnapshot] = field(default_factory=dict) + + @property + def is_empty(self) -> bool: + return not self.trees + + def to_json(self) -> dict[str, Any]: + return {"trees": [tree.to_json() for tree in self.trees.values()]} + + @classmethod + def from_json(cls, data: Any) -> ConversationSnapshot: + if not isinstance(data, dict): + return cls() + raw_trees = data.get("trees", []) + if isinstance(raw_trees, dict): + candidates = raw_trees.values() + elif isinstance(raw_trees, list): + candidates = raw_trees + else: + return cls() + + trees: dict[TreeIdentity, TreeSnapshot] = {} + for raw_tree in candidates: + snapshot = TreeSnapshot.from_json(raw_tree) + if snapshot is None: + continue + trees[snapshot.identity] = snapshot + return cls(trees=trees) + + def get_tree(self, identity: TreeIdentity) -> TreeSnapshot | None: + return self.trees.get(identity) + + def with_tree(self, tree_snapshot: TreeSnapshot) -> ConversationSnapshot: + trees = dict(self.trees) + trees[tree_snapshot.identity] = tree_snapshot + return ConversationSnapshot(trees=trees) + + def without_tree(self, identity: TreeIdentity) -> ConversationSnapshot: + trees = dict(self.trees) + trees.pop(identity, None) + return ConversationSnapshot(trees=trees) + + def without_scope(self, scope: MessageScope) -> ConversationSnapshot: + """Remove every tree belonging to one platform chat.""" + return ConversationSnapshot( + trees={ + identity: tree + for identity, tree in self.trees.items() + if identity.scope != scope + } + ) + + +def _legacy_scope( + root_id: str, + nodes: dict[str, Any], +) -> MessageScope | None: + """Derive scope from pre-scope snapshots without retaining a runtime shim.""" + root = nodes.get(root_id) + if not isinstance(root, dict): + root = next( + ( + node + for node in nodes.values() + if isinstance(node, dict) and str(node.get("node_id")) == root_id + ), + None, + ) + if not isinstance(root, dict): + return None + return node_scope_from_snapshot(root) + + +def node_scope_from_snapshot(data: dict[str, Any]) -> MessageScope | None: + """Read the redundant scope carried by legacy node payloads, if present.""" + incoming = data.get("incoming") + if not isinstance(incoming, dict): + return None + platform = incoming.get("platform") + chat_id = incoming.get("chat_id") + if platform is None or chat_id is None: + return None + return MessageScope(platform=str(platform), chat_id=str(chat_id)) + + +def node_to_snapshot(node: MessageNode) -> dict[str, Any]: + return { + "node_id": node.node_id, + "status_message_id": node.status_message_id, + "state": node.state.value, + "parent_id": node.parent_id, + "parent_reference_id": node.parent_reference_id, + "session_id": node.session_id, + } + + +def node_from_snapshot(data: dict[str, Any], scope: MessageScope) -> MessageNode: + state = MessageState(data["state"]) + status_message_id = _optional_id( + data.get("status_message_id"), + "status_message_id", + ) + if state in (MessageState.PENDING, MessageState.IN_PROGRESS) and ( + status_message_id is None + ): + raise ValueError("Runnable tree snapshot node requires a status message") + parent_id = _optional_id(data.get("parent_id"), "parent_id") + parent_reference_id = _optional_id( + data.get("parent_reference_id"), + "parent_reference_id", + ) + if parent_reference_id is None: + parent_reference_id = parent_id + return MessageNode( + node_id=_required_id(data.get("node_id"), "node_id"), + scope=scope, + prompt="", + status_message_id=status_message_id, + state=state, + parent_id=parent_id, + parent_reference_id=parent_reference_id, + session_id=_optional_id(data.get("session_id"), "session_id"), + ) + + +def _required_id(value: Any, field_name: str) -> str: + normalized = _optional_id(value, field_name) + if normalized is None: + raise ValueError(f"Tree snapshot {field_name} is required") + return normalized + + +def _optional_id(value: Any, field_name: str) -> str | None: + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, str | int): + raise ValueError(f"Tree snapshot {field_name} must be a string or integer") + normalized = str(value) + if not normalized: + raise ValueError(f"Tree snapshot {field_name} cannot be empty") + return normalized diff --git a/src/free_claude_code/messaging/trees/transitions.py b/src/free_claude_code/messaging/trees/transitions.py new file mode 100644 index 0000000..38b9901 --- /dev/null +++ b/src/free_claude_code/messaging/trees/transitions.py @@ -0,0 +1,174 @@ +"""Detached transition values crossing the messaging tree ownership boundary.""" + +from dataclasses import dataclass +from enum import Enum + +from ..models import MessageScope +from .identity import TreeIdentity +from .node import MessageReferenceKind, MessageState +from .snapshot import TreeSnapshot + + +class CancellationUiOwner(Enum): + """Component responsible for the final user-visible cancellation edit.""" + + RUNNER = "runner" + WORKFLOW = "workflow" + + +class CancellationReason(Enum): + """Customer operation that cancelled a running messaging claim.""" + + STOP = "stop" + CLEAR = "clear" + + +class AdmissionRejection(Enum): + """Why a turn was not admitted to an execution tree.""" + + DUPLICATE = "duplicate" + PARENT_REMOVED = "parent_removed" + + +@dataclass(frozen=True, slots=True) +class NodeUiTarget: + """Copied node coordinates needed for an external UI effect.""" + + scope: MessageScope + node_id: str + status_message_id: str + + +@dataclass(frozen=True, slots=True) +class NodeClaim: + """Exclusive permission to execute one node in a conversation tree.""" + + identity: TreeIdentity + claim_id: str + node: NodeUiTarget + prompt: str + parent_session_id: str | None + + +@dataclass(frozen=True, slots=True) +class QueueEntry: + """One immutable queue-position update.""" + + node: NodeUiTarget + position: int + + +@dataclass(frozen=True, slots=True) +class QueueDecision: + """Atomic result of admitting a node to a tree.""" + + claim: NodeClaim | None + position: int | None + snapshot: TreeSnapshot | None + rejection: AdmissionRejection | None = None + + @property + def accepted(self) -> bool: + return self.snapshot is not None + + +@dataclass(frozen=True, slots=True) +class CompletionResult: + """Atomic result of releasing a claim and selecting its successor.""" + + next_claim: NodeClaim | None + queue: tuple[QueueEntry, ...] + + +@dataclass(frozen=True, slots=True) +class CancellationEffect: + """Copied cancellation fact for the UI layer.""" + + node: NodeUiTarget + ui_owner: CancellationUiOwner + + +@dataclass(frozen=True, slots=True) +class TreeCancellation: + """Single-tree cancellation transition consumed by the manager.""" + + nodes: tuple[NodeUiTarget, ...] + active_claim: NodeClaim | None + queue_update: tuple[QueueEntry, ...] | None + + +@dataclass(frozen=True, slots=True) +class CancellationResult: + """External effects and persistence snapshots from a cancellation request.""" + + effects: tuple[CancellationEffect, ...] = () + snapshots: tuple[TreeSnapshot, ...] = () + + +@dataclass(frozen=True, slots=True) +class MessageSubtreeRemoval: + """Single-tree atomic cancellation and reference-subtree removal.""" + + cancellation: TreeCancellation + removed_message_ids: frozenset[str] + removed_entire_tree: bool + + +@dataclass(frozen=True, slots=True) +class MessageSubtreeRemovalResult: + """Manager result for a literal message-subtree clear.""" + + cancellation: CancellationResult + removed_tree_identity: TreeIdentity | None + delete_message_ids: frozenset[str] + tree_matched: bool + + +@dataclass(frozen=True, slots=True) +class ReplyTarget: + """Resolved reply destination and advisory position before admission.""" + + node_id: str + reference_id: str + reference_kind: MessageReferenceKind + queue_position: int | None + + +@dataclass(frozen=True, slots=True) +class NodeView: + """Immutable read model for diagnostics, tests, and smoke assertions.""" + + identity: TreeIdentity + node_id: str + state: MessageState + parent_id: str | None + session_id: str | None + + +@dataclass(frozen=True, slots=True) +class FailureResult: + """Atomic node failure plus copied child UI effects.""" + + affected: tuple[NodeUiTarget, ...] + queue_update: tuple[QueueEntry, ...] | None + snapshot: TreeSnapshot | None + + +__all__ = [ + "AdmissionRejection", + "CancellationEffect", + "CancellationReason", + "CancellationResult", + "CancellationUiOwner", + "CompletionResult", + "FailureResult", + "MessageSubtreeRemoval", + "MessageSubtreeRemovalResult", + "NodeClaim", + "NodeUiTarget", + "NodeView", + "QueueDecision", + "QueueEntry", + "ReplyTarget", + "TreeCancellation", +] diff --git a/src/free_claude_code/messaging/turn_intake.py b/src/free_claude_code/messaging/turn_intake.py new file mode 100644 index 0000000..a16b2a2 --- /dev/null +++ b/src/free_claude_code/messaging/turn_intake.py @@ -0,0 +1,241 @@ +"""Inbound messaging turn intake and queue admission.""" + +from collections.abc import Awaitable, Callable + +from loguru import logger + +from free_claude_code.core.trace import trace_event + +from .cli_event_constants import STATUS_MESSAGE_PREFIXES +from .command_context import MessagingCommandContext +from .command_dispatcher import ( + dispatch_command, + parse_command_base, +) +from .models import AdmissionToken, IncomingMessage, MessageScope +from .platforms.ports import OutboundMessenger +from .session import SessionStore +from .trees import ( + AdmissionRejection, + NodeClaim, + QueueDecision, + QueueEntry, + ReplyTarget, +) + + +class MessagingTurnIntake: + """Owns inbound turn classification and queue admission.""" + + def __init__( + self, + *, + platform_name: str, + outbound: OutboundMessenger, + session_store: SessionStore, + command_context: MessagingCommandContext, + resolve_reply: Callable[[MessageScope, str], Awaitable[ReplyTarget | None]], + admit_turn: Callable[ + [IncomingMessage, str, str | None, AdmissionToken], + Awaitable[QueueDecision | None], + ], + format_status: Callable[[str, str, str | None], str], + get_parse_mode: Callable[[], str | None], + record_outgoing_message: Callable[[str, str, str | None, str], bool], + ) -> None: + self.platform_name = platform_name + self.outbound = outbound + self.session_store = session_store + self._command_context = command_context + self._resolve_reply = resolve_reply + self._admit_turn = admit_turn + self._format_status = format_status + self._get_parse_mode = get_parse_mode + self._record_outgoing_message = record_outgoing_message + + async def handle_message( + self, + incoming: IncomingMessage, + *, + admission_token: AdmissionToken, + ) -> None: + """ + Handle an inbound platform message and queue it if it is a user prompt. + """ + cmd_base = parse_command_base(incoming.text) + + if await dispatch_command(self._command_context, incoming, cmd_base): + return + + text = incoming.text or "" + if any(text.startswith(p) for p in STATUS_MESSAGE_PREFIXES): + return + + reply_target: ReplyTarget | None = None + + if incoming.is_reply() and incoming.reply_to_message_id: + reply_id = incoming.reply_to_message_id + reply_target = await self._resolve_reply(incoming.scope, reply_id) + if reply_target is not None: + logger.info( + "Found tree for reply, parent node: {}", reply_target.node_id + ) + + node_id = incoming.message_id + status_text = self._get_initial_status(reply_target) + if incoming.status_message_id: + status_msg_id = incoming.status_message_id + await self.outbound.queue_edit_message( + incoming.chat_id, + status_msg_id, + status_text, + parse_mode=self._get_parse_mode(), + fire_and_forget=False, + ) + else: + status_msg_id = await self.outbound.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" + ) + if status_msg_id is None: + return + + decision = await self._admit_turn( + incoming, + status_msg_id, + reply_target.reference_id if reply_target is not None else None, + admission_token, + ) + if decision is None: + logger.info( + "Discarded messaging admission invalidated by a stop/clear boundary for node {}", + node_id, + ) + await self._discard_rejected_messages( + incoming, + status_msg_id, + include_prompt=False, + ) + return + if not decision.accepted: + include_prompt = decision.rejection is AdmissionRejection.PARENT_REMOVED + logger.debug( + "Rejected messaging admission for node {}: {}", + node_id, + decision.rejection.value + if decision.rejection is not None + else "unknown", + ) + await self._discard_rejected_messages( + incoming, + status_msg_id, + include_prompt=include_prompt, + ) + return + + if decision.position is not None and status_msg_id: + trace_event( + stage="routing", + event="turn.queued", + source=self.platform_name, + chat_id=incoming.chat_id, + platform_message_id=node_id, + status_message_id=status_msg_id, + queue_size=decision.position, + ) + await self.outbound.queue_edit_message( + incoming.chat_id, + status_msg_id, + self._format_status( + "📋", + "Queued", + f"(position {decision.position}) - waiting...", + ), + parse_mode=self._get_parse_mode(), + ) + + async def _discard_rejected_messages( + self, + incoming: IncomingMessage, + status_message_id: str, + *, + include_prompt: bool, + ) -> None: + """Remove messages created by an admission that cannot commit.""" + message_ids = {status_message_id} + if include_prompt: + message_ids.add(str(incoming.message_id)) + try: + await self.outbound.queue_delete_messages( + incoming.chat_id, + list(message_ids), + fire_and_forget=False, + ) + except Exception as exc: + logger.debug( + "Failed to remove rejected status message: {}", + type(exc).__name__, + ) + try: + self.session_store.forget_tracked_message_ids( + incoming.platform, + incoming.chat_id, + message_ids, + ) + except Exception as exc: + logger.debug( + "Failed to forget rejected status message: {}", + type(exc).__name__, + ) + + async def update_queue_positions(self, queue: tuple[QueueEntry, ...]) -> None: + """Refresh queued status messages after a dequeue.""" + for entry in queue: + self.outbound.fire_and_forget( + self.outbound.queue_edit_message( + entry.node.scope.chat_id, + entry.node.status_message_id, + self._format_status( + "📋", + "Queued", + f"(position {entry.position}) - waiting...", + ), + parse_mode=self._get_parse_mode(), + ) + ) + + async def mark_node_processing(self, claim: NodeClaim) -> None: + """Update the dequeued node's status to processing immediately.""" + self.outbound.fire_and_forget( + self.outbound.queue_edit_message( + claim.node.scope.chat_id, + claim.node.status_message_id, + self._format_status("🔄", "Processing...", None), + parse_mode=self._get_parse_mode(), + ) + ) + + def _get_initial_status( + self, + reply_target: ReplyTarget | None, + ) -> str: + """Get initial status message text.""" + if reply_target is not None: + if reply_target.queue_position is not None: + return self._format_status( + "📋", + "Queued", + f"(position {reply_target.queue_position}) - waiting...", + ) + return self._format_status("🔄", "Continuing conversation...", None) + + return self._format_status("⏳", "Launching new Claude CLI instance...", None) + + +__all__ = ["MessagingTurnIntake"] diff --git a/src/free_claude_code/messaging/ui_updates.py b/src/free_claude_code/messaging/ui_updates.py new file mode 100644 index 0000000..55c0ff4 --- /dev/null +++ b/src/free_claude_code/messaging/ui_updates.py @@ -0,0 +1,99 @@ +"""Throttled platform UI updates driven by transcript rendering.""" + +import time +from collections.abc import Callable + +from loguru import logger + +from .platforms.ports import OutboundMessenger +from .safe_diagnostics import format_exception_for_log +from .transcript import RenderCtx, TranscriptBuffer + + +class ThrottledTranscriptEditor: + """Rate-limited status message edits from a growing transcript.""" + + def __init__( + self, + *, + outbound: OutboundMessenger, + parse_mode: str | None, + get_limit_chars: Callable[[], int], + transcript: TranscriptBuffer, + render_ctx: RenderCtx, + node_id: str, + chat_id: str, + status_msg_id: str, + debug_platform_edits: bool, + log_messaging_error_details: bool = False, + ) -> None: + self._outbound = outbound + self._parse_mode = parse_mode + self._get_limit_chars = get_limit_chars + self._transcript = transcript + self._render_ctx = render_ctx + self._node_id = node_id + self._chat_id = chat_id + self._status_msg_id = status_msg_id + self._debug_platform_edits = debug_platform_edits + self._log_messaging_error_details = log_messaging_error_details + self._last_ui_update = 0.0 + self._last_displayed_text: str | None = None + self._last_status: str | None = None + + @property + def last_status(self) -> str | None: + return self._last_status + + async def update(self, status: str | None = None, *, force: bool = False) -> None: + """Render transcript + optional status line and edit the platform message.""" + now = time.time() + if not force and now - self._last_ui_update < 1.0: + return + + self._last_ui_update = now + if status is not None: + self._last_status = status + try: + display = self._transcript.render( + self._render_ctx, + limit_chars=self._get_limit_chars(), + status=status, + ) + except Exception as e: + logger.warning( + "Transcript render failed for node {}: {}", + self._node_id, + format_exception_for_log( + e, log_full_message=self._log_messaging_error_details + ), + ) + return + if display and display != self._last_displayed_text: + logger.debug( + "PLATFORM_EDIT: node_id={} chat_id={} msg_id={} force={} status={!r} chars={}", + self._node_id, + self._chat_id, + self._status_msg_id, + bool(force), + status, + len(display), + ) + if self._debug_platform_edits: + logger.debug("PLATFORM_EDIT_TEXT:\n{}", display) + self._last_displayed_text = display + try: + await self._outbound.queue_edit_message( + self._chat_id, + self._status_msg_id, + display, + parse_mode=self._parse_mode, + ) + except Exception as e: + logger.warning( + "Failed to update platform for node {}: {}", + self._node_id, + format_exception_for_log( + e, log_full_message=self._log_messaging_error_details + ), + ) diff --git a/src/free_claude_code/messaging/voice.py b/src/free_claude_code/messaging/voice.py new file mode 100644 index 0000000..a52a974 --- /dev/null +++ b/src/free_claude_code/messaging/voice.py @@ -0,0 +1,376 @@ +"""Platform-neutral voice note helpers.""" + +import asyncio +from collections.abc import Awaitable, Callable +from contextvars import ContextVar +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Protocol +from uuid import uuid4 + +from .models import MessageScope + + +async def _await_owned_task[T]( + task: asyncio.Task[T], +) -> tuple[T, asyncio.CancelledError | None]: + """Finish an owned task before returning any caller cancellation.""" + cancellation: asyncio.CancelledError | None = None + current = asyncio.current_task() + while True: + cancelling_before = current.cancelling() if current is not None else 0 + try: + return await asyncio.shield(task), cancellation + except asyncio.CancelledError as exc: + if current is None or ( + current.cancelling() <= cancelling_before and task.done() + ): + raise + cancellation = cancellation or exc + + +class Transcriber(Protocol): + """Consumer-owned voice transcription boundary.""" + + async def transcribe(self, file_path: Path) -> str: ... + + async def close(self) -> None: ... + + +@dataclass(frozen=True, slots=True) +class PendingVoiceClaim: + """Opaque ownership token for one pending voice-note generation.""" + + scope: MessageScope + voice_message_id: str + claim_id: str + + +_current_voice_claim: ContextVar[PendingVoiceClaim | None] = ContextVar( + "current_voice_claim", + default=None, +) + + +class VoiceHandoffOutcome(Enum): + """Exclusive outcome of publishing one transcribed voice message.""" + + REJECTED = "rejected" + COMPLETED = "completed" + CANCELLED = "cancelled" + + +@dataclass(frozen=True, slots=True) +class VoiceCancellationResult: + """Released ownership for one successfully cancelled user voice note.""" + + scope: MessageScope + voice_message_id: str + status_message_id: str | None + delete_message_ids: frozenset[str] + + +@dataclass(slots=True) +class _PendingVoice: + claim: PendingVoiceClaim + status_message_id: str | None = None + handoff_task: asyncio.Task[None] | None = None + + +class PendingVoiceRegistry: + """Own atomic reservation, cancellation, and handoff of voice notes.""" + + def __init__(self) -> None: + self._pending: dict[tuple[MessageScope, str], _PendingVoice] = {} + self._lock = asyncio.Lock() + self._active_cancellations: dict[PendingVoiceClaim, int] = {} + + async def reserve( + self, + scope: MessageScope, + voice_message_id: str, + ) -> PendingVoiceClaim | None: + async with self._lock: + key = (scope, voice_message_id) + if key in self._pending: + return None + claim = PendingVoiceClaim( + scope=scope, + voice_message_id=voice_message_id, + claim_id=uuid4().hex, + ) + self._pending[key] = _PendingVoice(claim=claim) + return claim + + async def bind_status( + self, + claim: PendingVoiceClaim, + status_message_id: str, + ) -> bool: + async with self._lock: + entry = self._entry_for_claim(claim) + if entry is None: + return False + if entry.status_message_id is not None: + return entry.status_message_id == status_message_id + status_key = (claim.scope, status_message_id) + existing = self._pending.get(status_key) + if existing is not None and existing is not entry: + return False + entry.status_message_id = status_message_id + self._pending[status_key] = entry + return True + + async def handoff( + self, + claim: PendingVoiceClaim, + callback: Callable[[], Awaitable[None]], + ) -> VoiceHandoffOutcome: + """Run a published handoff while retaining its cancellable ownership.""" + async with self._lock: + entry = self._entry_for_claim(claim) + if ( + entry is None + or entry.status_message_id is None + or entry.handoff_task is not None + ): + return VoiceHandoffOutcome.REJECTED + task = asyncio.create_task( + self._run_callback(claim, callback), + name=f"voice-handoff-{claim.claim_id}", + ) + entry.handoff_task = task + + current = asyncio.current_task() + cancelling_before = current.cancelling() if current is not None else 0 + try: + await asyncio.shield(task) + except BaseException as error: + caller_cancelled = ( + isinstance(error, asyncio.CancelledError) + and current is not None + and (current.cancelling() > cancelling_before or not task.done()) + ) + if caller_cancelled: + task.cancel() + child_error: BaseException | None = None + try: + await self._drain((task,)) + except BaseException as drained_error: + child_error = drained_error + await self._finish_ownership(entry) + if child_error is not None: + raise child_error from None + raise error from None + + completed, cancellation = await self._finish_ownership(entry) + if cancellation is not None and not self._is_fatal(error): + raise cancellation from None + if completed or self._is_fatal(error): + raise error + return VoiceHandoffOutcome.CANCELLED + + completed, cancellation = await self._finish_ownership(entry) + if cancellation is not None: + raise cancellation + if completed: + return VoiceHandoffOutcome.COMPLETED + return VoiceHandoffOutcome.CANCELLED + + async def discard(self, claim: PendingVoiceClaim) -> bool: + async with self._lock: + entry = self._entry_for_claim(claim) + if entry is None: + return False + self._remove(entry) + task = entry.handoff_task + cancellation = await self._cancel_and_drain(task) + if cancellation is not None: + raise cancellation + return True + + async def cancel( + self, scope: MessageScope, reply_id: str + ) -> VoiceCancellationResult | None: + current_claim = _current_voice_claim.get() + self._protect_claim(current_claim) + try: + async with self._lock: + entry = self._pending.get((scope, reply_id)) + if entry is None or self._is_excluded(entry, current_claim): + return None + self._remove(entry) + task = entry.handoff_task + result = self._cancellation_result(entry, reply_id) + cancellation = await self._cancel_and_drain(task) + if cancellation is not None: + raise cancellation + return result + finally: + self._unprotect_claim(current_claim) + + async def cancel_all(self) -> tuple[VoiceCancellationResult, ...]: + """Cancel every unique pending voice note and drain published handoffs.""" + return await self._cancel_matching_scope(None) + + async def cancel_scope( + self, scope: MessageScope + ) -> tuple[VoiceCancellationResult, ...]: + """Cancel every unique pending voice note in one platform chat.""" + return await self._cancel_matching_scope(scope) + + async def _cancel_matching_scope( + self, + scope: MessageScope | None, + ) -> tuple[VoiceCancellationResult, ...]: + current_claim = _current_voice_claim.get() + self._protect_claim(current_claim) + try: + async with self._lock: + entries = tuple( + { + entry.claim: entry + for (entry_scope, _reference_id), entry in self._pending.items() + if (scope is None or entry_scope == scope) + and not self._is_excluded(entry, current_claim) + }.values() + ) + for entry in entries: + self._remove(entry) + + tasks = tuple( + task for entry in entries if (task := entry.handoff_task) is not None + ) + for task in tasks: + task.cancel() + cancellation = await self._drain(tasks) + if cancellation is not None: + raise cancellation + return tuple(self._cancellation_result(entry) for entry in entries) + finally: + self._unprotect_claim(current_claim) + + async def _finish_ownership( + self, + entry: _PendingVoice, + ) -> tuple[bool, asyncio.CancelledError | None]: + finish_task = asyncio.create_task( + self._complete_if_owned(entry), + name=f"voice-handoff-finish-{entry.claim.claim_id}", + ) + return await _await_owned_task(finish_task) + + async def _complete_if_owned(self, entry: _PendingVoice) -> bool: + async with self._lock: + if self._entry_for_claim(entry.claim) is not entry: + return False + self._remove(entry) + return True + + @staticmethod + async def _run_callback( + claim: PendingVoiceClaim, + callback: Callable[[], Awaitable[None]], + ) -> None: + token = _current_voice_claim.set(claim) + try: + await callback() + finally: + _current_voice_claim.reset(token) + + @staticmethod + async def _cancel_and_drain( + task: asyncio.Task[None] | None, + ) -> asyncio.CancelledError | None: + if task is None or task is asyncio.current_task(): + return None + task.cancel() + return await PendingVoiceRegistry._drain((task,)) + + @staticmethod + async def _drain( + tasks: tuple[asyncio.Task[None], ...], + ) -> asyncio.CancelledError | None: + if not tasks: + return None + drain_task = asyncio.create_task( + PendingVoiceRegistry._consume_results(tasks), + name="voice-handoff-drain", + ) + _, cancellation = await _await_owned_task(drain_task) + return cancellation + + @staticmethod + async def _consume_results(tasks: tuple[asyncio.Task[None], ...]) -> None: + fatal_error: BaseException | None = None + for task in tasks: + try: + await task + except asyncio.CancelledError, Exception: + continue + except BaseException as error: + fatal_error = fatal_error or error + if fatal_error is not None: + raise fatal_error + + @staticmethod + def _is_fatal(error: BaseException) -> bool: + return not isinstance(error, (asyncio.CancelledError, Exception)) + + @staticmethod + def _cancellation_result( + entry: _PendingVoice, + reference_id: str | None = None, + ) -> VoiceCancellationResult: + delete_message_ids = {entry.claim.voice_message_id} + if reference_id is not None and reference_id != entry.claim.voice_message_id: + delete_message_ids.clear() + if entry.status_message_id is not None: + delete_message_ids.add(entry.status_message_id) + return VoiceCancellationResult( + scope=entry.claim.scope, + voice_message_id=entry.claim.voice_message_id, + status_message_id=entry.status_message_id, + delete_message_ids=frozenset(delete_message_ids), + ) + + def _entry_for_claim(self, claim: PendingVoiceClaim) -> _PendingVoice | None: + entry = self._pending.get((claim.scope, claim.voice_message_id)) + if entry is None or entry.claim != claim: + return None + return entry + + def _is_excluded( + self, + entry: _PendingVoice, + current_claim: PendingVoiceClaim | None, + ) -> bool: + return ( + entry.claim == current_claim + or self._active_cancellations.get(entry.claim, 0) > 0 + ) + + def _protect_claim(self, claim: PendingVoiceClaim | None) -> None: + if claim is None: + return + self._active_cancellations[claim] = self._active_cancellations.get(claim, 0) + 1 + + def _unprotect_claim(self, claim: PendingVoiceClaim | None) -> None: + if claim is None: + return + remaining = self._active_cancellations[claim] - 1 + if remaining: + self._active_cancellations[claim] = remaining + else: + self._active_cancellations.pop(claim) + + def _remove(self, entry: _PendingVoice) -> None: + voice_key = (entry.claim.scope, entry.claim.voice_message_id) + if self._pending.get(voice_key) is entry: + self._pending.pop(voice_key) + if entry.status_message_id is None: + return + status_key = (entry.claim.scope, entry.status_message_id) + if self._pending.get(status_key) is entry: + self._pending.pop(status_key) diff --git a/src/free_claude_code/messaging/workflow.py b/src/free_claude_code/messaging/workflow.py new file mode 100644 index 0000000..f020036 --- /dev/null +++ b/src/free_claude_code/messaging/workflow.py @@ -0,0 +1,714 @@ +"""Messaging workflow coordinator for Discord and Telegram prompts.""" + +import asyncio +from collections.abc import Coroutine +from typing import Any + +from loguru import logger + +from free_claude_code.core.trace import trace_event + +from .command_context import ReplyClearResult, StopOutcome +from .command_dispatcher import parse_command_base +from .managed_protocols import ManagedClaudeSessionManagerProtocol +from .models import AdmissionToken, IncomingMessage, MessageScope +from .node_runner import MessagingNodeRunner +from .platforms.ports import ( + MessagingStartupNotice, + OutboundMessenger, + VoiceCancellation, +) +from .rendering.profiles import build_rendering_profile +from .safe_diagnostics import format_exception_for_log +from .session import SessionStore +from .transcript import RenderCtx +from .trees import ( + CancellationReason, + CancellationResult, + CancellationUiOwner, + ConversationSnapshot, + FailureResult, + NodeUiTarget, + QueueDecision, + ReplyTarget, + TreeQueueManager, +) +from .turn_intake import MessagingTurnIntake +from .voice import VoiceCancellationResult + + +async def _finish_owned_operation[T]( + operation: Coroutine[Any, Any, T], + *, + name: str, +) -> T: + """Finish owned work, preserving its failures before caller cancellation.""" + task = asyncio.create_task(operation, name=name) + current = asyncio.current_task() + cancellation: asyncio.CancelledError | None = None + while not task.done(): + try: + await asyncio.shield(task) + except asyncio.CancelledError as exc: + if current is not None and current.cancelling(): + cancellation = cancellation or exc + except Exception: + break + + # Task.result() raises the owned operation's failure. Caller cancellation + # is restored only after successful completion. + result = task.result() + if cancellation is not None: + raise cancellation + return result + + +def _stop_outcome( + voices: tuple[VoiceCancellationResult, ...], + cancellation: CancellationResult, +) -> StopOutcome: + """Summarize distinct stopped owners and whether existing status UI covers them.""" + status_coverage: dict[tuple[MessageScope, str], bool] = {} + for voice in voices: + key = (voice.scope, voice.voice_message_id) + status_coverage[key] = ( + status_coverage.get(key, False) or voice.status_message_id is not None + ) + for effect in cancellation.effects: + status_coverage[(effect.node.scope, effect.node.node_id)] = True + return StopOutcome( + cancelled_count=len(status_coverage), + status_feedback_scopes=frozenset( + scope for (scope, _owner_id), covered in status_coverage.items() if covered + ), + fallback_required=any(not covered for covered in status_coverage.values()), + ) + + +class MessagingWorkflow: + """Own messaging state transitions and their external side effects.""" + + def __init__( + self, + outbound: OutboundMessenger, + cli_manager: ManagedClaudeSessionManagerProtocol, + session_store: SessionStore, + *, + platform_name: str | None = None, + voice_cancellation: VoiceCancellation | None = None, + debug_platform_edits: bool = False, + debug_subagent_stack: bool = False, + log_raw_cli_diagnostics: bool = False, + log_messaging_error_details: bool = False, + ) -> None: + self.platform_name = platform_name or "messaging" + self.outbound = outbound + self.voice_cancellation = voice_cancellation + self.cli_manager = cli_manager + self.session_store = session_store + self._log_messaging_error_details = log_messaging_error_details + self._rendering_profile = build_rendering_profile(self.platform_name) + self._state_lock = asyncio.Lock() + self._stop_generation = 0 + self._clear_generations: dict[MessageScope, int] = {} + self._pending_restored_status_targets: tuple[NodeUiTarget, ...] = () + + self._tree_queue: TreeQueueManager + self.node_runner = MessagingNodeRunner( + platform_name=self.platform_name, + outbound=outbound, + cli_manager=cli_manager, + session_store=session_store, + get_tree_queue=lambda: self._tree_queue, + format_status=self.format_status, + get_parse_mode=self._parse_mode, + get_render_ctx=self.get_render_ctx, + get_limit_chars=self._get_limit_chars, + debug_platform_edits=debug_platform_edits, + debug_subagent_stack=debug_subagent_stack, + log_raw_cli_diagnostics=log_raw_cli_diagnostics, + log_messaging_error_details=log_messaging_error_details, + ) + self.turn_intake = MessagingTurnIntake( + platform_name=self.platform_name, + outbound=outbound, + session_store=session_store, + command_context=self, + resolve_reply=self.resolve_reply, + admit_turn=self._admit_turn_if_current, + format_status=self.format_status, + get_parse_mode=self._parse_mode, + record_outgoing_message=self.record_outgoing_message, + ) + self._tree_queue = self._build_tree_queue() + + def _build_tree_queue( + self, snapshot: ConversationSnapshot | None = None + ) -> TreeQueueManager: + if snapshot is None: + return TreeQueueManager( + self.node_runner.process_node, + queue_update_callback=self.turn_intake.update_queue_positions, + node_started_callback=self.turn_intake.mark_node_processing, + unexpected_failure_callback=self._apply_unexpected_failure, + log_messaging_error_details=self._log_messaging_error_details, + ) + return TreeQueueManager.from_snapshot( + snapshot, + self.node_runner.process_node, + queue_update_callback=self.turn_intake.update_queue_positions, + node_started_callback=self.turn_intake.mark_node_processing, + unexpected_failure_callback=self._apply_unexpected_failure, + log_messaging_error_details=self._log_messaging_error_details, + ) + + def format_status(self, emoji: str, label: str, suffix: str | None = None) -> str: + return self._rendering_profile.format_status(emoji, label, suffix) + + def _parse_mode(self) -> str | None: + return self._rendering_profile.parse_mode + + def get_render_ctx(self) -> RenderCtx: + return self._rendering_profile.render_ctx + + def _get_limit_chars(self) -> int: + return self._rendering_profile.limit_chars + + @property + def tree_queue(self) -> TreeQueueManager: + """Expose the manager facade for diagnostics and smoke tests.""" + return self._tree_queue + + def restore(self) -> None: + """Restore and reconcile persisted conversations before platform start.""" + snapshot = self.session_store.load_conversation_snapshot() + if snapshot.is_empty: + return + logger.info("Restoring {} conversation trees...", len(snapshot.trees)) + self._tree_queue = self._build_tree_queue(snapshot) + normalized = self._tree_queue.restored_snapshot + if normalized is not None and normalized != snapshot: + self.session_store.save_conversation_snapshot(normalized) + self._pending_restored_status_targets = self._tree_queue.restored_stale_targets + + async def repair_restored_statuses(self) -> None: + """Replace stale queued/processing UI after delivery becomes available.""" + targets = self._pending_restored_status_targets + self._pending_restored_status_targets = () + for target in targets: + if self.platform_name != "messaging" and ( + target.scope.platform != self.platform_name + ): + continue + try: + await self.outbound.queue_edit_message( + target.scope.chat_id, + target.status_message_id, + self.format_status("❌", "Interrupted by server restart"), + parse_mode=self._parse_mode(), + fire_and_forget=False, + ) + except Exception as exc: + logger.debug( + "Failed to repair restored status for node {}: {}", + target.node_id, + type(exc).__name__, + ) + + async def publish_startup_notice(self, notice: MessagingStartupNotice) -> None: + """Publish one notice, then transfer its receipt to clear ownership.""" + scope = MessageScope(platform=self.platform_name, chat_id=notice.chat_id) + async with self._state_lock: + clear_generation = self._clear_generations.get(scope, 0) + + try: + message_id = await self.outbound.queue_send_message( + notice.chat_id, + self.format_status( + "🚀", + "Claude Code Proxy is online!", + f"({notice.transport_label})", + ), + parse_mode=self._parse_mode(), + fire_and_forget=False, + ) + except Exception as exc: + logger.warning( + "Could not publish messaging startup notice: {}", + format_exception_for_log( + exc, + log_full_message=self._log_messaging_error_details, + ), + ) + return + + if not message_id: + return + + publisher_task = asyncio.current_task() + await _finish_owned_operation( + self._finalize_startup_notice( + notice, + message_id, + clear_generation, + publisher_task, + ), + name="messaging-finalize-startup-notice", + ) + + async def _finalize_startup_notice( + self, + notice: MessagingStartupNotice, + message_id: str, + clear_generation: int, + publisher_task: asyncio.Task[Any] | None, + ) -> None: + """Commit one delivery receipt or compensate it outside the state lock.""" + async with self._state_lock: + must_discard = ( + publisher_task is not None and publisher_task.cancelling() > 0 + ) or clear_generation != self._clear_generations.get( + MessageScope(platform=self.platform_name, chat_id=notice.chat_id), 0 + ) + if not must_discard: + must_discard = not self.record_outgoing_message( + self.platform_name, + notice.chat_id, + message_id, + "startup", + ) + + if must_discard: + await self._discard_startup_notice(notice.chat_id, message_id) + + async def _discard_startup_notice( + self, + chat_id: str, + message_id: str, + ) -> None: + """Delete an uncommitted notice or retain its ID for a later clear.""" + try: + await self.outbound.queue_delete_messages( + chat_id, + [message_id], + fire_and_forget=False, + ) + except Exception as exc: + logger.warning( + "Could not discard messaging startup notice: {}", + format_exception_for_log( + exc, + log_full_message=self._log_messaging_error_details, + ), + ) + async with self._state_lock: + tracked = self.record_outgoing_message( + self.platform_name, + chat_id, + message_id, + "startup", + ) + if not tracked: + logger.warning( + "Messaging startup notice could neither be deleted nor tracked" + ) + return + + async with self._state_lock: + self.forget_tracked_message_ids( + self.platform_name, + chat_id, + {message_id}, + ) + + async def close(self) -> None: + """Finish every owned task and durable write before releasing delivery.""" + await self.stop_all_tasks() + await self._tree_queue.wait_idle() + self.session_store.flush_pending_save() + + async def handle_message(self, incoming: IncomingMessage) -> None: + """Handle one platform message.""" + trace_event( + stage="ingress", + event="turn.received", + source=self.platform_name, + chat_id=incoming.chat_id, + platform_message_id=incoming.message_id, + reply_to_message_id=incoming.reply_to_message_id, + thread_id=incoming.message_thread_id, + message_text=incoming.text or "", + ) + with logger.contextualize( + chat_id=incoming.chat_id, + node_id=incoming.message_id, + ): + is_standalone_clear = ( + parse_command_base(incoming.text) == "/clear" + and not incoming.is_reply() + ) + async with self._state_lock: + admission_token = AdmissionToken( + stop_generation=self._stop_generation, + clear_generation=self._clear_generations.get(incoming.scope, 0), + ) + if not is_standalone_clear: + self._record_incoming_message(incoming) + try: + await self.turn_intake.handle_message( + incoming, + admission_token=admission_token, + ) + except BaseException: + if is_standalone_clear: + async with self._state_lock: + self._record_incoming_message(incoming) + raise + + async def resolve_reply( + self, + scope: MessageScope, + reference_id: str, + ) -> ReplyTarget | None: + return await self._tree_queue.resolve_reply(scope, reference_id) + + async def _admit_turn_if_current( + self, + incoming: IncomingMessage, + status_message_id: str, + parent_reference_id: str | None, + admission_token: AdmissionToken, + ) -> QueueDecision | None: + return await _finish_owned_operation( + self._admit_if_current( + incoming, + status_message_id, + parent_reference_id, + admission_token, + ), + name=f"messaging-admit-{incoming.message_id}", + ) + + async def _admit_if_current( + self, + incoming: IncomingMessage, + status_message_id: str, + parent_reference_id: str | None, + admission_token: AdmissionToken, + ) -> QueueDecision | None: + """Commit admission and its exact snapshot as one owned transaction.""" + async with self._state_lock: + current_token = AdmissionToken( + stop_generation=self._stop_generation, + clear_generation=self._clear_generations.get(incoming.scope, 0), + ) + if admission_token != current_token: + return None + decision = await self._tree_queue.admit( + incoming, + status_message_id, + parent_reference_id=parent_reference_id, + ) + if decision.snapshot is not None: + self.session_store.save_tree_snapshot(decision.snapshot) + return decision + + def get_tree_count(self) -> int: + return self._tree_queue.get_tree_count() + + async def stop_reply( + self, + scope: MessageScope, + reply_id: str, + ) -> StopOutcome: + """Stop the exact voice/tree owner of one replied-to message.""" + return await _finish_owned_operation( + self._stop_reply(scope, reply_id), + name=f"messaging-stop-reply-{reply_id}", + ) + + async def _stop_reply( + self, + scope: MessageScope, + reply_id: str, + ) -> StopOutcome: + voice_result = await self._cancel_pending_voice(scope, reply_id) + if voice_result is not None: + self.render_voice_stopped(voice_result) + + async with self._state_lock: + node_id = await self._tree_queue.resolve_node_id(scope, reply_id) + if node_id is None: + voices = (voice_result,) if voice_result is not None else () + return _stop_outcome(voices, CancellationResult()) + result = await self._tree_queue.cancel_node( + scope, + node_id, + reason=CancellationReason.STOP, + ) + self._apply_cancellation_result(result) + + voices = (voice_result,) if voice_result is not None else () + return _stop_outcome(voices, result) + + async def clear_reply( + self, + scope: MessageScope, + reply_id: str, + ) -> ReplyClearResult | None: + """Clear the exact voice/tree owner of one replied-to message.""" + return await _finish_owned_operation( + self._clear_reply(scope, reply_id), + name=f"messaging-clear-reply-{reply_id}", + ) + + async def _clear_reply( + self, + scope: MessageScope, + reply_id: str, + ) -> ReplyClearResult | None: + voice_result = await self._cancel_pending_voice(scope, reply_id) + + async with self._state_lock: + subtree = await self._tree_queue.remove_message_subtree( + scope, + reply_id, + reason=CancellationReason.CLEAR, + ) + if not subtree.tree_matched and voice_result is None: + return None + self._save_cancellation_snapshots(subtree.cancellation) + if subtree.removed_tree_identity is not None: + self.session_store.remove_tree_snapshot(subtree.removed_tree_identity) + + delete_message_ids = set(subtree.delete_message_ids) + if voice_result is not None: + delete_message_ids.update(voice_result.delete_message_ids) + return ReplyClearResult( + delete_message_ids=frozenset(delete_message_ids), + tree_matched=subtree.tree_matched, + ) + + async def stop_all_tasks(self) -> StopOutcome: + """Stop every pending and active messaging task.""" + return await _finish_owned_operation( + self._stop_all_tasks(), + name="messaging-stop-all", + ) + + async def _stop_all_tasks(self) -> StopOutcome: + voice_results = await self._cancel_all_pending_voices() + for voice in voice_results: + self.render_voice_stopped(voice) + async with self._state_lock: + self._stop_generation += 1 + logger.info("Cancelling tree queue tasks...") + result = await self._tree_queue.cancel_all(reason=CancellationReason.STOP) + logger.info("Cancelled {} nodes", len(result.effects)) + self._apply_cancellation_result(result) + logger.info("Stopping all CLI sessions...") + await self.cli_manager.stop_all() + return _stop_outcome(voice_results, result) + + async def clear_chat(self, platform: str, chat_id: str) -> frozenset[str]: + """Clear FCC state atomically with respect to later turn admission.""" + return await _finish_owned_operation( + self._clear_chat(platform, chat_id), + name=f"messaging-clear-{platform}-{chat_id}", + ) + + async def _clear_chat( + self, + platform: str, + chat_id: str, + ) -> frozenset[str]: + """Reset one chat's FCC state and return all tracked deletion IDs.""" + clear_scope = MessageScope(platform=platform, chat_id=chat_id) + voice_results = await self._cancel_pending_voices_in_scope(clear_scope) + async with self._state_lock: + delete_message_ids: set[str] = set() + for voice in voice_results: + delete_message_ids.update(voice.delete_message_ids) + delete_message_ids.update( + self.session_store.get_tracked_message_ids_for_chat(platform, chat_id) + ) + + delete_message_ids.update( + await self._tree_queue.get_message_ids_for_chat(platform, chat_id) + ) + # All fallible/cancellable reads precede the commit boundary. Once + # the scope generation advances, state removal is one-way work. + self._clear_generations[clear_scope] = ( + self._clear_generations.get(clear_scope, 0) + 1 + ) + failures: list[Exception] = [] + try: + await self._tree_queue.clear_scope( + clear_scope, + reason=CancellationReason.CLEAR, + ) + except Exception as exc: + failures.append(exc) + try: + self.session_store.clear_scope(clear_scope) + except Exception as exc: + failures.append(exc) + logger.warning( + "Failed to persist final cleared session state: {}", + type(exc).__name__, + ) + if len(failures) == 1: + raise failures[0] + if failures: + raise ExceptionGroup("Chat clear failed", failures) + return frozenset(delete_message_ids) + + async def _cancel_all_pending_voices( + self, + ) -> tuple[VoiceCancellationResult, ...]: + cancellation = self.voice_cancellation + if cancellation is None: + return () + return await cancellation.cancel_all_pending_voices() + + async def _cancel_pending_voices_in_scope( + self, + scope: MessageScope, + ) -> tuple[VoiceCancellationResult, ...]: + cancellation = self.voice_cancellation + if cancellation is None: + return () + return await cancellation.cancel_pending_voices_in_scope(scope) + + async def _cancel_pending_voice( + self, + scope: MessageScope, + reply_id: str, + ) -> VoiceCancellationResult | None: + cancellation = self.voice_cancellation + if cancellation is None: + return None + return await cancellation.cancel_pending_voice(scope, reply_id) + + def render_voice_stopped(self, result: VoiceCancellationResult) -> None: + """Publish terminal UI for a voice cancelled before tree ownership.""" + if result.status_message_id is None: + return + self.outbound.fire_and_forget( + self.outbound.queue_edit_message( + result.scope.chat_id, + result.status_message_id, + self.format_status("⏹", "Stopped."), + parse_mode=self._parse_mode(), + ) + ) + + def forget_tracked_message_ids( + self, + platform: str, + chat_id: str, + message_ids: set[str], + ) -> None: + try: + self.session_store.forget_tracked_message_ids( + platform, chat_id, message_ids + ) + except Exception as exc: + logger.warning( + "Failed to update managed-message log after clear: {}", + type(exc).__name__, + ) + + def record_outgoing_message( + self, + platform: str, + chat_id: str, + msg_id: str | None, + kind: str, + ) -> bool: + """Record an outgoing message ID for /clear and report ownership.""" + if not msg_id: + return False + try: + self.session_store.record_message_id( + platform, + chat_id, + str(msg_id), + "out", + kind, + ) + except Exception as exc: + logger.debug( + "Failed to record message_id: {}", + format_exception_for_log( + exc, + log_full_message=self._log_messaging_error_details, + ), + ) + return False + return True + + def _record_incoming_message(self, incoming: IncomingMessage) -> bool: + """Record an inbound prompt, voice note, or command for standalone clear.""" + command = parse_command_base(incoming.text) + kind = ( + "command" + if command.startswith("/") + else "voice" + if incoming.status_message_id is not None + else "prompt" + ) + try: + self.session_store.record_message_id( + incoming.platform, + incoming.chat_id, + str(incoming.message_id), + "in", + kind, + ) + except Exception as exc: + logger.debug( + "Failed to record managed inbound message_id: {}", + format_exception_for_log( + exc, + log_full_message=self._log_messaging_error_details, + ), + ) + return False + return True + + def _save_cancellation_snapshots(self, result: CancellationResult) -> None: + """Persist transition snapshots without publishing cancellation UI.""" + for snapshot in result.snapshots: + self.session_store.save_tree_snapshot(snapshot) + + def _apply_cancellation_result(self, result: CancellationResult) -> None: + """Apply detached UI and persistence effects from one transition.""" + for effect in result.effects: + if effect.ui_owner is CancellationUiOwner.WORKFLOW: + self.outbound.fire_and_forget( + self.outbound.queue_edit_message( + effect.node.scope.chat_id, + effect.node.status_message_id, + self.format_status("⏹", "Stopped."), + parse_mode=self._parse_mode(), + ) + ) + self._save_cancellation_snapshots(result) + + def _apply_unexpected_failure(self, result: FailureResult) -> None: + """Persist and render a failure that escaped the total node runner.""" + if result.snapshot is not None: + self.session_store.save_tree_snapshot(result.snapshot) + for target in result.affected: + self.outbound.fire_and_forget( + self.outbound.queue_edit_message( + target.scope.chat_id, + target.status_message_id, + self.format_status("💥", "Task Failed"), + parse_mode=self._parse_mode(), + ) + ) + + +__all__ = ["MessagingWorkflow"] diff --git a/src/free_claude_code/providers/__init__.py b/src/free_claude_code/providers/__init__.py new file mode 100644 index 0000000..773a170 --- /dev/null +++ b/src/free_claude_code/providers/__init__.py @@ -0,0 +1,12 @@ +"""Shared provider lifecycle contracts. + +Ordinary OpenAI-compatible vendors are immutable profiles. Concrete adapter +classes exist only for providers with stateful or algorithmic behavior. +""" + +from .base import BaseProvider, ProviderConfig + +__all__ = [ + "BaseProvider", + "ProviderConfig", +] diff --git a/src/free_claude_code/providers/base.py b/src/free_claude_code/providers/base.py new file mode 100644 index 0000000..a7dd4b9 --- /dev/null +++ b/src/free_claude_code/providers/base.py @@ -0,0 +1,136 @@ +"""Base provider interface - extend this to implement your own provider.""" + +from abc import ABC, abstractmethod +from collections.abc import AsyncIterator +from dataclasses import dataclass + +from loguru import logger + +from free_claude_code.application.model_metadata import ProviderModelInfo +from free_claude_code.config.constants import HTTP_CONNECT_TIMEOUT_DEFAULT +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.diagnostics import ( + exception_cause_types, + redacted_exception_traceback, +) +from free_claude_code.core.trace import trace_event +from free_claude_code.providers.model_listing import model_infos_from_ids + + +@dataclass(frozen=True, slots=True) +class ProviderConfig: + """Resolved immutable configuration for one provider instance. + + 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 + 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 = HTTP_CONNECT_TIMEOUT_DEFAULT + enable_thinking: bool = True + proxy: str = "" + log_raw_sse_events: bool = False + log_api_error_tracebacks: bool = False + + +class BaseProvider(ABC): + """Base class for all providers. Extend this to add your own.""" + + def __init__(self, config: ProviderConfig): + self._config = config + + def _is_thinking_enabled( + self, request: MessagesRequest, thinking_enabled: bool | None = None + ) -> bool: + """Return whether thinking should be enabled for this request.""" + thinking = request.thinking + config_enabled = ( + self._config.enable_thinking + if thinking_enabled is None + else thinking_enabled + ) + request_enabled = True + if thinking is not None: + if "enabled" in thinking.model_fields_set and thinking.enabled is not None: + request_enabled = thinking.enabled + if thinking.type == "disabled": + request_enabled = False + return config_enabled and request_enabled + + @abstractmethod + def preflight_stream( + self, request: MessagesRequest, *, thinking_enabled: bool | None = None + ) -> None: + """Validate the upstream request before opening an SSE stream.""" + + def _log_stream_transport_error( + self, + tag: str, + req_tag: str, + error: Exception, + *, + request_id: str | None = None, + ) -> None: + """Log streaming transport failures (metadata-only unless verbose is enabled).""" + response = getattr(error, "response", None) + http_status = ( + getattr(response, "status_code", None) if response is not None else None + ) + cause_types = exception_cause_types(error) + trace_event( + stage="provider", + event="provider.response.transport_error", + source="provider", + provider=tag, + request_id=request_id, + exc_type=type(error).__name__, + http_status=http_status, + cause_types=cause_types, + ) + + if self._config.log_api_error_tracebacks: + logger.error( + "{}_ERROR:{} exc_type={}\n{}", + tag, + req_tag, + type(error).__name__, + redacted_exception_traceback(error), + ) + return + logger.error( + "{}_ERROR:{} exc_type={} http_status={} cause_types={}", + tag, + req_tag, + type(error).__name__, + http_status, + ",".join(cause_types) if cause_types else None, + ) + + @abstractmethod + async def cleanup(self) -> None: + """Release any resources held by this provider.""" + + @abstractmethod + async def list_model_ids(self) -> frozenset[str]: + """Return the model ids currently advertised by this provider.""" + + async def list_model_infos(self) -> frozenset[ProviderModelInfo]: + """Return advertised model ids with optional provider capability metadata.""" + return model_infos_from_ids(await self.list_model_ids()) + + @abstractmethod + def stream_response( + self, + request: MessagesRequest, + input_tokens: int = 0, + *, + request_id: str | None = None, + thinking_enabled: bool | None = None, + ) -> AsyncIterator[str]: + """Stream response in Anthropic SSE format.""" diff --git a/src/free_claude_code/providers/cloudflare/__init__.py b/src/free_claude_code/providers/cloudflare/__init__.py new file mode 100644 index 0000000..ca87af6 --- /dev/null +++ b/src/free_claude_code/providers/cloudflare/__init__.py @@ -0,0 +1,8 @@ +"""Cloudflare AI REST provider package.""" + +from .client import CloudflareProvider, cloudflare_ai_base_url + +__all__ = ( + "CloudflareProvider", + "cloudflare_ai_base_url", +) diff --git a/src/free_claude_code/providers/cloudflare/client.py b/src/free_claude_code/providers/cloudflare/client.py new file mode 100644 index 0000000..d37920d --- /dev/null +++ b/src/free_claude_code/providers/cloudflare/client.py @@ -0,0 +1,168 @@ +"""Cloudflare Workers AI provider using OpenAI-compatible chat completions.""" + +from collections.abc import Iterator, Mapping +from dataclasses import replace +from typing import Any +from urllib.parse import quote + +import httpx + +from free_claude_code.application.errors import ApplicationUnavailableError +from free_claude_code.application.model_metadata import ProviderModelInfo +from free_claude_code.config.provider_catalog import CLOUDFLARE_AI_REST_ROOT +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.http import maybe_await_aclose +from free_claude_code.providers.model_listing import ( + ModelListResponseError, + extract_openai_model_ids, + model_infos_from_ids, +) +from free_claude_code.providers.openai_chat import ( + OpenAIChatProfile, + OpenAIChatProvider, + OpenAIChatRequestPolicy, + build_openai_chat_request_body, +) +from free_claude_code.providers.rate_limit import ProviderRateLimiter + +_REQUEST_POLICY = OpenAIChatRequestPolicy( + provider_name="CLOUDFLARE", + include_extra_body=True, + max_tokens_field="max_completion_tokens", +) +_PROFILE = OpenAIChatProfile(_REQUEST_POLICY) + + +def cloudflare_ai_base_url(api_root: str | None, account_id: str) -> str: + """Return the account-scoped Cloudflare Workers AI OpenAI-compatible base URL.""" + + return f"{_cloudflare_account_api_url(api_root, account_id)}/ai/v1" + + +def _cloudflare_model_search_url(api_root: str | None, account_id: str) -> str: + """Return the Cloudflare account model-search endpoint URL.""" + + return f"{_cloudflare_account_api_url(api_root, account_id)}/ai/models/search" + + +def _cloudflare_account_api_url(api_root: str | None, account_id: str) -> str: + """Return the account-scoped Cloudflare API root URL.""" + + stripped_account = account_id.strip() + if not stripped_account: + raise ApplicationUnavailableError( + "CLOUDFLARE_ACCOUNT_ID is not set. Add it to your .env file." + ) + root = (api_root or CLOUDFLARE_AI_REST_ROOT).rstrip("/") + encoded_account = quote(stripped_account, safe="") + return f"{root}/accounts/{encoded_account}" + + +class CloudflareProvider(OpenAIChatProvider): + """Cloudflare Workers AI OpenAI-compatible chat provider.""" + + def __init__( + self, + config: ProviderConfig, + *, + account_id: str, + rate_limiter: ProviderRateLimiter, + ): + base_url = cloudflare_ai_base_url(config.base_url, account_id) + self._model_search_url = _cloudflare_model_search_url( + config.base_url, account_id + ) + self._model_list_client = httpx.AsyncClient( + proxy=config.proxy or None, + timeout=httpx.Timeout( + config.http_read_timeout, + connect=config.http_connect_timeout, + read=config.http_read_timeout, + write=config.http_write_timeout, + ), + ) + super().__init__( + replace(config, base_url=base_url), + profile=_PROFILE, + rate_limiter=rate_limiter, + ) + + async def cleanup(self) -> None: + """Release provider client resources.""" + await super().cleanup() + await self._model_list_client.aclose() + + async def list_model_ids(self) -> frozenset[str]: + """Return Cloudflare Workers AI model ids from account model search.""" + return frozenset(info.model_id for info in await self.list_model_infos()) + + async def list_model_infos(self) -> frozenset[ProviderModelInfo]: + """Return Cloudflare Workers AI model ids from account model search.""" + response = await self._model_list_client.get( + self._model_search_url, + params={"format": "openrouter"}, + headers=self._model_list_headers(), + ) + try: + response.raise_for_status() + try: + payload = response.json() + except ValueError as exc: + raise ModelListResponseError( + "CLOUDFLARE model-list response is malformed: invalid JSON" + ) from exc + return model_infos_from_ids( + extract_openai_model_ids(payload, provider_name="CLOUDFLARE") + ) + finally: + await maybe_await_aclose(response) + + def _build_request_body( + self, request: MessagesRequest, thinking_enabled: bool | None = None + ) -> dict: + return build_openai_chat_request_body( + request, + thinking_enabled=self._is_thinking_enabled(request, thinking_enabled), + policy=_REQUEST_POLICY, + postprocessors=(_apply_cloudflare_request_quirks,), + ) + + def _handle_extra_reasoning( + self, delta: Any, ledger: Any, *, thinking_enabled: bool + ) -> Iterator[str]: + """Map Cloudflare's ``reasoning`` delta field to Anthropic thinking.""" + reasoning = _cloudflare_reasoning(delta) + if not thinking_enabled or not reasoning: + return + yield from ledger.ensure_thinking_block() + yield ledger.emit_thinking_delta(reasoning) + + def _model_list_headers(self) -> dict[str, str]: + return {"Authorization": f"Bearer {self._api_key}"} + + +def _apply_cloudflare_request_quirks( + body: dict[str, Any], _request: MessagesRequest, thinking_enabled: bool +) -> None: + """Attach Cloudflare Workers AI chat-template thinking control.""" + extra_body = body.setdefault("extra_body", {}) + if not isinstance(extra_body, dict): + return + chat_template_kwargs = extra_body.setdefault("chat_template_kwargs", {}) + if isinstance(chat_template_kwargs, dict): + chat_template_kwargs.setdefault("thinking", thinking_enabled) + + +def _cloudflare_reasoning(delta: Any) -> str | None: + reasoning = getattr(delta, "reasoning", None) + if isinstance(reasoning, str) and reasoning: + return reasoning + + model_extra = getattr(delta, "model_extra", None) + if isinstance(model_extra, Mapping): + reasoning = model_extra.get("reasoning") + if isinstance(reasoning, str) and reasoning: + return reasoning + + return None diff --git a/src/free_claude_code/providers/deepseek/__init__.py b/src/free_claude_code/providers/deepseek/__init__.py new file mode 100644 index 0000000..7a1e886 --- /dev/null +++ b/src/free_claude_code/providers/deepseek/__init__.py @@ -0,0 +1,5 @@ +"""DeepSeek provider exports.""" + +from .client import DeepSeekProvider + +__all__ = ["DeepSeekProvider"] diff --git a/src/free_claude_code/providers/deepseek/client.py b/src/free_claude_code/providers/deepseek/client.py new file mode 100644 index 0000000..dfaa211 --- /dev/null +++ b/src/free_claude_code/providers/deepseek/client.py @@ -0,0 +1,46 @@ +"""DeepSeek provider implementation (OpenAI-compatible Chat Completions).""" + +from typing import Any + +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.openai_chat import ( + OpenAIChatProfile, + OpenAIChatProvider, + OpenAIChatRequestPolicy, + usage_int, +) +from free_claude_code.providers.rate_limit import ProviderRateLimiter + +from .compat import build_deepseek_request_body + +_PROFILE = OpenAIChatProfile(OpenAIChatRequestPolicy(provider_name="DEEPSEEK")) + + +class DeepSeekProvider(OpenAIChatProvider): + """DeepSeek using ``https://api.deepseek.com`` Chat Completions.""" + + def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter): + super().__init__( + config, + profile=_PROFILE, + rate_limiter=rate_limiter, + ) + + def _build_request_body( + self, request: MessagesRequest, thinking_enabled: bool | None = None + ) -> dict: + return build_deepseek_request_body( + request, + thinking_enabled=self._is_thinking_enabled(request, thinking_enabled), + ) + + def _anthropic_usage_fields(self, usage_info: Any) -> dict[str, int]: + usage_fields: dict[str, int] = {} + cache_hit_tokens = usage_int(usage_info, "prompt_cache_hit_tokens") + if cache_hit_tokens is not None: + usage_fields["cache_read_input_tokens"] = cache_hit_tokens + cache_miss_tokens = usage_int(usage_info, "prompt_cache_miss_tokens") + if cache_miss_tokens is not None: + usage_fields["cache_creation_input_tokens"] = cache_miss_tokens + return usage_fields diff --git a/src/free_claude_code/providers/deepseek/compat.py b/src/free_claude_code/providers/deepseek/compat.py new file mode 100644 index 0000000..a6248a8 --- /dev/null +++ b/src/free_claude_code/providers/deepseek/compat.py @@ -0,0 +1,432 @@ +"""DeepSeek Anthropic-to-OpenAI chat request policy.""" + +from collections.abc import Mapping +from typing import Any + +from loguru import logger + +from free_claude_code.application.errors import InvalidRequestError +from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS +from free_claude_code.core.anthropic import ( + dump_messages_request, + serialize_tool_result_content, +) +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.providers.openai_chat import ( + OpenAIChatRequestPolicy, + build_openai_chat_request_body, +) + +_REQUEST_POLICY = OpenAIChatRequestPolicy( + provider_name="DEEPSEEK", + include_extra_body=True, +) + +_UNSUPPORTED_MESSAGE_BLOCK_TYPES = frozenset( + { + "image", + "document", + "server_tool_use", + "web_search_tool_result", + "web_fetch_tool_result", + } +) +_STRIPPABLE_MESSAGE_BLOCK_TYPES = frozenset({"image", "document"}) +_OMITTED_ATTACHMENT_TEXT = ( + "[attachment omitted: DeepSeek does not support image or document inputs]" +) +_OMITTED_ATTACHMENT_BLOCK = {"type": "text", "text": _OMITTED_ATTACHMENT_TEXT} + + +def build_deepseek_request_body( + request_data: MessagesRequest, *, thinking_enabled: bool +) -> dict: + """Build a DeepSeek Chat Completions body from an Anthropic request.""" + logger.debug( + "DEEPSEEK_REQUEST: chat build model={} msgs={}", + request_data.model, + len(request_data.messages), + ) + + data = dump_messages_request(request_data) + if "messages" in data: + data["messages"] = _strip_unsupported_attachment_blocks(data["messages"]) + _validate_deepseek_request_dict(data) + _downgrade_forced_tool_choice(data) + + has_tool_history = _has_tool_history(data) + has_replayable_tool_thinking = _has_replayable_tool_thinking(data) + unsafe_tool_followup = has_tool_history and not has_replayable_tool_thinking + effective_thinking_enabled = thinking_enabled and not unsafe_tool_followup + if thinking_enabled: + if unsafe_tool_followup: + logger.debug( + "DEEPSEEK_REQUEST: disabling thinking for tool follow-up without " + "replayable thinking model={} msgs={} tools={}", + data.get("model"), + len(data.get("messages", [])), + len(data.get("tools", [])), + ) + _remove_deepseek_thinking_hints(data) + elif has_tool_history: + logger.debug( + "DEEPSEEK_REQUEST: keeping thinking for tool follow-up with " + "replayable thinking model={} msgs={} tools={}", + data.get("model"), + len(data.get("messages", [])), + len(data.get("tools", [])), + ) + elif data.get("tools") or data.get("tool_choice"): + logger.debug( + "DEEPSEEK_REQUEST: keeping thinking for initial tool request " + "model={} msgs={} tools={}", + data.get("model"), + len(data.get("messages", [])), + len(data.get("tools", [])), + ) + + if "messages" in data: + data["messages"] = _normalize_tool_result_content( + sanitize_deepseek_messages_for_openai( + data["messages"], + thinking_enabled=effective_thinking_enabled, + ) + ) + + sanitized_request = MessagesRequest.model_validate(data) + body = build_openai_chat_request_body( + sanitized_request, + thinking_enabled=effective_thinking_enabled, + policy=_REQUEST_POLICY, + postprocessors=(_apply_deepseek_chat_extras,), + ) + if "max_tokens" not in body or body.get("max_tokens") is None: + body["max_tokens"] = ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS + + logger.debug( + "DEEPSEEK_REQUEST: build done model={} msgs={} tools={}", + body.get("model"), + len(body.get("messages", [])), + len(body.get("tools", [])), + ) + return body + + +def sanitize_deepseek_messages_for_openai( + messages: Any, *, thinking_enabled: bool +) -> Any: + """Filter assistant content before converting to DeepSeek Chat Completions.""" + if not isinstance(messages, list): + return messages + + sanitized: list[Any] = [] + for message in messages: + if not isinstance(message, dict): + sanitized.append(message) + continue + if message.get("role") != "assistant": + sanitized.append(message) + continue + content = message.get("content") + if not isinstance(content, list): + sanitized.append(message) + continue + + if not thinking_enabled: + filtered = [ + block + for block in content + if not ( + isinstance(block, dict) + and block.get("type") in ("thinking", "redacted_thinking") + ) + ] + else: + filtered = [ + block + for block in content + if not ( + isinstance(block, dict) and block.get("type") == "redacted_thinking" + ) + ] + new_msg = dict(message) + new_msg["content"] = filtered or "" + sanitized.append(new_msg) + return sanitized + + +def _strip_unsupported_attachment_blocks(messages: Any) -> Any: + if not isinstance(messages, list): + return messages + + stripped: list[Any] = [] + top_level_dropped: dict[str, int] = {} + nested_dropped: dict[str, int] = {} + placeholder_replacements = 0 + + for message in messages: + if not isinstance(message, dict): + stripped.append(message) + continue + content = message.get("content") + if not isinstance(content, list): + stripped.append(message) + continue + + new_content: list[Any] = [] + message_dropped_attachment = False + for block in content: + if isinstance(block, dict): + btype = block.get("type") + if btype in _STRIPPABLE_MESSAGE_BLOCK_TYPES: + top_level_dropped[btype] = top_level_dropped.get(btype, 0) + 1 + message_dropped_attachment = True + continue + if btype == "tool_result": + inner = block.get("content") + if isinstance(inner, list): + filtered_inner: list[Any] = [] + for sub in inner: + if ( + isinstance(sub, dict) + and sub.get("type") in _STRIPPABLE_MESSAGE_BLOCK_TYPES + ): + sub_type = sub["type"] + nested_dropped[sub_type] = ( + nested_dropped.get(sub_type, 0) + 1 + ) + continue + filtered_inner.append(sub) + if not filtered_inner: + filtered_inner = [_OMITTED_ATTACHMENT_BLOCK] + placeholder_replacements += 1 + new_block = dict(block) + new_block["content"] = filtered_inner + new_content.append(new_block) + continue + new_content.append(block) + if not new_content and message_dropped_attachment: + new_content = [_OMITTED_ATTACHMENT_BLOCK] + placeholder_replacements += 1 + new_msg = dict(message) + new_msg["content"] = new_content + stripped.append(new_msg) + + if top_level_dropped or nested_dropped: + logger.warning( + "DEEPSEEK_REQUEST: stripped unsupported attachment blocks " + "(top_level={} nested_in_tool_result={} placeholder_tool_results={}). " + "DeepSeek has no vision/document support; the model will not see this content.", + dict(top_level_dropped), + dict(nested_dropped), + placeholder_replacements, + ) + return stripped + + +def _is_server_listed_tool(tool: Mapping[str, Any]) -> bool: + name = (tool.get("name") or "").strip() + if name in ("web_search", "web_fetch"): + return True + typ = tool.get("type") + if isinstance(typ, str): + return typ.startswith("web_search") or typ.startswith("web_fetch") + return False + + +def _walk_block_list_for_unsupported(blocks: Any, *, where: str) -> None: + if not isinstance(blocks, list): + return + for block in blocks: + if not isinstance(block, dict): + continue + btype = block.get("type") + if btype in _UNSUPPORTED_MESSAGE_BLOCK_TYPES: + raise InvalidRequestError( + f"DeepSeek native does not support {btype!r} blocks ({where})." + ) + if btype == "tool_result" and "content" in block: + _walk_block_list_for_unsupported( + block["content"], where=f"{where} (tool_result content)" + ) + + +def _validate_deepseek_request_dict(data: dict[str, Any]) -> None: + mcp = data.get("mcp_servers") + if mcp: + raise InvalidRequestError("DeepSeek does not support mcp_servers on requests.") + + for tool in data.get("tools") or (): + if not isinstance(tool, dict): + continue + if _is_server_listed_tool(tool): + raise InvalidRequestError( + "DeepSeek does not support listed Anthropic server tools " + "(web_search / web_fetch). Remove them or use a different provider." + ) + + for i, message in enumerate(data.get("messages") or ()): + if not isinstance(message, dict): + continue + content = message.get("content") + if isinstance(content, list): + _walk_block_list_for_unsupported(content, where=f"messages[{i}].content") + + system = data.get("system") + if isinstance(system, list): + _walk_block_list_for_unsupported(system, where="system") + + +def _has_tool_history_blocks(message: Mapping[str, Any]) -> bool: + role = message.get("role") + content = message.get("content") + if not isinstance(content, list): + return False + + for block in content: + if not isinstance(block, dict): + continue + btype = block.get("type") + if role == "assistant" and btype == "tool_use": + return True + if role == "user" and btype == "tool_result": + return True + return False + + +def _has_replayable_thinking_before_tool_use(message: Mapping[str, Any]) -> bool: + if message.get("role") != "assistant": + return False + content = message.get("content") + if not isinstance(content, list): + return False + + has_thinking = isinstance(message.get("reasoning_content"), str) + for block in content: + if not isinstance(block, dict): + continue + btype = block.get("type") + if btype == "thinking" and isinstance(block.get("thinking"), str): + has_thinking = True + continue + if btype == "tool_use": + return has_thinking + return False + + +def _has_tool_history(data: dict[str, Any]) -> bool: + for message in data.get("messages") or (): + if isinstance(message, Mapping) and _has_tool_history_blocks(message): + return True + return False + + +def _has_replayable_tool_thinking(data: dict[str, Any]) -> bool: + for message in data.get("messages") or (): + if isinstance(message, Mapping) and _has_replayable_thinking_before_tool_use( + message + ): + return True + return False + + +def _remove_deepseek_thinking_hints(data: dict[str, Any]) -> None: + output_config = data.get("output_config") + if isinstance(output_config, dict) and "effort" in output_config: + cleaned_output_config = dict(output_config) + cleaned_output_config.pop("effort", None) + if cleaned_output_config: + data["output_config"] = cleaned_output_config + else: + data.pop("output_config", None) + + context_management = data.get("context_management") + if not isinstance(context_management, dict): + return + edits = context_management.get("edits") + if not isinstance(edits, list): + return + filtered_edits = [ + edit + for edit in edits + if not ( + isinstance(edit, dict) + and isinstance(edit.get("type"), str) + and edit["type"].startswith("clear_thinking_") + ) + ] + if len(filtered_edits) == len(edits): + return + cleaned_context_management = dict(context_management) + if filtered_edits: + cleaned_context_management["edits"] = filtered_edits + data["context_management"] = cleaned_context_management + else: + cleaned_context_management.pop("edits", None) + if cleaned_context_management: + data["context_management"] = cleaned_context_management + else: + data.pop("context_management", None) + + +def _normalize_tool_result_content(messages: Any) -> Any: + if not isinstance(messages, list): + return messages + + normalized: list[Any] = [] + for message in messages: + if not isinstance(message, dict): + normalized.append(message) + continue + + content = message.get("content") + if not isinstance(content, list): + normalized.append(message) + continue + + new_content: list[Any] = [] + for block in content: + if not isinstance(block, dict): + new_content.append(block) + continue + + if block.get("type") == "tool_result": + normalized_block = dict(block) + normalized_block["content"] = serialize_tool_result_content( + block.get("content") + ) + new_content.append(normalized_block) + else: + new_content.append(block) + + new_msg = dict(message) + new_msg["content"] = new_content + normalized.append(new_msg) + + return normalized + + +def _downgrade_forced_tool_choice(data: dict[str, Any]) -> None: + tool_choice = data.get("tool_choice") + if not isinstance(tool_choice, dict): + return + if tool_choice.get("type") != "tool" or not isinstance( + tool_choice.get("name"), str + ): + return + logger.debug( + "DEEPSEEK_REQUEST: downgrading forced tool_choice to auto for unsupported " + "native request shape tool={}", + tool_choice["name"], + ) + data["tool_choice"] = {"type": "auto"} + + +def _apply_deepseek_chat_extras( + body: dict[str, Any], _request_data: MessagesRequest, thinking_enabled: bool +) -> None: + if not thinking_enabled or body.get("model") == "deepseek-reasoner": + return + extra_body = body.setdefault("extra_body", {}) + if isinstance(extra_body, dict): + extra_body.setdefault("thinking", {"type": "enabled"}) diff --git a/src/free_claude_code/providers/failure_policy.py b/src/free_claude_code/providers/failure_policy.py new file mode 100644 index 0000000..f65fb5f --- /dev/null +++ b/src/free_claude_code/providers/failure_policy.py @@ -0,0 +1,393 @@ +"""Provider-owned SDK classification and retry qualification.""" + +import json +from collections.abc import Callable, Mapping +from contextlib import suppress +from dataclasses import replace +from typing import Any + +import httpx +import openai + +from free_claude_code.core.diagnostics import ( + extract_upstream_error_detail, + format_execution_failure_message, + safe_exception_message, +) +from free_claude_code.core.failures import ExecutionFailure, FailureKind + +MarkRateLimited = Callable[[float], None] +ProviderFailureOverride = Callable[[Exception], ExecutionFailure | None] + +_RATE_LIMIT_MARKERS = frozenset({"rate_limit", "rate limit", "too many requests"}) +_OVERLOAD_MARKERS = frozenset( + { + "resourceexhausted", + "resource exhausted", + "limit reached", + "overloaded", + "capacity", + } +) +_INTERNAL_ERROR_MARKERS = frozenset({"internal_server_error", "internal server error"}) +_AUTHENTICATION_MESSAGE = "Provider authentication failed. Check API key." +_RATE_LIMIT_MESSAGE = "Provider rate limit reached. Please retry shortly." +_INVALID_REQUEST_MESSAGE = "Invalid request sent to provider." +_OVERLOADED_MESSAGE = "Provider is currently overloaded. Please retry." + + +def classify_provider_failure( + exc: Exception, + *, + provider_name: str, + read_timeout_s: float | None, + request_id: str | None, + mark_rate_limited: MarkRateLimited, + provider_failure_override: ProviderFailureOverride | None = None, +) -> ExecutionFailure: + """Return one detailed canonical failure after provider retries are exhausted.""" + if isinstance(exc, ExecutionFailure): + failure = exc + message = failure.message + request_id_line = f"Request ID: {request_id}" if request_id else None + if request_id_line and request_id_line not in message: + message = f"{message}\n\n{request_id_line}" + return replace(failure, message=message) + + failure = ( + provider_failure_override(exc) + if provider_failure_override is not None + else None + ) + if failure is None: + failure = _classify_provider_failure( + exc, + read_timeout_s=read_timeout_s, + mark_rate_limited=mark_rate_limited, + ) + message = format_execution_failure_message( + failure, + extract_upstream_error_detail(exc), + upstream_name=provider_name, + request_id=request_id, + ) + return replace(failure, message=message) + + +def overloaded_provider_failure() -> ExecutionFailure: + """Return the canonical provider-overload meaning and stable wording.""" + return _failure(FailureKind.OVERLOADED, 529, _OVERLOADED_MESSAGE, True) + + +def retryable_transient_status(exc: BaseException) -> int | None: + """Infer a retryable HTTP-like status from one upstream exception.""" + if isinstance(exc, ExecutionFailure): + status = exc.status_code + return status if exc.retryable and _is_retryable_status(status) else None + if isinstance(exc, openai.RateLimitError): + return 429 + if isinstance(exc, httpx.HTTPStatusError): + status = exc.response.status_code + return status if _is_retryable_status(status) else None + + status = _status_from_exception(exc) + if _is_retryable_status(status): + return status + + body_status = _status_from_body(getattr(exc, "body", None)) + if _is_retryable_status(body_status): + return body_status + + text = transient_error_text(exc) + if _has_marker(text, _RATE_LIMIT_MARKERS): + return 429 + if _has_marker(text, _OVERLOAD_MARKERS): + return 503 + if _has_marker(text, _INTERNAL_ERROR_MARKERS): + return 500 + return None + + +def is_transient_overload_error(exc: BaseException) -> bool: + """Return whether an upstream exception reports overload or capacity pressure.""" + if isinstance(exc, ExecutionFailure): + return exc.kind == FailureKind.OVERLOADED + return _has_marker(transient_error_text(exc), _OVERLOAD_MARKERS) + + +def transient_error_text(exc: BaseException) -> str: + """Combine exception, body, and response text for provider classification.""" + parts = [str(exc)] + body = getattr(exc, "body", None) + if body is not None: + parts.append(_body_to_text(body)) + response = getattr(exc, "response", None) + if response is not None: + with suppress(Exception): + parts.append(response.text) + return " ".join(part for part in parts if part).lower() + + +def is_retryable_provider_error(exc: BaseException) -> bool: + """Return whether provider policy permits stream retry or recovery.""" + if isinstance(exc, ExecutionFailure): + return exc.retryable + if isinstance(exc, openai.AuthenticationError | openai.BadRequestError): + return False + if retryable_transient_status(exc) is not None: + return True + return isinstance( + exc, + ( + TimeoutError, + httpx.TimeoutException, + httpx.ConnectError, + httpx.ReadError, + httpx.WriteError, + httpx.RemoteProtocolError, + httpx.NetworkError, + openai.APITimeoutError, + openai.APIConnectionError, + ), + ) + + +def retryable_upstream_status(exc: BaseException) -> int | None: + """Return a status eligible for provider-opening backoff.""" + status = retryable_transient_status(exc) + return status if status is not None and _is_retryable_status(status) else None + + +def retryable_upstream_transport_error(exc: BaseException) -> bool: + """Return whether a pre-response transport failure can be retried.""" + if isinstance(exc, ExecutionFailure): + return exc.retryable and retryable_transient_status(exc) is None + if isinstance(exc, openai.AuthenticationError | openai.BadRequestError): + return False + return isinstance( + exc, + ( + TimeoutError, + httpx.TimeoutException, + httpx.ConnectError, + httpx.ReadError, + httpx.WriteError, + httpx.RemoteProtocolError, + httpx.NetworkError, + openai.APITimeoutError, + openai.APIConnectionError, + ), + ) + + +def provider_error_message( + exc: BaseException, + *, + read_timeout_s: float | None = None, +) -> str: + """Map raw provider exception types to stable customer-facing wording.""" + if isinstance(exc, ExecutionFailure): + return exc.message + if isinstance(exc, 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(exc, httpx.ConnectTimeout | httpx.ConnectError): + return "Could not connect to provider." + if isinstance(exc, httpx.RemoteProtocolError): + return "Provider connection was interrupted before a response was received." + if isinstance(exc, 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(exc, openai.RateLimitError): + return _RATE_LIMIT_MESSAGE + if isinstance(exc, openai.AuthenticationError): + return _AUTHENTICATION_MESSAGE + if isinstance(exc, openai.BadRequestError): + return _INVALID_REQUEST_MESSAGE + return safe_exception_message(exc) + + +def _classify_provider_failure( + exc: Exception, + *, + read_timeout_s: float | None, + mark_rate_limited: MarkRateLimited, +) -> ExecutionFailure: + if isinstance(exc, ExecutionFailure): + if exc.kind == FailureKind.RATE_LIMIT: + mark_rate_limited(60) + return exc + + if isinstance(exc, openai.AuthenticationError): + return _failure(FailureKind.AUTHENTICATION, 401, _AUTHENTICATION_MESSAGE, False) + if isinstance(exc, openai.RateLimitError): + mark_rate_limited(60) + return _failure(FailureKind.RATE_LIMIT, 429, _RATE_LIMIT_MESSAGE, True) + if isinstance(exc, openai.BadRequestError): + return _failure( + FailureKind.INVALID_REQUEST, 400, _INVALID_REQUEST_MESSAGE, False + ) + if isinstance(exc, openai.APITimeoutError): + return _failure(FailureKind.TIMEOUT, 500, _stable_upstream(500), True) + if isinstance(exc, openai.APIConnectionError): + return _failure(FailureKind.UNAVAILABLE, 500, _stable_upstream(500), True) + if isinstance(exc, openai.InternalServerError): + status = retryable_transient_status(exc) or getattr(exc, "status_code", None) + if is_transient_overload_error(exc): + return overloaded_provider_failure() + if isinstance(status, int) and 500 <= status <= 599: + return _failure( + FailureKind.UPSTREAM, + status, + _stable_upstream(status), + True, + ) + return _failure(FailureKind.UPSTREAM, 500, _stable_upstream(500), True) + if isinstance(exc, openai.APIError): + status = retryable_transient_status(exc) + if status == 429: + mark_rate_limited(60) + return _failure(FailureKind.RATE_LIMIT, 429, _RATE_LIMIT_MESSAGE, True) + if is_transient_overload_error(exc): + return overloaded_provider_failure() + effective_status = status or getattr(exc, "status_code", None) + if not isinstance(effective_status, int): + effective_status = 500 + return _failure( + FailureKind.UPSTREAM, + effective_status, + _stable_upstream(effective_status), + is_retryable_provider_error(exc), + ) + + if isinstance(exc, httpx.HTTPStatusError): + status = exc.response.status_code + if status in (401, 403): + return _failure( + FailureKind.AUTHENTICATION, 401, _AUTHENTICATION_MESSAGE, False + ) + if status == 429: + mark_rate_limited(60) + return _failure(FailureKind.RATE_LIMIT, 429, _RATE_LIMIT_MESSAGE, True) + if status == 400: + return _failure( + FailureKind.INVALID_REQUEST, 400, _INVALID_REQUEST_MESSAGE, False + ) + if status in (502, 503, 504): + return overloaded_provider_failure() + return _failure( + FailureKind.UPSTREAM, + status, + _stable_upstream(status), + _is_retryable_status(status), + ) + + kind = FailureKind.UPSTREAM + if isinstance(exc, TimeoutError | httpx.TimeoutException): + kind = FailureKind.TIMEOUT + elif isinstance(exc, httpx.ConnectError | httpx.NetworkError): + kind = FailureKind.UNAVAILABLE + return _failure( + kind, + 502, + provider_error_message(exc, read_timeout_s=read_timeout_s), + is_retryable_provider_error(exc), + ) + + +def _failure( + kind: FailureKind, + status_code: int, + message: str, + retryable: bool, +) -> ExecutionFailure: + return ExecutionFailure( + kind=kind, + status_code=status_code, + message=message, + retryable=retryable, + ) + + +def _stable_upstream(status_code: int) -> str: + if status_code in (502, 503, 504): + return "Provider is temporarily unavailable. Please retry." + return "Provider API request failed." + + +def _status_from_exception(exc: BaseException) -> int | None: + status = getattr(exc, "status_code", None) + return status if isinstance(status, int) else None + + +def _status_from_body(body: Any) -> int | None: + for item in _body_candidates(body): + if not isinstance(item, Mapping): + continue + for key in ("status", "status_code", "code"): + status = _coerce_status(item.get(key)) + if status is not None: + return status + type_status = _status_from_type_fields(item) + if type_status is not None: + return type_status + return None + + +def _body_candidates(body: Any) -> tuple[Any, ...]: + if isinstance(body, str): + try: + return _body_candidates(json.loads(body)) + except ValueError: + return (body,) + if isinstance(body, bytes): + return _body_candidates(body.decode("utf-8", errors="replace")) + if isinstance(body, Mapping): + nested = body.get("error") + return (body, nested) if isinstance(nested, Mapping) else (body,) + return (body,) + + +def _coerce_status(value: Any) -> int | None: + if isinstance(value, int): + return value + if isinstance(value, str) and value.isdigit(): + return int(value) + return None + + +def _status_from_type_fields(item: Mapping[str, Any]) -> int | None: + values = [ + value.lower() + for key in ("type", "code") + if isinstance((value := item.get(key)), str) + ] + text = " ".join(values) + if _has_marker(text, _RATE_LIMIT_MARKERS): + return 429 + if _has_marker(text, _OVERLOAD_MARKERS): + return 503 + if _has_marker(text, _INTERNAL_ERROR_MARKERS): + return 500 + return None + + +def _body_to_text(body: Any) -> str: + if isinstance(body, bytes): + return body.decode("utf-8", errors="replace") + if isinstance(body, str): + return body + try: + return json.dumps(body, ensure_ascii=False, separators=(",", ":")) + except TypeError: + return str(body) + + +def _has_marker(text: str, markers: frozenset[str]) -> bool: + return any(marker in text for marker in markers) + + +def _is_retryable_status(status: int | None) -> bool: + return isinstance(status, int) and (status == 429 or 500 <= status <= 599) diff --git a/src/free_claude_code/providers/gemini/__init__.py b/src/free_claude_code/providers/gemini/__init__.py new file mode 100644 index 0000000..2e0bd84 --- /dev/null +++ b/src/free_claude_code/providers/gemini/__init__.py @@ -0,0 +1,5 @@ +"""Google AI Studio Gemini (OpenAI-compat) adapter.""" + +from .client import GeminiProvider + +__all__ = ["GeminiProvider"] diff --git a/src/free_claude_code/providers/gemini/client.py b/src/free_claude_code/providers/gemini/client.py new file mode 100644 index 0000000..b5874e9 --- /dev/null +++ b/src/free_claude_code/providers/gemini/client.py @@ -0,0 +1,62 @@ +"""Google AI Studio Gemini provider (OpenAI-compatible chat completions).""" + +from copy import deepcopy +from typing import Any + +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.openai_chat import ( + OpenAIChatProfile, + OpenAIChatProvider, + OpenAIChatRequestPolicy, + build_openai_chat_request_body, +) +from free_claude_code.providers.rate_limit import ProviderRateLimiter + +from .quirks import apply_gemini_request_quirks + +_MAX_TOOL_CALL_EXTRA_CONTENT_CACHE = 4096 +_REQUEST_POLICY = OpenAIChatRequestPolicy(provider_name="GEMINI") +_PROFILE = OpenAIChatProfile(_REQUEST_POLICY) + + +class GeminiProvider(OpenAIChatProvider): + """Gemini API using ``https://generativelanguage.googleapis.com/v1beta/openai/``.""" + + def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter): + super().__init__( + config, + profile=_PROFILE, + rate_limiter=rate_limiter, + ) + self._tool_call_extra_content_by_id: dict[str, dict[str, Any]] = {} + + def _record_tool_call_extra_content( + self, tool_call_id: str, extra_content: dict[str, Any] + ) -> None: + if ( + tool_call_id not in self._tool_call_extra_content_by_id + and len(self._tool_call_extra_content_by_id) + >= _MAX_TOOL_CALL_EXTRA_CONTENT_CACHE + ): + self._tool_call_extra_content_by_id.pop( + next(iter(self._tool_call_extra_content_by_id)) + ) + self._tool_call_extra_content_by_id[tool_call_id] = deepcopy(extra_content) + + def _build_request_body( + self, request: MessagesRequest, thinking_enabled: bool | None = None + ) -> dict: + return build_openai_chat_request_body( + request, + thinking_enabled=self._is_thinking_enabled(request, thinking_enabled), + policy=_REQUEST_POLICY, + postprocessors=( + lambda body, request_data, enabled: apply_gemini_request_quirks( + body, + request_data, + enabled, + tool_call_extra_content_by_id=self._tool_call_extra_content_by_id, + ), + ), + ) diff --git a/src/free_claude_code/providers/gemini/quirks.py b/src/free_claude_code/providers/gemini/quirks.py new file mode 100644 index 0000000..ca5c16b --- /dev/null +++ b/src/free_claude_code/providers/gemini/quirks.py @@ -0,0 +1,171 @@ +"""Gemini request-body quirks for the shared OpenAI-chat provider.""" + +from copy import deepcopy +from typing import Any, cast + +from free_claude_code.core.anthropic.models import MessagesRequest + +GEMINI_SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator" + + +def apply_gemini_request_quirks( + body: dict[str, Any], + request_data: MessagesRequest, + thinking_enabled: bool, + *, + tool_call_extra_content_by_id: dict[str, dict[str, Any]] | None = None, +) -> None: + """Apply Google-specific request extensions after common OpenAI conversion.""" + extra_body: dict[str, Any] = {} + request_extra = request_data.extra_body + if isinstance(request_extra, dict): + extra_body.update(deepcopy(request_extra)) + + if thinking_enabled: + _apply_thinking_config(extra_body) + else: + body["reasoning_effort"] = "none" + + if extra_body: + body["extra_body"] = extra_body + + _apply_gemini_tool_call_signatures( + body, + tool_call_extra_content_by_id=tool_call_extra_content_by_id, + ) + + +def _ensure_dict(container: dict[str, Any], key: str) -> dict[str, Any]: + value = container.get(key) + if isinstance(value, dict): + return cast(dict[str, Any], value) + nested: dict[str, Any] = {} + container[key] = nested + return nested + + +def _apply_thinking_config(extra_body: dict[str, Any]) -> None: + # OpenAI's SDK merges its ``extra_body`` argument into the request JSON. + # Google expects its extension fields under a literal JSON ``extra_body`` key. + literal_extra_body = _ensure_dict(extra_body, "extra_body") + google_section = _ensure_dict(literal_extra_body, "google") + thinking_cfg = _ensure_dict(google_section, "thinking_config") + thinking_cfg.setdefault("include_thoughts", True) + + +def _is_gemini_3_model(model: Any) -> bool: + return "gemini-3" in str(model).lower() + + +def _thought_signature_from_extra_content(extra_content: Any) -> str | None: + if not isinstance(extra_content, dict): + return None + google = extra_content.get("google") + if not isinstance(google, dict): + return None + signature = google.get("thought_signature") + return signature if isinstance(signature, str) and signature else None + + +def _tool_call_thought_signature(tool_call: dict[str, Any]) -> str | None: + return _thought_signature_from_extra_content(tool_call.get("extra_content")) + + +def _set_tool_call_thought_signature(tool_call: dict[str, Any], signature: str) -> None: + extra_content = tool_call.get("extra_content") + if not isinstance(extra_content, dict): + extra_content = {} + tool_call["extra_content"] = extra_content + google = extra_content.get("google") + if not isinstance(google, dict): + google = {} + extra_content["google"] = google + google["thought_signature"] = signature + + +def _message_has_standard_user_content(message: dict[str, Any]) -> bool: + if message.get("role") != "user": + return False + content = message.get("content") + if isinstance(content, str): + return bool(content.strip()) + if isinstance(content, list): + return any( + isinstance(part, dict) + and isinstance(part.get("text"), str) + and bool(part["text"].strip()) + for part in content + ) + return False + + +def _current_turn_start_index(messages: list[Any]) -> int: + for index in range(len(messages) - 1, -1, -1): + message = messages[index] + if isinstance(message, dict) and _message_has_standard_user_content(message): + return index + return -1 + + +def _apply_cached_tool_call_signatures( + messages: list[Any], tool_call_extra_content_by_id: dict[str, dict[str, Any]] +) -> None: + if not tool_call_extra_content_by_id: + return + for message in messages: + if not isinstance(message, dict) or message.get("role") != "assistant": + continue + tool_calls = message.get("tool_calls") + if not isinstance(tool_calls, list): + continue + for tool_call in tool_calls: + if not isinstance(tool_call, dict) or _tool_call_thought_signature( + tool_call + ): + continue + tool_call_id = tool_call.get("id") + if tool_call_id is None: + continue + cached_extra_content = tool_call_extra_content_by_id.get(str(tool_call_id)) + if not cached_extra_content: + continue + cached_signature = _thought_signature_from_extra_content( + cached_extra_content + ) + if cached_signature: + tool_call["extra_content"] = deepcopy(cached_extra_content) + + +def _apply_gemini_3_missing_current_turn_signatures( + body: dict[str, Any], messages: list[Any] +) -> None: + if not _is_gemini_3_model(body.get("model")): + return + + start_index = _current_turn_start_index(messages) + for message in messages[start_index + 1 :]: + if not isinstance(message, dict) or message.get("role") != "assistant": + continue + tool_calls = message.get("tool_calls") + if not isinstance(tool_calls, list) or not tool_calls: + continue + first_tool_call = tool_calls[0] + if not isinstance(first_tool_call, dict): + continue + if _tool_call_thought_signature(first_tool_call): + continue + _set_tool_call_thought_signature( + first_tool_call, GEMINI_SKIP_THOUGHT_SIGNATURE_VALIDATOR + ) + + +def _apply_gemini_tool_call_signatures( + body: dict[str, Any], + *, + tool_call_extra_content_by_id: dict[str, dict[str, Any]] | None, +) -> None: + messages = body.get("messages") + if not isinstance(messages, list): + return + _apply_cached_tool_call_signatures(messages, tool_call_extra_content_by_id or {}) + _apply_gemini_3_missing_current_turn_signatures(body, messages) diff --git a/src/free_claude_code/providers/github_models/__init__.py b/src/free_claude_code/providers/github_models/__init__.py new file mode 100644 index 0000000..d86c4d6 --- /dev/null +++ b/src/free_claude_code/providers/github_models/__init__.py @@ -0,0 +1,5 @@ +"""GitHub Models provider.""" + +from .client import GitHubModelsProvider + +__all__ = ["GitHubModelsProvider"] diff --git a/src/free_claude_code/providers/github_models/client.py b/src/free_claude_code/providers/github_models/client.py new file mode 100644 index 0000000..d7aab56 --- /dev/null +++ b/src/free_claude_code/providers/github_models/client.py @@ -0,0 +1,147 @@ +"""GitHub Models provider using OpenAI-compatible chat completions.""" + +from collections.abc import Mapping, Sequence +from typing import Any + +import httpx + +from free_claude_code.application.model_metadata import ProviderModelInfo +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.http import maybe_await_aclose +from free_claude_code.providers.model_listing import ( + ModelListResponseError, + model_infos_from_ids, +) +from free_claude_code.providers.openai_chat import ( + OpenAIChatProfile, + OpenAIChatProvider, + OpenAIChatRequestPolicy, + build_openai_chat_request_body, +) +from free_claude_code.providers.rate_limit import ProviderRateLimiter + +GITHUB_MODELS_CATALOG_URL = "https://models.github.ai/catalog/models" +GITHUB_MODELS_API_VERSION = "2026-03-10" + +_REQUEST_POLICY = OpenAIChatRequestPolicy( + provider_name="GITHUB_MODELS", +) +_PROFILE = OpenAIChatProfile(_REQUEST_POLICY) +_REQUIRED_MODEL_CAPABILITIES = frozenset({"streaming", "tool-calling"}) + + +class GitHubModelsProvider(OpenAIChatProvider): + """GitHub Models OpenAI-compatible inference provider.""" + + def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter): + self._catalog_url = GITHUB_MODELS_CATALOG_URL + self._model_list_client = httpx.AsyncClient( + proxy=config.proxy or None, + timeout=httpx.Timeout( + config.http_read_timeout, + connect=config.http_connect_timeout, + read=config.http_read_timeout, + write=config.http_write_timeout, + ), + ) + super().__init__( + config, + profile=_PROFILE, + rate_limiter=rate_limiter, + default_headers=_github_models_default_headers(), + ) + + async def cleanup(self) -> None: + """Release provider client resources.""" + await super().cleanup() + await self._model_list_client.aclose() + + async def list_model_ids(self) -> frozenset[str]: + """Return GitHub Models ids that support FCC's streaming tool workflow.""" + return frozenset(info.model_id for info in await self.list_model_infos()) + + async def list_model_infos(self) -> frozenset[ProviderModelInfo]: + """Return stream/tool-capable GitHub Models catalog ids.""" + response = await self._model_list_client.get( + self._catalog_url, + headers=self._model_list_headers(), + ) + try: + response.raise_for_status() + try: + payload = response.json() + except ValueError as exc: + raise ModelListResponseError( + "GITHUB_MODELS model-list response is malformed: invalid JSON" + ) from exc + return model_infos_from_ids( + _extract_supported_github_model_ids(payload), + ) + finally: + await maybe_await_aclose(response) + + def _build_request_body( + self, request: MessagesRequest, thinking_enabled: bool | None = None + ) -> dict: + return build_openai_chat_request_body( + request, + thinking_enabled=self._is_thinking_enabled(request, thinking_enabled), + policy=_REQUEST_POLICY, + ) + + def _model_list_headers(self) -> dict[str, str]: + return _github_models_api_headers(self._api_key) + + +def _github_models_default_headers() -> dict[str, str]: + return { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": GITHUB_MODELS_API_VERSION, + } + + +def _github_models_api_headers(api_key: str) -> dict[str, str]: + return { + **_github_models_default_headers(), + "Authorization": f"Bearer {api_key}", + } + + +def _extract_supported_github_model_ids(payload: Any) -> frozenset[str]: + """Extract stream/tool-capable model ids from GitHub's catalog array.""" + if not _is_sequence(payload): + raise ModelListResponseError( + "GITHUB_MODELS model-list response is malformed: expected top-level array" + ) + + model_ids: set[str] = set() + for item in payload: + if not isinstance(item, Mapping): + raise ModelListResponseError( + "GITHUB_MODELS model-list response is malformed: expected every item to be an object" + ) + model_id = item.get("id") + if not isinstance(model_id, str) or not model_id.strip(): + raise ModelListResponseError( + "GITHUB_MODELS model-list response is malformed: expected every item to include id" + ) + capabilities = item.get("capabilities") + if not _supports_streaming_tools(capabilities): + continue + model_ids.add(model_id) + + return frozenset(model_ids) + + +def _supports_streaming_tools(capabilities: Any) -> bool: + if not _is_sequence(capabilities): + return False + capability_names = {item for item in capabilities if isinstance(item, str)} + return capability_names >= _REQUIRED_MODEL_CAPABILITIES + + +def _is_sequence(value: Any) -> bool: + return isinstance(value, Sequence) and not isinstance( + value, str | bytes | bytearray + ) diff --git a/src/free_claude_code/providers/http.py b/src/free_claude_code/providers/http.py new file mode 100644 index 0000000..1a83abb --- /dev/null +++ b/src/free_claude_code/providers/http.py @@ -0,0 +1,51 @@ +"""Shared HTTP lifecycle helpers for upstream provider clients.""" + +import inspect +from typing import Any + +from loguru import logger + +from free_claude_code.core.trace import trace_event + + +async def maybe_await_aclose(response: Any) -> None: + """Call ``aclose`` on httpx-like responses; ignore sync test doubles.""" + close = getattr(response, "aclose", None) + if not callable(close): + return + result = close() + if inspect.isawaitable(result): + await result + + +async def close_provider_stream( + stream: Any, + *, + active_error: BaseException | None, + provider_name: str, + request_id: str | None, +) -> None: + """Close one stream without letting cleanup change its established outcome.""" + try: + await maybe_await_aclose(stream) + except Exception as close_error: + active_error_type = ( + type(active_error).__name__ if active_error is not None else None + ) + trace_event( + stage="provider", + event="provider.stream.close_failed", + source="provider", + provider=provider_name, + request_id=request_id, + close_exc_type=type(close_error).__name__, + preserved_exc_type=active_error_type, + ) + logger.warning( + "{}_STREAM_CLOSE_FAILED request_id={} close_exc_type={} " + "preserved_exc_type={}", + provider_name, + request_id, + type(close_error).__name__, + active_error_type, + ) diff --git a/src/free_claude_code/providers/lmstudio/__init__.py b/src/free_claude_code/providers/lmstudio/__init__.py new file mode 100644 index 0000000..2674a9c --- /dev/null +++ b/src/free_claude_code/providers/lmstudio/__init__.py @@ -0,0 +1,5 @@ +"""LM Studio provider - OpenAI-compatible chat completions API.""" + +from .client import LMStudioProvider + +__all__ = ["LMStudioProvider"] diff --git a/src/free_claude_code/providers/lmstudio/client.py b/src/free_claude_code/providers/lmstudio/client.py new file mode 100644 index 0000000..84f3a2f --- /dev/null +++ b/src/free_claude_code/providers/lmstudio/client.py @@ -0,0 +1,127 @@ +"""LM Studio provider implementation (OpenAI-compatible chat completions). + +Switched from LM Studio's native Anthropic Messages endpoint (2026-07-04): +the newer ``/v1/messages`` path renders Claude Code conversations through the +model's jinja chat template with strict role-alternation rules and a fragile +``[TOOL_CALLS]`` parser — observed leaking control tokens into tool names +(``[TOOL_CALLS]Read``) and dumping whole tool calls into text +(``Read[ARGS]{...}``), which ends agent runs silently. The OpenAI +``/v1/chat/completions`` path is LM Studio's mature parsing route, and fcc's +OpenAI provider layers its own tool-call assembly, think-tag parsing, and +heuristic recovery on top. +""" + +import time + +import httpx +from loguru import logger + +from free_claude_code.application.errors import InvalidRequestError +from free_claude_code.core.anthropic import ( + ReasoningReplayMode, + build_base_request_body, + get_token_count, +) +from free_claude_code.core.anthropic.conversion import OpenAIConversionError +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.openai_chat import ( + OpenAIChatProfile, + OpenAIChatProvider, + OpenAIChatRequestPolicy, +) +from free_claude_code.providers.rate_limit import ProviderRateLimiter + +_PROFILE = OpenAIChatProfile(OpenAIChatRequestPolicy(provider_name="LMSTUDIO")) + + +class LMStudioProvider(OpenAIChatProvider): + """LM Studio via its OpenAI-compatible chat completions endpoint.""" + + # LM Studio truncates the stream silently (no terminal event) when the + # prompt exceeds the loaded context. Refuse clearly over-budget prompts + # up front with the same "prompt is too long" invalid_request_error the + # real Anthropic API uses, so Claude Code can compact/retry instead of + # dying mid-stream. + _CONTEXT_CACHE_TTL_S = 30.0 + + def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter): + super().__init__( + config, + profile=_PROFILE, + rate_limiter=rate_limiter, + ) + self._loaded_context_cache: tuple[float, int | None] = (0.0, None) + + def _build_request_body( + self, request: MessagesRequest, thinking_enabled: bool | None = None + ) -> dict: + """Build an OpenAI chat body from the Anthropic request. + + Prior-turn thinking is never replayed: Mistral-family templates have + no assistant reasoning field, and replaying ```` text inflates + the local context for no benefit. New-response thinking still streams + back via ``reasoning_content``/```` parsing in the provider. + """ + try: + return build_base_request_body( + request, + reasoning_replay=ReasoningReplayMode.DISABLED, + ) + except OpenAIConversionError as exc: + raise InvalidRequestError(str(exc)) from exc + + def preflight_stream( + self, request: MessagesRequest, *, thinking_enabled: bool | None = None + ) -> None: + super().preflight_stream(request, thinking_enabled=thinking_enabled) + self._preflight_context_budget(request) + + def _preflight_context_budget(self, request: MessagesRequest) -> None: + loaded_context = self._loaded_context_length() + if loaded_context is None: + return + estimate = get_token_count( + request.messages, + request.system, + request.tools, + ) + # The estimate is cl100k-based and undercounts local tokenizers + # (observed ~8% low vs devstral); a request above 90% of the loaded + # context is already past where client-side compaction should have + # fired, and letting it through risks a silent LM Studio truncation. + budget = int(loaded_context * 0.9) + if estimate > budget: + raise InvalidRequestError( + f"prompt is too long: {estimate} tokens > {budget} " + f"maximum (90% of loaded LM Studio context {loaded_context})" + ) + + def _loaded_context_length(self) -> int | None: + """Best-effort loaded context length from LM Studio's REST API, cached.""" + cached_at, cached_value = self._loaded_context_cache + if time.monotonic() - cached_at < self._CONTEXT_CACHE_TTL_S: + return cached_value + + value: int | None = None + try: + root = self._base_url + root = root[: -len("/v1")] if root.endswith("/v1") else root + response = httpx.get(f"{root}/api/v0/models", timeout=2.0) + response.raise_for_status() + loaded = [ + model.get("loaded_context_length") + for model in response.json().get("data", []) + if model.get("state") == "loaded" + and isinstance(model.get("loaded_context_length"), int) + ] + # ponytail: single-model setups in practice; with several loaded + # models the most generous ceiling still makes a valid backstop. + value = max(loaded) if loaded else None + except Exception as error: # backstop only — never block the request + logger.debug( + "LMSTUDIO context preflight unavailable: {}", type(error).__name__ + ) + value = None + self._loaded_context_cache = (time.monotonic(), value) + return value diff --git a/src/free_claude_code/providers/mistral/__init__.py b/src/free_claude_code/providers/mistral/__init__.py new file mode 100644 index 0000000..050ba32 --- /dev/null +++ b/src/free_claude_code/providers/mistral/__init__.py @@ -0,0 +1,5 @@ +"""Mistral La Plateforme provider exports.""" + +from .client import MistralProvider + +__all__ = ["MistralProvider"] diff --git a/src/free_claude_code/providers/mistral/client.py b/src/free_claude_code/providers/mistral/client.py new file mode 100644 index 0000000..9040110 --- /dev/null +++ b/src/free_claude_code/providers/mistral/client.py @@ -0,0 +1,68 @@ +"""Mistral La Plateforme provider implementation (OpenAI-compatible chat completions).""" + +from typing import Any + +from loguru import logger + +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.openai_chat import ( + OpenAIChatProfile, + OpenAIChatProvider, + OpenAIChatRequestPolicy, + build_openai_chat_request_body, +) +from free_claude_code.providers.rate_limit import ProviderRateLimiter + +from .reasoning import ( + apply_mistral_reasoning_request_shape, + clone_body_without_mistral_reasoning, + is_mistral_reasoning_rejection, + normalize_mistral_stream, +) + +_REQUEST_POLICY = OpenAIChatRequestPolicy(provider_name="MISTRAL") +_PROFILE = OpenAIChatProfile(_REQUEST_POLICY) + + +class MistralProvider(OpenAIChatProvider): + """Mistral API using ``https://api.mistral.ai/v1/chat/completions``.""" + + def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter): + super().__init__( + config, + profile=_PROFILE, + rate_limiter=rate_limiter, + ) + + def _build_request_body( + self, request: MessagesRequest, thinking_enabled: bool | None = None + ) -> dict: + effective_thinking_enabled = self._is_thinking_enabled( + request, thinking_enabled + ) + body = build_openai_chat_request_body( + request, + thinking_enabled=effective_thinking_enabled, + policy=_REQUEST_POLICY, + ) + apply_mistral_reasoning_request_shape( + body, thinking_enabled=effective_thinking_enabled + ) + return body + + def _get_retry_request_body(self, error: Exception, body: dict) -> dict | None: + """Retry once without Mistral reasoning fields when a model rejects them.""" + if not is_mistral_reasoning_rejection(error): + return None + retry_body = clone_body_without_mistral_reasoning(body) + if retry_body is None: + return None + logger.warning( + "MISTRAL_STREAM: retrying without reasoning after upstream rejection" + ) + return retry_body + + async def _create_stream(self, body: dict) -> tuple[Any, dict]: + stream, final_body = await super()._create_stream(body) + return normalize_mistral_stream(stream), final_body diff --git a/src/free_claude_code/providers/mistral/reasoning.py b/src/free_claude_code/providers/mistral/reasoning.py new file mode 100644 index 0000000..9b62d1c --- /dev/null +++ b/src/free_claude_code/providers/mistral/reasoning.py @@ -0,0 +1,408 @@ +"""Mistral La Plateforme reasoning compatibility helpers.""" + +import json +from collections.abc import AsyncIterator, Mapping, Sequence +from copy import deepcopy +from types import SimpleNamespace +from typing import Any + +import openai + +from free_claude_code.providers.http import maybe_await_aclose + +MISTRAL_REASONING_EFFORT = "high" + +_REASONING_FIELD_NAMES = frozenset( + { + "reasoning_content", + "reasoning_effort", + "thinkchunk", + } +) +_REJECTION_WORDS = ("unsupported", "unknown", "invalid", "forbidden", "extra") + + +def apply_mistral_reasoning_request_shape( + body: dict[str, Any], *, thinking_enabled: bool +) -> None: + """Apply Mistral's native reasoning request shape in-place.""" + if thinking_enabled: + body["reasoning_effort"] = MISTRAL_REASONING_EFFORT + else: + body.pop("reasoning_effort", None) + + messages = body.get("messages") + if not isinstance(messages, list): + return + + for message in messages: + if not isinstance(message, dict) or message.get("role") != "assistant": + continue + reasoning = _clean_text(message.pop("reasoning_content", None)) + if thinking_enabled and reasoning: + message["content"] = _content_with_prepended_thinking( + message.get("content"), reasoning + ) + elif not thinking_enabled: + message["content"] = _content_without_thinking(message.get("content")) + + +def clone_body_without_mistral_reasoning( + body: dict[str, Any], +) -> dict[str, Any] | None: + """Return a body clone with Mistral reasoning fields removed.""" + cloned = deepcopy(body) + removed = cloned.pop("reasoning_effort", None) is not None + + messages = cloned.get("messages") + if isinstance(messages, list): + for message in messages: + if not isinstance(message, dict): + continue + if message.pop("reasoning_content", None) is not None: + removed = True + content = message.get("content") + stripped_content, content_removed = _strip_mistral_thinking_content(content) + if content_removed: + message["content"] = stripped_content + removed = True + + if not removed: + return None + return cloned + + +def is_mistral_reasoning_rejection(error: Exception) -> bool: + """Return whether an upstream error rejects Mistral reasoning request fields.""" + status_code = getattr(error, "status_code", None) + if not isinstance(error, openai.BadRequestError) and status_code not in (400, 422): + return False + + error_body = getattr(error, "body", None) + if _contains_reasoning_rejection(error_body): + return True + + error_text_parts = [str(error)] + response = getattr(error, "response", None) + if response is not None: + try: + response_text = response.text + except Exception: + response_text = None + if response_text: + if _contains_reasoning_rejection(_json_payload(response_text)): + return True + error_text_parts.append(response_text) + + if error_body is not None: + error_text_parts.append(json.dumps(error_body, default=str)) + + return _reasoning_rejection_text(" ".join(error_text_parts)) + + +def normalize_mistral_stream(stream: Any) -> AsyncIterator[Any]: + """Yield OpenAI-chat chunks with Mistral native thinking chunks normalized.""" + + async def _iter() -> AsyncIterator[Any]: + try: + async for chunk in stream: + yield normalize_mistral_chunk(chunk) + finally: + await maybe_await_aclose(stream) + + return _iter() + + +def normalize_mistral_chunk(chunk: Any) -> Any: + """Normalize one Mistral OpenAI-compatible stream chunk.""" + choices = getattr(chunk, "choices", None) + if not choices: + return chunk + + normalized_choices: list[Any] = [] + changed = False + for choice in choices: + delta = getattr(choice, "delta", None) + normalized_delta = _normalize_mistral_delta(delta) + if normalized_delta is delta: + normalized_choices.append(choice) + continue + changed = True + normalized_choices.append( + SimpleNamespace( + delta=normalized_delta, + finish_reason=getattr(choice, "finish_reason", None), + ) + ) + + if not changed: + return chunk + return SimpleNamespace( + choices=normalized_choices, + usage=getattr(chunk, "usage", None), + ) + + +def _normalize_mistral_delta(delta: Any) -> Any: + if delta is None: + return delta + + content = _field(delta, "content") + text, reasoning, changed = _split_mistral_content(content) + native_reasoning = _extract_native_reasoning(delta) + if native_reasoning: + reasoning = "\n".join(part for part in (reasoning, native_reasoning) if part) + if isinstance(content, str): + text = content + changed = True + + existing_reasoning = _field(delta, "reasoning_content") + if isinstance(existing_reasoning, str) and existing_reasoning: + reasoning = "\n".join(part for part in (existing_reasoning, reasoning) if part) + + if not changed: + return delta + + return SimpleNamespace( + content=text, + reasoning_content=reasoning or None, + tool_calls=_field(delta, "tool_calls"), + ) + + +def _content_with_prepended_thinking( + content: Any, reasoning: str +) -> list[dict[str, Any]]: + chunks = [_thinking_chunk(reasoning)] + chunks.extend(_content_to_mistral_text_chunks(content)) + return chunks + + +def _content_without_thinking(content: Any) -> Any: + stripped, _ = _strip_mistral_thinking_content(content) + return stripped + + +def _strip_mistral_thinking_content(content: Any) -> tuple[Any, bool]: + if not _is_sequence(content): + return content, False + + text_parts: list[str] = [] + kept_chunks: list[Any] = [] + removed = False + can_collapse_to_text = True + + for chunk in content: + chunk_type = _chunk_type(chunk) + if _is_thinking_chunk_type(chunk_type): + removed = True + continue + if chunk_type == "text": + text = _chunk_text(chunk) + if text: + text_parts.append(text) + continue + kept_chunks.append(chunk) + can_collapse_to_text = False + + if can_collapse_to_text: + return "".join(text_parts), removed + return kept_chunks, removed + + +def _content_to_mistral_text_chunks(content: Any) -> list[dict[str, Any]]: + if isinstance(content, str): + return [_text_chunk(content)] if content else [] + if not _is_sequence(content): + text = _clean_text(content) + return [_text_chunk(text)] if text else [] + + chunks: list[dict[str, Any]] = [] + for chunk in content: + chunk_type = _chunk_type(chunk) + if _is_thinking_chunk_type(chunk_type): + continue + text = _chunk_text(chunk) + if text: + chunks.append(_text_chunk(text)) + return chunks + + +def _split_mistral_content(content: Any) -> tuple[str | None, str | None, bool]: + if not _is_sequence(content): + return None, None, False + + text_parts: list[str] = [] + reasoning_parts: list[str] = [] + for chunk in content: + chunk_type = _chunk_type(chunk) + if _is_thinking_chunk_type(chunk_type): + reasoning = _chunk_reasoning(chunk) + if reasoning: + reasoning_parts.append(reasoning) + continue + text = _chunk_text(chunk) + if text: + text_parts.append(text) + + return ( + "".join(text_parts) or None, + "".join(reasoning_parts) or None, + bool(text_parts or reasoning_parts), + ) + + +def _extract_native_reasoning(delta: Any) -> str | None: + for name in ("thinking", "reasoning"): + value = _field(delta, name) + text = _chunk_reasoning(value) + if text: + return text + return None + + +def _contains_reasoning_rejection(value: Any) -> bool: + if value is None: + return False + if isinstance(value, Mapping): + for key, item in value.items(): + key_text = str(key).lower() + if key_text in _REASONING_FIELD_NAMES: + return True + if key_text in {"loc", "param"} and _is_reasoning_field_path(item): + return True + if _contains_reasoning_rejection(item): + return True + return False + if _is_sequence(value): + if _is_reasoning_field_path(value): + return True + return any(_contains_reasoning_rejection(item) for item in value) + if isinstance(value, str): + return _reasoning_rejection_text(value) + return False + + +def _is_reasoning_field_path(value: Any) -> bool: + if not _is_sequence(value): + return False + parts = [str(part).lower() for part in value] + if any(part in _REASONING_FIELD_NAMES for part in parts): + return True + return "thinking" in parts and bool( + {"body", "messages", "assistant", "content"} & set(parts) + ) + + +def _reasoning_rejection_text(value: str) -> bool: + lowered = value.lower() + if "reasoning input" in lowered and any( + phrase in lowered + for phrase in ( + "not enabled", + "not supported", + "unsupported", + "disabled", + ) + ): + return True + if any(field in lowered for field in _REASONING_FIELD_NAMES): + return True + if "thinking" not in lowered or not any( + word in lowered for word in _REJECTION_WORDS + ): + return False + return any( + marker in lowered + for marker in ( + "body.messages", + "assistant.thinking", + "messages.assistant", + "content.thinking", + "field: thinking", + "field 'thinking'", + 'field "thinking"', + "property: thinking", + "property 'thinking'", + 'property "thinking"', + "parameter: thinking", + "parameter 'thinking'", + 'parameter "thinking"', + "param: thinking", + "param 'thinking'", + 'param "thinking"', + ) + ) + + +def _json_payload(value: str) -> Any: + try: + return json.loads(value) + except json.JSONDecodeError: + return None + + +def _thinking_chunk(reasoning: str) -> dict[str, Any]: + return { + "type": "thinking", + "thinking": [_text_chunk(reasoning)], + } + + +def _text_chunk(text: str) -> dict[str, str]: + return {"type": "text", "text": text} + + +def _chunk_reasoning(chunk: Any) -> str | None: + if isinstance(chunk, str): + return chunk or None + thinking = _field(chunk, "thinking") + if thinking is not None: + text = _chunk_text(thinking) + if text: + return text + text = _chunk_text(chunk) + return text or None + + +def _chunk_text(chunk: Any) -> str: + if isinstance(chunk, str): + return chunk + if _is_sequence(chunk): + return "".join(_chunk_text(part) for part in chunk) + text = _field(chunk, "text") + if isinstance(text, str): + return text + content = _field(chunk, "content") + if isinstance(content, str): + return content + return "" + + +def _chunk_type(chunk: Any) -> str | None: + value = _field(chunk, "type") + if isinstance(value, str): + return value + return None + + +def _is_thinking_chunk_type(chunk_type: str | None) -> bool: + return chunk_type in {"thinking", "reasoning"} + + +def _clean_text(value: Any) -> str | None: + if isinstance(value, str): + return value if value else None + return None + + +def _field(item: Any, name: str) -> Any: + if isinstance(item, Mapping): + return item.get(name) + return getattr(item, name, None) + + +def _is_sequence(value: Any) -> bool: + return isinstance(value, Sequence) and not isinstance( + value, str | bytes | bytearray + ) diff --git a/src/free_claude_code/providers/model_listing.py b/src/free_claude_code/providers/model_listing.py new file mode 100644 index 0000000..3d0f6f1 --- /dev/null +++ b/src/free_claude_code/providers/model_listing.py @@ -0,0 +1,107 @@ +"""Provider model-list response parsing helpers.""" + +from collections.abc import Iterable, Mapping, Sequence +from typing import Any + +from free_claude_code.application.model_metadata import ( + ProviderModelInfo as _ProviderModelInfo, +) + + +class ModelListResponseError(ValueError): + """A provider model-list response cannot be parsed safely.""" + + def __init__(self, message: str) -> None: + super().__init__(message) + self.message = message + + +def model_infos_from_ids( + model_ids: Iterable[str], *, supports_thinking: bool | None = None +) -> frozenset[_ProviderModelInfo]: + """Build unknown-capability model metadata from plain provider model ids.""" + return frozenset( + _ProviderModelInfo(model_id=model_id, supports_thinking=supports_thinking) + for model_id in model_ids + if model_id.strip() + ) + + +def extract_openai_model_ids(payload: Any, *, provider_name: str) -> frozenset[str]: + """Extract model ids from an OpenAI-compatible ``/models`` response.""" + data = _field(payload, "data") + if not _is_sequence(data): + raise _malformed(provider_name, "expected top-level data array") + + model_ids: set[str] = set() + for item in data: + model_id = _field(item, "id") + if not isinstance(model_id, str) or not model_id.strip(): + raise _malformed(provider_name, "expected every data item to include id") + model_ids.add(model_id) + + if not model_ids: + raise _malformed(provider_name, "response did not include any model ids") + return frozenset(model_ids) + + +def extract_openrouter_tool_model_ids( + payload: Any, *, provider_name: str +) -> frozenset[str]: + """Extract OpenRouter model ids that advertise tool-use support.""" + return frozenset( + info.model_id + for info in extract_openrouter_tool_model_infos( + payload, provider_name=provider_name + ) + ) + + +def extract_openrouter_tool_model_infos( + payload: Any, *, provider_name: str +) -> frozenset[_ProviderModelInfo]: + """Extract OpenRouter tool-capable model ids with thinking capability metadata.""" + data = _field(payload, "data") + if not _is_sequence(data): + raise _malformed(provider_name, "expected top-level data array") + + model_infos: set[_ProviderModelInfo] = set() + for item in data: + model_id = _field(item, "id") + if not isinstance(model_id, str) or not model_id.strip(): + raise _malformed(provider_name, "expected every data item to include id") + + supported_parameters = _field(item, "supported_parameters") + if not _is_sequence(supported_parameters): + continue + supported_parameter_names = { + param for param in supported_parameters if isinstance(param, str) + } + if supported_parameter_names.isdisjoint({"tools", "tool_choice"}): + continue + model_infos.add( + _ProviderModelInfo( + model_id=model_id, + supports_thinking="reasoning" in supported_parameter_names, + ) + ) + + return frozenset(model_infos) + + +def _field(item: Any, name: str) -> Any: + if isinstance(item, Mapping): + return item.get(name) + return getattr(item, name, None) + + +def _is_sequence(value: Any) -> bool: + return isinstance(value, Sequence) and not isinstance( + value, str | bytes | bytearray + ) + + +def _malformed(provider_name: str, reason: str) -> ModelListResponseError: + return ModelListResponseError( + f"{provider_name} model-list response is malformed: {reason}" + ) diff --git a/src/free_claude_code/providers/nvidia_nim/__init__.py b/src/free_claude_code/providers/nvidia_nim/__init__.py new file mode 100644 index 0000000..ee73b06 --- /dev/null +++ b/src/free_claude_code/providers/nvidia_nim/__init__.py @@ -0,0 +1,5 @@ +"""NVIDIA NIM provider package.""" + +from .client import NvidiaNimProvider + +__all__ = ["NvidiaNimProvider"] diff --git a/src/free_claude_code/providers/nvidia_nim/client.py b/src/free_claude_code/providers/nvidia_nim/client.py new file mode 100644 index 0000000..231f9ff --- /dev/null +++ b/src/free_claude_code/providers/nvidia_nim/client.py @@ -0,0 +1,147 @@ +"""NVIDIA NIM provider implementation.""" + +import json +from collections.abc import Mapping +from typing import Any + +import openai +from loguru import logger + +from free_claude_code.config.nim import NimSettings +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.failures import ExecutionFailure +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.failure_policy import ( + overloaded_provider_failure, +) +from free_claude_code.providers.openai_chat import ( + OpenAIChatProfile, + OpenAIChatProvider, + OpenAIChatRequestPolicy, +) +from free_claude_code.providers.rate_limit import ProviderRateLimiter + +from .request_options import build_nim_request_body +from .retry import ( + clone_body_without_chat_template, + clone_body_without_reasoning_budget, + clone_body_without_reasoning_content, +) +from .tool_schema import ( + body_without_nim_tool_argument_aliases, + nim_tool_argument_aliases_from_body, +) + +_DEGRADED_FUNCTION_STATE = "degraded function cannot be invoked" +_PROFILE = OpenAIChatProfile(OpenAIChatRequestPolicy(provider_name="NIM")) + + +class NvidiaNimProvider(OpenAIChatProvider): + """NVIDIA NIM provider using official OpenAI client.""" + + def __init__( + self, + config: ProviderConfig, + *, + nim_settings: NimSettings, + rate_limiter: ProviderRateLimiter, + ): + super().__init__( + config, + profile=_PROFILE, + rate_limiter=rate_limiter, + ) + self._nim_settings = nim_settings + + def _build_request_body( + self, request: MessagesRequest, thinking_enabled: bool | None = None + ) -> dict: + """Internal helper for tests and shared building.""" + return build_nim_request_body( + request, + self._nim_settings, + thinking_enabled=self._is_thinking_enabled(request, thinking_enabled), + ) + + def _prepare_create_body(self, body: dict[str, Any]) -> dict[str, Any]: + """Strip private request metadata before calling NVIDIA NIM.""" + return body_without_nim_tool_argument_aliases(body) + + def _tool_argument_aliases(self, body: dict[str, Any]) -> dict[str, dict[str, str]]: + """Return NIM tool argument aliases captured while building this request.""" + return nim_tool_argument_aliases_from_body(body) + + def _get_retry_request_body(self, error: Exception, body: dict) -> dict | None: + """Retry once with a downgraded body when NIM rejects a known field.""" + status_code = getattr(error, "status_code", None) + bad_request_like = isinstance(error, openai.BadRequestError) or ( + status_code == 400 + ) + + error_text = str(error) + error_body = getattr(error, "body", None) + if error_body is not None: + error_text = f"{error_text} {json.dumps(error_body, default=str)}" + error_text = error_text.lower() + + if _is_reasoning_budget_rejection(error_text) and ( + bad_request_like or status_code == 500 + ): + retry_body = clone_body_without_reasoning_budget(body) + if retry_body is None: + return None + logger.warning( + "NIM_STREAM: retrying without reasoning budget after upstream rejection" + ) + return retry_body + + if not bad_request_like: + return None + + if "chat_template" in error_text: + retry_body = clone_body_without_chat_template(body) + if retry_body is None: + return None + logger.warning("NIM_STREAM: retrying without chat_template after 400 error") + return retry_body + + if "reasoning_content" in error_text: + retry_body = clone_body_without_reasoning_content(body) + if retry_body is None: + return None + logger.warning( + "NIM_STREAM: retrying without reasoning_content after 400 error" + ) + return retry_body + + return None + + def _provider_failure_override(self, error: Exception) -> ExecutionFailure | None: + """Map NVIDIA Cloud Function deployment failure onto canonical overload.""" + if not isinstance(error, openai.BadRequestError): + return None + if getattr(error, "status_code", None) != 400: + return None + body = getattr(error, "body", None) + if not isinstance(body, Mapping): + return None + detail = body.get("detail") + if not isinstance(detail, str): + return None + function_ref, separator, state = detail.lower().partition(": ") + function_id = function_ref.removeprefix("function id ").strip(" '\"") + if ( + not separator + or not function_ref.startswith("function id ") + or not function_id + or state.strip() != _DEGRADED_FUNCTION_STATE + ): + return None + return overloaded_provider_failure() + + +def _is_reasoning_budget_rejection(error_text: str) -> bool: + """Return whether NIM rejected optional thinking budget control.""" + if "reasoning_budget" in error_text: + return True + return "thinking_token_budget" in error_text and "reasoning_config" in error_text diff --git a/src/free_claude_code/providers/nvidia_nim/request_options.py b/src/free_claude_code/providers/nvidia_nim/request_options.py new file mode 100644 index 0000000..7c88a1a --- /dev/null +++ b/src/free_claude_code/providers/nvidia_nim/request_options.py @@ -0,0 +1,108 @@ +"""NVIDIA NIM request option injection.""" + +from typing import Any + +from free_claude_code.config.nim import NimSettings +from free_claude_code.core.anthropic import set_if_not_none +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.providers.openai_chat import ( + OpenAIChatRequestPolicy, + build_openai_chat_request_body, +) + +from .tool_schema import sanitize_nim_tool_schemas + +_REQUEST_POLICY = OpenAIChatRequestPolicy(provider_name="NIM") + + +def build_nim_request_body( + request_data: MessagesRequest, nim: NimSettings, *, thinking_enabled: bool +) -> dict[str, Any]: + """Build OpenAI-format request body from Anthropic request plus NIM settings.""" + return build_openai_chat_request_body( + request_data, + thinking_enabled=thinking_enabled, + policy=_REQUEST_POLICY, + postprocessors=( + lambda body, request, enabled: apply_nim_request_options( + body, + request, + enabled, + nim=nim, + ), + ), + ) + + +def apply_nim_request_options( + body: dict[str, Any], + request_data: MessagesRequest, + thinking_enabled: bool, + *, + nim: NimSettings, +) -> None: + """Apply NIM schema repairs and configured request defaults.""" + sanitize_nim_tool_schemas(body) + + max_tokens = body.get("max_tokens") or request_data.max_tokens + 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) + + 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 + + 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 + + extra_body: dict[str, Any] = {} + request_extra = request_data.extra_body + if request_extra: + extra_body.update(request_extra) + + if thinking_enabled: + chat_template_kwargs = extra_body.setdefault( + "chat_template_kwargs", {"thinking": True, "enable_thinking": True} + ) + if isinstance(chat_template_kwargs, dict): + chat_template_kwargs.setdefault("reasoning_budget", max_tokens) + + req_top_k = request_data.top_k + 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 + + +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 diff --git a/src/free_claude_code/providers/nvidia_nim/retry.py b/src/free_claude_code/providers/nvidia_nim/retry.py new file mode 100644 index 0000000..335a0b6 --- /dev/null +++ b/src/free_claude_code/providers/nvidia_nim/retry.py @@ -0,0 +1,72 @@ +"""NVIDIA NIM retry-body downgrade helpers.""" + +from collections.abc import Callable +from copy import deepcopy +from typing import Any + + +def clone_body_without_reasoning_budget(body: dict[str, Any]) -> dict[str, Any] | None: + """Clone a request body and strip only reasoning_budget fields.""" + return _clone_strip_extra_body(body, _strip_reasoning_budget_fields) + + +def clone_body_without_chat_template(body: dict[str, Any]) -> dict[str, Any] | None: + """Clone a request body and strip NIM chat-template control fields.""" + return _clone_strip_extra_body(body, _strip_chat_template_fields) + + +def clone_body_without_reasoning_content( + body: dict[str, Any], +) -> dict[str, Any] | None: + """Clone a request body and strip assistant message ``reasoning_content`` fields.""" + cloned_body = deepcopy(body) + if not _strip_message_reasoning_content(cloned_body): + return None + return cloned_body + + +def _clone_strip_extra_body( + body: dict[str, Any], + strip: Callable[[dict[str, Any]], bool], +) -> dict[str, Any] | None: + cloned_body = deepcopy(body) + extra_body = cloned_body.get("extra_body") + if not isinstance(extra_body, dict): + return None + if not strip(extra_body): + return None + if not extra_body: + cloned_body.pop("extra_body", None) + return cloned_body + + +def _strip_reasoning_budget_fields(extra_body: dict[str, Any]) -> bool: + removed = extra_body.pop("reasoning_budget", None) is not None + chat_template_kwargs = extra_body.get("chat_template_kwargs") + if ( + isinstance(chat_template_kwargs, dict) + and chat_template_kwargs.pop("reasoning_budget", None) is not None + ): + removed = True + return removed + + +def _strip_chat_template_fields(extra_body: dict[str, Any]) -> bool: + removed = extra_body.pop("chat_template", None) is not None + if extra_body.pop("chat_template_kwargs", None) is not None: + removed = True + return removed + + +def _strip_message_reasoning_content(body: dict[str, Any]) -> bool: + removed = False + messages = body.get("messages") + if not isinstance(messages, list): + return False + for message in messages: + if ( + isinstance(message, dict) + and message.pop("reasoning_content", None) is not None + ): + removed = True + return removed diff --git a/src/free_claude_code/providers/nvidia_nim/tool_schema.py b/src/free_claude_code/providers/nvidia_nim/tool_schema.py new file mode 100644 index 0000000..11186e8 --- /dev/null +++ b/src/free_claude_code/providers/nvidia_nim/tool_schema.py @@ -0,0 +1,255 @@ +"""NVIDIA NIM tool schema sanitization and private argument aliases.""" + +from typing import Any + +_SCHEMA_VALUE_KEYS = frozenset( + { + "additionalProperties", + "additionalItems", + "unevaluatedProperties", + "unevaluatedItems", + "items", + "contains", + "propertyNames", + "if", + "then", + "else", + "not", + } +) +_SCHEMA_LIST_KEYS = frozenset({"allOf", "anyOf", "oneOf", "prefixItems"}) +_SCHEMA_MAP_KEYS = frozenset( + {"properties", "patternProperties", "$defs", "definitions", "dependentSchemas"} +) +NIM_TOOL_ARGUMENT_ALIASES_KEY = "_fcc_nim_tool_argument_aliases" +_NIM_TOOL_PARAMETER_ALIAS_PREFIX = "_fcc_arg_" +_NIM_UNSAFE_TOOL_PARAMETER_NAMES = frozenset({"type"}) + + +def sanitize_nim_tool_schemas(body: dict[str, Any]) -> None: + """Sanitize only tool parameter schemas, preserving tool calls/history.""" + tools = body.get("tools") + if not isinstance(tools, list): + return + + tool_argument_aliases: dict[str, dict[str, str]] = {} + sanitized_tools: list[Any] = [] + for tool in tools: + if not isinstance(tool, dict): + sanitized_tools.append(tool) + continue + sanitized_tool = dict(tool) + function = tool.get("function") + if isinstance(function, dict): + sanitized_function = dict(function) + parameters = function.get("parameters") + if isinstance(parameters, dict): + _, sanitized_parameters = _sanitize_nim_schema_node(parameters) + sanitized_parameters, argument_aliases = _alias_nim_tool_parameters( + sanitized_parameters + ) + sanitized_function["parameters"] = sanitized_parameters + tool_name = function.get("name") + if argument_aliases and isinstance(tool_name, str) and tool_name: + tool_argument_aliases[tool_name] = argument_aliases + sanitized_tool["function"] = sanitized_function + sanitized_tools.append(sanitized_tool) + + body["tools"] = sanitized_tools + if tool_argument_aliases: + body[NIM_TOOL_ARGUMENT_ALIASES_KEY] = tool_argument_aliases + else: + body.pop(NIM_TOOL_ARGUMENT_ALIASES_KEY, None) + + +def nim_tool_argument_aliases_from_body( + body: dict[str, Any], +) -> dict[str, dict[str, str]]: + """Return validated private NIM tool argument aliases from a built body.""" + raw_aliases = body.get(NIM_TOOL_ARGUMENT_ALIASES_KEY) + if not isinstance(raw_aliases, dict): + return {} + + aliases: dict[str, dict[str, str]] = {} + for tool_name, tool_aliases in raw_aliases.items(): + if not isinstance(tool_name, str) or not isinstance(tool_aliases, dict): + continue + sanitized_aliases = { + alias: original + for alias, original in tool_aliases.items() + if isinstance(alias, str) and isinstance(original, str) + } + if sanitized_aliases: + aliases[tool_name] = sanitized_aliases + return aliases + + +def body_without_nim_tool_argument_aliases(body: dict[str, Any]) -> dict[str, Any]: + """Return a request body with private alias metadata stripped before upstream I/O.""" + if NIM_TOOL_ARGUMENT_ALIASES_KEY not in body: + return body + upstream_body = dict(body) + upstream_body.pop(NIM_TOOL_ARGUMENT_ALIASES_KEY, None) + return upstream_body + + +def _sanitize_nim_schema_node(value: Any) -> tuple[bool, Any]: + """Remove boolean JSON Schema subschemas that hosted NIM rejects.""" + if isinstance(value, bool): + return False, None + if isinstance(value, dict): + sanitized: dict[str, Any] = {} + for key, item in value.items(): + if key in _SCHEMA_VALUE_KEYS: + keep, sanitized_item = _sanitize_nim_schema_node(item) + if keep: + sanitized[key] = sanitized_item + elif key in _SCHEMA_LIST_KEYS and isinstance(item, list): + sanitized_items: list[Any] = [] + for schema_item in item: + keep, sanitized_item = _sanitize_nim_schema_node(schema_item) + if keep: + sanitized_items.append(sanitized_item) + if sanitized_items: + sanitized[key] = sanitized_items + elif key in _SCHEMA_MAP_KEYS and isinstance(item, dict): + sanitized_map: dict[str, Any] = {} + for map_key, schema_item in item.items(): + keep, sanitized_item = _sanitize_nim_schema_node(schema_item) + if keep: + sanitized_map[map_key] = sanitized_item + sanitized[key] = sanitized_map + else: + sanitized[key] = item + return True, sanitized + if isinstance(value, list): + sanitized_items = [] + for item in value: + keep, sanitized_item = _sanitize_nim_schema_node(item) + if keep: + sanitized_items.append(sanitized_item) + return True, sanitized_items + return True, value + + +def _needs_nim_tool_parameter_alias(name: str) -> bool: + return name in _NIM_UNSAFE_TOOL_PARAMETER_NAMES + + +def _make_nim_tool_parameter_alias(name: str, reserved: set[str]) -> str: + safe_tail = "".join( + character if character.isalnum() or character == "_" else "_" + for character in name + ).strip("_") + if not safe_tail: + safe_tail = "arg" + candidate = f"{_NIM_TOOL_PARAMETER_ALIAS_PREFIX}{safe_tail}" + alias = candidate + suffix = 2 + while alias in reserved: + alias = f"{candidate}_{suffix}" + suffix += 1 + reserved.add(alias) + return alias + + +def _collect_nim_tool_property_names(value: Any) -> set[str]: + names: set[str] = set() + if isinstance(value, dict): + properties = value.get("properties") + if isinstance(properties, dict): + for property_name, property_schema in properties.items(): + if isinstance(property_name, str): + names.add(property_name) + names.update(_collect_nim_tool_property_names(property_schema)) + for key, item in value.items(): + if key != "properties": + names.update(_collect_nim_tool_property_names(item)) + elif isinstance(value, list): + for item in value: + names.update(_collect_nim_tool_property_names(item)) + return names + + +def _alias_nim_schema_property_names( + value: Any, + *, + reserved: set[str], + alias_to_original: dict[str, str], + original_to_alias: dict[str, str], +) -> Any: + if isinstance(value, list): + return [ + _alias_nim_schema_property_names( + item, + reserved=reserved, + alias_to_original=alias_to_original, + original_to_alias=original_to_alias, + ) + for item in value + ] + if not isinstance(value, dict): + return value + + local_aliases: dict[str, str] = {} + aliased_value: dict[str, Any] = {} + properties = value.get("properties") + if isinstance(properties, dict): + aliased_properties: dict[str, Any] = {} + for property_name, property_schema in properties.items(): + aliased_schema = _alias_nim_schema_property_names( + property_schema, + reserved=reserved, + alias_to_original=alias_to_original, + original_to_alias=original_to_alias, + ) + if isinstance(property_name, str) and _needs_nim_tool_parameter_alias( + property_name + ): + alias = original_to_alias.get(property_name) + if alias is None: + alias = _make_nim_tool_parameter_alias(property_name, reserved) + alias_to_original[alias] = property_name + original_to_alias[property_name] = alias + local_aliases[property_name] = alias + aliased_properties[alias] = aliased_schema + else: + aliased_properties[property_name] = aliased_schema + aliased_value["properties"] = aliased_properties + + for key, item in value.items(): + if key == "properties": + continue + if key == "required" and isinstance(item, list): + aliased_value[key] = [ + local_aliases.get(required_item, required_item) + if isinstance(required_item, str) + else required_item + for required_item in item + ] + continue + aliased_value[key] = _alias_nim_schema_property_names( + item, + reserved=reserved, + alias_to_original=alias_to_original, + original_to_alias=original_to_alias, + ) + + return aliased_value + + +def _alias_nim_tool_parameters( + parameters: dict[str, Any], +) -> tuple[dict[str, Any], dict[str, str]]: + alias_to_original: dict[str, str] = {} + original_to_alias: dict[str, str] = {} + reserved = _collect_nim_tool_property_names(parameters) + aliased_parameters = _alias_nim_schema_property_names( + parameters, + reserved=reserved, + alias_to_original=alias_to_original, + original_to_alias=original_to_alias, + ) + if not alias_to_original: + return parameters, {} + return aliased_parameters, alias_to_original diff --git a/src/free_claude_code/providers/nvidia_nim/voice.py b/src/free_claude_code/providers/nvidia_nim/voice.py new file mode 100644 index 0000000..89496f7 --- /dev/null +++ b/src/free_claude_code/providers/nvidia_nim/voice.py @@ -0,0 +1,113 @@ +"""NVIDIA NIM / Riva offline ASR for voice notes (provider-owned transport).""" + +import asyncio +from pathlib import Path + +from loguru import logger + +# NVIDIA NIM Whisper model mapping: (function_id, language_code) +_NIM_ASR_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"), + # function-id from NVIDIA NIM API docs (parakeet-ctc-0.6b-es). + "nvidia/parakeet-ctc-0.6b-es": ("a9eeee8f-b509-4712-b19d-194361fa5f31", "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"), +} + +_RIVA_SERVER = "grpc.nvcf.nvidia.com:443" + + +class NvidiaNimTranscriber: + """Own configured NVIDIA NIM / Riva transcription.""" + + def __init__(self, *, model: str, api_key: str) -> None: + self._model = model + self._key = api_key.strip() + self._lock = asyncio.Lock() + self._closed = False + + async def transcribe(self, file_path: Path) -> str: + """Transcribe one audio file without blocking the event loop.""" + async with self._lock: + if self._closed: + raise RuntimeError("NVIDIA NIM transcriber is closed.") + worker = asyncio.create_task( + asyncio.to_thread(self._transcribe_sync, file_path) + ) + try: + return await asyncio.shield(worker) + except asyncio.CancelledError: + await _wait_for_thread_exit(worker) + raise + + async def close(self) -> None: + """Close this stateless adapter to future work.""" + self._closed = True + async with self._lock: + self._key = "" + + def _transcribe_sync(self, file_path: Path) -> str: + if not self._key: + raise ValueError( + "NVIDIA NIM transcription requires a non-empty " + "nvidia_nim_api_key (configure NVIDIA_NIM_API_KEY)." + ) + model_config = _NIM_ASR_MODEL_MAP.get(self._model) + if model_config is None: + raise ValueError( + f"No NVIDIA NIM config found for model: {self._model}. " + f"Supported models: {', '.join(_NIM_ASR_MODEL_MAP)}" + ) + function_id, language_code = model_config + try: + import riva.client + except ImportError as exc: + raise ImportError( + "NVIDIA NIM transcription requires the voice extra. " + "Install with: uv sync --extra voice" + ) from exc + + auth = riva.client.Auth( + use_ssl=True, + uri=_RIVA_SERVER, + metadata_args=[ + ["function-id", function_id], + ["authorization", f"Bearer {self._key}"], + ], + ) + try: + asr_service = riva.client.ASRService(auth) + config = riva.client.RecognitionConfig( + language_code=language_code, + max_alternatives=1, + verbatim_transcripts=True, + ) + data = file_path.read_bytes() + response = asr_service.offline_recognize(data, config) + + transcript = "" + results = getattr(response, "results", None) + if results and results[0].alternatives: + transcript = results[0].alternatives[0].transcript + logger.debug("NIM transcription: {} chars", len(transcript)) + return transcript or "(no speech detected)" + finally: + auth.channel.close() + + +async def _wait_for_thread_exit(worker: asyncio.Task[str]) -> None: + """Wait through repeated caller cancellation without cancelling thread work.""" + while not worker.done(): + try: + await asyncio.shield(asyncio.wait((worker,))) + except asyncio.CancelledError: + continue + if not worker.cancelled(): + worker.exception() diff --git a/src/free_claude_code/providers/open_router/__init__.py b/src/free_claude_code/providers/open_router/__init__.py new file mode 100644 index 0000000..f4f9418 --- /dev/null +++ b/src/free_claude_code/providers/open_router/__init__.py @@ -0,0 +1,5 @@ +"""OpenRouter provider exports.""" + +from .client import OpenRouterProvider + +__all__ = ["OpenRouterProvider"] diff --git a/src/free_claude_code/providers/open_router/client.py b/src/free_claude_code/providers/open_router/client.py new file mode 100644 index 0000000..d45abfd --- /dev/null +++ b/src/free_claude_code/providers/open_router/client.py @@ -0,0 +1,231 @@ +"""OpenRouter provider implementation.""" + +import json +from collections.abc import Iterator, Mapping, Sequence +from typing import Any + +from free_claude_code.application.model_metadata import ProviderModelInfo +from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS +from free_claude_code.core.anthropic.models import MessagesRequest, ThinkingConfig +from free_claude_code.core.anthropic.streaming import AnthropicStreamLedger +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.model_listing import ( + extract_openrouter_tool_model_ids, + extract_openrouter_tool_model_infos, +) +from free_claude_code.providers.openai_chat import ( + OpenAIChatProfile, + OpenAIChatProvider, + OpenAIChatRequestPolicy, + build_openai_chat_request_body, + validate_extra_body_does_not_override_canonical_fields, +) +from free_claude_code.providers.rate_limit import ProviderRateLimiter + +_REQUEST_POLICY = OpenAIChatRequestPolicy( + provider_name="OPENROUTER", + include_extra_body=True, + extra_body_validator=validate_extra_body_does_not_override_canonical_fields, + default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, +) +_PROFILE = OpenAIChatProfile(_REQUEST_POLICY) + + +class OpenRouterProvider(OpenAIChatProvider): + """OpenRouter provider using the OpenAI-compatible Chat Completions API.""" + + def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter): + super().__init__( + config, + profile=_PROFILE, + rate_limiter=rate_limiter, + ) + + def _build_request_body( + self, request: MessagesRequest, thinking_enabled: bool | None = None + ) -> dict: + effective_thinking_enabled = self._is_thinking_enabled( + request, thinking_enabled + ) + return build_openai_chat_request_body( + request, + thinking_enabled=effective_thinking_enabled, + policy=_REQUEST_POLICY, + postprocessors=( + _apply_openrouter_reasoning_policy, + _apply_openrouter_reasoning_details_replay, + ), + ) + + async def list_model_ids(self) -> frozenset[str]: + """Only advertise OpenRouter models that can run Claude Code tools.""" + payload = await self._client.models.list() + return extract_openrouter_tool_model_ids( + payload, provider_name=self._provider_name + ) + + async def list_model_infos(self) -> frozenset[ProviderModelInfo]: + """Advertise OpenRouter tool models with reasoning capability metadata.""" + payload = await self._client.models.list() + return extract_openrouter_tool_model_infos( + payload, provider_name=self._provider_name + ) + + def _handle_extra_reasoning( + self, delta: Any, ledger: AnthropicStreamLedger, *, thinking_enabled: bool + ) -> Iterator[str]: + """Map OpenRouter reasoning details onto Anthropic thinking blocks.""" + if not thinking_enabled: + return iter(()) + return _iter_openrouter_reasoning_detail_events(delta, ledger) + + +def _apply_openrouter_reasoning_policy( + body: dict[str, Any], request: MessagesRequest, thinking_enabled: bool +) -> None: + if not thinking_enabled: + return + extra_body = body.setdefault("extra_body", {}) + if not isinstance(extra_body, dict): + return + reasoning = extra_body.setdefault("reasoning", {"enabled": True}) + if not isinstance(reasoning, dict): + return + reasoning.setdefault("enabled", True) + budget_tokens = _thinking_budget_tokens(request.thinking) + if isinstance(budget_tokens, int): + reasoning.setdefault("max_tokens", budget_tokens) + + +def _apply_openrouter_reasoning_details_replay( + body: dict[str, Any], request: MessagesRequest, thinking_enabled: bool +) -> None: + if not thinking_enabled: + return + assistant_details = _assistant_reasoning_details(request.messages) + if not assistant_details: + return + messages = body.get("messages") + if not isinstance(messages, list): + return + + cursor = 0 + for details in assistant_details: + for index in range(cursor, len(messages)): + message = messages[index] + if not isinstance(message, dict) or message.get("role") != "assistant": + continue + existing = message.get("reasoning_details") + if isinstance(existing, list): + existing.extend(details) + else: + message["reasoning_details"] = list(details) + cursor = index + 1 + break + + +def _assistant_reasoning_details(messages: Any) -> list[list[dict[str, Any]]]: + if not _is_sequence(messages): + return [] + result: list[list[dict[str, Any]]] = [] + for message in messages: + if _field(message, "role") != "assistant": + continue + details = _redacted_reasoning_details(_field(message, "content")) + if details: + result.append(details) + return result + + +def _redacted_reasoning_details(content: Any) -> list[dict[str, Any]]: + if not _is_sequence(content): + return [] + details: list[dict[str, Any]] = [] + for block in content: + if _field(block, "type") != "redacted_thinking": + continue + data = _field(block, "data") + if not isinstance(data, str) or not data: + continue + parsed = _json_payload(data) + if isinstance(parsed, list): + details.extend(item for item in parsed if isinstance(item, dict)) + elif isinstance(parsed, dict): + details.append(parsed) + else: + details.append({"type": "reasoning.encrypted", "data": data}) + return details + + +def _thinking_budget_tokens(thinking: ThinkingConfig | None) -> int | None: + value = thinking.budget_tokens if thinking is not None else None + return value if isinstance(value, int) and not isinstance(value, bool) else None + + +def _iter_openrouter_reasoning_detail_events( + delta: Any, ledger: AnthropicStreamLedger +) -> Iterator[str]: + details = _field(delta, "reasoning_details") + if details is None: + extra = _field(delta, "model_extra") + if isinstance(extra, Mapping): + details = extra.get("reasoning_details") + if not _is_sequence(details): + return + + native_reasoning = _field(delta, "reasoning_content") + has_native_reasoning = isinstance(native_reasoning, str) and bool(native_reasoning) + for detail in details: + encrypted = _reasoning_detail_encrypted(detail) + if encrypted: + yield from ledger.close_content_blocks() + index = ledger.blocks.allocate_index() + yield ledger.content_block_start(index, "redacted_thinking", data=encrypted) + yield ledger.content_block_stop(index) + continue + if has_native_reasoning: + continue + text = _reasoning_detail_text(detail) + if not text: + continue + yield from ledger.ensure_thinking_block() + yield ledger.emit_thinking_delta(text) + + +def _reasoning_detail_text(detail: Any) -> str | None: + kind = str(_field(detail, "type") or "").lower() + if "encrypted" in kind or "redacted" in kind: + return None + for key in ("text", "content", "reasoning"): + value = _field(detail, key) + if isinstance(value, str) and value: + return value + return None + + +def _reasoning_detail_encrypted(detail: Any) -> str | None: + kind = str(_field(detail, "type") or "").lower() + if "encrypted" not in kind and "redacted" not in kind and "summary" not in kind: + return None + if isinstance(detail, Mapping): + return json.dumps(dict(detail), separators=(",", ":")) + return None + + +def _json_payload(value: str) -> Any: + try: + return json.loads(value) + except json.JSONDecodeError: + return None + + +def _field(item: Any, name: str) -> Any: + if isinstance(item, Mapping): + return item.get(name) + return getattr(item, name, None) + + +def _is_sequence(value: Any) -> bool: + return isinstance(value, Sequence) and not isinstance( + value, str | bytes | bytearray + ) diff --git a/src/free_claude_code/providers/openai_chat/__init__.py b/src/free_claude_code/providers/openai_chat/__init__.py new file mode 100644 index 0000000..fa3ece4 --- /dev/null +++ b/src/free_claude_code/providers/openai_chat/__init__.py @@ -0,0 +1,40 @@ +"""OpenAI-compatible provider family.""" + +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.rate_limit import ProviderRateLimiter + +from .base_url import openai_v1_base_url +from .extra_body import validate_extra_body_does_not_override_canonical_fields +from .profiles import OPENAI_CHAT_PROFILES, OpenAIChatProfile +from .provider import OpenAIChatProvider +from .request_policy import OpenAIChatRequestPolicy, build_openai_chat_request_body +from .usage import usage_int + + +def create_openai_chat_provider( + provider_id: str, + config: ProviderConfig, + rate_limiter: ProviderRateLimiter, +) -> OpenAIChatProvider: + """Construct one profile-driven provider.""" + profile = OPENAI_CHAT_PROFILES.get(provider_id) + if profile is None: + raise KeyError(f"No declarative OpenAI-chat profile for {provider_id!r}") + return OpenAIChatProvider( + config, + profile=profile, + rate_limiter=rate_limiter, + ) + + +__all__ = [ + "OPENAI_CHAT_PROFILES", + "OpenAIChatProfile", + "OpenAIChatProvider", + "OpenAIChatRequestPolicy", + "build_openai_chat_request_body", + "create_openai_chat_provider", + "openai_v1_base_url", + "usage_int", + "validate_extra_body_does_not_override_canonical_fields", +] diff --git a/src/free_claude_code/providers/openai_chat/base_url.py b/src/free_claude_code/providers/openai_chat/base_url.py new file mode 100644 index 0000000..4cda6e8 --- /dev/null +++ b/src/free_claude_code/providers/openai_chat/base_url.py @@ -0,0 +1,7 @@ +"""OpenAI-compatible API base URL policy.""" + + +def openai_v1_base_url(base_url: str) -> str: + """Return the canonical ``/v1`` API base for a server root or API base.""" + normalized = base_url.rstrip("/") + return normalized if normalized.endswith("/v1") else f"{normalized}/v1" diff --git a/src/free_claude_code/providers/openai_chat/extra_body.py b/src/free_claude_code/providers/openai_chat/extra_body.py new file mode 100644 index 0000000..7f08b1f --- /dev/null +++ b/src/free_claude_code/providers/openai_chat/extra_body.py @@ -0,0 +1,32 @@ +"""Validation helpers for OpenAI-chat ``extra_body`` passthrough.""" + +from typing import Any + +CANONICAL_OPENAI_CHAT_BODY_KEYS = frozenset( + { + "model", + "messages", + "tools", + "tool_choice", + "stream", + "max_tokens", + "max_completion_tokens", + "temperature", + "top_p", + "metadata", + "stop", + "stop_sequences", + "stream_options", + } +) + + +def validate_extra_body_does_not_override_canonical_fields( + extra: dict[str, Any], +) -> None: + """Reject extras that would replace FCC-owned chat-completion fields.""" + bad = CANONICAL_OPENAI_CHAT_BODY_KEYS & extra.keys() + if bad: + raise ValueError( + f"extra_body must not override canonical request fields: {sorted(bad)}" + ) diff --git a/src/free_claude_code/providers/openai_chat/output_cap.py b/src/free_claude_code/providers/openai_chat/output_cap.py new file mode 100644 index 0000000..08cea7e --- /dev/null +++ b/src/free_claude_code/providers/openai_chat/output_cap.py @@ -0,0 +1,83 @@ +"""Recover from upstream ``max_(completion_)tokens`` too-large 400 rejections. + +Some OpenAI-compatible providers (Groq, NVIDIA NIM, ...) cap the per-request +output token count below what Claude Code asks for and reject the whole request +with an HTTP 400 that names the allowed maximum, e.g.:: + + max_completion_tokens must be less than or equal to 40960, ... + +This module parses that maximum and clamps the request body so the provider can +retry once and succeed. The provider also remembers the learned cap per model +so later requests clamp proactively instead of paying the 400 every time. +""" + +import json +import re +from typing import Any + +import openai + +# Body keys that carry the output-token budget across OpenAI-compatible policies. +_OUTPUT_TOKEN_FIELDS = ("max_completion_tokens", "max_tokens") + +# Only treat a 400 as an output-cap rejection when it names one of these fields. +_OUTPUT_TOKEN_KEYWORDS = ("max_completion_tokens", "max_tokens") + +# Comparator phrases that precede the allowed maximum in provider error text. +_CAP_PATTERNS: tuple[re.Pattern[str], ...] = ( + re.compile(r"less than or equal to\s+(\d+)"), + re.compile(r"smaller than or equal to\s+(\d+)"), + re.compile(r"<=\s*(\d+)"), + re.compile(r"at most\s+(\d+)"), + re.compile(r"must not exceed\s+(\d+)"), + re.compile(r"maximum(?:\s+value)?(?:\s+for\s+\S+)?\s+is\s+(\d+)"), + re.compile(r"maximum(?:\s+allowed)?(?:\s+value)?\s+of\s+(\d+)"), +) + + +def _is_bad_request(error: Exception) -> bool: + return isinstance(error, openai.BadRequestError) or ( + getattr(error, "status_code", None) == 400 + ) + + +def _error_text(error: Exception) -> str: + text = str(error) + body = getattr(error, "body", None) + if body is not None: + text = f"{text} {json.dumps(body, default=str)}" + return text.lower() + + +def parse_output_token_cap(error: Exception) -> int | None: + """Return the allowed output-token maximum named in a 400 rejection, if any.""" + if not _is_bad_request(error): + return None + + text = _error_text(error) + if not any(keyword in text for keyword in _OUTPUT_TOKEN_KEYWORDS): + return None + + for pattern in _CAP_PATTERNS: + match = pattern.search(text) + if match: + cap = int(match.group(1)) + if cap > 0: + return cap + return None + + +def clamp_output_tokens(body: dict[str, Any], cap: int) -> dict[str, Any] | None: + """Return a shallow clone with output-token fields clamped to ``cap``. + + Returns ``None`` when nothing needs clamping (no output field exceeds the + cap), so callers can avoid a pointless identical retry. + """ + clamped: dict[str, Any] | None = None + for field in _OUTPUT_TOKEN_FIELDS: + value = body.get(field) + if isinstance(value, int) and not isinstance(value, bool) and value > cap: + if clamped is None: + clamped = dict(body) + clamped[field] = cap + return clamped diff --git a/src/free_claude_code/providers/openai_chat/profiles.py b/src/free_claude_code/providers/openai_chat/profiles.py new file mode 100644 index 0000000..2dff029 --- /dev/null +++ b/src/free_claude_code/providers/openai_chat/profiles.py @@ -0,0 +1,229 @@ +"""Declarative profiles for providers with no adapter-specific runtime behavior.""" + +from collections.abc import Mapping +from copy import deepcopy +from dataclasses import dataclass +from typing import Any + +from free_claude_code.application.errors import InvalidRequestError +from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS +from free_claude_code.core.anthropic import ReasoningReplayMode +from free_claude_code.core.anthropic.models import MessagesRequest + +from .base_url import openai_v1_base_url +from .extra_body import validate_extra_body_does_not_override_canonical_fields +from .request_policy import OpenAIChatPostprocessor, OpenAIChatRequestPolicy + + +@dataclass(frozen=True, slots=True) +class OpenAIChatProfile: + """Immutable behavior differences for one ordinary OpenAI-chat provider.""" + + request_policy: OpenAIChatRequestPolicy + postprocessors: tuple[OpenAIChatPostprocessor, ...] = () + normalize_base_url: bool = False + + @property + def provider_name(self) -> str: + return self.request_policy.provider_name + + def base_url(self, configured: str) -> str: + return openai_v1_base_url(configured) if self.normalize_base_url else configured + + +def _apply_cohere_request_quirks( + body: dict[str, Any], request: MessagesRequest, thinking_enabled: bool +) -> None: + _merge_allowed_cohere_extra_body(body, request.extra_body) + body["reasoning_effort"] = "high" if thinking_enabled else "none" + + +_COHERE_EXTRA_BODY_KEYS = frozenset( + { + "frequency_penalty", + "presence_penalty", + "response_format", + "seed", + } +) + + +def _merge_allowed_cohere_extra_body(body: dict[str, Any], extra_body: Any) -> None: + if extra_body in (None, {}): + return + if not isinstance(extra_body, Mapping): + raise InvalidRequestError("Cohere extra_body must be an object when provided.") + + unsupported = sorted( + str(key) for key in extra_body if key not in _COHERE_EXTRA_BODY_KEYS + ) + if unsupported: + raise InvalidRequestError( + "Cohere extra_body supports only these keys: " + f"{sorted(_COHERE_EXTRA_BODY_KEYS)}. Unsupported: {unsupported}" + ) + body.update({str(key): deepcopy(value) for key, value in extra_body.items()}) + + +def _apply_kimi_thinking_policy( + body: dict[str, Any], _request: MessagesRequest, thinking_enabled: bool +) -> None: + if thinking_enabled: + return + extra_body = body.setdefault("extra_body", {}) + if isinstance(extra_body, dict): + extra_body["thinking"] = {"type": "disabled"} + + +def _apply_minimax_thinking_policy( + body: dict[str, Any], _request: MessagesRequest, thinking_enabled: bool +) -> None: + extra_body = body.setdefault("extra_body", {}) + if not isinstance(extra_body, dict): + return + extra_body["reasoning_split"] = True + extra_body["thinking"] = ( + {"type": "adaptive"} if thinking_enabled else {"type": "disabled"} + ) + + +def _apply_wafer_thinking_policy( + body: dict[str, Any], _request: MessagesRequest, thinking_enabled: bool +) -> None: + extra_body = body.setdefault("extra_body", {}) + if isinstance(extra_body, dict): + extra_body["thinking"] = ( + {"type": "enabled"} if thinking_enabled else {"type": "disabled"} + ) + + +def _apply_zai_thinking_policy( + body: dict[str, Any], _request: MessagesRequest, thinking_enabled: bool +) -> None: + extra_body = body.setdefault("extra_body", {}) + if not isinstance(extra_body, dict): + return + extra_body["thinking"] = ( + {"type": "enabled", "clear_thinking": False} + if thinking_enabled + else {"type": "disabled"} + ) + + +OPENAI_CHAT_PROFILES: dict[str, OpenAIChatProfile] = { + "mistral_codestral": OpenAIChatProfile( + OpenAIChatRequestPolicy(provider_name="CODESTRAL") + ), + "opencode": OpenAIChatProfile(OpenAIChatRequestPolicy(provider_name="OPENCODE")), + "opencode_go": OpenAIChatProfile( + OpenAIChatRequestPolicy(provider_name="OPENCODE_GO") + ), + "vercel": OpenAIChatProfile( + OpenAIChatRequestPolicy(provider_name="VERCEL", include_extra_body=True) + ), + "huggingface": OpenAIChatProfile( + OpenAIChatRequestPolicy( + provider_name="HUGGINGFACE", + include_extra_body=True, + reasoning_replay=ReasoningReplayMode.DISABLED, + ) + ), + "cohere": OpenAIChatProfile( + OpenAIChatRequestPolicy( + provider_name="COHERE", + strip_message_names=True, + unsupported_body_keys=frozenset( + { + "audio", + "logit_bias", + "metadata", + "modalities", + "n", + "parallel_tool_calls", + "prediction", + "service_tier", + "store", + "top_logprobs", + } + ), + ), + postprocessors=(_apply_cohere_request_quirks,), + ), + "wafer": OpenAIChatProfile( + OpenAIChatRequestPolicy( + provider_name="WAFER", + default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + ), + postprocessors=(_apply_wafer_thinking_policy,), + ), + "kimi": OpenAIChatProfile( + OpenAIChatRequestPolicy( + provider_name="KIMI", + reject_extra_body_message=( + "Kimi Chat Completions API does not support caller extra_body on requests." + ), + default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + ), + postprocessors=(_apply_kimi_thinking_policy,), + ), + "minimax": OpenAIChatProfile( + OpenAIChatRequestPolicy( + provider_name="MINIMAX", + default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + max_tokens_field="max_completion_tokens", + ), + postprocessors=(_apply_minimax_thinking_policy,), + ), + "cerebras": OpenAIChatProfile( + OpenAIChatRequestPolicy( + provider_name="CEREBRAS", + include_extra_body=True, + max_tokens_field="max_completion_tokens", + ) + ), + "groq": OpenAIChatProfile( + OpenAIChatRequestPolicy( + provider_name="GROQ", + include_extra_body=True, + max_tokens_field="max_completion_tokens", + strip_message_names=True, + unsupported_body_keys=frozenset({"logprobs", "logit_bias", "top_logprobs"}), + normalize_n_to_one=True, + ) + ), + "sambanova": OpenAIChatProfile( + OpenAIChatRequestPolicy(provider_name="SAMBANOVA", include_extra_body=True) + ), + "fireworks": OpenAIChatProfile( + OpenAIChatRequestPolicy( + provider_name="FIREWORKS", + include_extra_body=True, + extra_body_validator=validate_extra_body_does_not_override_canonical_fields, + default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + ) + ), + "zai": OpenAIChatProfile( + OpenAIChatRequestPolicy( + provider_name="ZAI", + reject_extra_body_message=( + "Z.ai Chat Completions API does not support caller extra_body on requests." + ), + default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + ), + postprocessors=(_apply_zai_thinking_policy,), + ), + "llamacpp": OpenAIChatProfile( + OpenAIChatRequestPolicy( + provider_name="LLAMACPP", + default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + ), + normalize_base_url=True, + ), + "ollama": OpenAIChatProfile( + OpenAIChatRequestPolicy( + provider_name="OLLAMA", + default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + ), + normalize_base_url=True, + ), +} diff --git a/src/free_claude_code/providers/openai_chat/provider.py b/src/free_claude_code/providers/openai_chat/provider.py new file mode 100644 index 0000000..f7d7549 --- /dev/null +++ b/src/free_claude_code/providers/openai_chat/provider.py @@ -0,0 +1,837 @@ +"""Concrete OpenAI-compatible provider and per-request stream execution.""" + +import asyncio +import sys +import uuid +from collections.abc import AsyncIterator, Iterator, Mapping +from typing import Any + +import httpx +from loguru import logger +from openai import AsyncOpenAI + +from free_claude_code.core.anthropic import ( + ContentType, + HeuristicToolParser, + ThinkTagParser, +) +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.anthropic.streaming import ( + AnthropicStreamLedger, + accept_tool_json_repair, + continuation_suffix, + make_text_recovery_body, + make_tool_repair_body, + map_stop_reason, + parse_complete_tool_input, + tool_schemas_by_name, +) +from free_claude_code.core.failures import ExecutionFailure +from free_claude_code.core.trace import provider_chat_body_snapshot, trace_event +from free_claude_code.providers.base import BaseProvider, ProviderConfig +from free_claude_code.providers.failure_policy import classify_provider_failure +from free_claude_code.providers.http import ( + close_provider_stream, + maybe_await_aclose, +) +from free_claude_code.providers.model_listing import extract_openai_model_ids +from free_claude_code.providers.rate_limit import ProviderRateLimiter +from free_claude_code.providers.stream_recovery import ( + MIDSTREAM_RECOVERY_ATTEMPTS, + RecoveryController, + RecoveryFailureAction, + TruncatedProviderStreamError, + is_retryable_stream_error, +) + +from .output_cap import clamp_output_tokens, parse_output_token_cap +from .profiles import OpenAIChatProfile +from .request_policy import build_openai_chat_request_body +from .tool_calls import ( + OpenAIToolCallAssembler, + all_emitted_tools_complete, + has_committed_sse_output, + iter_heuristic_tool_use_sse, + started_tool_states, + tool_call_extra_content, +) +from .usage import ( + clone_without_stream_usage, + is_stream_usage_rejection, + request_stream_usage, + usage_int, +) + + +class OpenAIChatProvider(BaseProvider): + """OpenAI-compatible ``/chat/completions`` provider configured by a profile.""" + + def __init__( + self, + config: ProviderConfig, + *, + profile: OpenAIChatProfile, + rate_limiter: ProviderRateLimiter, + default_headers: Mapping[str, str] | None = None, + ): + super().__init__(config) + self._profile = profile + self._provider_name = profile.provider_name + self._api_key = config.api_key + self._base_url = profile.base_url(config.base_url).rstrip("/") + # Learned per-model output-token caps from upstream 400 rejections, so + # later requests clamp proactively instead of paying the 400 each time. + self._model_output_caps: dict[str, int] = {} + self._rate_limiter = rate_limiter + http_client = None + if config.proxy: + http_client = httpx.AsyncClient( + proxy=config.proxy, + timeout=httpx.Timeout( + config.http_read_timeout, + connect=config.http_connect_timeout, + read=config.http_read_timeout, + write=config.http_write_timeout, + ), + ) + self._client = AsyncOpenAI( + api_key=self._api_key, + base_url=self._base_url, + max_retries=0, + default_headers=default_headers, + timeout=httpx.Timeout( + config.http_read_timeout, + connect=config.http_connect_timeout, + read=config.http_read_timeout, + write=config.http_write_timeout, + ), + http_client=http_client, + ) + + async def cleanup(self) -> None: + """Release HTTP client resources.""" + client = getattr(self, "_client", None) + if client is not None: + await client.close() + + async def list_model_ids(self) -> frozenset[str]: + """Return model ids from the provider's OpenAI-compatible models endpoint.""" + payload = await self._client.models.list() + return extract_openai_model_ids(payload, provider_name=self._provider_name) + + def _build_request_body( + self, request: MessagesRequest, thinking_enabled: bool | None = None + ) -> dict[str, Any]: + """Build a provider request from the immutable profile.""" + return build_openai_chat_request_body( + request, + thinking_enabled=self._is_thinking_enabled(request, thinking_enabled), + policy=self._profile.request_policy, + postprocessors=self._profile.postprocessors, + ) + + def preflight_stream( + self, request: MessagesRequest, *, thinking_enabled: bool | None = None + ) -> None: + """Validate OpenAI-chat request conversion before streaming.""" + self._build_request_body(request, thinking_enabled=thinking_enabled) + + def _handle_extra_reasoning( + self, delta: Any, ledger: AnthropicStreamLedger, *, thinking_enabled: bool + ) -> Iterator[str]: + """Hook for provider-specific reasoning.""" + return iter(()) + + def _get_retry_request_body(self, error: Exception, body: dict) -> dict | None: + """Return a modified request body for one retry, or None.""" + return None + + def _provider_failure_override(self, error: Exception) -> ExecutionFailure | None: + """Return provider-specific failure semantics, or defer to shared policy.""" + return None + + def _prepare_create_body(self, body: dict[str, Any]) -> dict[str, Any]: + """Return the body passed to the upstream OpenAI-compatible client.""" + return body + + def _record_tool_call_extra_content( + self, tool_call_id: str, extra_content: dict[str, Any] + ) -> None: + """Hook for providers that must replay OpenAI tool-call metadata later.""" + + def _tool_argument_aliases(self, body: dict[str, Any]) -> dict[str, dict[str, str]]: + """Return provider-specific per-tool argument aliases for this request.""" + return {} + + def _anthropic_usage_fields(self, usage_info: Any) -> dict[str, int]: + """Return provider-specific Anthropic usage fields for final SSE usage.""" + return {} + + async def _create_stream(self, body: dict) -> tuple[Any, dict]: + """Create a streaming chat completion with bounded request fallbacks.""" + body = self._apply_learned_output_cap(body) + used_retry_kinds: set[str] = set() + + while True: + try: + create_body = self._prepare_create_body(body) + stream = await self._rate_limiter.execute_with_retry( + self._client.chat.completions.create, + provider_failure_override=self._provider_failure_override, + **create_body, + stream=True, + ) + return stream, body + except Exception as error: + retry_body = self._next_create_retry_body(error, body, used_retry_kinds) + if retry_body is None: + raise + body = retry_body + + def _next_create_retry_body( + self, + error: Exception, + body: dict, + used_retry_kinds: set[str], + ) -> dict | None: + retry_body = self._retry_body_for_output_cap(error, body) + if retry_body is not None: + return retry_body + + if "stream_usage" not in used_retry_kinds and is_stream_usage_rejection(error): + retry_body = clone_without_stream_usage(body) + if retry_body is not None: + used_retry_kinds.add("stream_usage") + logger.warning( + "{}_STREAM: retrying without stream_options.include_usage " + "after upstream rejection", + self._provider_name, + ) + return retry_body + + if "provider_specific" not in used_retry_kinds: + retry_body = self._get_retry_request_body(error, body) + if retry_body is not None: + used_retry_kinds.add("provider_specific") + return retry_body + + return None + + def _apply_learned_output_cap(self, body: dict) -> dict: + """Clamp output tokens to a previously learned cap for this model.""" + model = body.get("model") + if not isinstance(model, str): + return body + cap = self._model_output_caps.get(model) + if cap is None: + return body + clamped = clamp_output_tokens(body, cap) + return clamped if clamped is not None else body + + def _retry_body_for_output_cap(self, error: Exception, body: dict) -> dict | None: + """Learn an upstream output-token cap from a 400 and clamp for one retry.""" + cap = parse_output_token_cap(error) + if cap is None: + return None + model = body.get("model") + if isinstance(model, str): + previous = self._model_output_caps.get(model) + cap = cap if previous is None else min(previous, cap) + self._model_output_caps[model] = cap + clamped = clamp_output_tokens(body, cap) + if clamped is None: + return None + logger.warning( + "{}_STREAM: clamping output tokens to {} after upstream cap rejection", + self._provider_name, + cap, + ) + return clamped + + def stream_response( + self, + request: MessagesRequest, + input_tokens: int = 0, + *, + request_id: str | None = None, + thinking_enabled: bool | None = None, + ) -> AsyncIterator[str]: + """Stream response in Anthropic SSE format.""" + runner = _OpenAIChatStreamRunner( + self, + request=request, + input_tokens=input_tokens, + request_id=request_id, + thinking_enabled=thinking_enabled, + ) + return runner.run() + + +class _OpenAIChatStreamRunner: + """Own one OpenAI-chat request's stream, parsing, and recovery state.""" + + def __init__( + self, + provider: OpenAIChatProvider, + *, + request: MessagesRequest, + input_tokens: int, + request_id: str | None, + thinking_enabled: bool | None, + ) -> None: + self._provider = provider + self._request = request + self._input_tokens = input_tokens + self._request_id = request_id + self._thinking_enabled = thinking_enabled + self._message_id = f"msg_{uuid.uuid4()}" + self._tool_calls = OpenAIToolCallAssembler( + record_extra_content=provider._record_tool_call_extra_content + ) + + async def run(self) -> AsyncIterator[str]: + """Convert the upstream OpenAI-chat stream into Anthropic SSE.""" + tag = self._provider._provider_name + req_tag = f" request_id={self._request_id}" if self._request_id else "" + ledger = self._new_ledger() + recovery = RecoveryController( + provider_name=tag, + request_id=self._request_id, + ) + + def hold_event(event: str) -> Iterator[str]: + yield from recovery.push(event) + + def hold_events(events: Iterator[str]) -> Iterator[str]: + for event in events: + yield from hold_event(event) + + body = self._provider._build_request_body( + self._request, thinking_enabled=self._thinking_enabled + ) + request_stream_usage(body) + thinking_enabled = self._provider._is_thinking_enabled( + self._request, self._thinking_enabled + ) + trace_event( + stage="provider", + event="provider.request.sent", + source="provider", + provider=tag, + request_id=self._request_id, + gateway_model=self._request.model, + downstream_model=body.get("model"), + message_count=len(body.get("messages", [])), + tool_count=len(body.get("tools", [])), + body=provider_chat_body_snapshot(body), + ) + + think_parser = ThinkTagParser() + heuristic_parser = HeuristicToolParser() + finish_reason = None + usage_info = None + tool_argument_aliases: dict[str, dict[str, str]] = {} + tool_argument_alias_buffers: dict[int, str] = {} + + async with self._provider._rate_limiter.concurrency_slot(): + while True: + if not ledger.message_started: + for event in hold_event(ledger.message_start()): + yield event + stream: Any | None = None + stream_opened = False + try: + stream, body = await self._provider._create_stream(body) + stream_opened = True + tool_argument_aliases = self._provider._tool_argument_aliases(body) + async for chunk in stream: + chunk_usage = getattr(chunk, "usage", None) + if chunk_usage is not 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) + + reasoning = getattr(delta, "reasoning_content", None) + if thinking_enabled and isinstance(reasoning, str): + for event in hold_events(ledger.ensure_thinking_block()): + yield event + if reasoning: + for event in hold_event( + ledger.emit_thinking_delta(reasoning) + ): + yield event + + for event in self._provider._handle_extra_reasoning( + delta, + ledger, + thinking_enabled=thinking_enabled, + ): + for out_event in hold_event(event): + yield out_event + + if delta.content: + for part in think_parser.feed(delta.content): + if part.type == ContentType.THINKING: + if not thinking_enabled: + continue + for event in hold_events( + ledger.ensure_thinking_block() + ): + yield event + for event in hold_event( + ledger.emit_thinking_delta(part.content) + ): + yield event + else: + ( + filtered_text, + detected_tools, + ) = heuristic_parser.feed(part.content) + + if filtered_text: + for event in hold_events( + ledger.ensure_text_block() + ): + yield event + for event in hold_event( + ledger.emit_text_delta(filtered_text) + ): + yield event + + for tool_use in detected_tools: + for event in iter_heuristic_tool_use_sse( + ledger, tool_use + ): + for out_event in hold_event(event): + yield out_event + + if delta.tool_calls: + for event in hold_events(ledger.close_content_blocks()): + yield event + for tool_call in delta.tool_calls: + extra_content = tool_call_extra_content(tool_call) + tool_call_info = { + "index": tool_call.index, + "id": tool_call.id, + "function": { + "name": tool_call.function.name, + "arguments": tool_call.function.arguments, + }, + } + if extra_content: + tool_call_info["extra_content"] = extra_content + for event in self._tool_calls.process_tool_call( + tool_call_info, + ledger, + tool_argument_aliases=tool_argument_aliases, + tool_argument_alias_buffers=tool_argument_alias_buffers, + ): + for out_event in hold_event(event): + yield out_event + + if finish_reason is None: + raise TruncatedProviderStreamError( + "Provider stream ended without finish_reason." + ) + break + + except asyncio.CancelledError, GeneratorExit: + raise + except Exception as error: + generated_output = has_committed_sse_output(ledger) + complete_tool_salvageable = ( + generated_output + and ledger.has_emitted_tool_block() + and all_emitted_tools_complete(ledger, self._request) + ) + decision = recovery.advance_failure( + error, + stream_opened=stream_opened, + generated_output=generated_output, + complete_tool_salvageable=complete_tool_salvageable, + ) + if decision.action == RecoveryFailureAction.EARLY_RETRY: + ledger = self._new_ledger() + think_parser = ThinkTagParser() + heuristic_parser = HeuristicToolParser() + finish_reason = None + usage_info = None + tool_argument_aliases = {} + tool_argument_alias_buffers = {} + continue + + if decision.action == RecoveryFailureAction.MIDSTREAM_RECOVERY: + try: + recovery_events = await self._recovery_events( + body=body, + ledger=ledger, + error=error, + tool_argument_alias_buffers=tool_argument_alias_buffers, + ) + except Exception as recovery_error: + trace_event( + stage="provider", + event="provider.recovery.failed", + source="provider", + provider=tag, + request_id=self._request_id, + exc_type=type(recovery_error).__name__, + ) + recovery_events = None + if recovery_events is not None: + for event in recovery.flush_uncommitted(decision): + yield event + for event in recovery_events: + yield event + return + + self._provider._log_stream_transport_error( + tag, req_tag, error, request_id=self._request_id + ) + failure = classify_provider_failure( + error, + provider_name=tag, + read_timeout_s=self._provider._config.http_read_timeout, + request_id=self._request_id, + mark_rate_limited=( + self._provider._rate_limiter.extend_reactive_block + ), + provider_failure_override=( + self._provider._provider_failure_override + ), + ) + error_trace: dict[str, Any] = { + "stage": "provider", + "event": "provider.response.error", + "source": "provider", + "provider": tag, + "request_id": self._request_id, + "exc_type": type(error).__name__, + "failure_kind": failure.kind.value, + "status_code": failure.status_code, + "provider_retryable": failure.retryable, + } + if self._provider._config.log_api_error_tracebacks: + error_trace["error_message"] = failure.message + trace_event(**error_trace) + if ( + not decision.committed + and decision.has_buffered + and complete_tool_salvageable + ): + for event in recovery.flush(): + yield event + elif not decision.committed: + recovery.discard() + raise failure from error + for event in ledger.close_unclosed_blocks(): + yield event + raise failure from error + finally: + if stream is not None: + await close_provider_stream( + stream, + active_error=sys.exception(), + provider_name=tag, + request_id=self._request_id, + ) + + remaining = think_parser.flush() + if remaining: + if remaining.type == ContentType.THINKING: + if not thinking_enabled: + remaining = None + else: + for event in hold_events(ledger.ensure_thinking_block()): + yield event + for event in hold_event( + ledger.emit_thinking_delta(remaining.content) + ): + yield event + if remaining and remaining.type == ContentType.TEXT: + for event in hold_events(ledger.ensure_text_block()): + yield event + for event in hold_event(ledger.emit_text_delta(remaining.content)): + yield event + + for tool_use in heuristic_parser.flush(): + for event in iter_heuristic_tool_use_sse(ledger, tool_use): + for out_event in hold_event(event): + yield out_event + + has_emitted_tool = ledger.has_emitted_tool_block() + has_content_blocks = ( + ledger.blocks.text_index != -1 + or ledger.blocks.thinking_index != -1 + or has_emitted_tool + ) + if not has_content_blocks or ( + not has_emitted_tool + and not ledger.accumulated_text.strip() + and ledger.accumulated_reasoning.strip() + ): + for event in hold_events(ledger.ensure_text_block()): + yield event + for event in hold_event(ledger.emit_text_delta(" ")): + yield event + + for event in self._tool_calls.flush_tool_argument_alias_buffers( + ledger, tool_argument_aliases, tool_argument_alias_buffers + ): + for out_event in hold_event(event): + yield out_event + + for event in self._tool_calls.flush_task_arg_buffers(ledger): + for out_event in hold_event(event): + yield out_event + + for event in hold_events(ledger.close_all_blocks()): + yield event + + completion = usage_int(usage_info, "completion_tokens") + if isinstance(completion, int): + output_tokens = completion + else: + output_tokens = ledger.estimate_output_tokens() + provider_input = usage_int(usage_info, "prompt_tokens") + if provider_input is not None: + logger.debug( + "TOKEN_ESTIMATE: our={} provider={} diff={:+d}", + self._input_tokens, + provider_input, + provider_input - self._input_tokens, + ) + input_tokens = ( + provider_input if provider_input is not None else self._input_tokens + ) + trace_event( + stage="provider", + event="provider.response.completed", + source="provider", + provider=tag, + request_id=self._request_id, + finish_reason=(None if finish_reason is None else str(finish_reason)), + output_tokens=output_tokens, + prompt_tokens=input_tokens, + prompt_tokens_estimate=self._input_tokens, + ) + for event in hold_event( + ledger.message_delta( + ledger.final_stop_reason(map_stop_reason(finish_reason)), + output_tokens, + input_tokens=input_tokens, + usage_fields=self._provider._anthropic_usage_fields(usage_info), + ) + ): + yield event + for event in hold_event(ledger.message_stop()): + yield event + for event in recovery.flush(): + yield event + + async def _collect_recovery_text(self, body: dict[str, Any]) -> tuple[str, str]: + """Collect a complete text/reasoning continuation stream.""" + last_error: Exception | None = None + for attempt in range(MIDSTREAM_RECOVERY_ATTEMPTS): + stream: Any | None = None + try: + stream, _ = await self._provider._create_stream(body) + text_parts: list[str] = [] + thinking_parts: list[str] = [] + terminal_seen = False + async for chunk in stream: + if not getattr(chunk, "choices", None): + continue + choice = chunk.choices[0] + if choice.finish_reason is not None: + terminal_seen = True + delta = choice.delta + if delta is None: + continue + reasoning = getattr(delta, "reasoning_content", None) + if isinstance(reasoning, str) and reasoning: + thinking_parts.append(reasoning) + content = getattr(delta, "content", None) + if isinstance(content, str) and content: + text_parts.append(content) + if not terminal_seen: + raise TruncatedProviderStreamError( + "Recovery stream ended without finish_reason." + ) + return "".join(text_parts), "".join(thinking_parts) + except Exception as error: + last_error = error + if not is_retryable_stream_error(error): + raise + trace_event( + stage="provider", + event="provider.recovery.retry", + source="provider", + provider=self._provider._provider_name, + recovery_kind="openai_text", + attempt=attempt + 1, + max_attempts=MIDSTREAM_RECOVERY_ATTEMPTS, + exc_type=type(error).__name__, + ) + finally: + if stream is not None: + await maybe_await_aclose(stream) + if last_error is not None: + raise last_error + return "", "" + + async def _recovery_events( + self, + *, + body: dict[str, Any], + ledger: AnthropicStreamLedger, + error: Exception, + tool_argument_alias_buffers: dict[int, str], + ) -> list[str] | None: + """Build terminal recovery events when the interrupted stream permits it.""" + if not is_retryable_stream_error(error): + return None + + if ledger.has_emitted_tool_block(): + if not all_emitted_tools_complete(ledger, self._request): + repair_events = await self._repair_tool_args( + body=body, + ledger=ledger, + tool_argument_alias_buffers=tool_argument_alias_buffers, + ) + if repair_events is None: + return None + else: + repair_events = [] + events = list(repair_events) + events.extend(ledger.close_all_blocks()) + events.append( + ledger.message_delta( + ledger.final_stop_reason("end_turn"), + ledger.estimate_output_tokens(), + ) + ) + events.append(ledger.message_stop()) + trace_event( + stage="provider", + event="provider.recovery.tool_salvaged", + source="provider", + provider=self._provider._provider_name, + request_id=self._request_id, + ) + return events + + partial_text = ledger.accumulated_text + partial_thinking = ledger.accumulated_reasoning + if not partial_text and not partial_thinking: + return None + + recovery_body = make_text_recovery_body(body, partial_text, partial_thinking) + text, thinking = await self._collect_recovery_text(recovery_body) + text_suffix = continuation_suffix(partial_text, text) + thinking_suffix = continuation_suffix(partial_thinking, thinking) + events: list[str] = [] + if thinking_suffix: + events.extend(ledger.ensure_thinking_block()) + events.append(ledger.emit_thinking_delta(thinking_suffix)) + if text_suffix: + events.extend(ledger.ensure_text_block()) + events.append(ledger.emit_text_delta(text_suffix)) + if not events: + return None + events.extend(ledger.close_all_blocks()) + events.append( + ledger.message_delta( + ledger.final_stop_reason("end_turn"), ledger.estimate_output_tokens() + ) + ) + events.append(ledger.message_stop()) + trace_event( + stage="provider", + event="provider.recovery.continued", + source="provider", + provider=self._provider._provider_name, + request_id=self._request_id, + ) + return events + + async def _repair_tool_args( + self, + *, + body: dict[str, Any], + ledger: AnthropicStreamLedger, + tool_argument_alias_buffers: dict[int, str], + ) -> list[str] | None: + schemas = tool_schemas_by_name(self._request) + events: list[str] = [] + for tool_index, state in started_tool_states(ledger): + block = ledger.tool_block_for_tool_index(tool_index) + emitted_prefix = block.content if block is not None else "" + repair_prefix = emitted_prefix + if not repair_prefix and state.name == "Task" and state.task_arg_buffer: + repair_prefix = state.task_arg_buffer + if not repair_prefix and tool_index in tool_argument_alias_buffers: + repair_prefix = tool_argument_alias_buffers[tool_index] + if ( + parse_complete_tool_input(repair_prefix, state.name, schemas) + is not None + ): + if not emitted_prefix and repair_prefix: + events.append(ledger.emit_tool_delta(tool_index, repair_prefix)) + continue + + schema = schemas.get(state.name) + recovery_body = make_tool_repair_body( + body, + tool_name=state.name, + prefix=repair_prefix, + input_schema=schema.input_schema if schema is not None else None, + ) + accepted_suffix: str | None = None + for attempt in range(MIDSTREAM_RECOVERY_ATTEMPTS): + text, _ = await self._collect_recovery_text(recovery_body) + repair = accept_tool_json_repair( + repair_prefix, + text, + tool_name=state.name, + schemas=schemas, + ) + if repair is not None: + accepted_suffix = repair.suffix + trace_event( + stage="provider", + event="provider.recovery.tool_repaired", + source="provider", + provider=self._provider._provider_name, + tool_name=state.name, + attempt=attempt + 1, + ) + break + if accepted_suffix is None: + return None + to_emit = ( + accepted_suffix if emitted_prefix else repair_prefix + accepted_suffix + ) + if to_emit: + events.append(ledger.emit_tool_delta(tool_index, to_emit)) + if not all_emitted_tools_complete(ledger, self._request): + return None + return events + + def _new_ledger(self) -> AnthropicStreamLedger: + return AnthropicStreamLedger( + self._message_id, + self._request.model, + self._input_tokens, + log_raw_events=self._provider._config.log_raw_sse_events, + ) diff --git a/src/free_claude_code/providers/openai_chat/request_policy.py b/src/free_claude_code/providers/openai_chat/request_policy.py new file mode 100644 index 0000000..c509061 --- /dev/null +++ b/src/free_claude_code/providers/openai_chat/request_policy.py @@ -0,0 +1,123 @@ +"""Request-body policy for OpenAI-compatible chat providers.""" + +from collections.abc import Callable, Iterable +from copy import deepcopy +from dataclasses import dataclass, field +from typing import Any, Literal + +from loguru import logger + +from free_claude_code.application.errors import InvalidRequestError +from free_claude_code.core.anthropic import ReasoningReplayMode, build_base_request_body +from free_claude_code.core.anthropic.conversion import OpenAIConversionError +from free_claude_code.core.anthropic.models import MessagesRequest + +MaxTokensField = Literal["max_tokens", "max_completion_tokens"] +OpenAIChatPostprocessor = Callable[[dict[str, Any], MessagesRequest, bool], None] +ExtraBodyValidator = Callable[[dict[str, Any]], None] + + +@dataclass(frozen=True, slots=True) +class OpenAIChatRequestPolicy: + """Provider policy for Anthropic-to-OpenAI chat request conversion.""" + + provider_name: str + include_extra_body: bool = False + extra_body_validator: ExtraBodyValidator | None = None + reject_extra_body_message: str | None = None + default_max_tokens: int | None = None + max_tokens_field: MaxTokensField = "max_tokens" + reasoning_replay: ReasoningReplayMode | None = None + strip_message_names: bool = False + unsupported_body_keys: frozenset[str] = field(default_factory=frozenset) + normalize_n_to_one: bool = False + + +def build_openai_chat_request_body( + request_data: MessagesRequest, + *, + thinking_enabled: bool, + policy: OpenAIChatRequestPolicy, + postprocessors: Iterable[OpenAIChatPostprocessor] = (), +) -> dict[str, Any]: + """Build an OpenAI-compatible chat request body from an Anthropic request.""" + logger.debug( + "{}_REQUEST: conversion start model={} msgs={}", + policy.provider_name, + request_data.model, + len(request_data.messages), + ) + try: + reasoning_replay = policy.reasoning_replay + if reasoning_replay is None: + reasoning_replay = ( + ReasoningReplayMode.REASONING_CONTENT + if thinking_enabled + else ReasoningReplayMode.DISABLED + ) + body = build_base_request_body( + request_data, + default_max_tokens=policy.default_max_tokens, + reasoning_replay=reasoning_replay, + ) + except OpenAIConversionError as exc: + raise InvalidRequestError(str(exc)) from exc + + request_extra = request_data.extra_body + if isinstance(request_extra, dict) and request_extra: + if policy.reject_extra_body_message: + raise InvalidRequestError(policy.reject_extra_body_message) + if policy.include_extra_body: + extra_body = deepcopy(request_extra) + if policy.extra_body_validator is not None: + try: + policy.extra_body_validator(extra_body) + except ValueError as exc: + raise InvalidRequestError(str(exc)) from exc + body["extra_body"] = extra_body + + _apply_common_openai_chat_policy(body, policy) + + for postprocess in postprocessors: + postprocess(body, request_data, thinking_enabled) + + logger.debug( + "{}_REQUEST: conversion done model={} msgs={} tools={}", + policy.provider_name, + body.get("model"), + len(body.get("messages", [])), + len(body.get("tools", [])), + ) + return body + + +def _apply_common_openai_chat_policy( + body: dict[str, Any], policy: OpenAIChatRequestPolicy +) -> None: + if policy.strip_message_names: + _strip_message_names(body.get("messages")) + + for key in policy.unsupported_body_keys: + body.pop(key, None) + + if policy.max_tokens_field == "max_completion_tokens": + _normalize_max_completion_tokens(body) + + if policy.normalize_n_to_one and body.get("n") is not None: + body["n"] = 1 + + +def _strip_message_names(messages: Any) -> None: + if not isinstance(messages, list): + return + for message in messages: + if isinstance(message, dict): + message.pop("name", None) + + +def _normalize_max_completion_tokens(body: dict[str, Any]) -> None: + if "max_completion_tokens" in body: + body.pop("max_tokens", None) + return + if "max_tokens" in body and body["max_tokens"] is not None: + body["max_completion_tokens"] = body.pop("max_tokens") diff --git a/src/free_claude_code/providers/openai_chat/tool_calls.py b/src/free_claude_code/providers/openai_chat/tool_calls.py new file mode 100644 index 0000000..7c3d7e3 --- /dev/null +++ b/src/free_claude_code/providers/openai_chat/tool_calls.py @@ -0,0 +1,285 @@ +"""OpenAI-chat tool-call assembly helpers.""" + +import json +import uuid +from collections.abc import Callable, Iterator +from typing import Any + +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.anthropic.streaming import ( + AnthropicStreamLedger, + tool_schemas_by_name, +) + +RecordToolExtraContent = Callable[[str, dict[str, Any]], None] + + +def iter_heuristic_tool_use_sse( + ledger: AnthropicStreamLedger, tool_use: dict[str, Any] +) -> Iterator[str]: + """Emit SSE for one heuristic tool_use block.""" + if tool_use.get("name") == "Task" and isinstance(tool_use.get("input"), dict): + task_input = tool_use["input"] + if task_input.get("run_in_background") is not False: + task_input["run_in_background"] = False + yield from ledger.close_content_blocks() + block_idx = ledger.blocks.allocate_index() + yield ledger.content_block_start( + block_idx, + "tool_use", + id=tool_use["id"], + name=tool_use["name"], + ) + yield ledger.content_block_delta( + block_idx, + "input_json_delta", + json.dumps(tool_use["input"]), + ) + yield ledger.content_block_stop(block_idx) + + +def tool_call_extra_content(tool_call: Any) -> dict[str, Any] | None: + """Return provider-specific extra tool-call metadata from OpenAI objects.""" + if isinstance(tool_call, dict): + value = tool_call.get("extra_content") + return value if isinstance(value, dict) else None + + value = getattr(tool_call, "extra_content", None) + if isinstance(value, dict): + return value + + model_extra = getattr(tool_call, "model_extra", None) + if isinstance(model_extra, dict): + value = model_extra.get("extra_content") + if isinstance(value, dict): + return value + + pydantic_extra = getattr(tool_call, "__pydantic_extra__", None) + if isinstance(pydantic_extra, dict): + value = pydantic_extra.get("extra_content") + if isinstance(value, dict): + return value + + return None + + +def has_committed_sse_output(ledger: AnthropicStreamLedger) -> bool: + """Return whether any assistant content escaped the builder.""" + return ( + ledger.blocks.text_index != -1 + or ledger.blocks.thinking_index != -1 + or ledger.has_emitted_tool_block() + ) + + +def started_tool_states(ledger: AnthropicStreamLedger) -> list[tuple[int, Any]]: + """Return started tool states in stream order.""" + return [ + (tool_index, state) + for tool_index, state in ledger.blocks.tool_states.items() + if state.started + ] + + +def all_emitted_tools_complete( + ledger: AnthropicStreamLedger, request: MessagesRequest +) -> bool: + """Return whether every emitted tool block has schema-valid input.""" + return ledger.can_salvage_tool_use(tool_schemas_by_name(request)) + + +class OpenAIToolCallAssembler: + """Assemble OpenAI tool-call deltas into Anthropic SSE tool blocks.""" + + def __init__( + self, *, record_extra_content: RecordToolExtraContent | None = None + ) -> None: + self._record_extra_content = record_extra_content + + def process_tool_call( + self, + tc: dict[str, Any], + ledger: AnthropicStreamLedger, + *, + tool_argument_aliases: dict[str, dict[str, str]] | None = None, + tool_argument_alias_buffers: dict[int, str] | None = None, + ) -> Iterator[str]: + """Process a single tool-call delta and yield Anthropic SSE events.""" + raw_index = tc.get("index", 0) + tc_index = raw_index if isinstance(raw_index, int) else 0 + if tc_index < 0: + tc_index = len(ledger.blocks.tool_states) + + fn_delta = tc.get("function", {}) + incoming_name = fn_delta.get("name") + arguments = fn_delta.get("arguments", "") or "" + + if tc.get("id") is not None: + ledger.blocks.set_stream_tool_id(tc_index, tc.get("id")) + + raw_extra_content = tc.get("extra_content") + extra_content = ( + raw_extra_content + if isinstance(raw_extra_content, dict) and raw_extra_content + else None + ) + if extra_content: + ledger.blocks.set_tool_extra_content(tc_index, extra_content) + + if incoming_name is not None: + ledger.blocks.register_tool_name(tc_index, incoming_name) + + state = ledger.blocks.tool_states.get(tc_index) + resolved_id = (state.tool_id if state and state.tool_id else None) or tc.get( + "id" + ) + resolved_name = (state.name if state else "") or "" + + if not state or not state.started: + name_ok = bool((resolved_name or "").strip()) + if name_ok: + tool_id = str(resolved_id) if resolved_id else f"tool_{uuid.uuid4()}" + display_name = (resolved_name or "").strip() or "tool_call" + start_extra_content = state.extra_content if state else extra_content + if start_extra_content: + self._record_tool_call_extra_content(tool_id, start_extra_content) + yield ledger.start_tool_block( + tc_index, + tool_id, + display_name, + extra_content=start_extra_content, + ) + state = ledger.blocks.tool_states[tc_index] + if state.pre_start_args: + pre = state.pre_start_args + state.pre_start_args = "" + yield from self._emit_tool_arg_delta( + ledger, + tc_index, + pre, + tool_argument_aliases=tool_argument_aliases, + tool_argument_alias_buffers=tool_argument_alias_buffers, + ) + + state = ledger.blocks.tool_states.get(tc_index) + if state is not None and state.tool_id and extra_content: + self._record_tool_call_extra_content(state.tool_id, extra_content) + if not arguments: + return + if state is None or not state.started: + state = ledger.blocks.ensure_tool_state(tc_index) + if not (resolved_name or "").strip(): + state.pre_start_args += arguments + return + + yield from self._emit_tool_arg_delta( + ledger, + tc_index, + arguments, + tool_argument_aliases=tool_argument_aliases, + tool_argument_alias_buffers=tool_argument_alias_buffers, + ) + + def flush_task_arg_buffers(self, ledger: AnthropicStreamLedger) -> Iterator[str]: + """Emit buffered Task args as a single JSON delta.""" + for tool_index, out in ledger.blocks.flush_task_arg_buffers(): + yield ledger.emit_tool_delta(tool_index, out) + + def flush_tool_argument_alias_buffers( + self, + ledger: AnthropicStreamLedger, + tool_argument_aliases: dict[str, dict[str, str]], + tool_argument_alias_buffers: dict[int, str], + ) -> Iterator[str]: + """Emit remaining aliased args without losing malformed JSON.""" + for tool_index, buffered_args in list(tool_argument_alias_buffers.items()): + if not buffered_args: + tool_argument_alias_buffers.pop(tool_index, None) + continue + state = ledger.blocks.tool_states.get(tool_index) + if state is None or state.name == "Task": + continue + aliases = tool_argument_aliases.get(state.name, {}) + if not aliases: + continue + restored = self._restore_aliased_tool_arguments(buffered_args, aliases) + yield ledger.emit_tool_delta( + tool_index, + restored if restored is not None else buffered_args, + ) + tool_argument_alias_buffers.pop(tool_index, None) + + def _emit_tool_arg_delta( + self, + ledger: AnthropicStreamLedger, + tc_index: int, + args: str, + *, + tool_argument_aliases: dict[str, dict[str, str]] | None = None, + tool_argument_alias_buffers: dict[int, str] | None = None, + ) -> Iterator[str]: + """Emit one argument fragment for a started tool block.""" + if not args: + return + state = ledger.blocks.tool_states.get(tc_index) + if state is None: + return + if state.name == "Task": + parsed = ledger.blocks.buffer_task_args(tc_index, args) + if parsed is not None: + yield ledger.emit_tool_delta(tc_index, json.dumps(parsed)) + return + aliases = ( + tool_argument_aliases.get(state.name, {}) if tool_argument_aliases else {} + ) + if aliases: + if tool_argument_alias_buffers is None: + restored = self._restore_aliased_tool_arguments(args, aliases) + if restored is not None: + yield ledger.emit_tool_delta(tc_index, restored) + return + + buffered_args = tool_argument_alias_buffers.get(tc_index, "") + args + restored = self._restore_aliased_tool_arguments(buffered_args, aliases) + if restored is None: + tool_argument_alias_buffers[tc_index] = buffered_args + return + tool_argument_alias_buffers.pop(tc_index, None) + yield ledger.emit_tool_delta(tc_index, restored) + return + yield ledger.emit_tool_delta(tc_index, args) + + def _restore_aliased_tool_arguments( + self, argument_json: str, aliases: dict[str, str] + ) -> str | None: + try: + parsed = json.loads(argument_json) + except json.JSONDecodeError: + return None + if not isinstance(parsed, dict): + return argument_json + restored = self._restore_aliased_tool_argument_value(parsed, aliases) + return json.dumps(restored) + + def _restore_aliased_tool_argument_value( + self, value: Any, aliases: dict[str, str] + ) -> Any: + if isinstance(value, dict): + return { + aliases.get(key, key): self._restore_aliased_tool_argument_value( + item, aliases + ) + for key, item in value.items() + } + if isinstance(value, list): + return [ + self._restore_aliased_tool_argument_value(item, aliases) + for item in value + ] + return value + + def _record_tool_call_extra_content( + self, tool_call_id: str, extra_content: dict[str, Any] + ) -> None: + if self._record_extra_content is not None: + self._record_extra_content(tool_call_id, extra_content) diff --git a/src/free_claude_code/providers/openai_chat/usage.py b/src/free_claude_code/providers/openai_chat/usage.py new file mode 100644 index 0000000..4be8dff --- /dev/null +++ b/src/free_claude_code/providers/openai_chat/usage.py @@ -0,0 +1,98 @@ +"""OpenAI-chat streamed usage request and extraction helpers.""" + +import json +from collections.abc import Mapping +from typing import Any + +import openai + +_USAGE_OPTION_KEYS = ("stream_options", "include_usage") +_USAGE_REJECTION_WORDS = ( + "unsupported", + "not supported", + "unknown", + "unrecognized", + "unexpected", + "invalid", + "extra", + "forbidden", + "not permitted", +) + + +def request_stream_usage(body: dict[str, Any]) -> None: + """Ask an OpenAI-compatible streaming endpoint for its final usage chunk.""" + stream_options = body.get("stream_options") + if stream_options is None: + body["stream_options"] = {"include_usage": True} + return + if isinstance(stream_options, dict): + stream_options["include_usage"] = True + + +def clone_without_stream_usage(body: dict[str, Any]) -> dict[str, Any] | None: + """Return a clone with only ``include_usage`` removed from stream options.""" + stream_options = body.get("stream_options") + if not isinstance(stream_options, dict): + return None + if "include_usage" not in stream_options: + return None + + retry_body = dict(body) + retry_stream_options = dict(stream_options) + retry_stream_options.pop("include_usage", None) + if retry_stream_options: + retry_body["stream_options"] = retry_stream_options + else: + retry_body.pop("stream_options", None) + return retry_body + + +def is_stream_usage_rejection(error: Exception) -> bool: + """Return whether upstream rejected the optional streamed-usage request.""" + if not _is_bad_request_like(error): + return False + text = _error_text(error) + if not any(key in text for key in _USAGE_OPTION_KEYS): + return False + return any(word in text for word in _USAGE_REJECTION_WORDS) + + +def usage_int(usage_info: Any, key: str) -> int | None: + """Extract an integer usage field from OpenAI SDK objects or plain dicts.""" + if usage_info is None: + return None + if isinstance(usage_info, Mapping): + value = usage_info.get(key) + else: + value = getattr(usage_info, key, None) + if value is None: + extra = getattr(usage_info, "model_extra", None) + if isinstance(extra, Mapping): + value = extra.get(key) + return value if isinstance(value, int) and not isinstance(value, bool) else None + + +def _is_bad_request_like(error: Exception) -> bool: + if isinstance(error, openai.BadRequestError): + return True + status = getattr(error, "status_code", None) + if status is None: + response = getattr(error, "response", None) + status = ( + getattr(response, "status_code", None) if response is not None else None + ) + return status in (400, 422) + + +def _error_text(error: Exception) -> str: + parts = [str(error)] + body = getattr(error, "body", None) + if body is not None: + parts.append(json.dumps(body, default=str)) + response = getattr(error, "response", None) + if response is not None: + text = getattr(response, "text", None) + if isinstance(text, str) and text: + parts.append(text) + return " ".join(parts).lower() diff --git a/src/free_claude_code/providers/rate_limit.py b/src/free_claude_code/providers/rate_limit.py new file mode 100644 index 0000000..5ec7337 --- /dev/null +++ b/src/free_claude_code/providers/rate_limit.py @@ -0,0 +1,233 @@ +"""Provider-owned upstream rate limiting and retry policy.""" + +import asyncio +import random +import time +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager +from typing import Any, TypeVar + +from loguru import logger + +from free_claude_code.core.rate_limit import StrictSlidingWindowLimiter +from free_claude_code.core.trace import trace_event +from free_claude_code.providers.failure_policy import ( + ProviderFailureOverride, + retryable_upstream_status, + retryable_upstream_transport_error, +) + +T = TypeVar("T") + +UPSTREAM_TRANSIENT_TOTAL_ATTEMPTS = 5 +DEFAULT_UPSTREAM_MAX_RETRIES = UPSTREAM_TRANSIENT_TOTAL_ATTEMPTS - 1 + + +class ProviderRateLimiter: + """ + Rate limiter owned by one provider instance. + + Blocks that provider's requests when a rate-limit error is encountered + (reactive) and throttles its requests with a strict rolling window + (proactive). + + 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 or 5xx retry backoff is active. + Concurrency limit - caps simultaneously open streams. + """ + + def __init__( + self, + rate_limit: int = 40, + rate_window: float = 60.0, + max_concurrency: int = 5, + ): + 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) + self._max_concurrency = max_concurrency + self._proactive_limiter = StrictSlidingWindowLimiter( + self._rate_limit, self._rate_window + ) + self._blocked_until: float = 0 + self._concurrency_sem = asyncio.Semaphore(max_concurrency) + logger.info( + "ProviderRateLimiter initialized " + f"({rate_limit} req / {rate_window}s, max_concurrency={max_concurrency})" + ) + + 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. + """ + # A reactive deadline can be installed or extended while this task waits + # for proactive capacity. Commit the proactive timestamp only if that + # deadline is still clear, so retries neither burst nor consume unused quota. + waited_reactively = False + while True: + waited_reactively = ( + await self._wait_for_reactive_block() or waited_reactively + ) + if await self._proactive_limiter.acquire_if(lambda: not self.is_blocked()): + return waited_reactively + + async def _wait_for_reactive_block(self) -> bool: + waited = False + while (wait_time := self.remaining_wait()) > 0: + logger.warning( + "Provider rate limit active (reactive), waiting {:.1f}s...", + wait_time, + ) + await asyncio.sleep(wait_time) + waited = True + return waited + + def extend_reactive_block(self, seconds: float) -> None: + """ + Extend this provider's reactive block by at least ``seconds`` from now. + + Args: + seconds: Positive minimum duration for the resulting block. + """ + if seconds <= 0: + raise ValueError("reactive block duration must be > 0") + now = time.monotonic() + self._blocked_until = max(self._blocked_until, now + seconds) + logger.warning( + "Provider rate limit set for {:.1f}s (reactive)", + max(0.0, self._blocked_until - now), + ) + + 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, + provider_failure_override: ProviderFailureOverride | None = None, + max_retries: int = DEFAULT_UPSTREAM_MAX_RETRIES, + 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 transient limits. + + Waits for the proactive limiter before each attempt. On ``429`` (rate limit) + or upstream ``5xx`` server errors, applies exponential backoff with jitter + and sets the reactive block before retrying. Pre-response transport errors + use the same attempt budget and backoff schedule without setting the + reactive provider block. + + Args: + fn: Async callable to execute. + provider_failure_override: Optional provider-specific semantic + classifier applied before shared retry qualification. + 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 + total_attempts = 1 + max_retries + + for attempt in range(total_attempts): + await self.wait_if_blocked() + + try: + return await fn(*args, **kwargs) + except Exception as e: + effective_error = ( + provider_failure_override(e) + if provider_failure_override is not None + else None + ) + if effective_error is None: + effective_error = e + status = retryable_upstream_status(effective_error) + transport_error = status is None and retryable_upstream_transport_error( + effective_error + ) + if status is None and not transport_error: + raise + + if status is None: + label = f"Provider transport error ({type(e).__name__})" + else: + label = ( + "Rate limited (429)" + if status == 429 + else f"Upstream server error ({status})" + ) + last_exc = e + if attempt >= max_retries: + logger.warning( + "{} retry exhausted after {} retries (attempts={})", + label, + max_retries, + total_attempts, + ) + break + + delay = min(base_delay * (2**attempt), max_delay) + delay += random.uniform(0, jitter) + attempt_no = attempt + 1 + logger.warning( + "{}, attempt {}/{}. Retrying in {:.1f}s...", + label, + attempt_no, + total_attempts, + delay, + ) + trace_event( + stage="provider", + event="provider.retry.scheduled", + source="provider", + status_code=status, + exc_type=type(e).__name__, + attempt=attempt_no, + max_attempts=total_attempts, + delay_s=round(delay, 3), + ) + if status is not None: + self.extend_reactive_block(delay) + await asyncio.sleep(delay) + + assert last_exc is not None + raise last_exc diff --git a/src/free_claude_code/providers/runtime/__init__.py b/src/free_claude_code/providers/runtime/__init__.py new file mode 100644 index 0000000..9470b00 --- /dev/null +++ b/src/free_claude_code/providers/runtime/__init__.py @@ -0,0 +1,11 @@ +"""App-scoped provider runtime facade.""" + +from .config import build_provider_config +from .factory import create_provider +from .runtime import ProviderRuntime + +__all__ = [ + "ProviderRuntime", + "build_provider_config", + "create_provider", +] diff --git a/src/free_claude_code/providers/runtime/config.py b/src/free_claude_code/providers/runtime/config.py new file mode 100644 index 0000000..c0c28b2 --- /dev/null +++ b/src/free_claude_code/providers/runtime/config.py @@ -0,0 +1,68 @@ +"""Provider configuration construction from neutral catalog metadata.""" + +from free_claude_code.application.errors import ApplicationUnavailableError +from free_claude_code.config.provider_catalog import ProviderDescriptor +from free_claude_code.config.settings import Settings +from free_claude_code.providers.base import ProviderConfig + + +def string_setting(settings: Settings, attr_name: str | None, default: str = "") -> str: + """Return a string-valued settings attribute, ignoring non-string mocks.""" + if attr_name is None: + return default + value = getattr(settings, attr_name, default) + return value if isinstance(value, str) else default + + +def provider_credential(descriptor: ProviderDescriptor, settings: Settings) -> str: + """Return the configured credential for a provider descriptor.""" + if descriptor.static_credential is not None: + return descriptor.static_credential + if descriptor.credential_attr: + return string_setting(settings, descriptor.credential_attr) + return "" + + +def require_provider_credential( + descriptor: ProviderDescriptor, credential: str +) -> None: + """Raise a user-facing configuration error when a required key is missing.""" + if descriptor.credential_env is None: + return + if credential and credential.strip(): + return + message = f"{descriptor.credential_env} is not set. Add it to your .env file." + if descriptor.credential_url: + message = f"{message} Get a key at {descriptor.credential_url}" + raise ApplicationUnavailableError(message) + + +def build_provider_config( + descriptor: ProviderDescriptor, settings: Settings +) -> ProviderConfig: + """Build shared provider configuration for one provider descriptor.""" + credential = provider_credential(descriptor, settings) + require_provider_credential(descriptor, credential) + base_url = string_setting( + settings, descriptor.base_url_attr, descriptor.default_base_url or "" + ) + resolved_base_url = base_url or descriptor.default_base_url + if not resolved_base_url: + raise AssertionError( + f"Provider {descriptor.provider_id!r} has no configured base URL." + ) + proxy = string_setting(settings, descriptor.proxy_attr) + return ProviderConfig( + api_key=credential, + base_url=resolved_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, + enable_thinking=settings.enable_model_thinking, + proxy=proxy, + log_raw_sse_events=settings.log_raw_sse_events, + log_api_error_tracebacks=settings.log_api_error_tracebacks, + ) diff --git a/src/free_claude_code/providers/runtime/discovery.py b/src/free_claude_code/providers/runtime/discovery.py new file mode 100644 index 0000000..d9bea0e --- /dev/null +++ b/src/free_claude_code/providers/runtime/discovery.py @@ -0,0 +1,99 @@ +"""Provider model-list discovery and background refresh.""" + +import asyncio +from collections.abc import Callable + +from loguru import logger + +from free_claude_code.application.model_metadata import ProviderModelInfo +from free_claude_code.config.model_refs import configured_chat_model_refs +from free_claude_code.config.provider_catalog import PROVIDER_CATALOG +from free_claude_code.config.settings import Settings +from free_claude_code.providers.base import BaseProvider + +from .config import provider_credential +from .model_cache import ProviderModelCache +from .validation import provider_query_failure_reason + +ProviderResolver = Callable[[str], BaseProvider] + + +def referenced_provider_ids(settings: Settings) -> frozenset[str]: + """Return provider ids referenced by configured chat model refs.""" + return frozenset(ref.provider_id for ref in configured_chat_model_refs(settings)) + + +def model_list_provider_ids_for_settings(settings: Settings) -> tuple[str, ...]: + """Return providers worth discovering for this process configuration.""" + referenced_ids = referenced_provider_ids(settings) + provider_ids: list[str] = [] + for provider_id, descriptor in PROVIDER_CATALOG.items(): + if descriptor.local: + if provider_id in referenced_ids: + provider_ids.append(provider_id) + continue + if ( + descriptor.credential_env is not None + and provider_credential(descriptor, settings).strip() + ): + provider_ids.append(provider_id) + return tuple(provider_ids) + + +class ProviderModelDiscovery: + """Refresh provider model-list metadata for one provider runtime.""" + + def __init__( + self, + settings: Settings, + provider_resolver: ProviderResolver, + model_cache: ProviderModelCache, + ) -> None: + self._settings = settings + self._provider_resolver = provider_resolver + self._model_cache = model_cache + + async def refresh_model_list_cache(self, *, only_missing: bool = False) -> None: + """Best-effort refresh of model lists for usable providers.""" + provider_ids = model_list_provider_ids_for_settings(self._settings) + if only_missing: + provider_ids = tuple( + provider_id + for provider_id in provider_ids + if not self._model_cache.has_provider(provider_id) + ) + await self._refresh_model_infos(provider_ids) + + async def _refresh_model_infos(self, provider_ids: tuple[str, ...]) -> None: + tasks: dict[str, asyncio.Task[frozenset[ProviderModelInfo]]] = {} + for provider_id in provider_ids: + try: + provider = self._provider_resolver(provider_id) + except Exception as exc: + self._log_discovery_failure(provider_id, exc) + continue + tasks[provider_id] = asyncio.create_task(provider.list_model_infos()) + + if not tasks: + return + + results = await asyncio.gather(*tasks.values(), return_exceptions=True) + for (provider_id, _task), result in zip(tasks.items(), results, strict=True): + if isinstance(result, BaseException): + if isinstance(result, asyncio.CancelledError): + raise result + self._log_discovery_failure(provider_id, result) + continue + self._model_cache.cache_model_infos(provider_id, result) + logger.info( + "Provider model discovery cached: provider={} models={}", + provider_id, + len(result), + ) + + def _log_discovery_failure(self, provider_id: str, exc: BaseException) -> None: + logger.warning( + "Provider model discovery skipped: provider={} reason={}", + provider_id, + provider_query_failure_reason(exc, self._settings), + ) diff --git a/src/free_claude_code/providers/runtime/factory.py b/src/free_claude_code/providers/runtime/factory.py new file mode 100644 index 0000000..78e7984 --- /dev/null +++ b/src/free_claude_code/providers/runtime/factory.py @@ -0,0 +1,148 @@ +"""Provider construction from declarative profiles and exceptional adapters.""" + +from collections.abc import Callable + +from free_claude_code.application.errors import UnknownProviderError +from free_claude_code.config.provider_catalog import PROVIDER_CATALOG +from free_claude_code.config.settings import Settings +from free_claude_code.providers.base import BaseProvider, ProviderConfig +from free_claude_code.providers.openai_chat import ( + OPENAI_CHAT_PROFILES, + create_openai_chat_provider, +) +from free_claude_code.providers.rate_limit import ProviderRateLimiter + +from .config import build_provider_config + +ProviderFactory = Callable[ + [ProviderConfig, Settings, ProviderRateLimiter], BaseProvider +] + + +def _create_nvidia_nim( + config: ProviderConfig, + settings: Settings, + rate_limiter: ProviderRateLimiter, +) -> BaseProvider: + from free_claude_code.providers.nvidia_nim import NvidiaNimProvider + + return NvidiaNimProvider( + config, + nim_settings=settings.nim, + rate_limiter=rate_limiter, + ) + + +def _create_open_router( + config: ProviderConfig, + _settings: Settings, + rate_limiter: ProviderRateLimiter, +) -> BaseProvider: + from free_claude_code.providers.open_router import OpenRouterProvider + + return OpenRouterProvider(config, rate_limiter=rate_limiter) + + +def _create_mistral( + config: ProviderConfig, + _settings: Settings, + rate_limiter: ProviderRateLimiter, +) -> BaseProvider: + from free_claude_code.providers.mistral import MistralProvider + + return MistralProvider(config, rate_limiter=rate_limiter) + + +def _create_deepseek( + config: ProviderConfig, + _settings: Settings, + rate_limiter: ProviderRateLimiter, +) -> BaseProvider: + from free_claude_code.providers.deepseek import DeepSeekProvider + + return DeepSeekProvider(config, rate_limiter=rate_limiter) + + +def _create_lmstudio( + config: ProviderConfig, + _settings: Settings, + rate_limiter: ProviderRateLimiter, +) -> BaseProvider: + from free_claude_code.providers.lmstudio import LMStudioProvider + + return LMStudioProvider(config, rate_limiter=rate_limiter) + + +def _create_cloudflare( + config: ProviderConfig, + settings: Settings, + rate_limiter: ProviderRateLimiter, +) -> BaseProvider: + from free_claude_code.providers.cloudflare import CloudflareProvider + + return CloudflareProvider( + config, + account_id=settings.cloudflare_account_id, + rate_limiter=rate_limiter, + ) + + +def _create_gemini( + config: ProviderConfig, + _settings: Settings, + rate_limiter: ProviderRateLimiter, +) -> BaseProvider: + from free_claude_code.providers.gemini import GeminiProvider + + return GeminiProvider(config, rate_limiter=rate_limiter) + + +def _create_github_models( + config: ProviderConfig, + _settings: Settings, + rate_limiter: ProviderRateLimiter, +) -> BaseProvider: + from free_claude_code.providers.github_models import GitHubModelsProvider + + return GitHubModelsProvider(config, rate_limiter=rate_limiter) + + +_SPECIAL_PROVIDER_FACTORIES: dict[str, ProviderFactory] = { + "nvidia_nim": _create_nvidia_nim, + "open_router": _create_open_router, + "mistral": _create_mistral, + "deepseek": _create_deepseek, + "lmstudio": _create_lmstudio, + "cloudflare": _create_cloudflare, + "gemini": _create_gemini, + "github_models": _create_github_models, +} + +_profiled_ids = set(OPENAI_CHAT_PROFILES) +_special_ids = set(_SPECIAL_PROVIDER_FACTORIES) +if _profiled_ids & _special_ids or _profiled_ids | _special_ids != set( + PROVIDER_CATALOG +): + raise AssertionError( + "Every provider must have exactly one construction owner: " + f"profiles={_profiled_ids!r} special={_special_ids!r} " + f"catalog={set(PROVIDER_CATALOG)!r}" + ) + + +def create_provider(provider_id: str, settings: Settings) -> BaseProvider: + """Create a provider instance for a supported provider id.""" + descriptor = PROVIDER_CATALOG.get(provider_id) + if descriptor is None: + raise UnknownProviderError.for_provider(provider_id, PROVIDER_CATALOG) + + config = build_provider_config(descriptor, settings) + rate_limiter = ProviderRateLimiter( + rate_limit=config.rate_limit or 40, + rate_window=config.rate_window or 60.0, + max_concurrency=config.max_concurrency, + ) + factory = _SPECIAL_PROVIDER_FACTORIES.get(provider_id) + if factory is not None: + return factory(config, settings, rate_limiter) + return create_openai_chat_provider(provider_id, config, rate_limiter) diff --git a/src/free_claude_code/providers/runtime/model_cache.py b/src/free_claude_code/providers/runtime/model_cache.py new file mode 100644 index 0000000..22f4bee --- /dev/null +++ b/src/free_claude_code/providers/runtime/model_cache.py @@ -0,0 +1,71 @@ +"""Provider model-list metadata cache.""" + +from collections.abc import Iterable + +from free_claude_code.application.model_metadata import ProviderModelInfo +from free_claude_code.config.provider_catalog import SUPPORTED_PROVIDER_IDS +from free_claude_code.providers.model_listing import model_infos_from_ids + + +class ProviderModelCache: + """Store provider model metadata for instant model-list responses.""" + + def __init__(self) -> None: + self._model_infos_by_provider: dict[str, dict[str, ProviderModelInfo]] = {} + + def cache_model_ids(self, provider_id: str, model_ids: Iterable[str]) -> None: + """Store raw provider model ids with unknown capability metadata.""" + self.cache_model_infos(provider_id, model_infos_from_ids(model_ids)) + + def cache_model_infos( + self, provider_id: str, model_infos: Iterable[ProviderModelInfo] + ) -> None: + """Store provider model metadata by raw provider model id.""" + clean_infos = { + info.model_id: info for info in model_infos if info.model_id.strip() + } + self._model_infos_by_provider[provider_id] = clean_infos + + def cached_model_ids(self) -> dict[str, frozenset[str]]: + """Return cached raw provider model ids by provider.""" + return { + provider_id: frozenset(infos) + for provider_id, infos in self._model_infos_by_provider.items() + } + + def has_provider(self, provider_id: str) -> bool: + """Return whether this provider has any cached model-list result.""" + return provider_id in self._model_infos_by_provider + + def cached_model_supports_thinking( + self, provider_id: str, model_id: str + ) -> bool | None: + """Return cached thinking support when a provider exposes it.""" + info = self._model_infos_by_provider.get(provider_id, {}).get(model_id) + if info is None: + return None + return info.supports_thinking + + def cached_prefixed_model_refs(self) -> tuple[str, ...]: + """Return cached provider models in user-selectable ``provider/model`` form.""" + return tuple(info.model_id for info in self.cached_prefixed_model_infos()) + + def cached_prefixed_model_infos(self) -> tuple[ProviderModelInfo, ...]: + """Return cached provider models with user-selectable prefixed ids.""" + infos: list[ProviderModelInfo] = [] + for provider_id in SUPPORTED_PROVIDER_IDS: + provider_infos = self._model_infos_by_provider.get(provider_id, {}) + infos.extend( + ProviderModelInfo( + model_id=f"{provider_id}/{info.model_id}", + supports_thinking=info.supports_thinking, + ) + for info in sorted( + provider_infos.values(), key=lambda item: item.model_id + ) + ) + return tuple(infos) + + def clear(self) -> None: + """Clear all cached model metadata.""" + self._model_infos_by_provider.clear() diff --git a/src/free_claude_code/providers/runtime/runtime.py b/src/free_claude_code/providers/runtime/runtime.py new file mode 100644 index 0000000..1974e80 --- /dev/null +++ b/src/free_claude_code/providers/runtime/runtime.py @@ -0,0 +1,48 @@ +"""One closable generation of lazily constructed provider clients.""" + +import asyncio +from collections.abc import MutableMapping + +from free_claude_code.config.settings import Settings +from free_claude_code.providers.base import BaseProvider + +from .factory import create_provider + + +class ProviderRuntime: + """Own provider instances for one immutable settings snapshot.""" + + def __init__( + self, + settings: Settings, + providers: MutableMapping[str, BaseProvider] | None = None, + ) -> None: + self.settings = settings + self._providers = providers if providers is not None else {} + + def is_cached(self, provider_id: str) -> bool: + """Return whether a provider for this id is already cached.""" + return provider_id in self._providers + + def resolve_provider(self, provider_id: str) -> BaseProvider: + """Return an existing provider or create it lazily.""" + if provider_id not in self._providers: + self._providers[provider_id] = create_provider(provider_id, self.settings) + return self._providers[provider_id] + + async def cleanup(self) -> None: + """Release every provider client constructed by this generation.""" + errors: list[Exception] = [] + for provider_id, provider in list(self._providers.items()): + try: + await provider.cleanup() + except asyncio.CancelledError: + raise + except Exception as exc: + errors.append(exc) + else: + self._providers.pop(provider_id, None) + if len(errors) == 1: + raise errors[0] + if len(errors) > 1: + raise ExceptionGroup("One or more provider cleanups failed", errors) diff --git a/src/free_claude_code/providers/runtime/validation.py b/src/free_claude_code/providers/runtime/validation.py new file mode 100644 index 0000000..1da0082 --- /dev/null +++ b/src/free_claude_code/providers/runtime/validation.py @@ -0,0 +1,122 @@ +"""Configured provider model validation.""" + +import asyncio +from collections import defaultdict +from collections.abc import Callable + +import httpx +from loguru import logger + +from free_claude_code.application.errors import ApplicationUnavailableError +from free_claude_code.application.model_metadata import ProviderModelInfo +from free_claude_code.config.model_refs import ( + ConfiguredChatModelRef, + configured_chat_model_refs, +) +from free_claude_code.config.settings import Settings +from free_claude_code.core.failures import ExecutionFailure +from free_claude_code.providers.base import BaseProvider +from free_claude_code.providers.model_listing import ModelListResponseError + +from .model_cache import ProviderModelCache + +ProviderResolver = Callable[[str], BaseProvider] + + +def provider_query_failure_reason(exc: BaseException, settings: Settings) -> str: + """Return a concise model-list query failure reason for user-facing logs.""" + if isinstance(exc, ModelListResponseError): + return f"malformed model-list response: {exc.message}" + if isinstance(exc, httpx.HTTPStatusError): + return f"query failure: HTTP {exc.response.status_code}" + if isinstance(exc, ApplicationUnavailableError): + return f"query failure: {exc.message}" + if isinstance(exc, ExecutionFailure) and settings.log_api_error_tracebacks: + return f"query failure: {exc.message}" + return f"query failure: {type(exc).__name__}" + + +class ConfiguredModelValidator: + """Validate configured provider/model refs against upstream model lists.""" + + def __init__( + self, + settings: Settings, + provider_resolver: ProviderResolver, + model_cache: ProviderModelCache, + ) -> None: + self._settings = settings + self._provider_resolver = provider_resolver + self._model_cache = model_cache + + async def validate_configured_models(self) -> None: + """Fail unless every configured chat model exists upstream.""" + refs = configured_chat_model_refs(self._settings) + refs_by_provider: dict[str, list[ConfiguredChatModelRef]] = defaultdict(list) + for ref in refs: + refs_by_provider[ref.provider_id].append(ref) + + failures: list[str] = [] + tasks: dict[str, asyncio.Task[frozenset[ProviderModelInfo]]] = {} + for provider_id, provider_refs in refs_by_provider.items(): + try: + provider = self._provider_resolver(provider_id) + except Exception as exc: + failures.extend( + self._format_provider_query_failures(provider_refs, exc) + ) + continue + tasks[provider_id] = asyncio.create_task(provider.list_model_infos()) + + if tasks: + results = await asyncio.gather(*tasks.values(), return_exceptions=True) + for (provider_id, _task), result in zip( + tasks.items(), results, strict=True + ): + provider_refs = refs_by_provider[provider_id] + if isinstance(result, BaseException): + if isinstance(result, asyncio.CancelledError): + raise result + failures.extend( + self._format_provider_query_failures(provider_refs, result) + ) + continue + self._model_cache.cache_model_infos(provider_id, result) + model_ids = self._model_cache.cached_model_ids()[provider_id] + failures.extend( + self._format_missing_model_failure(ref) + for ref in provider_refs + if ref.model_id not in model_ids + ) + + if failures: + message = "Configured model validation failed:\n" + "\n".join( + f"- {failure}" for failure in failures + ) + raise ApplicationUnavailableError(message) + + logger.info( + "Configured provider models validated: models={} providers={}", + len(refs), + len(refs_by_provider), + ) + + def _format_provider_query_failures( + self, + refs: list[ConfiguredChatModelRef], + exc: BaseException, + ) -> list[str]: + reason = provider_query_failure_reason(exc, self._settings) + return [self._format_model_validation_failure(ref, reason) for ref in refs] + + def _format_missing_model_failure(self, ref: ConfiguredChatModelRef) -> str: + return self._format_model_validation_failure(ref, "missing model") + + @staticmethod + def _format_model_validation_failure( + ref: ConfiguredChatModelRef, problem: str + ) -> str: + return ( + f"sources={','.join(ref.sources)} provider={ref.provider_id} " + f"model={ref.model_id} problem={problem}" + ) diff --git a/src/free_claude_code/providers/stream_recovery.py b/src/free_claude_code/providers/stream_recovery.py new file mode 100644 index 0000000..4ea1816 --- /dev/null +++ b/src/free_claude_code/providers/stream_recovery.py @@ -0,0 +1,222 @@ +"""Provider-owned stream holdback and recovery decisions.""" + +import time +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum + +import httpx +import openai + +from free_claude_code.core.failures import ExecutionFailure +from free_claude_code.core.trace import trace_event + +from .failure_policy import retryable_transient_status + +EARLY_TRANSPARENT_TOTAL_ATTEMPTS = 5 +EARLY_TRANSPARENT_MAX_RETRIES = EARLY_TRANSPARENT_TOTAL_ATTEMPTS - 1 +MIDSTREAM_RECOVERY_ATTEMPTS = 5 +EARLY_HOLDBACK_SECONDS = 0.75 +RECOVERY_BUFFER_MAX_BYTES = 65_536 + + +class TruncatedProviderStreamError(RuntimeError): + """An upstream stream ended without its required terminal marker.""" + + +class RecoveryFailureAction(StrEnum): + """How one provider stream should respond to an upstream failure.""" + + EARLY_RETRY = "early_retry" + MIDSTREAM_RECOVERY = "midstream_recovery" + FINAL_ERROR = "final_error" + + +@dataclass(frozen=True, slots=True) +class RecoveryDecision: + """Failure decision for one provider stream attempt.""" + + action: RecoveryFailureAction + retryable: bool + committed: bool + has_buffered: bool + early_retry_attempt: int | None = None + midstream_recovery_attempt: int | None = None + + +class RecoveryHoldbackBuffer: + """Briefly retain SSE so early cutoffs can be retried invisibly.""" + + def __init__( + self, + *, + holdback_seconds: float = EARLY_HOLDBACK_SECONDS, + max_bytes: int = RECOVERY_BUFFER_MAX_BYTES, + now: Callable[[], float] | None = None, + ) -> None: + self._holdback_seconds = holdback_seconds + self._max_bytes = max_bytes + self._now = now or time.monotonic + self._events: list[str] = [] + self._bytes = 0 + self._started_at: float | None = None + self.committed = False + + def push(self, event: str) -> list[str]: + if self.committed: + return [event] + if self._started_at is None: + self._started_at = self._now() + self._events.append(event) + self._bytes += len(event.encode("utf-8", errors="replace")) + if ( + self._bytes >= self._max_bytes + or self._now() - self._started_at >= self._holdback_seconds + ): + return self.flush() + return [] + + def flush(self) -> list[str]: + if self.committed: + return [] + self.committed = True + events = self._events + self._events = [] + self._bytes = 0 + self._started_at = None + return events + + def discard(self) -> None: + self._events = [] + self._bytes = 0 + self._started_at = None + + @property + def has_buffered(self) -> bool: + return bool(self._events) + + +class RecoveryController: + """Own holdback and retry counters for one provider stream lifecycle.""" + + def __init__(self, *, provider_name: str, request_id: str | None) -> None: + self._provider_name = provider_name + self._request_id = request_id + self._holdback = RecoveryHoldbackBuffer() + self._early_retry_count = 0 + self._midstream_recovery_count = 0 + + @property + def committed(self) -> bool: + return self._holdback.committed + + @property + def has_buffered(self) -> bool: + return self._holdback.has_buffered + + @property + def early_retries(self) -> int: + return self._early_retry_count + + @property + def midstream_recoveries(self) -> int: + return self._midstream_recovery_count + + def push(self, event: str) -> list[str]: + return self._holdback.push(event) + + def flush(self) -> list[str]: + return self._holdback.flush() + + def discard(self) -> None: + self._holdback.discard() + + def flush_uncommitted(self, decision: RecoveryDecision) -> list[str]: + if not decision.committed and decision.has_buffered: + return self.flush() + return [] + + def advance_failure( + self, + error: BaseException, + *, + stream_opened: bool, + generated_output: bool, + complete_tool_salvageable: bool, + ) -> RecoveryDecision: + retryable = is_retryable_stream_error(error) + committed = self._holdback.committed + has_buffered = self._holdback.has_buffered + + if ( + retryable + and stream_opened + and not committed + and not complete_tool_salvageable + and self._early_retry_count < EARLY_TRANSPARENT_MAX_RETRIES + ): + self._early_retry_count += 1 + self._holdback.discard() + self._holdback = RecoveryHoldbackBuffer() + trace_event( + stage="provider", + event="provider.recovery.early_retry", + source="provider", + provider=self._provider_name, + request_id=self._request_id, + retry_attempt=self._early_retry_count, + retryable=True, + ) + return RecoveryDecision( + action=RecoveryFailureAction.EARLY_RETRY, + retryable=True, + committed=False, + has_buffered=has_buffered, + early_retry_attempt=self._early_retry_count, + ) + + if ( + retryable + and generated_output + and self._midstream_recovery_count < MIDSTREAM_RECOVERY_ATTEMPTS + ): + self._midstream_recovery_count += 1 + return RecoveryDecision( + action=RecoveryFailureAction.MIDSTREAM_RECOVERY, + retryable=True, + committed=committed, + has_buffered=has_buffered, + midstream_recovery_attempt=self._midstream_recovery_count, + ) + + return RecoveryDecision( + action=RecoveryFailureAction.FINAL_ERROR, + retryable=retryable, + committed=committed, + has_buffered=has_buffered, + ) + + +def is_retryable_stream_error(exc: BaseException) -> bool: + """Return whether one stream failure qualifies for retry or recovery.""" + if isinstance(exc, TruncatedProviderStreamError): + return True + if isinstance(exc, ExecutionFailure): + return exc.retryable + if isinstance(exc, openai.AuthenticationError | openai.BadRequestError): + return False + if retryable_transient_status(exc) is not None: + return True + return isinstance( + exc, + ( + TimeoutError, + httpx.ReadTimeout, + httpx.ReadError, + httpx.RemoteProtocolError, + httpx.ConnectError, + httpx.NetworkError, + openai.APITimeoutError, + openai.APIConnectionError, + ), + ) diff --git a/src/free_claude_code/runtime/__init__.py b/src/free_claude_code/runtime/__init__.py new file mode 100644 index 0000000..570f323 --- /dev/null +++ b/src/free_claude_code/runtime/__init__.py @@ -0,0 +1 @@ +"""Application composition and process-lifetime resource ownership.""" diff --git a/src/free_claude_code/runtime/application.py b/src/free_claude_code/runtime/application.py new file mode 100644 index 0000000..94a644f --- /dev/null +++ b/src/free_claude_code/runtime/application.py @@ -0,0 +1,481 @@ +"""Single owner for application startup, shutdown, and runtime operations.""" + +import asyncio +import inspect +import logging +import os +import traceback +from collections.abc import Awaitable, Callable, Mapping +from typing import Any + +from loguru import logger + +import free_claude_code.cli.managed as cli_managed +import free_claude_code.messaging.session as messaging_session +import free_claude_code.messaging.workflow as messaging_workflow_module +from free_claude_code.application.errors import ApplicationUnavailableError +from free_claude_code.application.ports import StopResult +from free_claude_code.config.admin.persistence import ( + PreparedAdminUpdate, + commit_prepared_admin_update, + prepare_admin_update, +) +from free_claude_code.config.admin.status import provider_config_status +from free_claude_code.config.admin.values import load_value_state +from free_claude_code.config.env_files import ( + ANTHROPIC_AUTH_TOKEN_ENV, + process_env_key_is_effective, +) +from free_claude_code.config.model_refs import parse_provider_type +from free_claude_code.config.paths import messaging_state_dir_path +from free_claude_code.config.server_urls import local_admin_url, local_proxy_root_url +from free_claude_code.config.settings import Settings, get_settings +from free_claude_code.messaging.platforms import factory as messaging_platform_factory +from free_claude_code.messaging.platforms.factory import MessagingPlatformOptions +from free_claude_code.messaging.platforms.ports import ( + MessagingPlatformComponents, + MessagingRuntime, +) +from free_claude_code.messaging.voice import Transcriber + +from .provider_manager import ProviderRuntimeManager + +RestartCallback = Callable[[], Awaitable[None] | None] + + +async def best_effort( + name: str, + awaitable: Awaitable[Any], + *, + log_verbose_errors: bool = False, +) -> bool: + """Run one cleanup step and report whether it completed. + + The lifecycle owner intentionally applies no generic timeout here. Cancelling + an arbitrary cleanup at a deadline can abandon a half-closed SDK, thread, or + provider resource; resource-specific cleanup or the process supervisor owns + any force-termination deadline. + """ + try: + await awaitable + except Exception as exc: + if log_verbose_errors: + logger.warning( + "Shutdown step failed: {}: {}: {}", + name, + type(exc).__name__, + exc, + ) + else: + logger.warning( + "Shutdown step failed: {}: exc_type={}", + name, + type(exc).__name__, + ) + return False + return True + + +def warn_if_process_auth_token(settings: Settings) -> None: + """Warn when server auth was implicitly inherited from the shell.""" + model_config = getattr(settings, "model_config", Settings.model_config) + if process_env_key_is_effective(model_config, ANTHROPIC_AUTH_TOKEN_ENV): + logger.warning( + "ANTHROPIC_AUTH_TOKEN is set in the process environment but not in " + "a configured .env file. The proxy will require that token. Add " + "ANTHROPIC_AUTH_TOKEN= to .env to disable proxy auth, or set the " + "same token in .env to make server auth explicit." + ) + + +def startup_failure_message(settings: Settings, exc: Exception) -> str: + """Return the existing concise ASGI startup failure message.""" + if isinstance(exc, ApplicationUnavailableError): + return exc.message.strip() or "Server startup failed." + if settings.log_api_error_tracebacks: + return f"{type(exc).__name__}: {exc}" + return f"Server startup failed: exc_type={type(exc).__name__}" + + +class ApplicationRuntime: + """Own every process-lifetime resource used by one server instance.""" + + def __init__( + self, + provider_manager: ProviderRuntimeManager, + *, + transcriber: Transcriber | None, + restart_callback: RestartCallback | None = None, + ) -> None: + self.provider_manager = provider_manager + self._transcriber = transcriber + self._restart_callback = restart_callback + self._config_lock = asyncio.Lock() + self._pending_fields: list[str] = [] + self._messaging_runtime: MessagingRuntime | None = None + self._messaging_workflow: messaging_workflow_module.MessagingWorkflow | None = ( + None + ) + self._cli_manager: cli_managed.ManagedClaudeSessionManager | None = None + self._started = False + self._closed = False + self._provider_manager_closed = False + self._close_lock = asyncio.Lock() + + @property + def settings(self) -> Settings: + return self.provider_manager.current_settings() + + @property + def is_closed(self) -> bool: + """Whether this runtime released its complete ownership graph.""" + return self._closed + + async def start(self) -> None: + if self._started: + return + logger.info("Starting Claude Code Proxy...") + try: + warn_if_process_auth_token(self.settings) + await self._validate_configured_models_best_effort() + self.provider_manager.start_model_list_refresh() + await self._start_messaging_if_configured() + logging.getLogger("uvicorn.error").info( + "Admin UI: %s (local-only)", + local_admin_url(self.settings), + ) + self._started = True + except asyncio.CancelledError: + await self.close() + raise + except Exception as exc: + logger.error( + "Startup failed:\n{}", + startup_failure_message(self.settings, exc), + ) + await self.close() + raise + + async def close(self) -> bool: + async with self._close_lock: + if self._closed: + return True + logger.info("Shutdown requested, cleaning up...") + self._closed = await self._close_owned_resources() + if self._closed: + self._started = False + logger.info("Server shut down cleanly") + else: + logger.warning( + "Server shutdown incomplete; owned resources remain for retry" + ) + return self._closed + + async def apply_admin_config( + self, + updates: Mapping[str, Any], + ) -> dict[str, Any]: + """Apply one validated config update without splitting runtime ownership.""" + async with self._config_lock: + prepared = prepare_admin_update(updates) + if not prepared.valid: + return prepared.applied_response() + assert prepared.settings is not None + + if prepared.pending_fields: + result = self._commit_admin_update(prepared) + restart = self._restart_metadata( + prepared.pending_fields, + prepared.settings, + ) + result["restart"] = restart + self._pending_fields = ( + [] if restart["automatic"] else list(prepared.pending_fields) + ) + return result + + result: dict[str, Any] = {} + + def commit() -> None: + result.update(self._commit_admin_update(prepared)) + + await self.provider_manager.replace( + prepared.settings, + commit=commit, + reason="admin_apply", + ) + self._pending_fields = [] + result["restart"] = self._restart_metadata((), prepared.settings) + return result + + def admin_status(self) -> dict[str, Any]: + settings = self.settings + return { + "status": "running", + "host": settings.host, + "port": settings.port, + "model": settings.model, + "provider": parse_provider_type(settings.model), + "pending_fields": list(self._pending_fields), + "provider_status": provider_config_status(load_value_state()), + "cached_models": { + provider_id: sorted(model_ids) + for provider_id, model_ids in self.provider_manager.cached_model_ids().items() + }, + } + + async def test_provider(self, provider_id: str) -> dict[str, Any]: + lease = await self.provider_manager.acquire() + try: + provider = lease.resolve_provider(provider_id) + infos = await provider.list_model_infos() + except Exception as exc: + return { + "provider_id": provider_id, + "ok": False, + "error_type": type(exc).__name__, + } + finally: + await lease.release() + self.provider_manager.cache_model_infos(provider_id, infos) + return { + "provider_id": provider_id, + "ok": True, + "models": sorted(info.model_id for info in infos), + } + + async def refresh_models(self) -> dict[str, Any]: + await self.provider_manager.refresh_model_list_cache() + return { + "cached_models": { + provider_id: sorted(model_ids) + for provider_id, model_ids in self.provider_manager.cached_model_ids().items() + } + } + + async def request_restart(self) -> None: + callback = self._restart_callback + if callback is None: + return + result = callback() + if inspect.isawaitable(result): + await result + + async def stop_all(self) -> StopResult | None: + if self._messaging_workflow is not None: + outcome = await self._messaging_workflow.stop_all_tasks() + return StopResult(cancelled_count=outcome.cancelled_count) + if self._cli_manager is not None: + await self._cli_manager.stop_all() + return StopResult(source="cli_manager") + return None + + def _commit_admin_update( + self, + prepared: PreparedAdminUpdate, + ) -> dict[str, Any]: + result = commit_prepared_admin_update(prepared) + get_settings.cache_clear() + return result + + def _restart_metadata( + self, + fields: tuple[str, ...], + settings: Settings, + ) -> dict[str, Any]: + automatic = bool(fields and self._restart_callback is not None) + return { + "required": bool(fields), + "automatic": automatic, + "admin_url": local_admin_url(settings) if automatic else None, + "fields": list(fields), + } + + async def _validate_configured_models_best_effort(self) -> None: + try: + await self.provider_manager.validate_configured_models() + except ApplicationUnavailableError as exc: + logger.warning( + "Configured provider model validation failed during startup; " + "server will continue and requests will fail at provider resolution " + "when config is incomplete. {}", + exc.message, + ) + + async def _start_messaging_if_configured(self) -> None: + try: + components = messaging_platform_factory.create_messaging_components( + self.settings.messaging_platform, + self._messaging_options(), + ) + if components is not None: + await self._start_messaging_workflow(components) + except ImportError as exc: + cleaned = await self._cleanup_messaging() + if self.settings.log_api_error_tracebacks: + logger.warning("Messaging module import error: {}", exc) + else: + logger.warning( + "Messaging module import error: exc_type={}", + type(exc).__name__, + ) + if not cleaned: + raise RuntimeError("Messaging startup cleanup incomplete") from exc + except Exception as exc: + cleaned = await self._cleanup_messaging() + if self.settings.log_api_error_tracebacks: + logger.error("Failed to start messaging platform: {}", exc) + logger.error(traceback.format_exc()) + else: + logger.error( + "Failed to start messaging platform: exc_type={}", + type(exc).__name__, + ) + if not cleaned: + raise RuntimeError("Messaging startup cleanup incomplete") from exc + + def _messaging_options(self) -> MessagingPlatformOptions: + settings = self.settings + return MessagingPlatformOptions( + telegram_bot_token=settings.telegram_bot_token, + allowed_telegram_user_id=settings.allowed_telegram_user_id, + telegram_proxy_url=settings.telegram_proxy_url, + discord_bot_token=settings.discord_bot_token, + allowed_discord_channels=settings.allowed_discord_channels, + transcriber=self._transcriber, + messaging_rate_limit=settings.messaging_rate_limit, + messaging_rate_window=settings.messaging_rate_window, + log_raw_messaging_content=settings.log_raw_messaging_content, + log_messaging_error_details=settings.log_messaging_error_details, + log_api_error_tracebacks=settings.log_api_error_tracebacks, + ) + + async def _start_messaging_workflow( + self, + components: MessagingPlatformComponents, + ) -> None: + settings = self.settings + self._messaging_runtime = components.runtime + workspace = ( + os.path.abspath(settings.allowed_dir) + if settings.allowed_dir + else os.getcwd() + ) + os.makedirs(workspace, exist_ok=True) + data_path = os.path.abspath(messaging_state_dir_path()) + os.makedirs(data_path, exist_ok=True) + allowed_dirs = [workspace] if settings.allowed_dir else [] + + self._cli_manager = cli_managed.ManagedClaudeSessionManager( + workspace_path=workspace, + proxy_root_url=local_proxy_root_url(settings), + allowed_dirs=allowed_dirs, + auth_token=settings.anthropic_auth_token, + log_raw_cli_diagnostics=settings.log_raw_cli_diagnostics, + log_messaging_error_details=settings.log_messaging_error_details, + ) + session_store = messaging_session.SessionStore( + storage_path=os.path.join(data_path, "sessions.json"), + managed_message_cap=settings.max_message_log_entries_per_chat, + ) + workflow = messaging_workflow_module.MessagingWorkflow( + platform_name=components.name, + outbound=components.outbound, + voice_cancellation=components.voice_cancellation, + cli_manager=self._cli_manager, + session_store=session_store, + debug_platform_edits=settings.debug_platform_edits, + debug_subagent_stack=settings.debug_subagent_stack, + log_raw_cli_diagnostics=settings.log_raw_cli_diagnostics, + log_messaging_error_details=settings.log_messaging_error_details, + ) + self._messaging_workflow = workflow + workflow.restore() + components.runtime.on_message(workflow.handle_message) + await components.runtime.start() + await workflow.repair_restored_statuses() + if components.startup_notice is not None: + await workflow.publish_startup_notice(components.startup_notice) + logger.info("{} platform started with messaging workflow", components.name) + + async def _close_owned_resources(self) -> bool: + if not await self._cleanup_messaging(): + return False + if not await self._cleanup_transcriber(): + return False + if self._provider_manager_closed: + return True + verbose = self.settings.log_api_error_tracebacks + self._provider_manager_closed = await best_effort( + "provider_manager.close", + self.provider_manager.close(), + log_verbose_errors=verbose, + ) + return self._provider_manager_closed + + async def _cleanup_messaging(self) -> bool: + verbose = self.settings.log_api_error_tracebacks + workflow = self._messaging_workflow + runtime = self._messaging_runtime + cli_manager = self._cli_manager + + if runtime is not None: + quiesced = await best_effort( + "messaging_runtime.quiesce", + runtime.quiesce(), + log_verbose_errors=verbose, + ) + if not quiesced: + # Delivery must remain available until ingress is known stopped. + # Retaining the graph lets the next close retry this exact gate. + return False + + if workflow is not None: + closed = await best_effort( + "messaging_workflow.close", + workflow.close(), + log_verbose_errors=verbose, + ) + if not closed: + # Active workflow tasks may still need delivery, transcription, + # CLI sessions, and providers while a later close retries drain. + return False + if self._messaging_workflow is workflow: + self._messaging_workflow = None + if self._cli_manager is cli_manager: + self._cli_manager = None + elif cli_manager is not None: + drained = await best_effort( + "cli_manager.stop_all", + cli_manager.stop_all(), + log_verbose_errors=verbose, + ) + if not drained: + return False + if self._cli_manager is cli_manager: + self._cli_manager = None + + if runtime is not None: + closed = await best_effort( + "messaging_runtime.close", + runtime.close(), + log_verbose_errors=verbose, + ) + if not closed: + return False + if self._messaging_runtime is runtime: + self._messaging_runtime = None + return True + + async def _cleanup_transcriber(self) -> bool: + transcriber = self._transcriber + if transcriber is None: + return True + closed = await best_effort( + "transcriber.close", + transcriber.close(), + log_verbose_errors=self.settings.log_api_error_tracebacks, + ) + if closed and self._transcriber is transcriber: + self._transcriber = None + return closed diff --git a/src/free_claude_code/runtime/asgi.py b/src/free_claude_code/runtime/asgi.py new file mode 100644 index 0000000..654d53b --- /dev/null +++ b/src/free_claude_code/runtime/asgi.py @@ -0,0 +1,64 @@ +"""ASGI lifespan adapter for the application runtime owner.""" + +from typing import Any + +from loguru import logger +from starlette.types import ASGIApp, Receive, Scope, Send + +from .application import ApplicationRuntime, startup_failure_message + + +class RuntimeASGIApp: + """Delegate HTTP to FastAPI and lifespan to `ApplicationRuntime`.""" + + def __init__(self, app: ASGIApp, runtime: ApplicationRuntime) -> None: + self.app = app + self.runtime = runtime + + def __getattr__(self, name: str) -> Any: + return getattr(self.app, name) + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "lifespan": + await self.app(scope, receive, send) + return + await self._lifespan(receive, send) + + async def _lifespan(self, receive: Receive, send: Send) -> None: + started = False + while True: + message = await receive() + if message["type"] == "lifespan.startup": + try: + await self.runtime.start() + except Exception as exc: + await send( + { + "type": "lifespan.startup.failed", + "message": startup_failure_message( + self.runtime.settings, + exc, + ), + } + ) + return + started = True + await send({"type": "lifespan.startup.complete"}) + continue + + if message["type"] == "lifespan.shutdown": + if started: + try: + closed = await self.runtime.close() + except Exception as exc: + logger.error( + "Shutdown failed: exc_type={}", + type(exc).__name__, + ) + await send({"type": "lifespan.shutdown.failed", "message": ""}) + return + if not closed: + await send({"type": "lifespan.shutdown.failed", "message": ""}) + return + await send({"type": "lifespan.shutdown.complete"}) + return diff --git a/src/free_claude_code/runtime/bootstrap.py b/src/free_claude_code/runtime/bootstrap.py new file mode 100644 index 0000000..e13c255 --- /dev/null +++ b/src/free_claude_code/runtime/bootstrap.py @@ -0,0 +1,53 @@ +"""Single production composition root for the FCC server.""" + +import os +from pathlib import Path + +from free_claude_code.api.app import create_app +from free_claude_code.api.ports import ApiServices +from free_claude_code.config.logging_config import configure_logging +from free_claude_code.config.paths import server_log_path +from free_claude_code.config.settings import Settings +from free_claude_code.messaging.transcription import TranscriptionService +from free_claude_code.messaging.voice import Transcriber +from free_claude_code.providers.nvidia_nim.voice import NvidiaNimTranscriber + +from .application import ApplicationRuntime, RestartCallback +from .asgi import RuntimeASGIApp +from .provider_manager import ProviderRuntimeManager + + +def build_asgi_app( + settings: Settings, + restart_callback: RestartCallback | None = None, +) -> RuntimeASGIApp: + """Construct the complete server application and its resource owner.""" + log_path = Path(os.getenv("LOG_FILE", server_log_path())) + configure_logging(log_path, verbose_third_party=settings.log_raw_api_payloads) + provider_manager = ProviderRuntimeManager(settings) + runtime = ApplicationRuntime( + provider_manager, + transcriber=_create_transcriber(settings), + restart_callback=restart_callback, + ) + services = ApiServices( + requests=provider_manager, + admin=runtime, + tasks=runtime, + ) + return RuntimeASGIApp(create_app(services), runtime) + + +def _create_transcriber(settings: Settings) -> Transcriber | None: + if not settings.voice_note_enabled: + return None + if settings.whisper_device == "nvidia_nim": + return NvidiaNimTranscriber( + model=settings.whisper_model, + api_key=settings.nvidia_nim_api_key, + ) + return TranscriptionService( + model=settings.whisper_model, + device=settings.whisper_device, + huggingface_api_key=settings.huggingface_api_key, + ) diff --git a/src/free_claude_code/runtime/provider_manager.py b/src/free_claude_code/runtime/provider_manager.py new file mode 100644 index 0000000..b9390ab --- /dev/null +++ b/src/free_claude_code/runtime/provider_manager.py @@ -0,0 +1,399 @@ +"""Single-owner provider generations and application model catalog.""" + +import asyncio +from collections.abc import Callable, Iterable +from dataclasses import dataclass, field + +from loguru import logger + +from free_claude_code.application.errors import ApplicationUnavailableError +from free_claude_code.application.model_metadata import ProviderModelInfo +from free_claude_code.config.settings import Settings +from free_claude_code.core.trace import trace_event +from free_claude_code.providers.base import BaseProvider +from free_claude_code.providers.runtime import ProviderRuntime +from free_claude_code.providers.runtime.discovery import ProviderModelDiscovery +from free_claude_code.providers.runtime.model_cache import ProviderModelCache +from free_claude_code.providers.runtime.validation import ConfiguredModelValidator + +ProviderRuntimeFactory = Callable[[Settings], ProviderRuntime] +CommitConfig = Callable[[], None] + + +@dataclass(slots=True, eq=False) +class _ProviderGeneration: + generation_id: int + settings: Settings + runtime: ProviderRuntime + active_leases: int = 0 + retired: bool = False + closed: bool = False + drained: asyncio.Event = field(default_factory=asyncio.Event) + cleanup_task: asyncio.Task[bool] | None = None + + def __post_init__(self) -> None: + self.drained.set() + + +class ProviderGenerationLease: + """Idempotent lease retaining one provider generation.""" + + def __init__( + self, + manager: ProviderRuntimeManager, + generation: _ProviderGeneration, + ) -> None: + self._manager = manager + self._generation = generation + self._released = False + + @property + def generation_id(self) -> int: + return self._generation.generation_id + + @property + def settings(self) -> Settings: + return self._generation.settings + + def is_provider_cached(self, provider_id: str) -> bool: + return self._generation.runtime.is_cached(provider_id) + + def resolve_provider(self, provider_id: str) -> BaseProvider: + return self._generation.runtime.resolve_provider(provider_id) + + async def release(self) -> None: + if self._released: + return + self._released = True + await self._manager._release(self._generation) + + async def __aenter__(self) -> ProviderGenerationLease: + return self + + async def __aexit__(self, *_exc: object) -> None: + await self.release() + + +class ProviderRuntimeManager: + """Own provider generations, leases, discovery, and model metadata.""" + + def __init__( + self, + settings: Settings, + *, + runtime_factory: ProviderRuntimeFactory = ProviderRuntime, + ) -> None: + self._runtime_factory = runtime_factory + self._replace_lock = asyncio.Lock() + self._close_lock = asyncio.Lock() + self._model_cache = ProviderModelCache() + self._refresh_task: asyncio.Task[None] | None = None + self._next_generation_id = 2 + self._retired: dict[int, _ProviderGeneration] = {} + self._unpublished: set[ProviderRuntime] = set() + self._closing = False + self._closed = False + self._current = _ProviderGeneration( + generation_id=1, + settings=settings, + runtime=runtime_factory(settings), + ) + self._trace_published(self._current, previous=None, reason="startup") + + @property + def current_generation_id(self) -> int: + return self._current.generation_id + + async def acquire(self) -> ProviderGenerationLease: + if self._closing or self._closed: + raise ApplicationUnavailableError("Provider runtime is shutting down.") + generation = self._current + generation.active_leases += 1 + generation.drained.clear() + return ProviderGenerationLease(self, generation) + + def current_settings(self) -> Settings: + return self._current.settings + + def cached_model_ids(self) -> dict[str, frozenset[str]]: + return self._model_cache.cached_model_ids() + + def cached_model_supports_thinking( + self, provider_id: str, model_id: str + ) -> bool | None: + return self._model_cache.cached_model_supports_thinking(provider_id, model_id) + + def cached_prefixed_model_infos(self) -> tuple[ProviderModelInfo, ...]: + return self._model_cache.cached_prefixed_model_infos() + + def cache_model_infos( + self, + provider_id: str, + model_infos: Iterable[ProviderModelInfo], + ) -> None: + self._model_cache.cache_model_infos(provider_id, model_infos) + + async def validate_configured_models(self) -> None: + lease = await self.acquire() + try: + validator = ConfiguredModelValidator( + lease.settings, + lease.resolve_provider, + self._model_cache, + ) + await validator.validate_configured_models() + finally: + await lease.release() + + def start_model_list_refresh(self) -> None: + """Start one non-blocking refresh for the current generation.""" + if self._closing or self._closed: + return + if self._refresh_task is not None and not self._refresh_task.done(): + return + generation = self._current + self._refresh_task = asyncio.create_task( + self._refresh_generation(generation, only_missing=True) + ) + + async def refresh_model_list_cache(self) -> None: + """Run an explicit full refresh without racing replacement.""" + async with self._replace_lock: + if self._closing or self._closed: + raise ApplicationUnavailableError("Provider runtime is shutting down.") + await self._cancel_refresh() + await self._refresh_generation(self._current, only_missing=False) + + async def replace( + self, + settings: Settings, + *, + commit: CommitConfig, + reason: str = "admin_apply", + ) -> int: + """Prepare, commit, and atomically publish one replacement generation.""" + async with self._replace_lock: + if self._closing or self._closed: + raise ApplicationUnavailableError("Provider runtime is shutting down.") + await self._cancel_refresh() + await self._retry_unpublished_cleanup() + candidate_id = self._next_generation_id + candidate_runtime: ProviderRuntime | None = None + try: + candidate_runtime = self._runtime_factory(settings) + commit() + except Exception as exc: + trace_event( + stage="runtime", + event="provider_generation.replace_failed", + source="runtime", + current_generation_id=self._current.generation_id, + candidate_generation_id=candidate_id, + reason=reason, + exc_type=type(exc).__name__, + ) + if candidate_runtime is not None: + await self._cleanup_unpublished(candidate_runtime) + raise + + self._next_generation_id += 1 + assert candidate_runtime is not None + previous = self._current + candidate = _ProviderGeneration( + generation_id=candidate_id, + settings=settings, + runtime=candidate_runtime, + ) + self._current = candidate + previous.retired = True + self._retired[previous.generation_id] = previous + self._trace_published(candidate, previous=previous, reason=reason) + self._trace_retired(previous, reason=reason) + + self._refresh_task = asyncio.create_task( + self._refresh_generation(candidate, only_missing=False) + ) + if previous.active_leases == 0: + await self._close_generation(previous, forced=False) + return candidate.generation_id + + async def close(self) -> None: + """Reject new leases, drain existing work, and close every generation.""" + async with self._close_lock: + if self._closed: + return + async with self._replace_lock: + self._closing = True + await self._cancel_refresh() + current = self._current + if not current.retired: + current.retired = True + self._retired[current.generation_id] = current + self._trace_retired(current, reason="shutdown") + generations = tuple(self._retired.values()) + + await asyncio.gather( + *(generation.drained.wait() for generation in generations) + ) + generation_results = await asyncio.gather( + *( + self._close_generation(generation, forced=False) + for generation in generations + ) + ) + unpublished_closed = await self._retry_unpublished_cleanup() + if not all(generation_results) or not unpublished_closed: + raise RuntimeError("One or more provider runtimes failed to close.") + self._model_cache.clear() + self._closed = True + + async def _release(self, generation: _ProviderGeneration) -> None: + if generation.active_leases <= 0: + return + generation.active_leases -= 1 + if generation.active_leases != 0: + return + generation.drained.set() + if generation.retired and not self._closing: + await self._close_generation(generation, forced=False) + + async def _refresh_generation( + self, + generation: _ProviderGeneration, + *, + only_missing: bool, + ) -> None: + if generation.closed: + return + generation.active_leases += 1 + generation.drained.clear() + try: + discovery = ProviderModelDiscovery( + generation.settings, + generation.runtime.resolve_provider, + self._model_cache, + ) + await discovery.refresh_model_list_cache(only_missing=only_missing) + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning( + "Provider model discovery task failed: exc_type={}", + type(exc).__name__, + ) + finally: + await self._release(generation) + + async def _cancel_refresh(self) -> None: + task = self._refresh_task + self._refresh_task = None + if task is None or task.done(): + return + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + async def _cleanup_unpublished(self, runtime: ProviderRuntime) -> bool: + self._unpublished.add(runtime) + try: + await runtime.cleanup() + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning( + "Unpublished provider generation cleanup failed: exc_type={}", + type(exc).__name__, + ) + return False + self._unpublished.discard(runtime) + return True + + async def _retry_unpublished_cleanup(self) -> bool: + all_closed = True + for runtime in tuple(self._unpublished): + if not await self._cleanup_unpublished(runtime): + all_closed = False + return all_closed + + async def _close_generation( + self, + generation: _ProviderGeneration, + *, + forced: bool, + ) -> bool: + if generation.closed: + return True + if generation.active_leases != 0: + return False + task = generation.cleanup_task + if task is None: + task = asyncio.create_task( + self._run_generation_cleanup(generation, forced=forced), + name=f"provider-generation-cleanup-{generation.generation_id}", + ) + generation.cleanup_task = task + return await asyncio.shield(task) + + async def _run_generation_cleanup( + self, + generation: _ProviderGeneration, + *, + forced: bool, + ) -> bool: + task = asyncio.current_task() + try: + try: + await generation.runtime.cleanup() + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning( + "Provider generation cleanup failed: generation_id={} exc_type={}", + generation.generation_id, + type(exc).__name__, + ) + return False + + generation.closed = True + self._retired.pop(generation.generation_id, None) + trace_event( + stage="runtime", + event="provider_generation.closed", + source="runtime", + generation_id=generation.generation_id, + active_leases=generation.active_leases, + forced=forced, + outcome="ok", + ) + return True + finally: + if not generation.closed and generation.cleanup_task is task: + generation.cleanup_task = None + + @staticmethod + def _trace_published( + generation: _ProviderGeneration, + *, + previous: _ProviderGeneration | None, + reason: str, + ) -> None: + trace_event( + stage="runtime", + event="provider_generation.published", + source="runtime", + generation_id=generation.generation_id, + previous_generation_id=( + previous.generation_id if previous is not None else None + ), + reason=reason, + ) + + @staticmethod + def _trace_retired(generation: _ProviderGeneration, *, reason: str) -> None: + trace_event( + stage="runtime", + event="provider_generation.retired", + source="runtime", + generation_id=generation.generation_id, + active_leases=generation.active_leases, + reason=reason, + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..10436b4 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test suite package.""" diff --git a/tests/api/support.py b/tests/api/support.py new file mode 100644 index 0000000..c6bc0bb --- /dev/null +++ b/tests/api/support.py @@ -0,0 +1,61 @@ +"""Explicit test composition for the API adapter.""" + +from collections.abc import MutableMapping + +from fastapi import FastAPI + +from free_claude_code.api.app import create_app +from free_claude_code.api.ports import ApiServices +from free_claude_code.config.settings import Settings +from free_claude_code.providers.base import BaseProvider +from free_claude_code.providers.runtime import ProviderRuntime +from free_claude_code.runtime.application import ApplicationRuntime, RestartCallback +from free_claude_code.runtime.provider_manager import ProviderRuntimeManager + + +def create_test_app( + settings: Settings | None = None, + *, + providers: MutableMapping[str, BaseProvider] | None = None, + restart_callback: RestartCallback | None = None, +) -> FastAPI: + """Build an API app with explicit in-memory runtime services.""" + settings = settings or Settings() + if providers is None: + manager = ProviderRuntimeManager(settings) + else: + manager = ProviderRuntimeManager( + settings, + runtime_factory=lambda snapshot: ProviderRuntime( + snapshot, + dict(providers), + ), + ) + runtime = ApplicationRuntime( + manager, + transcriber=None, + restart_callback=restart_callback, + ) + return create_app( + ApiServices( + requests=manager, + admin=runtime, + tasks=runtime, + ) + ) + + +def runtime_for_app(app: FastAPI) -> ApplicationRuntime: + """Return the runtime supplied by :func:`create_test_app`.""" + runtime = app.state.services.admin + if not isinstance(runtime, ApplicationRuntime): + raise TypeError("Test app does not use ApplicationRuntime") + return runtime + + +def provider_manager_for_app(app: FastAPI) -> ProviderRuntimeManager: + """Return the provider manager supplied by :func:`create_test_app`.""" + manager = app.state.services.requests + if not isinstance(manager, ProviderRuntimeManager): + raise TypeError("Test app does not use ProviderRuntimeManager") + return manager diff --git a/tests/api/test_admin.py b/tests/api/test_admin.py new file mode 100644 index 0000000..add2d08 --- /dev/null +++ b/tests/api/test_admin.py @@ -0,0 +1,787 @@ +from pathlib import Path +from unittest.mock import patch + +import httpx +import pytest +from fastapi.testclient import TestClient + +from free_claude_code.config.admin.values import MASKED_SECRET +from free_claude_code.config.server_urls import local_admin_url +from free_claude_code.config.settings import Settings +from tests.api.support import create_test_app + + +def _local_client(app): + return TestClient(app, client=("127.0.0.1", 50000)) + + +def _set_home(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + +def _clear_process_config(monkeypatch) -> None: + for key in ( + "MODEL", + "NVIDIA_NIM_API_KEY", + "HUGGINGFACE_API_KEY", + "OPENROUTER_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "TELEGRAM_PROXY_URL", + "FCC_ENV_FILE", + "CLOUDFLARE_API_TOKEN", + "CLOUDFLARE_ACCOUNT_ID", + "GITHUB_MODELS_TOKEN", + "SAMBANOVA_API_KEY", + "HOST", + "PORT", + "VOICE_NOTE_ENABLED", + "WHISPER_DEVICE", + "LOG_FILE", + "ZAI_BASE_URL", + "CLAUDE_WORKSPACE", + "CLAUDE_CLI_BIN", + ): + monkeypatch.delenv(key, raising=False) + + +def test_admin_page_is_loopback_only(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + app = create_test_app() + + assert _local_client(app).get("/admin").status_code == 200 + remote_client = TestClient(app, client=("203.0.113.10", 50000)) + assert remote_client.get("/admin").status_code == 403 + + +def test_admin_page_no_longer_renders_generated_env_panel(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + app = create_test_app() + + response = _local_client(app).get("/admin") + + assert response.status_code == 200 + assert "Generated Env" not in response.text + assert "envPreview" not in response.text + + +def test_admin_page_no_longer_renders_global_status_header(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + app = create_test_app() + + response = _local_client(app).get("/admin") + + assert response.status_code == 200 + assert "Local Admin" not in response.text + assert "serverStatus" not in response.text + assert "modelBadge" not in response.text + + +def test_admin_static_no_longer_fetches_global_status_header(): + script = Path("src/free_claude_code/api/admin_static/admin.js").read_text( + encoding="utf-8" + ) + + assert 'api("/admin/api/status")' not in script + assert "updateHeader" not in script + assert '"Running"' not in script + assert "serverStatus" not in script + assert "modelBadge" not in script + + +def test_admin_static_hides_managed_source_label(): + script = Path("src/free_claude_code/api/admin_static/admin.js").read_text( + encoding="utf-8" + ) + + assert 'managed_env: "",' in script + assert "hasOwnProperty.call(labels, source)" in script + assert 'parts.push("locked")' in script + assert "sourceEl.textContent = source" in script + + +def test_admin_config_masks_secrets_and_exposes_manifest(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + + response = _local_client(app).get("/admin/api/config") + + assert response.status_code == 200 + body = response.json() + keys = {field["key"] for field in body["fields"]} + assert "ANTHROPIC_AUTH_TOKEN" in keys + assert "OPENROUTER_API_KEY" in keys + assert "FIREWORKS_API_KEY" in keys + assert "CLOUDFLARE_API_TOKEN" in keys + assert "CLOUDFLARE_ACCOUNT_ID" in keys + assert "GITHUB_MODELS_TOKEN" in keys + assert "GEMINI_API_KEY" in keys + assert "GROQ_API_KEY" in keys + assert "SAMBANOVA_API_KEY" in keys + assert "TELEGRAM_PROXY_URL" in keys + assert "CEREBRAS_API_KEY" in keys + assert "ZAI_BASE_URL" not in keys + assert "CLAUDE_WORKSPACE" not in keys + assert "CLAUDE_CLI_BIN" not in keys + assert "LOG_FILE" not in keys + auth_field = next( + field for field in body["fields"] if field["key"] == "ANTHROPIC_AUTH_TOKEN" + ) + assert auth_field["secret"] is True + assert auth_field["value"] == MASKED_SECRET + assert auth_field["source"] == "template" + telegram_proxy_field = next( + field for field in body["fields"] if field["key"] == "TELEGRAM_PROXY_URL" + ) + assert telegram_proxy_field["secret"] is True + restart_required = { + field["key"] for field in body["fields"] if field["restart_required"] is True + } + assert { + "ANTHROPIC_AUTH_TOKEN", + "DEBUG_PLATFORM_EDITS", + "DEBUG_SUBAGENT_STACK", + "LOG_RAW_API_PAYLOADS", + "LOG_API_ERROR_TRACEBACKS", + "LOG_RAW_MESSAGING_CONTENT", + "LOG_RAW_CLI_DIAGNOSTICS", + "LOG_MESSAGING_ERROR_DETAILS", + } <= restart_required + + +def test_admin_config_preserves_managed_env_source_contract(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + env_file = tmp_path / ".fcc" / ".env" + env_file.parent.mkdir(parents=True) + env_file.write_text("MODEL=open_router/managed-model\n", encoding="utf-8") + app = create_test_app() + + response = _local_client(app).get("/admin/api/config") + + assert response.status_code == 200 + body = response.json() + model_field = next(field for field in body["fields"] if field["key"] == "MODEL") + assert model_field["source"] == "managed_env" + assert model_field["locked"] is False + + +def test_admin_apply_masks_telegram_proxy_credentials(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + proxy_url = "https://user:password@proxy.example:8443" + + response = _local_client(app).post( + "/admin/api/config/apply", + json={"values": {"TELEGRAM_PROXY_URL": proxy_url}}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert "TELEGRAM_PROXY_URL=********" in body["env_preview"] + assert proxy_url not in body["env_preview"] + env_file = tmp_path / ".fcc" / ".env" + text = env_file.read_text(encoding="utf-8") + assert f"TELEGRAM_PROXY_URL={proxy_url}" in text + + +def test_admin_validate_rejects_bad_model_shape(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/validate", + json={"values": {"MODEL": "missing-provider-prefix"}}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["valid"] is False + assert any("provider type" in error for error in body["errors"]) + + +def test_admin_apply_writes_complete_managed_env_and_masks_preview( + monkeypatch, tmp_path +): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={ + "values": { + "MODEL": "open_router/test-model", + "OPENROUTER_API_KEY": "router-secret", + } + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert "OPENROUTER_API_KEY=********" in body["env_preview"] + env_file = tmp_path / ".fcc" / ".env" + text = env_file.read_text("utf-8") + assert "MODEL=open_router/test-model" in text + assert "OPENROUTER_API_KEY=router-secret" in text + assert "ANTHROPIC_AUTH_TOKEN=" in text + assert body["restart"] == { + "required": False, + "automatic": False, + "admin_url": None, + "fields": [], + } + + +def test_admin_apply_writes_fireworks_key_and_masks_preview(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={ + "values": { + "MODEL": "fireworks/test-model", + "FIREWORKS_API_KEY": "fw-secret", + } + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert "FIREWORKS_API_KEY=********" in body["env_preview"] + env_file = tmp_path / ".fcc" / ".env" + text = env_file.read_text(encoding="utf-8") + assert "MODEL=fireworks/test-model" in text + assert "FIREWORKS_API_KEY=fw-secret" in text + + +def test_admin_apply_writes_gemini_key_and_masks_preview(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={ + "values": { + "MODEL": "gemini/models/gemini-3.1-flash-lite", + "GEMINI_API_KEY": "gm-secret", + } + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert "GEMINI_API_KEY=********" in body["env_preview"] + env_file = tmp_path / ".fcc" / ".env" + text = env_file.read_text(encoding="utf-8") + assert "MODEL=gemini/models/gemini-3.1-flash-lite" in text + assert "GEMINI_API_KEY=gm-secret" in text + + +def test_admin_apply_writes_groq_key_and_masks_preview(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={ + "values": { + "MODEL": "groq/llama-3.3-70b-versatile", + "GROQ_API_KEY": "gq-secret", + } + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert "GROQ_API_KEY=********" in body["env_preview"] + env_file = tmp_path / ".fcc" / ".env" + text = env_file.read_text(encoding="utf-8") + assert "MODEL=groq/llama-3.3-70b-versatile" in text + assert "GROQ_API_KEY=gq-secret" in text + + +def test_admin_apply_writes_sambanova_key_and_masks_preview(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={ + "values": { + "MODEL": "sambanova/Meta-Llama-3.3-70B-Instruct", + "SAMBANOVA_API_KEY": "sn-secret", + } + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert "SAMBANOVA_API_KEY=********" in body["env_preview"] + env_file = tmp_path / ".fcc" / ".env" + text = env_file.read_text(encoding="utf-8") + assert "MODEL=sambanova/Meta-Llama-3.3-70B-Instruct" in text + assert "SAMBANOVA_API_KEY=sn-secret" in text + + +def test_admin_apply_writes_cerebras_key_and_masks_preview(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={ + "values": { + "MODEL": "cerebras/llama3.1-8b", + "CEREBRAS_API_KEY": "cb-secret", + } + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert "CEREBRAS_API_KEY=********" in body["env_preview"] + env_file = tmp_path / ".fcc" / ".env" + text = env_file.read_text(encoding="utf-8") + assert "MODEL=cerebras/llama3.1-8b" in text + assert "CEREBRAS_API_KEY=cb-secret" in text + + +def test_admin_apply_writes_cloudflare_fields_and_masks_preview(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={ + "values": { + "MODEL": "cloudflare/@cf/moonshotai/kimi-k2.6", + "CLOUDFLARE_API_TOKEN": "cf-secret", + "CLOUDFLARE_ACCOUNT_ID": "cf-account", + } + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert "CLOUDFLARE_API_TOKEN=********" in body["env_preview"] + assert "CLOUDFLARE_ACCOUNT_ID=cf-account" in body["env_preview"] + env_file = tmp_path / ".fcc" / ".env" + text = env_file.read_text(encoding="utf-8") + assert "MODEL=cloudflare/@cf/moonshotai/kimi-k2.6" in text + assert "CLOUDFLARE_API_TOKEN=cf-secret" in text + assert "CLOUDFLARE_ACCOUNT_ID=cf-account" in text + + +def test_admin_apply_writes_huggingface_key_and_masks_preview(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={ + "values": { + "MODEL": "huggingface/openai/gpt-oss-120b:fastest", + "HUGGINGFACE_API_KEY": "hf-secret", + } + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert body["pending_fields"] == [] + assert "HUGGINGFACE_API_KEY=********" in body["env_preview"] + env_file = tmp_path / ".fcc" / ".env" + text = env_file.read_text(encoding="utf-8") + assert "MODEL=huggingface/openai/gpt-oss-120b:fastest" in text + assert "HUGGINGFACE_API_KEY=hf-secret" in text + + +@pytest.mark.parametrize( + ("device", "credential_key"), + [ + ("nvidia_nim", "NVIDIA_NIM_API_KEY"), + ("cpu", "HUGGINGFACE_API_KEY"), + ], +) +def test_admin_key_change_requires_restart_for_active_voice_backend( + monkeypatch, + tmp_path, + device, + credential_key, +): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + env_file = tmp_path / ".fcc" / ".env" + env_file.parent.mkdir(parents=True) + env_file.write_text( + "\n".join( + [ + "VOICE_NOTE_ENABLED=true", + f"WHISPER_DEVICE={device}", + f"{credential_key}=old-key", + "", + ] + ), + encoding="utf-8", + ) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={"values": {credential_key: "new-key"}}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert body["pending_fields"] == [credential_key] + assert body["restart"] == { + "required": True, + "automatic": False, + "admin_url": None, + "fields": [credential_key], + } + + +@pytest.mark.parametrize( + ("key", "initial", "updated"), + [ + ("ANTHROPIC_AUTH_TOKEN", "old-token", "new-token"), + ("DEBUG_PLATFORM_EDITS", "true", "false"), + ("DEBUG_SUBAGENT_STACK", "true", "false"), + ("LOG_RAW_API_PAYLOADS", "true", "false"), + ("LOG_API_ERROR_TRACEBACKS", "true", "false"), + ("LOG_RAW_MESSAGING_CONTENT", "true", "false"), + ("LOG_RAW_CLI_DIAGNOSTICS", "true", "false"), + ("LOG_MESSAGING_ERROR_DETAILS", "true", "false"), + ], +) +def test_admin_constructor_captured_setting_requires_restart( + monkeypatch, + tmp_path, + key, + initial, + updated, +): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + env_file = tmp_path / ".fcc" / ".env" + env_file.parent.mkdir(parents=True) + env_file.write_text(f"{key}={initial}\n", encoding="utf-8") + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={"values": {key: updated}}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert body["pending_fields"] == [key] + assert body["restart"] == { + "required": True, + "automatic": False, + "admin_url": None, + "fields": [key], + } + + +def test_admin_apply_writes_cohere_key_and_masks_preview(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={ + "values": { + "MODEL": "cohere/command-a-plus-05-2026", + "COHERE_API_KEY": "cohere-secret", + } + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert "COHERE_API_KEY=********" in body["env_preview"] + env_file = tmp_path / ".fcc" / ".env" + text = env_file.read_text(encoding="utf-8") + assert "MODEL=cohere/command-a-plus-05-2026" in text + assert "COHERE_API_KEY=cohere-secret" in text + + +def test_admin_apply_writes_github_models_token_and_masks_preview( + monkeypatch, tmp_path +): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={ + "values": { + "MODEL": "github_models/openai/gpt-4.1", + "GITHUB_MODELS_TOKEN": "github-secret", + } + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert "GITHUB_MODELS_TOKEN=********" in body["env_preview"] + env_file = tmp_path / ".fcc" / ".env" + text = env_file.read_text(encoding="utf-8") + assert "MODEL=github_models/openai/gpt-4.1" in text + assert "GITHUB_MODELS_TOKEN=github-secret" in text + + +def test_admin_apply_preserves_hidden_diagnostics_and_smoke_values( + monkeypatch, tmp_path +): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + env_file = tmp_path / ".fcc" / ".env" + env_file.parent.mkdir(parents=True) + env_file.write_text( + "\n".join( + [ + "MODEL=nvidia_nim/old-model", + "LOG_RAW_API_PAYLOADS=true", + "FCC_SMOKE_MODEL_ZAI=zai/smoke-model", + "", + ] + ), + encoding="utf-8", + ) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={"values": {"MODEL": "open_router/test-model"}}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + text = env_file.read_text("utf-8") + assert "MODEL=open_router/test-model" in text + assert "LOG_RAW_API_PAYLOADS=true" in text + assert "FCC_SMOKE_MODEL_ZAI=zai/smoke-model" in text + + +def test_admin_apply_omits_stale_zai_base_url(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + env_file = tmp_path / ".fcc" / ".env" + env_file.parent.mkdir(parents=True) + env_file.write_text( + "\n".join( + [ + "MODEL=zai/glm-5.2", + "ZAI_API_KEY=zai-secret", + "ZAI_BASE_URL=https://custom.zai.invalid/v1", + "", + ] + ), + encoding="utf-8", + ) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={"values": {"MODEL": "zai/glm-5.2"}}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + text = env_file.read_text("utf-8") + assert "ZAI_API_KEY=zai-secret" in text + assert "ZAI_BASE_URL" not in text + + +def test_admin_apply_omits_stale_fixed_claude_runtime_settings(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + env_file = tmp_path / ".fcc" / ".env" + env_file.parent.mkdir(parents=True) + env_file.write_text( + "\n".join( + [ + "MODEL=open_router/test-model", + "CLAUDE_WORKSPACE=C:/custom/workspace", + "CLAUDE_CLI_BIN=claude-custom", + "", + ] + ), + encoding="utf-8", + ) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={"values": {"MODEL": "open_router/test-model"}}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + text = env_file.read_text("utf-8") + assert "MODEL=open_router/test-model" in text + assert "CLAUDE_WORKSPACE" not in text + assert "CLAUDE_CLI_BIN" not in text + + +def test_admin_apply_restart_required_reports_automatic_restart(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + callbacks: list[str] = [] + + async def restart_callback() -> None: + callbacks.append("restart") + + app = create_test_app(restart_callback=restart_callback) + + response = _local_client(app).post( + "/admin/api/config/apply", + json={"values": {"PORT": "9090"}}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert body["pending_fields"] == ["PORT"] + assert body["restart"] == { + "required": True, + "automatic": True, + "admin_url": "http://127.0.0.1:9090/admin", + "fields": ["PORT"], + } + assert callbacks == ["restart"] + + +def test_admin_apply_restart_required_reports_manual_fallback(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={"values": {"PORT": "9091"}}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert body["pending_fields"] == ["PORT"] + assert body["restart"] == { + "required": True, + "automatic": False, + "admin_url": None, + "fields": ["PORT"], + } + + +def test_admin_process_env_values_are_locked_and_not_written(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + monkeypatch.setenv("MODEL", "open_router/process-model") + app = create_test_app() + + config = _local_client(app).get("/admin/api/config").json() + model_field = next(field for field in config["fields"] if field["key"] == "MODEL") + assert model_field["locked"] is True + assert model_field["source"] == "process" + + response = _local_client(app).post( + "/admin/api/config/apply", + json={"values": {"MODEL": "deepseek/managed-model"}}, + ) + + assert response.status_code == 200 + env_file = tmp_path / ".fcc" / ".env" + assert "deepseek/managed-model" not in env_file.read_text("utf-8") + + +def test_admin_first_apply_migrates_repo_env(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + monkeypatch.chdir(tmp_path) + (tmp_path / ".env").write_text( + "MODEL=deepseek/deepseek-chat\nDEEPSEEK_API_KEY=deepseek-secret\n", + encoding="utf-8", + ) + app = create_test_app() + + config = _local_client(app).get("/admin/api/config").json() + model_field = next(field for field in config["fields"] if field["key"] == "MODEL") + assert model_field["value"] == "deepseek/deepseek-chat" + assert model_field["source"] == "repo_env" + + response = _local_client(app).post( + "/admin/api/config/apply", + json={"values": {}}, + ) + + assert response.status_code == 200 + managed_text = (tmp_path / ".fcc" / ".env").read_text("utf-8") + assert "MODEL=deepseek/deepseek-chat" in managed_text + assert "DEEPSEEK_API_KEY=deepseek-secret" in managed_text + + +def test_admin_local_provider_status_reports_reachable(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + + class FakeAsyncClient: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return None + + async def get(self, url: str): + return httpx.Response(200, json={"data": []}) + + with patch("free_claude_code.api.admin_routes.httpx.AsyncClient", FakeAsyncClient): + response = _local_client(app).get("/admin/api/providers/local-status") + + assert response.status_code == 200 + providers = response.json()["providers"] + assert {provider["status"] for provider in providers} == {"reachable"} + + +def test_admin_launch_url_uses_loopback_for_wildcard_host(): + settings = Settings.model_construct(host="0.0.0.0", port=8082) + + assert local_admin_url(settings) == "http://127.0.0.1:8082/admin" diff --git a/tests/api/test_api.py b/tests/api/test_api.py new file mode 100644 index 0000000..482f0db --- /dev/null +++ b/tests/api/test_api.py @@ -0,0 +1,430 @@ +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from free_claude_code.core.failures import ExecutionFailure, FailureKind +from free_claude_code.providers.nvidia_nim import NvidiaNimProvider +from tests.api.support import create_test_app + +app = create_test_app() + +# Mock provider +mock_provider = MagicMock(spec=NvidiaNimProvider) + +# Track stream_response calls for test_model_mapping +_stream_response_calls: list = [] + + +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" + + +async def _mock_pre_start_rate_limit(*args, **kwargs): + """Provider stream that fails before any downstream-visible SSE chunk.""" + _stream_response_calls.append((args, kwargs)) + raise ExecutionFailure( + kind=FailureKind.RATE_LIMIT, + status_code=429, + message="upstream is busy", + retryable=True, + ) + yield "unreachable" + + +async def _mock_empty_stream(*args, **kwargs): + """Provider stream that completes without a protocol frame.""" + _stream_response_calls.append((args, kwargs)) + if False: + yield "unreachable" + + +def _terminal_json_error(response, *, status_code: int): + assert response.status_code == status_code + assert response.headers["content-type"].startswith("application/json") + assert response.headers["x-should-retry"] == "false" + request_id = response.headers["request-id"] + payload = response.json() + assert payload["request_id"] == request_id + return payload["error"] + + +mock_provider.stream_response = _mock_stream_response + + +@pytest.fixture(scope="module") +def client(): + """HTTP client with provider resolution stubbed; patch only for this file.""" + with ( + patch( + "free_claude_code.api.routes.resolve_provider", + return_value=mock_provider, + ), + TestClient(app) as test_client, + ): + yield test_client + + +def test_root(client: TestClient): + response = client.get("/") + assert response.status_code == 200 + assert response.json()["status"] == "ok" + assert response.headers["request-id"].startswith("req_") + + +def test_health(client: TestClient): + response = client.get("/health") + assert response.status_code == 200 + assert response.json()["status"] == "healthy" + assert response.headers["request-id"].startswith("req_") + + +def test_models_list(client: TestClient): + response = client.get("/v1/models") + assert response.status_code == 200 + data = response.json() + assert data["has_more"] is False + ids = [item["id"] for item in data["data"]] + assert "claude-sonnet-4-20250514" in ids + assert data["first_id"] == ids[0] + assert data["last_id"] == ids[-1] + assert response.headers["x-request-id"] == response.headers["request-id"] + + +def test_probe_endpoints_return_204_with_allow_headers(client: TestClient): + responses = [ + client.head("/"), + client.options("/"), + client.head("/health"), + client.options("/health"), + client.head("/v1/messages"), + client.options("/v1/messages"), + client.head("/v1/messages/count_tokens"), + client.options("/v1/messages/count_tokens"), + ] + + for response in responses: + assert response.status_code == 204 + assert "Allow" in response.headers + + +def test_create_message_stream(client: TestClient): + """Create message returns streaming response.""" + _stream_response_calls.clear() + 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 + assert _stream_response_calls[0][1]["request_id"] == response.headers["request-id"] + + +def test_create_message_ingress_error_has_request_id_without_terminal_header( + client: TestClient, +): + response = client.post( + "/v1/messages", + json={"model": "test", "messages": [], "max_tokens": 10, "stream": True}, + ) + + assert response.status_code == 400 + assert "x-should-retry" not in response.headers + assert response.json()["request_id"] == response.headers["request-id"] + + +def test_create_message_schema_validation_has_request_id_without_terminal_header( + client: TestClient, +): + response = client.post( + "/v1/messages", + json={"model": "test", "messages": "not-a-list"}, + ) + + assert response.status_code == 422 + assert response.headers["request-id"].startswith("req_") + assert "x-should-retry" not in response.headers + + +def test_create_message_pre_start_provider_error_returns_terminal_json( + client: TestClient, +): + """Pre-start provider failures keep status without enabling client retries.""" + mock_provider.stream_response = _mock_pre_start_rate_limit + _stream_response_calls.clear() + payload = { + "model": "claude-3-sonnet", + "messages": [{"role": "user", "content": "Hi"}], + "max_tokens": 100, + "stream": True, + } + + with ( + patch("free_claude_code.api.response_streams.trace_event") as trace, + patch("free_claude_code.application.execution.trace_event") as execution_trace, + ): + response = client.post("/v1/messages", json=payload) + + error = _terminal_json_error(response, status_code=429) + assert error == {"type": "rate_limit_error", "message": "upstream is busy"} + request_id = response.headers["request-id"] + assert _stream_response_calls[0][1]["request_id"] == request_id + route_trace = next( + call.kwargs + for call in execution_trace.call_args_list + if call.kwargs.get("event") == "free_claude_code.api.route.resolved" + ) + assert route_trace["request_id"] == request_id + terminal_trace = next( + call.kwargs + for call in trace.call_args_list + if call.kwargs.get("event") + == "free_claude_code.api.response.terminal_execution_error" + ) + assert terminal_trace == { + "stage": "egress", + "event": "free_claude_code.api.response.terminal_execution_error", + "source": "api", + "wire_api": "messages", + "request_id": request_id, + "status_code": 429, + "error_type": "rate_limit_error", + "client_should_retry": False, + "exc_type": "ExecutionFailure", + "failure_kind": "rate_limit", + "provider_retryable": True, + } + mock_provider.stream_response = _mock_stream_response + + +def test_create_message_accepts_system_role_messages(client: TestClient): + """Create message accepts latest-client system messages.""" + mock_provider.stream_response = _mock_stream_response + _stream_response_calls.clear() + payload = { + "model": "claude-3-sonnet", + "messages": [ + {"role": "user", "content": "context"}, + {"role": "system", "content": "system prompt"}, + {"role": "user", "content": "Hi"}, + ], + "max_tokens": 100, + "stream": True, + } + + response = client.post("/v1/messages", json=payload) + + assert response.status_code == 200 + routed_request = _stream_response_calls[0][0][0] + assert [message.role for message in routed_request.messages] == ["user", "user"] + assert routed_request.system == "system prompt" + + +def test_model_mapping(client: TestClient): + # 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] + kwargs = _stream_response_calls[0][1] + assert args[0].model != "claude-3-haiku-20240307" + assert kwargs["thinking_enabled"] is True + + +@pytest.mark.parametrize( + ("failure", "expected_type"), + [ + ( + ExecutionFailure( + FailureKind.AUTHENTICATION, 401, "Invalid Key", retryable=False + ), + "authentication_error", + ), + ( + ExecutionFailure( + FailureKind.INVALID_REQUEST, + 400, + "Invalid request api_key=SECRET useful detail", + retryable=False, + ), + "invalid_request_error", + ), + ( + ExecutionFailure( + FailureKind.RATE_LIMIT, 429, "Too Many Requests", retryable=True + ), + "rate_limit_error", + ), + ( + ExecutionFailure( + FailureKind.OVERLOADED, 529, "Server Overloaded", retryable=True + ), + "overloaded_error", + ), + ( + ExecutionFailure( + FailureKind.UPSTREAM, 503, "Upstream failed", retryable=True + ), + "api_error", + ), + ], +) +def test_provider_execution_errors_preserve_status_and_type( + client: TestClient, + failure: ExecutionFailure, + expected_type: str, +): + base_payload = { + "model": "test", + "messages": [{"role": "user", "content": "Hi"}], + "max_tokens": 10, + "stream": True, + } + + def _raise_provider_error(*args, **kwargs): + raise failure + + try: + mock_provider.stream_response = _raise_provider_error + response = client.post("/v1/messages", json=base_payload) + error = _terminal_json_error(response, status_code=failure.status_code) + assert error["type"] == expected_type + assert "SECRET" not in error["message"] + if expected_type == "invalid_request_error": + assert "useful detail" in error["message"] + finally: + mock_provider.stream_response = _mock_stream_response + + +def test_empty_provider_stream_returns_terminal_json(client: TestClient): + mock_provider.stream_response = _mock_empty_stream + try: + response = client.post( + "/v1/messages", + json={ + "model": "test", + "messages": [{"role": "user", "content": "Hi"}], + "max_tokens": 10, + "stream": True, + }, + ) + error = _terminal_json_error(response, status_code=500) + assert error["type"] == "api_error" + assert error["message"] == "Stream ended before emitting a response." + finally: + mock_provider.stream_response = _mock_stream_response + + +def test_generic_stream_exception_returns_terminal_json(client: TestClient): + """Unexpected provider execution failures return detailed terminal JSON.""" + + 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, + }, + ) + error = _terminal_json_error(response, status_code=500) + assert error["type"] == "api_error" + assert error["message"] == "unexpected crash" + mock_provider.stream_response = _mock_stream_response + + +def test_generic_stream_exception_with_status_code_returns_terminal_json( + client: TestClient, +): + """Ad-hoc status_code attrs do not become retryable HTTP responses.""" + + 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, + }, + ) + error = _terminal_json_error(response, status_code=500) + assert error["type"] == "api_error" + assert error["message"] == "bad gateway" + mock_provider.stream_response = _mock_stream_response + + +def test_generic_stream_exception_empty_message_returns_non_empty_error( + client: TestClient, +): + """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, + }, + ) + error = _terminal_json_error(response, status_code=500) + assert error["type"] == "api_error" + assert error["message"] != "" + mock_provider.stream_response = _mock_stream_response + + +def test_count_tokens_endpoint(client: TestClient): + """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() + assert response.headers["request-id"].startswith("req_") + + +def test_stop_endpoint_no_workflow_no_cli_503(client: TestClient): + """POST /stop without messaging workflow or cli_manager returns 503.""" + # Ensure no messaging workflow or cli_manager on app state + if hasattr(app.state, "messaging_workflow"): + delattr(app.state, "messaging_workflow") + if hasattr(app.state, "cli_manager"): + delattr(app.state, "cli_manager") + response = client.post("/stop") + assert response.status_code == 503 diff --git a/tests/api/test_api_handlers.py b/tests/api/test_api_handlers.py new file mode 100644 index 0000000..4b636bc --- /dev/null +++ b/tests/api/test_api_handlers.py @@ -0,0 +1,558 @@ +import json +from collections.abc import AsyncIterator +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.responses import JSONResponse, StreamingResponse + +from free_claude_code.api.handlers import ( + MessagesHandler, + ResponsesHandler, + TokenCountHandler, +) +from free_claude_code.application.errors import InvalidRequestError +from free_claude_code.config.settings import Settings +from free_claude_code.core.anthropic.models import ( + Message, + MessagesRequest, + TokenCountRequest, +) +from free_claude_code.core.anthropic.streaming import format_sse_event +from free_claude_code.core.failures import ExecutionFailure, FailureKind +from free_claude_code.core.openai_responses import OpenAIResponsesRequest + +_CLASSIFIER_SYSTEM = ( + "You are a security monitor. Respond with yes or no." +) +_CLASSIFIER_USER = ( + "\nUser: review the repo\nWebFetch https://example.com: fetch\n" + "\n immediately." +) + + +class FakeProvider: + def __init__(self, events: list[str] | None = None) -> None: + self.preflight_calls: list[tuple[MessagesRequest, bool | None]] = [] + self.requests: list[MessagesRequest] = [] + self.stream_kwargs: list[dict[str, Any]] = [] + self.events = events or [ + 'event: message_start\ndata: {"type":"message_start"}\n\n', + 'event: message_stop\ndata: {"type":"message_stop"}\n\n', + ] + + def preflight_stream( + self, request: MessagesRequest, *, thinking_enabled: bool | None = None + ) -> None: + self.preflight_calls.append((request, thinking_enabled)) + + async def cleanup(self) -> None: + return None + + async def list_model_ids(self) -> frozenset[str]: + return frozenset({"test-model"}) + + async def stream_response( + self, + request: MessagesRequest, + input_tokens: int = 0, + *, + request_id: str | None = None, + thinking_enabled: bool | None = None, + ) -> AsyncIterator[str]: + self.requests.append(request) + self.stream_kwargs.append( + { + "input_tokens": input_tokens, + "request_id": request_id, + "thinking_enabled": thinking_enabled, + } + ) + for event in self.events: + yield event + + +async def _streaming_body_text(response: StreamingResponse) -> str: + parts: list[str] = [] + async for chunk in response.body_iterator: + if isinstance(chunk, bytes): + parts.append(chunk.decode("utf-8")) + else: + parts.append(str(chunk)) + return "".join(parts) + + +def _json_response_content(response: JSONResponse) -> dict[str, Any]: + content = json.loads(bytes(response.body).decode("utf-8")) + assert isinstance(content, dict) + return content + + +def _trace_events(trace_mock: MagicMock, event: str) -> list[dict[str, Any]]: + return [ + dict(call.kwargs) + for call in trace_mock.call_args_list + if call.kwargs.get("event") == event + ] + + +@pytest.mark.asyncio +async def test_messages_handler_passes_routed_request_and_stream_metadata() -> None: + provider = FakeProvider() + handler = MessagesHandler(Settings(), provider_resolver=lambda _: provider) + request = MessagesRequest( + model="nvidia_nim/test-model", + max_tokens=100, + messages=[Message(role="user", content="hi")], + ) + + response = await handler.create(request) + assert isinstance(response, StreamingResponse) + + body = await _streaming_body_text(response) + assert "message_start" in body + assert provider.requests[0].model == "test-model" + assert provider.stream_kwargs[0]["input_tokens"] > 0 + assert provider.stream_kwargs[0]["request_id"].startswith("req_") + assert provider.stream_kwargs[0]["thinking_enabled"] is True + assert len(provider.preflight_calls) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("stream", [True, False]) +async def test_messages_handler_preflight_invalid_request_stays_http_error( + stream: bool, +) -> None: + class RejectPreflightProvider(FakeProvider): + def preflight_stream( + self, + request: MessagesRequest, + *, + thinking_enabled: bool | None = None, + ) -> None: + raise InvalidRequestError("bad tool shape") + + provider = RejectPreflightProvider() + handler = MessagesHandler(Settings(), provider_resolver=lambda _: provider) + request = MessagesRequest( + model="nvidia_nim/test-model", + max_tokens=100, + messages=[Message(role="user", content="hi")], + stream=stream, + ) + + with pytest.raises(InvalidRequestError): + await handler.create(request) + + +@pytest.mark.asyncio +async def test_messages_handler_aggregates_provider_stream_when_stream_false() -> None: + provider = FakeProvider( + [ + format_sse_event( + "message_start", + { + "type": "message_start", + "message": { + "id": "msg_test", + "type": "message", + "role": "assistant", + "content": [], + "model": "test-model", + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": 7, "output_tokens": 1}, + }, + }, + ), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "OK"}, + }, + ), + format_sse_event( + "content_block_stop", {"type": "content_block_stop", "index": 0} + ), + format_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"input_tokens": 7, "output_tokens": 2}, + }, + ), + format_sse_event("message_stop", {"type": "message_stop"}), + ] + ) + handler = MessagesHandler(Settings(), provider_resolver=lambda _: provider) + request = MessagesRequest( + model="nvidia_nim/test-model", + max_tokens=100, + stream=False, + messages=[Message(role="user", content="hi")], + ) + + response = await handler.create(request) + + assert isinstance(response, JSONResponse) + assert response.headers["content-type"].startswith("application/json") + body = _json_response_content(response) + assert body["id"] == "msg_test" + assert body["type"] == "message" + assert body["role"] == "assistant" + assert body["model"] == "test-model" + assert body["content"] == [{"type": "text", "text": "OK"}] + assert body["stop_reason"] == "end_turn" + assert body["usage"] == {"input_tokens": 7, "output_tokens": 2} + + +@pytest.mark.asyncio +async def test_messages_handler_returns_error_json_for_stream_false_sse_error() -> None: + provider = FakeProvider( + [ + format_sse_event( + "error", + { + "type": "error", + "error": {"type": "api_error", "message": "upstream failed"}, + }, + ) + ] + ) + handler = MessagesHandler(Settings(), provider_resolver=lambda _: provider) + request = MessagesRequest( + model="nvidia_nim/test-model", + max_tokens=100, + stream=False, + messages=[Message(role="user", content="hi")], + ) + + response = await handler.create(request) + + assert isinstance(response, JSONResponse) + assert response.status_code == 500 + assert response.headers["x-should-retry"] == "false" + body = _json_response_content(response) + assert body["type"] == "error" + assert body["error"] == {"type": "api_error", "message": "upstream failed"} + assert body["request_id"].startswith("req_") + + +@pytest.mark.asyncio +async def test_messages_handler_discards_partial_stream_false_output_on_error() -> None: + provider = FakeProvider( + [ + format_sse_event( + "message_start", + { + "type": "message_start", + "message": { + "id": "msg_partial", + "type": "message", + "role": "assistant", + "content": [], + "model": "test-model", + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + }, + ), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "incomplete"}, + }, + ), + format_sse_event( + "error", + { + "type": "error", + "error": { + "type": "overloaded_error", + "message": "provider overloaded", + }, + }, + ), + ] + ) + handler = MessagesHandler(Settings(), provider_resolver=lambda _: provider) + request = MessagesRequest( + model="nvidia_nim/test-model", + max_tokens=100, + stream=False, + messages=[Message(role="user", content="hi")], + ) + + response = await handler.create(request) + + assert isinstance(response, JSONResponse) + assert response.status_code == 529 + assert response.headers["x-should-retry"] == "false" + body = _json_response_content(response) + assert body["error"] == { + "type": "overloaded_error", + "message": "provider overloaded", + } + assert "content" not in body + + +@pytest.mark.asyncio +async def test_messages_handler_stream_false_provider_exception_keeps_status() -> None: + class FailingProvider(FakeProvider): + async def stream_response( + self, + request: Any, + input_tokens: int = 0, + *, + request_id: str | None = None, + thinking_enabled: bool | None = None, + ) -> AsyncIterator[str]: + self.requests.append(request) + self.stream_kwargs.append( + { + "input_tokens": input_tokens, + "request_id": request_id, + "thinking_enabled": thinking_enabled, + } + ) + raise ExecutionFailure( + kind=FailureKind.RATE_LIMIT, + status_code=429, + message="upstream is busy", + retryable=True, + ) + yield "unreachable" + + provider = FailingProvider() + handler = MessagesHandler(Settings(), provider_resolver=lambda _: provider) + request = MessagesRequest( + model="nvidia_nim/test-model", + max_tokens=100, + stream=False, + messages=[Message(role="user", content="hi")], + ) + + response = await handler.create(request) + + assert isinstance(response, JSONResponse) + assert response.status_code == 429 + assert response.headers["x-should-retry"] == "false" + body = _json_response_content(response) + assert body["error"] == { + "type": "rate_limit_error", + "message": "upstream is busy", + } + + +@pytest.mark.asyncio +async def test_messages_handler_forces_no_thinking_for_safety_classifier() -> None: + provider = FakeProvider() + handler = MessagesHandler(Settings(), provider_resolver=lambda _: provider) + request = MessagesRequest( + model="nvidia_nim/test-model", + max_tokens=100, + system=_CLASSIFIER_SYSTEM, + messages=[Message(role="user", content=_CLASSIFIER_USER)], + ) + + with patch("free_claude_code.api.handlers.messages.trace_event") as trace_mock: + response = await handler.create(request) + assert isinstance(response, StreamingResponse) + await _streaming_body_text(response) + + assert provider.preflight_calls[0][1] is False + assert provider.stream_kwargs[0]["thinking_enabled"] is False + assert provider.requests[0].model == "test-model" + assert provider.requests[0].system == _CLASSIFIER_SYSTEM + assert _trace_events( + trace_mock, "free_claude_code.api.optimization.safety_classifier_no_thinking" + ) == [ + { + "stage": "routing", + "event": "free_claude_code.api.optimization.safety_classifier_no_thinking", + "source": "api", + "model": "test-model", + "changed": True, + } + ] + + +@pytest.mark.asyncio +async def test_messages_handler_preserves_thinking_for_non_classifier() -> None: + provider = FakeProvider() + handler = MessagesHandler(Settings(), provider_resolver=lambda _: provider) + request = MessagesRequest( + model="nvidia_nim/test-model", + max_tokens=100, + system="Explain XML formats.", + messages=[ + Message( + role="user", + content=( + "Explain ... and a tag " + "without making a verdict." + ), + ) + ], + ) + + with patch("free_claude_code.api.handlers.messages.trace_event") as trace_mock: + response = await handler.create(request) + assert isinstance(response, StreamingResponse) + await _streaming_body_text(response) + + assert provider.preflight_calls[0][1] is True + assert provider.stream_kwargs[0]["thinking_enabled"] is True + assert ( + _trace_events( + trace_mock, + "free_claude_code.api.optimization.safety_classifier_no_thinking", + ) + == [] + ) + + +@pytest.mark.asyncio +async def test_messages_handler_keeps_existing_no_thinking_for_classifier() -> None: + provider = FakeProvider() + handler = MessagesHandler(Settings(), provider_resolver=lambda _: provider) + request = MessagesRequest( + model="claude-3-freecc-no-thinking/nvidia_nim/test-model", + max_tokens=100, + system=_CLASSIFIER_SYSTEM, + messages=[Message(role="user", content=_CLASSIFIER_USER)], + ) + + with patch("free_claude_code.api.handlers.messages.trace_event") as trace_mock: + response = await handler.create(request) + assert isinstance(response, StreamingResponse) + await _streaming_body_text(response) + + assert provider.preflight_calls[0][1] is False + assert provider.stream_kwargs[0]["thinking_enabled"] is False + assert _trace_events( + trace_mock, "free_claude_code.api.optimization.safety_classifier_no_thinking" + ) == [ + { + "stage": "routing", + "event": "free_claude_code.api.optimization.safety_classifier_no_thinking", + "source": "api", + "model": "test-model", + "changed": False, + } + ] + + +@pytest.mark.asyncio +async def test_messages_handler_optimization_intercepts_before_provider_execution() -> ( + None +): + provider_resolver = MagicMock() + handler = MessagesHandler(Settings(), provider_resolver=provider_resolver) + request = MessagesRequest( + model="nvidia_nim/test-model", + max_tokens=100, + messages=[Message(role="user", content="quota check")], + ) + optimized = object() + + with patch( + "free_claude_code.api.handlers.messages.try_optimizations", + return_value=optimized, + ): + assert await handler.create(request) is optimized + + provider_resolver.assert_not_called() + + +@pytest.mark.asyncio +async def test_responses_handler_bypasses_message_only_optimizations() -> None: + provider = FakeProvider() + handler = ResponsesHandler(Settings(), provider_resolver=lambda _: provider) + + with patch( + "free_claude_code.api.handlers.messages.try_optimizations", + side_effect=AssertionError("Responses must not use message optimizations"), + ): + response = await handler.create( + OpenAIResponsesRequest( + model="nvidia_nim/test-model", + input="quota check", + ) + ) + + assert isinstance(response, StreamingResponse) + body = await _streaming_body_text(response) + assert "response.completed" in body + assert provider.requests[0].messages[0].content == "quota check" + + +@pytest.mark.asyncio +async def test_responses_handler_does_not_apply_safety_classifier_policy() -> None: + provider = FakeProvider() + handler = ResponsesHandler(Settings(), provider_resolver=lambda _: provider) + + with patch("free_claude_code.api.handlers.messages.trace_event") as trace_mock: + response = await handler.create( + OpenAIResponsesRequest( + model="nvidia_nim/test-model", + input=_CLASSIFIER_USER, + instructions=_CLASSIFIER_SYSTEM, + ) + ) + + assert isinstance(response, StreamingResponse) + await _streaming_body_text(response) + + assert provider.preflight_calls[0][1] is True + assert provider.stream_kwargs[0]["thinking_enabled"] is True + assert ( + _trace_events( + trace_mock, + "free_claude_code.api.optimization.safety_classifier_no_thinking", + ) + == [] + ) + + +def test_token_count_handler_routes_and_counts_tokens() -> None: + handler = TokenCountHandler( + Settings(), + token_counter=lambda messages, system, tools: len(messages) + 41, + ) + + with patch("free_claude_code.api.handlers.token_count.trace_event") as trace: + response = handler.count( + TokenCountRequest( + model="nvidia_nim/test-model", + messages=[Message(role="user", content="hi")], + ), + request_id="req_ingress", + ) + + assert response.input_tokens == 42 + assert all( + call.kwargs["request_id"] == "req_ingress" for call in trace.call_args_list + ) diff --git a/tests/api/test_app_lifespan_and_errors.py b/tests/api/test_app_lifespan_and_errors.py new file mode 100644 index 0000000..6a2e6da --- /dev/null +++ b/tests/api/test_app_lifespan_and_errors.py @@ -0,0 +1,379 @@ +import logging +from pathlib import Path +from typing import cast +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from free_claude_code.application.errors import ( + ApplicationUnavailableError, + InvalidRequestError, +) +from free_claude_code.config.settings import Settings +from free_claude_code.messaging.transcription import TranscriptionService +from free_claude_code.providers.nvidia_nim.client import NvidiaNimProvider +from free_claude_code.providers.nvidia_nim.voice import NvidiaNimTranscriber +from free_claude_code.runtime.application import ( + ApplicationRuntime, + startup_failure_message, + warn_if_process_auth_token, +) +from free_claude_code.runtime.asgi import RuntimeASGIApp +from free_claude_code.runtime.bootstrap import _create_transcriber, build_asgi_app +from free_claude_code.runtime.provider_manager import ProviderRuntimeManager +from tests.api.support import create_test_app + + +def _settings(**updates: object) -> Settings: + return Settings().model_copy(update=updates) + + +@pytest.fixture(autouse=True) +def _redirect_fcc_home(monkeypatch, tmp_path): + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + + +def test_warn_if_process_auth_token_logs_warning(monkeypatch): + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "process-token") + monkeypatch.setitem(Settings.model_config, "env_file", ()) + + with patch("free_claude_code.runtime.application.logger.warning") as warning: + warn_if_process_auth_token(Settings.model_construct()) + + warning.assert_called_once() + assert "ANTHROPIC_AUTH_TOKEN" in warning.call_args.args[0] + + +def test_warn_if_process_auth_token_skips_explicit_dotenv_config(monkeypatch, tmp_path): + env_file = tmp_path / ".env" + env_file.write_text("ANTHROPIC_AUTH_TOKEN=\n", encoding="utf-8") + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "process-token") + monkeypatch.setitem(Settings.model_config, "env_file", (env_file,)) + + with patch("free_claude_code.runtime.application.logger.warning") as warning: + warn_if_process_auth_token(Settings.model_construct()) + + warning.assert_not_called() + + +@pytest.mark.asyncio +async def test_runtime_startup_logs_admin_url_without_printed_server_banner(): + settings = _settings( + messaging_platform="none", + host="127.0.0.1", + port=9099, + ) + manager = ProviderRuntimeManager(settings) + runtime = ApplicationRuntime(manager, transcriber=None) + uvicorn_logger = MagicMock() + + with ( + patch("builtins.print") as printed, + patch.object(manager, "validate_configured_models", new=AsyncMock()), + patch.object(manager, "start_model_list_refresh") as start_refresh, + patch.object(manager, "close", new=AsyncMock()), + patch( + "free_claude_code.runtime.application.messaging_platform_factory.create_messaging_components", + return_value=None, + ), + patch.object(logging, "getLogger", return_value=uvicorn_logger) as get_logger, + ): + await runtime.start() + await runtime.close() + + printed.assert_not_called() + start_refresh.assert_called_once() + get_logger.assert_any_call("uvicorn.error") + uvicorn_logger.info.assert_called_once_with( + "Admin UI: %s (local-only)", + "http://127.0.0.1:9099/admin", + ) + + +def test_create_app_application_error_handler_returns_anthropic_format(): + app = create_test_app(_settings(log_api_error_tracebacks=False)) + + @app.get("/raise_application") + async def _raise_application(): + raise InvalidRequestError("bad request") + + response = TestClient(app).get("/raise_application") + + assert response.status_code == 400 + body = response.json() + assert body["type"] == "error" + assert body["error"]["type"] == "invalid_request_error" + assert body["request_id"] == response.headers["request-id"] + assert "x-should-retry" not in response.headers + + +def test_application_error_handler_does_not_log_error_message(): + app = create_test_app(_settings(log_api_error_tracebacks=False)) + secret = "provider-upstream-secret-detail" + + @app.get("/raise_application_secret") + async def _raise_application_secret(): + raise InvalidRequestError(secret) + + with patch("free_claude_code.api.app.logger.error") as log_error: + response = TestClient(app).get("/raise_application_secret") + + assert response.status_code == 400 + blob = " ".join( + str(value) for call in log_error.call_args_list for value in call.args + ) + assert secret not in blob + log_error.assert_not_called() + + +def test_create_app_general_exception_handler_returns_correlated_500(): + app = create_test_app(_settings(log_api_error_tracebacks=False)) + + @app.get("/raise_general") + async def _raise_general(): + raise RuntimeError("boom") + + response = TestClient(app, raise_server_exceptions=False).get("/raise_general") + + assert response.status_code == 500 + body = response.json() + assert body["type"] == "error" + assert body["error"]["type"] == "api_error" + assert body["request_id"] == response.headers["request-id"] + + +def test_general_exception_default_log_excludes_exception_message(): + app = create_test_app(_settings(log_api_error_tracebacks=False)) + secret = "user-provided-secret-token-xyzzy" + + @app.get("/raise_secret") + async def _raise_secret(): + raise ValueError(secret) + + with patch("free_claude_code.api.app.logger.error") as log_error: + response = TestClient(app, raise_server_exceptions=False).get("/raise_secret") + + assert response.status_code == 500 + blob = " ".join( + str(value) for call in log_error.call_args_list for value in call.args + ) + assert secret not in blob + assert "ValueError" in blob + + +@pytest.mark.asyncio +async def test_model_validation_failure_does_not_block_runtime_startup(): + settings = _settings(messaging_platform="none") + manager = ProviderRuntimeManager(settings) + runtime = ApplicationRuntime(manager, transcriber=None) + validation = AsyncMock(side_effect=ApplicationUnavailableError("bad model")) + + with ( + patch.object(manager, "validate_configured_models", new=validation), + patch.object(manager, "start_model_list_refresh") as start_refresh, + patch.object(manager, "close", new=AsyncMock()), + patch( + "free_claude_code.runtime.application.messaging_platform_factory.create_messaging_components", + return_value=None, + ), + ): + await runtime.start() + await runtime.close() + + validation.assert_awaited_once() + start_refresh.assert_called_once() + + +def test_startup_failure_message_preserves_existing_concise_contract(): + quiet = _settings(log_api_error_tracebacks=False) + verbose = _settings(log_api_error_tracebacks=True) + + assert startup_failure_message(quiet, RuntimeError("secret")) == ( + "Server startup failed: exc_type=RuntimeError" + ) + assert startup_failure_message(verbose, RuntimeError("visible")) == ( + "RuntimeError: visible" + ) + assert ( + startup_failure_message( + quiet, + ApplicationUnavailableError("configured model is unavailable"), + ) + == "configured model is unavailable" + ) + + +@pytest.mark.asyncio +async def test_runtime_asgi_app_starts_and_closes_owner_once(): + runtime = MagicMock(spec=ApplicationRuntime) + runtime.settings = _settings() + runtime.start = AsyncMock() + runtime.close = AsyncMock(return_value=True) + app = RuntimeASGIApp(AsyncMock(), runtime) + received = iter( + [ + {"type": "lifespan.startup"}, + {"type": "lifespan.shutdown"}, + ] + ) + sent: list[dict[str, str]] = [] + + async def receive(): + return next(received) + + async def send(message): + sent.append(message) + + await app({"type": "lifespan"}, receive, send) + + runtime.start.assert_awaited_once() + runtime.close.assert_awaited_once() + assert sent == [ + {"type": "lifespan.startup.complete"}, + {"type": "lifespan.shutdown.complete"}, + ] + + +@pytest.mark.asyncio +async def test_runtime_asgi_app_reports_incomplete_owned_shutdown() -> None: + runtime = MagicMock(spec=ApplicationRuntime) + runtime.settings = _settings() + runtime.start = AsyncMock() + runtime.close = AsyncMock(return_value=False) + app = RuntimeASGIApp(AsyncMock(), runtime) + received = iter( + [ + {"type": "lifespan.startup"}, + {"type": "lifespan.shutdown"}, + ] + ) + sent: list[dict[str, str]] = [] + + async def receive(): + return next(received) + + async def send(message): + sent.append(message) + + await app({"type": "lifespan"}, receive, send) + + assert sent == [ + {"type": "lifespan.startup.complete"}, + {"type": "lifespan.shutdown.failed", "message": ""}, + ] + + +@pytest.mark.asyncio +async def test_runtime_asgi_app_reports_concise_startup_failure(): + runtime = MagicMock(spec=ApplicationRuntime) + runtime.settings = _settings(log_api_error_tracebacks=False) + runtime.start = AsyncMock(side_effect=RuntimeError("secret")) + runtime.close = AsyncMock() + app = RuntimeASGIApp(AsyncMock(), runtime) + sent: list[dict[str, str]] = [] + + async def receive(): + return {"type": "lifespan.startup"} + + async def send(message): + sent.append(message) + + await app({"type": "lifespan"}, receive, send) + + assert sent == [ + { + "type": "lifespan.startup.failed", + "message": "Server startup failed: exc_type=RuntimeError", + } + ] + runtime.close.assert_not_awaited() + + +def test_bootstrap_configures_default_log_and_publishes_only_services(tmp_path): + log_path = tmp_path / "server.log" + settings = _settings() + + with ( + patch( + "free_claude_code.runtime.bootstrap.server_log_path", + return_value=log_path, + ), + patch("free_claude_code.runtime.bootstrap.configure_logging") as configure, + ): + asgi_app = build_asgi_app(settings) + + configure.assert_called_once_with( + Path(log_path), + verbose_third_party=settings.log_raw_api_payloads, + ) + api_app = cast(FastAPI, asgi_app.app) + assert set(api_app.state._state) == {"services"} + + +def test_bootstrap_honors_process_log_file_override(monkeypatch, tmp_path): + log_path = tmp_path / "custom.log" + monkeypatch.setenv("LOG_FILE", str(log_path)) + + with patch("free_claude_code.runtime.bootstrap.configure_logging") as configure: + build_asgi_app(_settings()) + + assert configure.call_args.args[0] == log_path + + +def test_bootstrap_constructs_fresh_runtime_owned_transcribers() -> None: + settings = _settings(voice_note_enabled=True, whisper_device="cpu") + + first = _create_transcriber(settings) + second = _create_transcriber(settings) + + assert isinstance(first, TranscriptionService) + assert isinstance(second, TranscriptionService) + assert first is not second + + +@pytest.mark.asyncio +async def test_bootstrap_constructs_isolated_runtime_resource_graphs() -> None: + settings = _settings( + model="nvidia_nim/test-model", + voice_note_enabled=True, + whisper_device="cpu", + ) + + with patch("free_claude_code.runtime.bootstrap.configure_logging"): + first = build_asgi_app(settings) + second = build_asgi_app(settings) + + first_lease = await first.runtime.provider_manager.acquire() + second_lease = await second.runtime.provider_manager.acquire() + try: + first_provider = first_lease.resolve_provider("nvidia_nim") + second_provider = second_lease.resolve_provider("nvidia_nim") + + assert isinstance(first_provider, NvidiaNimProvider) + assert isinstance(second_provider, NvidiaNimProvider) + assert first_provider._rate_limiter is not second_provider._rate_limiter + assert first.runtime._transcriber is not second.runtime._transcriber + finally: + await first_lease.release() + await second_lease.release() + await first.runtime.close() + await second.runtime.close() + + +def test_bootstrap_selects_nvidia_transcriber_without_loading_riva() -> None: + settings = _settings( + voice_note_enabled=True, + whisper_device="nvidia_nim", + whisper_model="openai/whisper-large-v3", + nvidia_nim_api_key="nvapi-test", + ) + + assert isinstance(_create_transcriber(settings), NvidiaNimTranscriber) + + +def test_bootstrap_disables_transcription_as_one_owned_resource() -> None: + assert _create_transcriber(_settings(voice_note_enabled=False)) is None diff --git a/tests/api/test_app_version.py b/tests/api/test_app_version.py new file mode 100644 index 0000000..7c02e46 --- /dev/null +++ b/tests/api/test_app_version.py @@ -0,0 +1,21 @@ +import warnings + +from fastapi.testclient import TestClient + +from free_claude_code.core.version import package_version +from tests.api.support import create_test_app + + +def test_fastapi_and_openapi_report_installed_package_version() -> None: + app = create_test_app() + + assert app.version == package_version() + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message="Duplicate Operation ID", + category=UserWarning, + ) + response = TestClient(app).get("/openapi.json") + assert response.status_code == 200 + assert response.json()["info"]["version"] == package_version() diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py new file mode 100644 index 0000000..6e94f2a --- /dev/null +++ b/tests/api/test_auth.py @@ -0,0 +1,122 @@ +from unittest.mock import patch + +from fastapi.testclient import TestClient + +from free_claude_code.api.dependencies import get_settings +from free_claude_code.config.settings import Settings +from tests.api.support import create_test_app + +app = create_test_app() + + +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("free_claude_code.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 + assert r.headers["request-id"].startswith("req_") + assert "x-should-retry" not in r.headers + + # 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("free_claude_code.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() + + +def test_anthropic_auth_token_normalizes_configured_whitespace(): + client = TestClient(app) + settings = Settings() + settings.anthropic_auth_token = " spaced-token \n" + app.dependency_overrides[get_settings] = lambda: settings + + payload = { + "model": "claude-3-sonnet", + "messages": [{"role": "user", "content": "hello"}], + } + + with patch("free_claude_code.api.routes.get_token_count", return_value=3): + r = client.post( + "/v1/messages/count_tokens", + json=payload, + headers={"Authorization": "Bearer spaced-token"}, + ) + assert r.status_code == 200 + assert r.json()["input_tokens"] == 3 + + app.dependency_overrides.clear() + + +def test_anthropic_auth_token_applies_to_models_endpoint(): + client = TestClient(app) + settings = Settings() + settings.anthropic_auth_token = "models-token" + app.dependency_overrides[get_settings] = lambda: settings + + r = client.get("/v1/models") + assert r.status_code == 401 + assert r.headers["x-request-id"] == r.headers["request-id"] + assert "x-should-retry" not in r.headers + + r = client.get("/v1/models", headers={"X-API-Key": "models-token"}) + assert r.status_code == 200 + assert "data" in r.json() + + app.dependency_overrides.clear() + + +def test_root_get_requires_auth_but_root_probes_are_public(): + client = TestClient(app) + settings = Settings() + settings.anthropic_auth_token = "root-token" + app.dependency_overrides[get_settings] = lambda: settings + + response = client.get("/") + assert response.status_code == 401 + + head = client.head("/") + assert head.status_code == 204 + assert head.headers["Allow"] == "GET, HEAD, OPTIONS" + + options = client.options("/") + assert options.status_code == 204 + assert options.headers["Allow"] == "GET, HEAD, OPTIONS" + + app.dependency_overrides.clear() diff --git a/tests/api/test_dependencies.py b/tests/api/test_dependencies.py new file mode 100644 index 0000000..d77830f --- /dev/null +++ b/tests/api/test_dependencies.py @@ -0,0 +1,147 @@ +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import HTTPException, Request + +from free_claude_code.api.dependencies import ( + get_services, + get_settings, + require_api_key, + resolve_provider, +) +from free_claude_code.api.ports import ApiServices +from free_claude_code.application.errors import ApplicationUnavailableError +from free_claude_code.application.ports import RequestRuntimeLease +from free_claude_code.config.settings import Settings +from tests.api.support import create_test_app + + +def _request(*, headers: dict[str, str], token: str) -> tuple[Request, Settings]: + request = Request( + { + "type": "http", + "method": "GET", + "path": "/", + "headers": [ + (key.lower().encode(), value.encode()) for key, value in headers.items() + ], + } + ) + settings = Settings.model_construct(anthropic_auth_token=token) + return request, settings + + +def _lease(*, provider=None, error: Exception | None = None): + lease = MagicMock(spec=RequestRuntimeLease) + lease.is_provider_cached.return_value = False + if error is None: + lease.resolve_provider.return_value = provider or MagicMock() + else: + lease.resolve_provider.side_effect = error + return lease + + +def test_get_services_reads_the_single_app_state_boundary() -> None: + app = create_test_app() + request = Request({"type": "http", "app": app}) + + services = get_services(request) + + assert services is app.state.services + assert isinstance(services, ApiServices) + + +def test_get_settings_reads_current_request_runtime_settings() -> None: + app = create_test_app( + Settings.model_construct( + model="deepseek/test-model", + anthropic_auth_token="", + ) + ) + + assert get_settings(app.state.services).model == "deepseek/test-model" + + +def test_resolve_provider_uses_retained_lease_and_logs_first_initialization() -> None: + provider = MagicMock() + lease = _lease(provider=provider) + + with patch("free_claude_code.api.dependencies.logger.info") as log_info: + result = resolve_provider("nvidia_nim", lease=lease) + + assert result is provider + lease.resolve_provider.assert_called_once_with("nvidia_nim") + log_info.assert_called_once_with("Provider initialized: {}", "nvidia_nim") + + +def test_resolve_provider_skips_initialization_log_for_cached_provider() -> None: + lease = _lease() + lease.is_provider_cached.return_value = True + + with patch("free_claude_code.api.dependencies.logger.info") as log_info: + resolve_provider("nvidia_nim", lease=lease) + + log_info.assert_not_called() + + +def test_resolve_provider_missing_key_preserves_readiness_error() -> None: + lease = _lease( + error=ApplicationUnavailableError( + "OPENROUTER_API_KEY is required. Get one at https://openrouter.ai" + ) + ) + + with pytest.raises(ApplicationUnavailableError) as exc_info: + resolve_provider("open_router", lease=lease) + + assert exc_info.value.status_code == 503 + assert "OPENROUTER_API_KEY" in exc_info.value.message + assert "openrouter.ai" in exc_info.value.message + + +def test_resolve_provider_unrelated_error_is_not_reclassified() -> None: + lease = _lease(error=ValueError("unrelated config")) + + with pytest.raises(ValueError, match="unrelated config"): + resolve_provider("nvidia_nim", lease=lease) + + +def test_require_api_key_allows_when_no_token_configured(): + request, settings = _request(headers={}, token="") + + require_api_key(request, settings) + + +def test_require_api_key_rejects_missing_token(): + request, settings = _request(headers={}, token="secret") + + with pytest.raises(HTTPException) as exc_info: + require_api_key(request, settings) + + assert exc_info.value.status_code == 401 + assert exc_info.value.detail == "Missing API key" + + +def test_require_api_key_accepts_x_api_key(): + request, settings = _request(headers={"x-api-key": "secret"}, token="secret") + + require_api_key(request, settings) + + +def test_require_api_key_accepts_bearer_token_and_strips_model_suffix(): + request, settings = _request( + headers={"authorization": "Bearer secret:claude-sonnet"}, + token="secret", + ) + + require_api_key(request, settings) + + +def test_require_api_key_rejects_invalid_token(): + request, settings = _request(headers={"x-api-key": "wrong"}, token="secret") + + with pytest.raises(HTTPException) as exc_info: + require_api_key(request, settings) + + assert exc_info.value.status_code == 401 + assert exc_info.value.detail == "Invalid API key" diff --git a/tests/api/test_detection.py b/tests/api/test_detection.py new file mode 100644 index 0000000..800e65b --- /dev/null +++ b/tests/api/test_detection.py @@ -0,0 +1,122 @@ +"""Edge case tests for api/detection.py.""" + +from unittest.mock import patch + +from free_claude_code.api.detection import ( + is_filepath_extraction_request, + is_prefix_detection_request, + is_safety_classifier_request, +) +from free_claude_code.core.anthropic.models 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( + "free_claude_code.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 TestIsSafetyClassifierRequest: + _SYSTEM = ( + "You are a security monitor. Respond with yes " + "or no." + ) + _USER = ( + "\nUser: review the repo\n" + "WebFetch https://example.com: fetch\n\n immediately." + ) + + def test_classifier_request_detected(self): + req = _make_request(self._USER, system=self._SYSTEM) + assert is_safety_classifier_request(req) is True + + def test_markers_split_across_system_and_user(self): + req = _make_request( + "\nWebFetch x\n", system=self._SYSTEM + ) + assert is_safety_classifier_request(req) is True + + def test_request_with_tools_is_not_classifier(self): + req = _make_request(self._USER, system=self._SYSTEM, tools=[{"name": "search"}]) + assert is_safety_classifier_request(req) is False + + def test_missing_transcript_marker(self): + req = _make_request(" immediately", system=self._SYSTEM) + assert is_safety_classifier_request(req) is False + + def test_missing_verdict_instruction(self): + req = _make_request( + "\nWebFetch x\n", system="just chatting" + ) + assert is_safety_classifier_request(req) is False + + def test_xml_content_without_verdict_instruction(self): + req = _make_request( + "Explain this format: ... and a tag." + ) + assert is_safety_classifier_request(req) is False + + +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/tests/api/test_execution_failure_contract.py b/tests/api/test_execution_failure_contract.py new file mode 100644 index 0000000..b2e86e5 --- /dev/null +++ b/tests/api/test_execution_failure_contract.py @@ -0,0 +1,421 @@ +"""Public commit-boundary behavior for canonical execution failures.""" + +from collections.abc import AsyncIterator +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from free_claude_code.core.anthropic.stream_contracts import parse_sse_text +from free_claude_code.core.anthropic.streaming import format_sse_event +from free_claude_code.core.failures import ExecutionFailure, FailureKind +from tests.api.support import create_test_app + +_PARTIAL_CONTENT = "PARTIAL_ASSISTANT_CONTENT" + + +class CanonicalFailureProvider: + """Provider double that raises one request-correlated canonical failure.""" + + def __init__( + self, + chunks: list[str], + *, + kind: FailureKind, + status_code: int, + message: str, + retryable: bool, + grouped: bool = False, + ) -> None: + self._chunks = chunks + self._kind = kind + self._status_code = status_code + self._message = message + self._retryable = retryable + self._grouped = grouped + self.preflight_stream = MagicMock() + self.stream_kwargs: list[dict[str, Any]] = [] + + async def stream_response( + self, + _request: object, + **kwargs: Any, + ) -> AsyncIterator[str]: + self.stream_kwargs.append(kwargs) + for chunk in self._chunks: + yield chunk + request_id = kwargs["request_id"] + failure = ExecutionFailure( + kind=self._kind, + status_code=self._status_code, + message=f"{self._message}\n\nRequest ID: {request_id}", + retryable=self._retryable, + ) + if self._grouped: + raise ExceptionGroup( + "provider stream and cleanup failed", + [ + RuntimeError("cleanup failed"), + ExceptionGroup("provider request failed", [failure]), + ], + ) + raise failure + + +def _messages_payload(*, stream: bool) -> dict[str, object]: + return { + "model": "nvidia_nim/test-model", + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 32, + "stream": stream, + } + + +def _responses_payload() -> dict[str, object]: + return { + "model": "nvidia_nim/test-model", + "input": "Hello", + "max_output_tokens": 32, + } + + +def _partial_anthropic_stream(*, close_block: bool) -> list[str]: + chunks = [ + format_sse_event("message_start", {"type": "message_start", "message": {}}), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": _PARTIAL_CONTENT}, + }, + ), + ] + if close_block: + chunks.append( + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ) + ) + return chunks + + +def _client_for(provider: CanonicalFailureProvider): + app = create_test_app() + return ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + TestClient(app), + ) + + +def _terminal_trace(trace_mock: MagicMock) -> dict[str, Any]: + return dict( + next( + call.kwargs + for call in trace_mock.call_args_list + if call.kwargs.get("event") + == "free_claude_code.api.response.terminal_execution_error" + ) + ) + + +def _grouped_rate_limit_provider(chunks: list[str]) -> CanonicalFailureProvider: + return CanonicalFailureProvider( + chunks, + kind=FailureKind.RATE_LIMIT, + status_code=429, + message="upstream is busy", + retryable=True, + grouped=True, + ) + + +@pytest.mark.parametrize( + ("path", "payload", "expected_type"), + [ + ("/v1/messages", _messages_payload(stream=True), "rate_limit_error"), + ("/v1/responses", _responses_payload(), "rate_limit_error"), + ], +) +def test_grouped_pre_start_execution_failure_keeps_canonical_wire_error( + path: str, + payload: dict[str, object], + expected_type: str, +) -> None: + provider = _grouped_rate_limit_provider([]) + resolver_patch, client = _client_for(provider) + + with ( + resolver_patch, + patch("free_claude_code.api.response_streams.trace_event") as trace_mock, + client, + ): + response = client.post(path, json=payload) + + request_id = response.headers["request-id"] + assert response.status_code == 429 + assert response.headers["x-should-retry"] == "false" + error = response.json()["error"] + assert error["type"] == expected_type + assert error["message"] == f"upstream is busy\n\nRequest ID: {request_id}" + trace = _terminal_trace(trace_mock) + assert trace["status_code"] == 429 + assert trace["error_type"] == "rate_limit_error" + assert trace["exc_type"] == "ExecutionFailure" + assert trace["failure_kind"] == "rate_limit" + + +@pytest.mark.parametrize("path", ["/v1/messages", "/v1/responses"]) +def test_grouped_post_start_execution_failure_keeps_canonical_terminal_event( + path: str, +) -> None: + provider = _grouped_rate_limit_provider(_partial_anthropic_stream(close_block=True)) + payload = ( + _messages_payload(stream=True) + if path == "/v1/messages" + else _responses_payload() + ) + resolver_patch, client = _client_for(provider) + + with ( + resolver_patch, + patch("free_claude_code.api.response_streams.trace_event") as trace_mock, + client, + ): + response = client.post(path, json=payload) + + request_id = response.headers["request-id"] + events = parse_sse_text(response.text) + if path == "/v1/messages": + assert events[-1].event == "error" + error = events[-1].data["error"] + else: + assert events[-1].event == "response.failed" + error = events[-1].data["response"]["error"] + assert response.status_code == 200 + assert error["type"] == "rate_limit_error" + assert error["message"] == f"upstream is busy\n\nRequest ID: {request_id}" + assert _terminal_trace(trace_mock)["failure_kind"] == "rate_limit" + + +def test_grouped_stream_false_execution_failure_discards_partial_content() -> None: + provider = _grouped_rate_limit_provider( + _partial_anthropic_stream(close_block=False) + ) + resolver_patch, client = _client_for(provider) + + with ( + resolver_patch, + patch("free_claude_code.api.response_streams.trace_event") as trace_mock, + client, + ): + response = client.post("/v1/messages", json=_messages_payload(stream=False)) + + request_id = response.headers["request-id"] + assert response.status_code == 429 + assert response.headers["x-should-retry"] == "false" + assert response.json()["error"] == { + "type": "rate_limit_error", + "message": f"upstream is busy\n\nRequest ID: {request_id}", + } + assert _PARTIAL_CONTENT not in response.text + trace = _terminal_trace(trace_mock) + assert trace["status_code"] == 429 + assert trace["error_type"] == "rate_limit_error" + assert trace["exc_type"] == "ExecutionFailure" + assert trace["failure_kind"] == "rate_limit" + + +def test_messages_pre_start_execution_failure_is_correlated_terminal_json() -> None: + provider = CanonicalFailureProvider( + [], + kind=FailureKind.RATE_LIMIT, + status_code=429, + message="upstream is busy", + retryable=True, + ) + resolver_patch, client = _client_for(provider) + + with resolver_patch, client: + response = client.post("/v1/messages", json=_messages_payload(stream=True)) + + request_id = response.headers["request-id"] + assert response.status_code == 429 + assert response.headers["content-type"].startswith("application/json") + assert response.headers["x-should-retry"] == "false" + assert "x-request-id" not in response.headers + assert response.json() == { + "type": "error", + "error": { + "type": "rate_limit_error", + "message": f"upstream is busy\n\nRequest ID: {request_id}", + }, + "request_id": request_id, + } + assert provider.stream_kwargs[0]["request_id"] == request_id + + +def test_responses_pre_start_execution_failure_is_correlated_terminal_json() -> None: + provider = CanonicalFailureProvider( + [], + kind=FailureKind.OVERLOADED, + status_code=529, + message="provider overloaded", + retryable=True, + ) + resolver_patch, client = _client_for(provider) + + with resolver_patch, client: + response = client.post("/v1/responses", json=_responses_payload()) + + request_id = response.headers["request-id"] + assert response.status_code == 529 + assert response.headers["content-type"].startswith("application/json") + assert response.headers["x-should-retry"] == "false" + assert response.headers["x-request-id"] == request_id + assert response.json() == { + "error": { + "message": f"provider overloaded\n\nRequest ID: {request_id}", + "type": "overloaded_error", + "param": None, + "code": None, + } + } + assert provider.stream_kwargs[0]["request_id"] == request_id + + +def test_messages_post_start_execution_failure_follows_closed_block() -> None: + provider = CanonicalFailureProvider( + _partial_anthropic_stream(close_block=True), + kind=FailureKind.OVERLOADED, + status_code=529, + message="provider overloaded", + retryable=True, + ) + resolver_patch, client = _client_for(provider) + + with ( + resolver_patch, + patch("free_claude_code.api.response_streams.trace_event") as trace_mock, + client, + ): + response = client.post("/v1/messages", json=_messages_payload(stream=True)) + + request_id = response.headers["request-id"] + events = parse_sse_text(response.text) + assert response.status_code == 200 + assert "x-should-retry" not in response.headers + assert [event.event for event in events] == [ + "message_start", + "content_block_start", + "content_block_delta", + "content_block_stop", + "error", + ] + assert events[-1].data["error"] == { + "type": "overloaded_error", + "message": f"provider overloaded\n\nRequest ID: {request_id}", + } + assert "message_stop" not in response.text + assert _terminal_trace(trace_mock) == { + "stage": "egress", + "event": "free_claude_code.api.response.terminal_execution_error", + "source": "api", + "wire_api": "messages", + "request_id": request_id, + "status_code": 529, + "error_type": "overloaded_error", + "client_should_retry": False, + "exc_type": "ExecutionFailure", + "failure_kind": "overloaded", + "provider_retryable": True, + } + + +def test_responses_post_start_execution_failure_retains_id_after_block_close() -> None: + provider = CanonicalFailureProvider( + _partial_anthropic_stream(close_block=True), + kind=FailureKind.RATE_LIMIT, + status_code=429, + message="upstream is busy", + retryable=True, + ) + resolver_patch, client = _client_for(provider) + + with ( + resolver_patch, + patch("free_claude_code.api.response_streams.trace_event") as trace_mock, + client, + ): + response = client.post("/v1/responses", json=_responses_payload()) + + request_id = response.headers["request-id"] + events = parse_sse_text(response.text) + event_names = [event.event for event in events] + created = events[0].data["response"] + failed = events[-1].data["response"] + assert response.status_code == 200 + assert response.headers["x-request-id"] == request_id + assert "x-should-retry" not in response.headers + assert event_names[0] == "response.created" + assert "response.output_item.done" in event_names + assert event_names.index("response.output_item.done") < event_names.index( + "response.failed" + ) + assert event_names[-1] == "response.failed" + assert failed["id"] == created["id"] + assert failed["status"] == "failed" + assert failed["error"] == { + "message": f"upstream is busy\n\nRequest ID: {request_id}", + "type": "rate_limit_error", + "param": None, + "code": None, + } + assert _terminal_trace(trace_mock) == { + "stage": "egress", + "event": "free_claude_code.api.response.terminal_execution_error", + "source": "api", + "wire_api": "responses", + "request_id": request_id, + "status_code": 429, + "error_type": "rate_limit_error", + "client_should_retry": False, + "exc_type": "ExecutionFailure", + "failure_kind": "rate_limit", + "provider_retryable": True, + } + + +def test_messages_stream_false_discards_partial_content_on_execution_failure() -> None: + provider = CanonicalFailureProvider( + _partial_anthropic_stream(close_block=False), + kind=FailureKind.RATE_LIMIT, + status_code=429, + message="upstream is busy", + retryable=True, + ) + resolver_patch, client = _client_for(provider) + + with resolver_patch, client: + response = client.post("/v1/messages", json=_messages_payload(stream=False)) + + request_id = response.headers["request-id"] + assert response.status_code == 429 + assert response.headers["content-type"].startswith("application/json") + assert response.headers["x-should-retry"] == "false" + assert response.json()["request_id"] == request_id + assert response.json()["error"] == { + "type": "rate_limit_error", + "message": f"upstream is busy\n\nRequest ID: {request_id}", + } + assert _PARTIAL_CONTENT not in response.text diff --git a/tests/api/test_model_listing.py b/tests/api/test_model_listing.py new file mode 100644 index 0000000..7fe8ae7 --- /dev/null +++ b/tests/api/test_model_listing.py @@ -0,0 +1,144 @@ +from fastapi.testclient import TestClient + +from free_claude_code.application.model_metadata import ProviderModelInfo +from free_claude_code.config.settings import Settings +from tests.api.support import create_test_app, provider_manager_for_app + + +def _settings( + *, + model: str = "deepseek/deepseek-chat", + model_opus: str | None = "open_router/anthropic/claude-opus", + model_haiku: str | None = "deepseek/deepseek-chat", +) -> Settings: + return Settings.model_construct( + model=model, + model_opus=model_opus, + model_sonnet=None, + model_haiku=model_haiku, + anthropic_auth_token="", + ) + + +def _cache_models(app, provider_id: str, *model_ids: str) -> None: + provider_manager_for_app(app).cache_model_infos( + provider_id, + {ProviderModelInfo(model_id) for model_id in model_ids}, + ) + + +def test_models_list_includes_configured_refs_cached_provider_models_and_aliases(): + app = create_test_app(_settings()) + _cache_models(app, "deepseek", "deepseek-chat") + _cache_models( + app, + "open_router", + "meta/llama-3.3", + "anthropic/claude-opus", + ) + + response = TestClient(app).get("/v1/models") + + assert response.status_code == 200 + data = response.json() + ids = [item["id"] for item in data["data"]] + assert ids[:6] == [ + "anthropic/deepseek/deepseek-chat", + "claude-3-freecc-no-thinking/deepseek/deepseek-chat", + "anthropic/open_router/anthropic/claude-opus", + "claude-3-freecc-no-thinking/open_router/anthropic/claude-opus", + "anthropic/open_router/meta/llama-3.3", + "claude-3-freecc-no-thinking/open_router/meta/llama-3.3", + ] + assert ids.count("anthropic/deepseek/deepseek-chat") == 1 + assert ids.count("anthropic/open_router/anthropic/claude-opus") == 1 + display_names = {item["id"]: item["display_name"] for item in data["data"]} + assert ( + display_names["anthropic/open_router/meta/llama-3.3"] + == "open_router/meta/llama-3.3" + ) + assert ( + display_names["claude-3-freecc-no-thinking/open_router/meta/llama-3.3"] + == "open_router/meta/llama-3.3 (no thinking)" + ) + assert "claude-sonnet-4-20250514" in ids + assert data["first_id"] == ids[0] + assert data["last_id"] == ids[-1] + assert data["has_more"] is False + + +def test_models_list_uses_thinking_metadata_for_cached_models(): + app = create_test_app(_settings(model_opus=None)) + manager = provider_manager_for_app(app) + _cache_models(app, "deepseek", "deepseek-chat") + manager.cache_model_infos( + "open_router", + { + ProviderModelInfo("reasoning-model", supports_thinking=True), + ProviderModelInfo("plain-model", supports_thinking=False), + }, + ) + + response = TestClient(app).get("/v1/models") + + assert response.status_code == 200 + ids = [item["id"] for item in response.json()["data"]] + assert "anthropic/open_router/reasoning-model" in ids + assert "claude-3-freecc-no-thinking/open_router/reasoning-model" in ids + assert "anthropic/open_router/plain-model" not in ids + assert "claude-3-freecc-no-thinking/open_router/plain-model" in ids + + +def test_models_list_uses_cached_metadata_for_configured_refs(): + app = create_test_app( + _settings( + model="open_router/plain-model", + model_opus=None, + model_haiku=None, + ) + ) + provider_manager_for_app(app).cache_model_infos( + "open_router", + {ProviderModelInfo("plain-model", supports_thinking=False)}, + ) + + response = TestClient(app).get("/v1/models") + + ids = [item["id"] for item in response.json()["data"]] + assert "anthropic/open_router/plain-model" not in ids + assert ids[0] == "claude-3-freecc-no-thinking/open_router/plain-model" + + +def test_models_list_includes_cached_wafer_models(): + app = create_test_app( + _settings( + model="wafer/DeepSeek-V4-Pro", + model_opus=None, + model_haiku=None, + ) + ) + _cache_models(app, "wafer", "DeepSeek-V4-Pro", "MiniMax-M2.7") + + response = TestClient(app).get("/v1/models") + + ids = [item["id"] for item in response.json()["data"]] + assert "anthropic/wafer/DeepSeek-V4-Pro" in ids + assert "claude-3-freecc-no-thinking/wafer/DeepSeek-V4-Pro" in ids + assert "anthropic/wafer/MiniMax-M2.7" in ids + assert "claude-3-freecc-no-thinking/wafer/MiniMax-M2.7" in ids + + +def test_models_list_works_with_empty_discovery_catalog(): + app = create_test_app(_settings()) + + response = TestClient(app).get("/v1/models") + + assert response.status_code == 200 + ids = [item["id"] for item in response.json()["data"]] + assert ids[:4] == [ + "anthropic/deepseek/deepseek-chat", + "claude-3-freecc-no-thinking/deepseek/deepseek-chat", + "anthropic/open_router/anthropic/claude-opus", + "claude-3-freecc-no-thinking/open_router/anthropic/claude-opus", + ] + assert "claude-sonnet-4-20250514" in ids diff --git a/tests/api/test_openai_responses.py b/tests/api/test_openai_responses.py new file mode 100644 index 0000000..7dd7a20 --- /dev/null +++ b/tests/api/test_openai_responses.py @@ -0,0 +1,964 @@ +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from free_claude_code.application.errors import InvalidRequestError +from free_claude_code.core.anthropic.stream_contracts import parse_sse_text +from free_claude_code.core.anthropic.streaming import format_sse_event +from free_claude_code.core.failures import ExecutionFailure, FailureKind +from tests.api.support import create_test_app + + +class FakeProvider: + def __init__(self, chunks: list[str]) -> None: + self.chunks = chunks + self.preflight_stream = MagicMock() + self.requests: list[Any] = [] + self.stream_kwargs: list[dict[str, Any]] = [] + + async def stream_response(self, request_data, **_kwargs): + self.requests.append(request_data) + self.stream_kwargs.append(_kwargs) + for chunk in self.chunks: + yield chunk + + +class PreStartFailingProvider(FakeProvider): + def __init__(self) -> None: + super().__init__([]) + + async def stream_response(self, request_data, **_kwargs): + self.requests.append(request_data) + self.stream_kwargs.append(_kwargs) + raise ExecutionFailure( + kind=FailureKind.RATE_LIMIT, + status_code=429, + message="upstream is busy", + retryable=True, + ) + yield "unreachable" + + +class PostStartFailingProvider(FakeProvider): + def __init__(self) -> None: + super().__init__([format_sse_event("message_start", {"type": "message_start"})]) + + async def stream_response(self, request_data, **_kwargs): + self.requests.append(request_data) + self.stream_kwargs.append(_kwargs) + for chunk in self.chunks: + yield chunk + raise RuntimeError("socket closed") + + +@pytest.fixture +def responses_client(): + provider = FakeProvider(_anthropic_text_stream("Hello from provider")) + app = create_test_app() + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + TestClient(app) as client, + ): + yield client, provider + + +def test_responses_probe_endpoints_return_204( + responses_client: tuple[TestClient, FakeProvider], +) -> None: + client, _provider = responses_client + + assert client.head("/v1/responses").status_code == 204 + assert client.options("/v1/responses").status_code == 204 + + +def test_create_response_stream_routes_through_provider( + responses_client: tuple[TestClient, FakeProvider], +) -> None: + client, provider = responses_client + + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "Hello", + "max_output_tokens": 32, + }, + ) + + assert response.status_code == 200 + assert "text/event-stream" in response.headers["content-type"] + assert response.headers["x-request-id"] == response.headers["request-id"] + events = parse_sse_text(response.text) + assert events[0].event == "response.created" + assert events[-1].event == "response.completed" + assert events[-1].data["response"]["output"][0]["content"][0]["text"] == ( + "Hello from provider" + ) + assert provider.preflight_stream.called + routed = provider.requests[0] + assert routed.model == "test-model" + assert routed.messages[0].role == "user" + assert routed.messages[0].content == "Hello" + assert routed.max_tokens == 32 + assert provider.stream_kwargs[0]["request_id"] == response.headers["request-id"] + + +def test_create_response_preflight_rejection_stays_an_ordinary_http_error() -> None: + provider = FakeProvider(_anthropic_text_stream("unused")) + provider.preflight_stream.side_effect = InvalidRequestError("bad tool shape") + app = create_test_app() + + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + TestClient(app) as client, + ): + response = client.post( + "/v1/responses", + json={"model": "nvidia_nim/test-model", "input": "Hello"}, + ) + + assert response.status_code == 400 + assert response.json()["error"] == { + "message": "bad tool shape", + "type": "invalid_request_error", + "param": None, + "code": None, + } + assert "x-should-retry" not in response.headers + assert provider.requests == [] + + +def test_create_response_accepts_unknown_top_level_extensions( + responses_client: tuple[TestClient, FakeProvider], +) -> None: + client, provider = responses_client + + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "Hello", + "provider_extension": {"enabled": True}, + }, + ) + + assert response.status_code == 200 + assert provider.requests[0].messages[0].content == "Hello" + + +def test_create_response_pre_start_provider_error_returns_openai_error() -> None: + provider = PreStartFailingProvider() + app = create_test_app() + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + patch("free_claude_code.api.response_streams.trace_event") as trace, + TestClient(app) as client, + ): + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "Hello", + }, + ) + + assert response.status_code == 429 + assert response.headers["x-should-retry"] == "false" + assert response.headers["x-request-id"] == response.headers["request-id"] + payload = response.json() + assert payload["error"]["type"] == "rate_limit_error" + assert payload["error"]["message"] == "upstream is busy" + request_id = response.headers["request-id"] + assert provider.stream_kwargs[0]["request_id"] == request_id + terminal_trace = next( + call.kwargs + for call in trace.call_args_list + if call.kwargs.get("event") + == "free_claude_code.api.response.terminal_execution_error" + ) + assert terminal_trace["wire_api"] == "responses" + assert terminal_trace["request_id"] == request_id + assert terminal_trace["status_code"] == 429 + assert terminal_trace["error_type"] == "rate_limit_error" + assert terminal_trace["client_should_retry"] is False + assert terminal_trace["failure_kind"] == "rate_limit" + assert terminal_trace["provider_retryable"] is True + + +def test_create_response_post_start_failure_preserves_response_id() -> None: + provider = PostStartFailingProvider() + app = create_test_app() + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + TestClient(app) as client, + ): + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "Hello", + }, + ) + + assert response.status_code == 200 + events = parse_sse_text(response.text) + assert [event.event for event in events] == ["response.created", "response.failed"] + assert events[-1].data["response"]["id"] == events[0].data["response"]["id"] + assert events[-1].data["response"]["status"] == "failed" + assert events[-1].data["response"]["error"]["message"] == "socket closed" + + +def test_create_response_stream_bypasses_local_message_optimizations() -> None: + provider = FakeProvider(_anthropic_text_stream("Provider response")) + app = create_test_app() + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + patch( + "free_claude_code.api.handlers.messages.try_optimizations", + side_effect=AssertionError("Responses must not use message optimizations"), + ), + TestClient(app) as client, + ): + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "quota check", + }, + ) + + assert response.status_code == 200 + completed = parse_sse_text(response.text)[-1].data["response"] + assert completed["output"][0]["content"][0]["text"] == "Provider response" + assert provider.requests[0].messages[0].content == "quota check" + + +def test_create_response_stream_false_returns_openai_error( + responses_client: tuple[TestClient, FakeProvider], +) -> None: + client, provider = responses_client + + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "Hello", + "stream": False, + }, + ) + + assert response.status_code == 400 + payload = response.json() + assert payload["error"]["type"] == "invalid_request_error" + assert "streaming only" in payload["error"]["message"] + assert provider.requests == [] + + +def test_create_response_stream_preserves_interleaved_reasoning_order() -> None: + provider = FakeProvider(_anthropic_interleaved_reasoning_stream()) + app = create_test_app() + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + TestClient(app) as client, + ): + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "Use reasoning and tools", + "stream": True, + "tools": [ + { + "type": "function", + "name": "echo", + "parameters": {"type": "object", "properties": {}}, + } + ], + }, + ) + + assert response.status_code == 200 + events = parse_sse_text(response.text) + assert "response.reasoning_text.delta" in [event.event for event in events] + completed = events[-1].data["response"] + assert [item["type"] for item in completed["output"]] == [ + "reasoning", + "message", + "function_call", + "reasoning", + "message", + ] + assert completed["output"][0]["content"][0]["text"] == "first thought" + assert completed["output"][1]["content"][0]["text"] == "first answer" + assert completed["output"][2]["arguments"] == '{"value":"FCC"}' + assert completed["output"][3]["content"][0]["text"] == "second thought" + assert completed["output"][4]["content"][0]["text"] == "final answer" + + +def test_create_response_tool_stream_emits_function_call() -> None: + provider = FakeProvider(_anthropic_tool_stream()) + app = create_test_app() + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + TestClient(app) as client, + ): + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "Use echo", + "stream": True, + "tools": [ + { + "type": "function", + "name": "echo", + "parameters": {"type": "object", "properties": {}}, + } + ], + }, + ) + + assert response.status_code == 200 + events = parse_sse_text(response.text) + completed = events[-1].data["response"] + call = completed["output"][0] + assert call["type"] == "function_call" + assert call["call_id"] == "toolu_1" + assert call["arguments"] == '{"value":"FCC"}' + + +def test_create_response_malformed_provider_function_call_fails_stream() -> None: + provider = FakeProvider( + _anthropic_tool_stream(partial_json='{"value":"FCC" "bad"}') + ) + app = create_test_app() + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + TestClient(app) as client, + ): + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "Use echo", + "stream": True, + "tools": [ + { + "type": "function", + "name": "echo", + "parameters": {"type": "object", "properties": {}}, + } + ], + }, + ) + + assert response.status_code == 200 + events = parse_sse_text(response.text) + assert events[-1].event == "response.failed" + failed = events[-1].data["response"] + assert failed["status"] == "failed" + assert failed["output"] == [] + assert "replay-unsafe Responses output" in failed["error"]["message"] + + +def test_create_response_accepts_codex_namespace_tool_request() -> None: + provider = FakeProvider(_anthropic_tool_stream(tool_name="mcp__node_repl__js")) + app = create_test_app() + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + TestClient(app) as client, + ): + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "Use JS", + "stream": True, + "tools": [ + {"type": "web_search", "external_web_access": True}, + {"type": "image_generation", "output_format": "png"}, + { + "type": "namespace", + "name": "mcp__node_repl", + "tools": [ + { + "type": "function", + "name": "js", + "parameters": { + "type": "object", + "properties": {"code": {"type": "string"}}, + }, + } + ], + }, + ], + }, + ) + + assert response.status_code == 200 + routed = provider.requests[0] + assert [tool.name for tool in routed.tools] == ["mcp__node_repl__js"] + completed = parse_sse_text(response.text)[-1].data["response"] + call = completed["output"][0] + assert call["namespace"] == "mcp__node_repl" + assert call["name"] == "js" + + +def test_create_response_accepts_codex_custom_tool_request() -> None: + provider = FakeProvider( + _anthropic_tool_stream( + tool_name="apply_patch", + partial_json='{"input":"*** Begin Patch"}', + ) + ) + app = create_test_app() + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + TestClient(app) as client, + ): + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "Use apply_patch", + "stream": True, + "tools": [ + { + "type": "custom", + "name": "apply_patch", + "description": "Apply repo patches", + "format": {"type": "text"}, + } + ], + "tool_choice": {"type": "custom", "name": "apply_patch"}, + }, + ) + + assert response.status_code == 200 + routed = provider.requests[0] + assert [tool.name for tool in routed.tools] == ["apply_patch"] + assert routed.tool_choice == {"type": "tool", "name": "apply_patch"} + events = parse_sse_text(response.text) + assert "response.custom_tool_call_input.delta" in [event.event for event in events] + completed = events[-1].data["response"] + call = completed["output"][0] + assert call["type"] == "custom_tool_call" + assert call["name"] == "apply_patch" + assert call["input"] == "*** Begin Patch" + + +def test_create_response_stream_provider_error_returns_response_failed() -> None: + provider = FakeProvider( + [ + format_sse_event( + "error", + { + "type": "error", + "error": { + "type": "api_error", + "message": "provider failed", + }, + }, + ) + ] + ) + app = create_test_app() + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + TestClient(app) as client, + ): + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "Hello", + "stream": True, + }, + ) + + assert response.status_code == 200 + events = parse_sse_text(response.text) + assert [event.event for event in events] == ["response.created", "response.failed"] + failed = events[-1].data["response"] + assert failed["id"] == events[0].data["response"]["id"] + assert failed["status"] == "failed" + assert failed["error"] == { + "message": "provider failed", + "type": "api_error", + "param": None, + "code": None, + } + + +def test_create_response_replays_prior_reasoning_as_reasoning_content() -> None: + provider = FakeProvider(_anthropic_text_stream("done")) + app = create_test_app() + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + TestClient(app) as client, + ): + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": [ + { + "id": "rs_1", + "type": "reasoning", + "summary": [], + "content": [ + {"type": "reasoning_text", "text": "Need the tool."} + ], + }, + { + "type": "function_call", + "call_id": "call_1", + "name": "echo", + "arguments": "{}", + }, + { + "type": "function_call_output", + "call_id": "call_1", + "output": "ok", + }, + { + "id": "rs_2", + "type": "reasoning", + "summary": [ + {"type": "summary_text", "text": "Use the result."} + ], + }, + {"role": "user", "content": "continue"}, + ], + "stream": True, + }, + ) + + assert response.status_code == 200 + routed = provider.requests[0] + assert routed.messages[0].role == "assistant" + assert routed.messages[0].reasoning_content == "Need the tool." + assert routed.messages[0].content[0].type == "tool_use" + assert routed.messages[1].role == "user" + assert routed.messages[1].content[0].type == "tool_result" + assert routed.messages[2].role == "assistant" + assert routed.messages[2].content == "" + assert routed.messages[2].reasoning_content == "Use the result." + assert routed.messages[3].role == "user" + assert routed.messages[3].content == "continue" + + +def test_create_response_quarantines_malformed_prior_function_call() -> None: + provider = FakeProvider(_anthropic_text_stream("done")) + app = create_test_app() + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + TestClient(app) as client, + ): + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": [ + {"role": "user", "content": "hello"}, + { + "type": "function_call", + "call_id": "call_bad", + "name": "echo", + "arguments": "{", + }, + { + "type": "function_call_output", + "call_id": "call_bad", + "output": "stale output", + }, + {"role": "user", "content": "continue"}, + ], + "stream": True, + }, + ) + + assert response.status_code == 200 + routed = provider.requests[0] + assert [message.role for message in routed.messages] == ["user", "user"] + assert routed.messages[0].content == "hello" + assert routed.messages[1].content == "continue" + completed = parse_sse_text(response.text)[-1].data["response"] + assert completed["output"][0]["content"][0]["text"] == "done" + + +@pytest.mark.parametrize( + ("reasoning", "expected_type", "expected_enabled"), + [ + ({"effort": "none"}, "disabled", False), + ({"effort": "low"}, "enabled", True), + ], +) +def test_create_response_maps_reasoning_effort_to_thinking_request( + reasoning: dict[str, str], + expected_type: str, + expected_enabled: bool, +) -> None: + provider = FakeProvider(_anthropic_text_stream("done")) + app = create_test_app() + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + TestClient(app) as client, + ): + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "Hello", + "stream": True, + "reasoning": reasoning, + }, + ) + + assert response.status_code == 200 + thinking = provider.requests[0].thinking + assert thinking.type == expected_type + assert thinking.enabled is expected_enabled + + +def test_create_response_maps_redacted_thinking_to_encrypted_reasoning() -> None: + provider = FakeProvider(_anthropic_redacted_thinking_stream()) + app = create_test_app() + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + TestClient(app) as client, + ): + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "Continue", + "stream": True, + }, + ) + + assert response.status_code == 200 + completed = parse_sse_text(response.text)[-1].data["response"] + assert completed["output"] == [ + { + "id": completed["output"][0]["id"], + "type": "reasoning", + "status": "completed", + "summary": [], + "encrypted_content": "opaque-redacted", + } + ] + assert "content" not in completed["output"][0] + + +def test_create_response_unsupported_tool_returns_openai_error( + responses_client: tuple[TestClient, FakeProvider], +) -> None: + client, _provider = responses_client + + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "Hello", + "tools": [{"type": "web_search_preview"}], + }, + ) + + assert response.status_code == 400 + payload = response.json() + assert payload["error"]["type"] == "invalid_request_error" + assert "Unsupported Responses tool type" in payload["error"]["message"] + + +def _anthropic_text_stream(text: str) -> list[str]: + return [ + format_sse_event("message_start", {"type": "message_start", "message": {}}), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": text}, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ), + format_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"input_tokens": 3, "output_tokens": 4}, + }, + ), + format_sse_event("message_stop", {"type": "message_stop"}), + ] + + +def _anthropic_tool_stream( + tool_name: str = "echo", partial_json: str = '{"value":"FCC"}' +) -> list[str]: + return [ + format_sse_event("message_start", {"type": "message_start", "message": {}}), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "tool_use", + "id": "toolu_1", + "name": tool_name, + "input": {}, + }, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "input_json_delta", + "partial_json": partial_json, + }, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ), + format_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "tool_use", "stop_sequence": None}, + "usage": {"input_tokens": 3, "output_tokens": 4}, + }, + ), + format_sse_event("message_stop", {"type": "message_stop"}), + ] + + +def _anthropic_reasoning_text_stream() -> list[str]: + return [ + format_sse_event("message_start", {"type": "message_start", "message": {}}), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "thinking", "thinking": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "thinking_delta", + "thinking": "provider reasoning", + }, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 1, + "content_block": {"type": "text", "text": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 1, + "delta": {"type": "text_delta", "text": "provider answer"}, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 1}, + ), + format_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"input_tokens": 3, "output_tokens": 4}, + }, + ), + format_sse_event("message_stop", {"type": "message_stop"}), + ] + + +def _anthropic_interleaved_reasoning_stream() -> list[str]: + return [ + format_sse_event("message_start", {"type": "message_start", "message": {}}), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "thinking", "thinking": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "thinking_delta", "thinking": "first thought"}, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 1, + "content_block": {"type": "text", "text": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 1, + "delta": {"type": "text_delta", "text": "first answer"}, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 1}, + ), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 2, + "content_block": { + "type": "tool_use", + "id": "toolu_1", + "name": "echo", + "input": {}, + }, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 2, + "delta": { + "type": "input_json_delta", + "partial_json": '{"value":"FCC"}', + }, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 2}, + ), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 3, + "content_block": {"type": "thinking", "thinking": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 3, + "delta": {"type": "thinking_delta", "thinking": "second thought"}, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 3}, + ), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 4, + "content_block": {"type": "text", "text": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 4, + "delta": {"type": "text_delta", "text": "final answer"}, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 4}, + ), + format_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"input_tokens": 3, "output_tokens": 4}, + }, + ), + format_sse_event("message_stop", {"type": "message_stop"}), + ] + + +def _anthropic_redacted_thinking_stream() -> list[str]: + return [ + format_sse_event("message_start", {"type": "message_start", "message": {}}), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "redacted_thinking", + "data": "opaque-redacted", + }, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ), + format_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"input_tokens": 3, "output_tokens": 4}, + }, + ), + format_sse_event("message_stop", {"type": "message_stop"}), + ] diff --git a/tests/api/test_optimization_handlers.py b/tests/api/test_optimization_handlers.py new file mode 100644 index 0000000..b4d7828 --- /dev/null +++ b/tests/api/test_optimization_handlers.py @@ -0,0 +1,235 @@ +"""Tests for api/optimization_handlers.py.""" + +from unittest.mock import patch + +from free_claude_code.api.optimization_handlers import ( + try_filepath_mock, + try_optimizations, + try_prefix_detection, + try_quota_mock, + try_suggestion_skip, + try_title_skip, +) +from free_claude_code.config.settings import Settings +from free_claude_code.core.anthropic.models import ( + ContentBlockText, + Message, + MessagesRequest, +) + + +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( + "free_claude_code.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( + "free_claude_code.api.optimization_handlers.is_prefix_detection_request", + return_value=(True, "/ask"), + ), + patch( + "free_claude_code.api.optimization_handlers.extract_command_prefix", + return_value="/ask", + ), + patch( + "free_claude_code.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( + "free_claude_code.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( + "free_claude_code.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( + "free_claude_code.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( + "free_claude_code.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( + "free_claude_code.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( + "free_claude_code.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( + "free_claude_code.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( + "free_claude_code.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( + "free_claude_code.api.optimization_handlers.is_filepath_extraction_request", + return_value=(True, "ls", "a.txt b.txt"), + ), + patch( + "free_claude_code.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( + "free_claude_code.api.optimization_handlers.is_filepath_extraction_request", + return_value=(True, "ls", "out"), + ), + patch( + "free_claude_code.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( + "free_claude_code.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/tests/api/test_ordinary_error_phases.py b/tests/api/test_ordinary_error_phases.py new file mode 100644 index 0000000..71d435e --- /dev/null +++ b/tests/api/test_ordinary_error_phases.py @@ -0,0 +1,255 @@ +"""Ordinary ingress, routing, readiness, and preflight error contracts.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest +from fastapi.testclient import TestClient + +from free_claude_code.application.errors import ( + ApplicationError, + ApplicationUnavailableError, + InvalidRequestError, + UnknownProviderError, +) +from free_claude_code.config.settings import Settings +from tests.api.support import create_test_app + +_PRODUCT_REQUESTS = ( + ( + "messages", + "/v1/messages", + { + "model": "open_router/test-model", + "messages": [{"role": "user", "content": "hello"}], + "stream": True, + }, + ), + ( + "responses", + "/v1/responses", + { + "model": "open_router/test-model", + "input": "hello", + }, + ), +) + + +def _settings(**updates: object) -> Settings: + return Settings().model_copy( + update={ + "anthropic_auth_token": "", + "log_api_error_tracebacks": False, + **updates, + } + ) + + +def _assert_ordinary_protocol_error( + response: httpx.Response, + *, + wire_api: str, + status_code: int, + error_type: str, + message: str, +) -> None: + assert response.status_code == status_code + assert response.headers["content-type"].startswith("application/json") + assert "x-should-retry" not in response.headers + + request_id = response.headers["request-id"] + assert request_id.startswith("req_") + if wire_api == "responses": + assert response.headers["x-request-id"] == request_id + assert response.json() == { + "error": { + "message": message, + "type": error_type, + "param": None, + "code": None, + } + } + return + + assert "x-request-id" not in response.headers + assert response.json() == { + "type": "error", + "error": {"type": error_type, "message": message}, + "request_id": request_id, + } + + +@pytest.mark.parametrize( + "error_type", + [InvalidRequestError, UnknownProviderError, ApplicationUnavailableError], +) +def test_application_errors_share_one_protocol_neutral_base( + error_type: type[ApplicationError], +) -> None: + assert isinstance(error_type("failure"), ApplicationError) + + +@pytest.mark.parametrize( + ("wire_api", "path", "payload"), + _PRODUCT_REQUESTS, + ids=("messages", "responses"), +) +def test_missing_provider_credential_is_protocol_specific_503_without_terminal_header( + wire_api: str, + path: str, + payload: dict[str, object], +) -> None: + message = ( + "OPENROUTER_API_KEY is not set. Add it to your .env file. " + "Get a key at https://openrouter.ai/keys" + ) + app = create_test_app( + _settings( + model="open_router/test-model", + open_router_api_key="", + ) + ) + + with TestClient(app) as client: + response = client.post(path, json=payload) + + _assert_ordinary_protocol_error( + response, + wire_api=wire_api, + status_code=503, + error_type="api_error", + message=message, + ) + + +@pytest.mark.parametrize( + ("wire_api", "path", "payload"), + _PRODUCT_REQUESTS, + ids=("messages", "responses"), +) +def test_runtime_acquisition_failure_is_protocol_specific_503_without_terminal_header( + wire_api: str, + path: str, + payload: dict[str, object], +) -> None: + message = "Provider runtime is shutting down." + app = create_test_app(_settings()) + acquire = AsyncMock(side_effect=ApplicationUnavailableError(message)) + + with ( + patch.object(app.state.services.requests, "acquire", new=acquire), + TestClient(app) as client, + ): + response = client.post(path, json=payload) + + acquire.assert_awaited_once() + _assert_ordinary_protocol_error( + response, + wire_api=wire_api, + status_code=503, + error_type="api_error", + message=message, + ) + + +@pytest.mark.parametrize( + ("wire_api", "path", "payload"), + _PRODUCT_REQUESTS, + ids=("messages", "responses"), +) +def test_unknown_provider_is_protocol_specific_400_without_terminal_header( + wire_api: str, + path: str, + payload: dict[str, object], +) -> None: + message = "Unknown provider_type: 'unknown'." + app = create_test_app(_settings()) + + with ( + patch( + "free_claude_code.api.routes.resolve_provider", + side_effect=UnknownProviderError(message), + ), + TestClient(app) as client, + ): + response = client.post(path, json=payload) + + _assert_ordinary_protocol_error( + response, + wire_api=wire_api, + status_code=400, + error_type="invalid_request_error", + message=message, + ) + + +@pytest.mark.parametrize( + ("wire_api", "path", "payload"), + _PRODUCT_REQUESTS, + ids=("messages", "responses"), +) +def test_preflight_rejection_is_protocol_specific_400_without_terminal_header( + wire_api: str, + path: str, + payload: dict[str, object], +) -> None: + message = "bad tool shape" + provider = MagicMock() + provider.preflight_stream.side_effect = InvalidRequestError(message) + app = create_test_app(_settings()) + + with ( + patch( + "free_claude_code.api.routes.resolve_provider", + return_value=provider, + ), + TestClient(app) as client, + ): + response = client.post(path, json=payload) + + provider.stream_response.assert_not_called() + _assert_ordinary_protocol_error( + response, + wire_api=wire_api, + status_code=400, + error_type="invalid_request_error", + message=message, + ) + + +@pytest.mark.parametrize( + ("wire_api", "path", "payload"), + _PRODUCT_REQUESTS, + ids=("messages", "responses"), +) +@pytest.mark.parametrize( + ("headers", "detail"), + [ + ({}, "Missing API key"), + ({"x-api-key": "wrong"}, "Invalid API key"), + ], + ids=("missing", "invalid"), +) +def test_proxy_auth_preserves_fastapi_detail_contract( + wire_api: str, + path: str, + payload: dict[str, object], + headers: dict[str, str], + detail: str, +) -> None: + app = create_test_app(_settings(anthropic_auth_token="secret")) + + with TestClient(app) as client: + response = client.post(path, json=payload, headers=headers) + + assert response.status_code == 401 + assert response.json() == {"detail": detail} + assert response.headers["content-type"].startswith("application/json") + assert "x-should-retry" not in response.headers + request_id = response.headers["request-id"] + assert request_id.startswith("req_") + if wire_api == "responses": + assert response.headers["x-request-id"] == request_id + else: + assert "x-request-id" not in response.headers diff --git a/tests/api/test_request_ids.py b/tests/api/test_request_ids.py new file mode 100644 index 0000000..29219b7 --- /dev/null +++ b/tests/api/test_request_ids.py @@ -0,0 +1,140 @@ +"""Tests for the pure-ASGI ingress correlation owner.""" + +import asyncio +from collections.abc import Iterator +from contextlib import contextmanager +from typing import cast +from unittest.mock import patch + +import pytest +from fastapi import Request +from starlette.datastructures import Headers +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.types import ASGIApp, Message, Scope + +from free_claude_code.api.request_ids import ( + RequestCorrelationMiddleware, + get_request_id, +) +from tests.api.support import create_test_app + + +def _http_scope(path: str) -> Scope: + return cast( + Scope, + { + "type": "http", + "asgi": {"version": "3.0", "spec_version": "2.4"}, + "http_version": "1.1", + "method": "POST", + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": b"", + "headers": [(b"anthropic-session-id", b"session_test")], + "client": None, + "server": None, + }, + ) + + +def test_application_uses_the_pure_asgi_correlation_owner() -> None: + app = create_test_app() + middleware_classes = [middleware.cls for middleware in app.user_middleware] + + assert sum(cls is RequestCorrelationMiddleware for cls in middleware_classes) == 1 + assert all(cls is not BaseHTTPMiddleware for cls in middleware_classes) + + +@pytest.mark.asyncio +async def test_correlation_context_and_headers_span_the_complete_stream() -> None: + response_started = asyncio.Event() + allow_body = asyncio.Event() + sent: list[Message] = [] + context_entries: list[dict[str, object]] = [] + context_exits: list[dict[str, object]] = [] + app_request_id: str | None = None + + async def app(scope: Scope, _receive, send) -> None: + nonlocal app_request_id + app_request_id = get_request_id(Request(scope)) + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [], + } + ) + response_started.set() + await allow_body.wait() + await send( + { + "type": "http.response.body", + "body": b"done", + "more_body": False, + } + ) + + @contextmanager + def contextualize(**fields: object) -> Iterator[None]: + context_entries.append(fields) + try: + yield + finally: + context_exits.append(fields) + + async def receive() -> Message: + raise AssertionError("Test application does not receive messages") + + async def send(message: Message) -> None: + sent.append(message) + + middleware = RequestCorrelationMiddleware(cast(ASGIApp, app)) + with patch( + "free_claude_code.api.request_ids.logger.contextualize", + side_effect=contextualize, + ): + request = asyncio.create_task( + middleware(_http_scope("/v1/responses"), receive, send) + ) + await response_started.wait() + + assert context_exits == [] + assert app_request_id is not None + headers = Headers(raw=sent[0]["headers"]) + assert headers["request-id"] == app_request_id + assert headers["x-request-id"] == app_request_id + assert context_entries == [ + { + "http_method": "POST", + "http_path": "/v1/responses", + "claude_session_id": "session_test", + "request_id": app_request_id, + } + ] + + allow_body.set() + await request + + assert context_exits == context_entries + + +@pytest.mark.asyncio +async def test_correlation_middleware_passes_non_http_scopes_unchanged() -> None: + observed_scope: Scope | None = None + + async def app(scope: Scope, _receive, _send) -> None: + nonlocal observed_scope + observed_scope = scope + + async def receive() -> Message: + return {"type": "lifespan.startup"} + + async def send(_message: Message) -> None: + return None + + scope = cast(Scope, {"type": "lifespan", "asgi": {"version": "3.0"}}) + await RequestCorrelationMiddleware(cast(ASGIApp, app))(scope, receive, send) + + assert observed_scope is scope + assert "state" not in scope diff --git a/tests/api/test_request_utils.py b/tests/api/test_request_utils.py new file mode 100644 index 0000000..6a1d310 --- /dev/null +++ b/tests/api/test_request_utils.py @@ -0,0 +1,641 @@ +"""Tests for API request detection and token counting helpers.""" + +from unittest.mock import MagicMock + +import pytest + +from free_claude_code.api.command_utils import extract_command_prefix +from free_claude_code.api.detection import ( + is_prefix_detection_request, + is_quota_check_request, + is_title_generation_request, +) +from free_claude_code.core.anthropic import get_token_count +from free_claude_code.core.anthropic.models import ( + Message, + MessagesRequest, + SystemContent, +) + + +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 = ( + "Generate a concise, sentence-case title (3-7 words) that captures the " + "main topic or goal of this coding session. Return JSON with a single " + '"title" field.' + ) + return [block] + + def test_title_generation_detected_via_system(self): + """Title gen detected by session title system prompt (sentence-case / JSON).""" + 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 + + def test_title_generation_return_json_coding_session_branch(self): + """JSON title field + session wording matches without sentence-case phrase.""" + block = MagicMock() + block.text = 'Return JSON with a single "title" field for this coding session.' + req = MagicMock(spec=MessagesRequest) + req.system = [block] + req.tools = None + + assert is_title_generation_request(req) is True + + +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_special_token_text_is_counted_as_plain_text(self): + """Tiktoken special-token strings should not break token estimates.""" + msg = MagicMock() + msg.content = "<|endoftext|>" + + count = get_token_count([msg], system="<|endoftext|>") + assert count > 0 + + 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_typed_content_blocks(self): + """Typed system content blocks are counted.""" + msg = MagicMock() + msg.content = "Hi" + count_no_sys = get_token_count([msg]) + system_blocks = [ + SystemContent(type="text", text="System prompt from typed block") + ] + count_with_system = get_token_count([msg], system=system_blocks) + assert count_with_system > 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/tests/api/test_request_utils_filepaths_and_suggestions.py b/tests/api/test_request_utils_filepaths_and_suggestions.py new file mode 100644 index 0000000..67a88ee --- /dev/null +++ b/tests/api/test_request_utils_filepaths_and_suggestions.py @@ -0,0 +1,130 @@ +from unittest.mock import MagicMock + +import pytest + +from free_claude_code.api.command_utils import extract_filepaths_from_command +from free_claude_code.api.detection import ( + is_filepath_extraction_request, + is_suggestion_mode_request, +) +from free_claude_code.core.anthropic.models 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/tests/api/test_response_streams.py b/tests/api/test_response_streams.py new file mode 100644 index 0000000..ce71e68 --- /dev/null +++ b/tests/api/test_response_streams.py @@ -0,0 +1,647 @@ +"""Tests for public SSE response start gating.""" + +import asyncio +import json +from collections.abc import AsyncGenerator +from typing import Any, cast +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.responses import JSONResponse, StreamingResponse +from starlette.types import Message, Scope + +from free_claude_code.api.request_ids import RequestCorrelationMiddleware +from free_claude_code.api.response_streams import ( + ManagedStreamingResponse, + anthropic_sse_streaming_response, + bind_response_lifetime, + terminal_execution_error_response, +) +from free_claude_code.core.anthropic import ( + anthropic_error_payload, + anthropic_failure_payload, +) +from free_claude_code.core.anthropic.stream_contracts import parse_sse_text +from free_claude_code.core.failures import ExecutionFailure, FailureKind + + +async def _body_chunks(chunks: list[str]) -> AsyncGenerator[str]: + for chunk in chunks: + yield chunk + + +async def _body_raises(exc: BaseException) -> AsyncGenerator[str]: + raise exc + yield "unreachable" + + +async def _body_then_raises( + chunks: list[str], exc: BaseException +) -> AsyncGenerator[str]: + for chunk in chunks: + yield chunk + raise exc + + +def _json_error(exc: BaseException) -> JSONResponse: + if isinstance(exc, ExecutionFailure): + return terminal_execution_error_response( + status_code=exc.status_code, + content=anthropic_failure_payload(exc), + ) + return JSONResponse( + status_code=500, + content={ + "type": "error", + "error": {"type": "api_error", "message": "failed"}, + }, + ) + + +async def _drain(response: StreamingResponse) -> str: + parts = [ + chunk.decode("utf-8") if isinstance(chunk, bytes) else str(chunk) + async for chunk in response.body_iterator + ] + return "".join(parts) + + +def _http_scope() -> Scope: + return cast( + Scope, + { + "type": "http", + "asgi": {"version": "3.0", "spec_version": "2.4"}, + "http_version": "1.1", + "method": "POST", + "scheme": "http", + "path": "/v1/messages", + "raw_path": b"/v1/messages", + "query_string": b"", + "headers": [], + "client": None, + "server": None, + }, + ) + + +async def _serve( + response: StreamingResponse, + *, + send: Any | None = None, +) -> list[Message]: + messages: list[Message] = [] + + async def receive() -> Message: + raise AssertionError("ASGI spec 2.4 responses must not read receive") + + async def collect(message: Message) -> None: + messages.append(message) + + await response(_http_scope(), receive, send or collect) + return messages + + +@pytest.mark.asyncio +async def test_anthropic_response_waits_for_first_chunk_before_returning() -> None: + ready = asyncio.Event() + + async def body() -> AsyncGenerator[str]: + await ready.wait() + yield 'event: message_start\ndata: {"type":"message_start"}\n\n' + + task = asyncio.create_task( + anthropic_sse_streaming_response( + body(), + pre_start_error_response=_json_error, + request_id="req_test", + ) + ) + + await asyncio.sleep(0) + assert not task.done() + + ready.set() + response = await asyncio.wait_for(task, timeout=1) + assert isinstance(response, StreamingResponse) + assert "message_start" in await _drain(response) + + +@pytest.mark.asyncio +async def test_anthropic_pre_start_provider_error_returns_non_200_json() -> None: + response = await anthropic_sse_streaming_response( + _body_raises( + ExecutionFailure( + kind=FailureKind.RATE_LIMIT, + status_code=429, + message="provider says slow down", + retryable=True, + ) + ), + pre_start_error_response=_json_error, + request_id="req_test", + ) + + assert isinstance(response, JSONResponse) + assert response.status_code == 429 + assert response.headers["x-should-retry"] == "false" + body = json.loads(bytes(response.body)) + assert body["error"]["type"] == "rate_limit_error" + assert body["error"]["message"] == "provider says slow down" + + +@pytest.mark.asyncio +async def test_pre_start_failure_closes_body_before_response_release() -> None: + lifecycle: list[str] = [] + + class FailingBody: + def __aiter__(self): + return self + + async def __anext__(self) -> str: + raise RuntimeError("provider failed") + + async def aclose(self) -> None: + lifecycle.append("body_closed") + + async def release() -> None: + lifecycle.append("lease_released") + + response = await anthropic_sse_streaming_response( + FailingBody(), + pre_start_error_response=_json_error, + request_id="req_pre_start_order", + ) + await bind_response_lifetime(response, release) + + assert lifecycle == ["body_closed", "lease_released"] + + +@pytest.mark.asyncio +async def test_terminal_execution_error_response_disables_client_retry() -> None: + response = terminal_execution_error_response( + status_code=429, + content=anthropic_error_payload( + error_type="rate_limit_error", + message="provider says slow down", + ), + ) + + assert isinstance(response, JSONResponse) + assert response.status_code == 429 + assert response.headers["x-should-retry"] == "false" + body = json.loads(bytes(response.body)) + assert body["error"] == { + "type": "rate_limit_error", + "message": "provider says slow down", + } + + +@pytest.mark.asyncio +async def test_anthropic_post_start_exception_emits_terminal_error_frame() -> None: + response = await anthropic_sse_streaming_response( + _body_then_raises( + ['event: message_start\ndata: {"type":"message_start"}\n\n'], + RuntimeError("socket cut"), + ), + pre_start_error_response=_json_error, + request_id="req_test", + ) + + assert isinstance(response, StreamingResponse) + text = await _drain(response) + events = parse_sse_text(text) + assert [event.event for event in events] == ["message_start", "error"] + assert events[-1].data["error"]["message"] == "socket cut" + + +@pytest.mark.asyncio +async def test_non_streaming_response_releases_resource_before_return() -> None: + release = AsyncMock() + response = JSONResponse({"ok": True}) + + result = await bind_response_lifetime(response, release) + + assert result is response + release.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_unmanaged_stream_is_closed_and_released_before_rejection() -> None: + release = AsyncMock() + close = AsyncMock() + + class Body: + def __aiter__(self): + return self + + async def __anext__(self) -> str: + return "unreachable" + + async def aclose(self) -> None: + await close() + + response = StreamingResponse(Body()) + + with pytest.raises(TypeError, match="ManagedStreamingResponse"): + await bind_response_lifetime(response, release) + + close.assert_awaited_once() + release.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_streaming_response_releases_after_normal_completion() -> None: + release = AsyncMock() + response = ManagedStreamingResponse(_body_chunks(["one", "two"])) + + result = await bind_response_lifetime(response, release) + + assert result is response + release.assert_not_awaited() + messages = await _serve(response) + assert b"".join(message.get("body", b"") for message in messages) == b"onetwo" + release.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_streaming_response_releases_after_body_failure() -> None: + release = AsyncMock() + response = ManagedStreamingResponse( + _body_then_raises(["one"], RuntimeError("stream failed")) + ) + await bind_response_lifetime(response, release) + + with pytest.raises(RuntimeError, match="stream failed"): + await _serve(response) + + release.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_streaming_response_releases_when_consumer_closes_early() -> None: + release = AsyncMock() + source_closed = asyncio.Event() + + async def body() -> AsyncGenerator[str]: + try: + yield "one" + yield "two" + finally: + source_closed.set() + + response = await anthropic_sse_streaming_response( + body(), + pre_start_error_response=_json_error, + request_id="req_test", + ) + assert isinstance(response, ManagedStreamingResponse) + await bind_response_lifetime(response, release) + + await response.aclose() + + assert source_closed.is_set() + release.assert_awaited_once() + + await response.aclose() + release.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_streaming_response_releases_when_consumer_is_cancelled() -> None: + release = AsyncMock() + entered = asyncio.Event() + source_closed = asyncio.Event() + + async def body() -> AsyncGenerator[str]: + try: + yield "one" + entered.set() + await asyncio.Event().wait() + finally: + source_closed.set() + + response = ManagedStreamingResponse(body()) + await bind_response_lifetime(response, release) + drain_task = asyncio.create_task(_serve(response)) + await entered.wait() + + drain_task.cancel() + with pytest.raises(asyncio.CancelledError): + await drain_task + + assert source_closed.is_set() + release.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_response_start_send_failure_closes_prefetched_tail_and_releases() -> ( + None +): + release = AsyncMock() + source_closed = asyncio.Event() + + async def body() -> AsyncGenerator[str]: + try: + yield "prefetched" + yield "tail" + finally: + source_closed.set() + + response = await anthropic_sse_streaming_response( + body(), + pre_start_error_response=_json_error, + request_id="req_test", + ) + assert isinstance(response, ManagedStreamingResponse) + await bind_response_lifetime(response, release) + + async def fail_on_start(message: Message) -> None: + assert message["type"] == "http.response.start" + raise RuntimeError("send start failed") + + with pytest.raises(RuntimeError, match="send start failed"): + await _serve(response, send=fail_on_start) + + assert source_closed.is_set() + release.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_first_body_send_failure_closes_prefetched_tail_and_releases() -> None: + release = AsyncMock() + source_closed = asyncio.Event() + + async def body() -> AsyncGenerator[str]: + try: + yield "prefetched" + yield "tail" + finally: + source_closed.set() + + response = await anthropic_sse_streaming_response( + body(), + pre_start_error_response=_json_error, + request_id="req_test", + ) + assert isinstance(response, ManagedStreamingResponse) + await bind_response_lifetime(response, release) + + async def fail_on_first_body(message: Message) -> None: + if message["type"] == "http.response.body": + raise RuntimeError("send body failed") + + with pytest.raises(RuntimeError, match="send body failed"): + await _serve(response, send=fail_on_first_body) + + assert source_closed.is_set() + release.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_asgi_23_correlation_boundary_preserves_response_start_cleanup() -> None: + release = AsyncMock() + source_closed = asyncio.Event() + + async def body() -> AsyncGenerator[str]: + try: + yield "prefetched" + yield "tail" + finally: + source_closed.set() + + response = await anthropic_sse_streaming_response( + body(), + pre_start_error_response=_json_error, + request_id="req_test", + ) + assert isinstance(response, ManagedStreamingResponse) + await bind_response_lifetime(response, release) + + async def app(scope, receive, send) -> None: + await response(scope, receive, send) + + async def receive() -> Message: + await asyncio.Event().wait() + raise AssertionError("unreachable") + + async def fail_on_start(message: Message) -> None: + assert message["type"] == "http.response.start" + raise OSError("client disconnected") + + scope = _http_scope() + scope["asgi"]["spec_version"] = "2.3" + + with pytest.raises(OSError, match="client disconnected"): + await RequestCorrelationMiddleware(app)(scope, receive, fail_on_start) + + assert source_closed.is_set() + release.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_first_body_source_failure_releases_response_lifetime() -> None: + release = AsyncMock() + response = ManagedStreamingResponse(_body_raises(RuntimeError("source failed"))) + await bind_response_lifetime(response, release) + + with pytest.raises(RuntimeError, match="source failed"): + await _serve(response) + + release.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_repeated_cancellation_waits_for_close_and_release_completion() -> None: + close_started = asyncio.Event() + allow_close = asyncio.Event() + close_finished = asyncio.Event() + release_started = asyncio.Event() + allow_release = asyncio.Event() + release_finished = asyncio.Event() + + class GatedBody: + def __aiter__(self): + return self + + async def __anext__(self) -> str: + raise StopAsyncIteration + + async def aclose(self) -> None: + close_started.set() + await allow_close.wait() + close_finished.set() + + async def release() -> None: + release_started.set() + await allow_release.wait() + release_finished.set() + + response = ManagedStreamingResponse(GatedBody()) + await bind_response_lifetime(response, release) + closing = asyncio.create_task(response.aclose()) + await close_started.wait() + + closing.cancel() + allow_close.set() + await release_started.wait() + closing.cancel() + allow_release.set() + + with pytest.raises(asyncio.CancelledError): + await closing + + assert close_finished.is_set() + assert release_finished.is_set() + + +@pytest.mark.asyncio +async def test_repeated_pre_start_cancellation_waits_for_body_close() -> None: + iteration_started = asyncio.Event() + close_started = asyncio.Event() + allow_close = asyncio.Event() + close_finished = asyncio.Event() + + class GatedPreStartBody: + def __aiter__(self): + return self + + async def __anext__(self) -> str: + iteration_started.set() + await asyncio.Event().wait() + raise AssertionError("unreachable") + + async def aclose(self) -> None: + close_started.set() + await allow_close.wait() + close_finished.set() + + response_task = asyncio.create_task( + anthropic_sse_streaming_response( + GatedPreStartBody(), + pre_start_error_response=_json_error, + request_id="req_pre_start_cancel", + ) + ) + await iteration_started.wait() + + response_task.cancel() + await close_started.wait() + response_task.cancel() + allow_close.set() + + with pytest.raises(asyncio.CancelledError): + await response_task + + assert close_finished.is_set() + + +@pytest.mark.asyncio +async def test_cancellation_during_pre_start_error_cleanup_waits_for_close() -> None: + close_started = asyncio.Event() + allow_close = asyncio.Event() + close_finished = asyncio.Event() + + class FailingPreStartBody: + def __aiter__(self): + return self + + async def __anext__(self) -> str: + raise RuntimeError("provider failed") + + async def aclose(self) -> None: + close_started.set() + await allow_close.wait() + close_finished.set() + + response_task = asyncio.create_task( + anthropic_sse_streaming_response( + FailingPreStartBody(), + pre_start_error_response=_json_error, + request_id="req_pre_start_error_cancel", + ) + ) + await close_started.wait() + + response_task.cancel() + allow_close.set() + + with pytest.raises(asyncio.CancelledError): + await response_task + + assert close_finished.is_set() + + +@pytest.mark.asyncio +async def test_cleanup_failures_are_trace_only_and_do_not_replace_success() -> None: + class CloseFails: + def __init__(self) -> None: + self._yielded = False + + def __aiter__(self): + return self + + async def __anext__(self) -> str: + if self._yielded: + raise StopAsyncIteration + self._yielded = True + return "ok" + + async def aclose(self) -> None: + raise RuntimeError("secret close detail") + + release = AsyncMock(side_effect=RuntimeError("secret release detail")) + response = ManagedStreamingResponse(CloseFails()) + await bind_response_lifetime(response, release) + + with ( + patch("free_claude_code.core.trace.trace_event") as close_trace, + patch("free_claude_code.api.response_streams.trace_event") as release_trace, + ): + messages = await _serve(response) + + assert b"".join(message.get("body", b"") for message in messages) == b"ok" + close_trace.assert_called_once() + assert close_trace.call_args.kwargs["owner"] == "ManagedStreamingResponse" + assert close_trace.call_args.kwargs["close_exc_type"] == "RuntimeError" + assert release_trace.call_args.kwargs["operation"] == "release_resource" + trace_blob = " ".join( + str(call) + for call in [*close_trace.call_args_list, *release_trace.call_args_list] + ) + assert "secret close detail" not in trace_blob + assert "secret release detail" not in trace_blob + + +@pytest.mark.asyncio +async def test_body_close_cancellation_propagates_without_releasing() -> None: + class CloseIsCancelled: + def __aiter__(self): + return self + + async def __anext__(self) -> str: + raise StopAsyncIteration + + async def aclose(self) -> None: + raise asyncio.CancelledError + + release = AsyncMock() + response = ManagedStreamingResponse(CloseIsCancelled()) + await bind_response_lifetime(response, release) + + with pytest.raises(asyncio.CancelledError): + await response.aclose() + + release.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_lease_release_cancellation_propagates() -> None: + release = AsyncMock(side_effect=asyncio.CancelledError) + response = ManagedStreamingResponse(_body_chunks([])) + await bind_response_lifetime(response, release) + + with pytest.raises(asyncio.CancelledError): + await response.aclose() + + release.assert_awaited_once() diff --git a/tests/api/test_routes_optimizations.py b/tests/api/test_routes_optimizations.py new file mode 100644 index 0000000..84ec12e --- /dev/null +++ b/tests/api/test_routes_optimizations.py @@ -0,0 +1,186 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from free_claude_code.api.dependencies import get_settings +from free_claude_code.api.ports import ApiServices +from free_claude_code.application.ports import StopResult +from free_claude_code.config.settings import Settings +from tests.api.support import create_test_app + +app = create_test_app() + + +@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( + "free_claude_code.api.optimization_handlers.is_prefix_detection_request", + return_value=(True, "/ask"), + ), + patch( + "free_claude_code.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( + "free_claude_code.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( + "free_claude_code.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_empty_messages_returns_400(client): + """POST /v1/messages/count_tokens with messages: [] matches messages validation.""" + payload = {"model": "claude-3-sonnet", "messages": []} + response = client.post("/v1/messages/count_tokens", 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("free_claude_code.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( + "free_claude_code.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_messaging_workflow(client): + session_control = MagicMock() + session_control.stop_all = AsyncMock(return_value=StopResult(cancelled_count=3)) + services = app.state.services + app.state.services = ApiServices( + requests=services.requests, + admin=services.admin, + tasks=session_control, + ) + + response = client.post("/stop") + + assert response.status_code == 200 + assert response.json()["cancelled_count"] == 3 + session_control.stop_all.assert_awaited_once() + + +def test_stop_cli_fallback_to_manager(client): + session_control = MagicMock() + session_control.stop_all = AsyncMock(return_value=StopResult(source="cli_manager")) + services = app.state.services + app.state.services = ApiServices( + requests=services.requests, + admin=services.admin, + tasks=session_control, + ) + + response = client.post("/stop") + + assert response.status_code == 200 + assert response.json()["source"] == "cli_manager" + session_control.stop_all.assert_awaited_once() diff --git a/tests/api/test_runtime_safe_logging.py b/tests/api/test_runtime_safe_logging.py new file mode 100644 index 0000000..faa6ec8 --- /dev/null +++ b/tests/api/test_runtime_safe_logging.py @@ -0,0 +1,64 @@ +"""Safe default logging tests for the application runtime owner.""" + +import logging +from unittest.mock import patch + +import pytest + +from free_claude_code.config.settings import Settings +from free_claude_code.runtime.application import ApplicationRuntime, best_effort +from free_claude_code.runtime.provider_manager import ProviderRuntimeManager + + +@pytest.mark.asyncio +async def test_messaging_start_failure_default_logs_exclude_traceback(caplog): + settings = Settings().model_copy( + update={ + "messaging_platform": "telegram", + "telegram_bot_token": "t", + "allowed_telegram_user_id": "1", + "log_api_error_tracebacks": False, + } + ) + runtime = ApplicationRuntime( + ProviderRuntimeManager(settings), + transcriber=None, + ) + + with ( + patch( + "free_claude_code.runtime.application.messaging_platform_factory.create_messaging_components", + side_effect=RuntimeError("SECRET_RUNTIME_DETAIL"), + ), + caplog.at_level(logging.ERROR), + ): + await runtime._start_messaging_if_configured() + + blob = " | ".join(record.getMessage() for record in caplog.records) + assert "SECRET_RUNTIME_DETAIL" not in blob + assert "exc_type=RuntimeError" in blob + + +@pytest.mark.asyncio +async def test_best_effort_default_logs_exclude_exception_text(caplog): + async def boom(): + raise ValueError("SECRET_SHUTDOWN") + + with caplog.at_level(logging.WARNING): + await best_effort("test_step", boom(), log_verbose_errors=False) + + blob = " | ".join(record.getMessage() for record in caplog.records) + assert "SECRET_SHUTDOWN" not in blob + assert "exc_type=ValueError" in blob + + +@pytest.mark.asyncio +async def test_best_effort_verbose_includes_exception_text(caplog): + async def boom(): + raise ValueError("VISIBLE_SHUTDOWN") + + with caplog.at_level(logging.WARNING): + await best_effort("test_step", boom(), log_verbose_errors=True) + + blob = " | ".join(record.getMessage() for record in caplog.records) + assert "VISIBLE_SHUTDOWN" in blob diff --git a/tests/api/test_safe_logging.py b/tests/api/test_safe_logging.py new file mode 100644 index 0000000..8c71847 --- /dev/null +++ b/tests/api/test_safe_logging.py @@ -0,0 +1,216 @@ +"""Tests that API and SSE logging avoid raw sensitive payloads by default.""" + +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import HTTPException +from fastapi.responses import JSONResponse + +from free_claude_code.api import request_errors +from free_claude_code.api.handlers import MessagesHandler, TokenCountHandler +from free_claude_code.application import execution +from free_claude_code.config.settings import Settings +from free_claude_code.core.anthropic import AnthropicStreamLedger +from free_claude_code.core.anthropic.models import Message, MessagesRequest + + +@pytest.mark.asyncio +async def test_create_message_skips_full_payload_debug_log_by_default(): + settings = Settings() + assert settings.log_raw_api_payloads is False + mock_provider = MagicMock() + + async def fake_stream(*_a, **_kw): + yield "event: ping\ndata: {}\n\n" + + mock_provider.stream_response = fake_stream + service = MessagesHandler(settings, provider_resolver=lambda _: mock_provider) + + request = MessagesRequest( + model="claude-3-haiku-20240307", + max_tokens=10, + messages=[Message(role="user", content="secret-user-text")], + ) + + with patch.object(execution.logger, "debug") as mock_debug: + await service.create(request) + + full_payload_calls = [ + c + for c in mock_debug.call_args_list + if c.args and str(c.args[0]) == "FULL_PAYLOAD [{}]: {}" + ] + assert not full_payload_calls + + +@pytest.mark.asyncio +async def test_create_message_logs_full_payload_when_opt_in(): + settings = Settings() + settings.log_raw_api_payloads = True + mock_provider = MagicMock() + + async def fake_stream(*_a, **_kw): + yield "event: ping\ndata: {}\n\n" + + mock_provider.stream_response = fake_stream + service = MessagesHandler(settings, provider_resolver=lambda _: mock_provider) + request = MessagesRequest( + model="claude-3-haiku-20240307", + max_tokens=10, + messages=[Message(role="user", content="visible")], + ) + + with patch.object(execution.logger, "debug") as mock_debug: + await service.create(request) + + keys = [c.args[0] for c in mock_debug.call_args_list if c.args] + assert any(k == "FULL_PAYLOAD [{}]: {}" for k in keys) + + +def test_stream_ledger_default_debug_has_no_serialized_json_content(): + with patch( + "free_claude_code.core.anthropic.streaming.emitter.logger.debug" + ) as mock_debug: + ledger = AnthropicStreamLedger("msg_x", "m", 1, log_raw_events=False) + ledger.message_start() + + assert mock_debug.call_count == 0 + + +def test_stream_ledger_raw_logging_includes_event_body_when_enabled(): + with patch( + "free_claude_code.core.anthropic.streaming.emitter.logger.debug" + ) as mock_debug: + ledger = AnthropicStreamLedger("msg_x", "m", 1, log_raw_events=True) + ledger.message_start() + + assert mock_debug.call_count == 1 + message = str(mock_debug.call_args) + assert "message_start" in message + assert "role" in message + + +def _flatten_log_calls(mock_log) -> str: + parts: list[str] = [] + for call in mock_log.call_args_list: + parts.extend(str(arg) for arg in call.args) + parts.append(repr(call.kwargs)) + return " ".join(parts) + + +@pytest.mark.asyncio +async def test_create_message_unexpected_error_default_logs_exclude_exception_text(): + settings = Settings() + assert settings.log_api_error_tracebacks is False + secret = "upstream-secret-token-abc" + + mock_provider = MagicMock() + + def stream_boom(*_a, **_kw): + raise RuntimeError(secret) + + mock_provider.stream_response = stream_boom + service = MessagesHandler(settings, provider_resolver=lambda _: mock_provider) + request = MessagesRequest( + model="claude-3-haiku-20240307", + max_tokens=10, + messages=[Message(role="user", content="hi")], + ) + + with patch.object(request_errors.logger, "error") as log_err: + response = await service.create(request) + + blob = _flatten_log_calls(log_err) + assert secret not in blob + assert "RuntimeError" in blob + assert isinstance(response, JSONResponse) + assert response.status_code == 500 + assert response.headers["x-should-retry"] == "false" + assert response.body + + +@pytest.mark.asyncio +async def test_create_message_unexpected_error_terminal_json_ignores_status_code(): + """Non-provider stream failures must not leak arbitrary HTTP status attributes.""" + + class WeirdError(Exception): + status_code = 418 + + settings = Settings() + mock_provider = MagicMock() + + def stream_boom(*_a, **_kw): + raise WeirdError("no") + + mock_provider.stream_response = stream_boom + service = MessagesHandler(settings, provider_resolver=lambda _: mock_provider) + request = MessagesRequest( + model="claude-3-haiku-20240307", + max_tokens=10, + messages=[Message(role="user", content="hi")], + ) + + response = await service.create(request) + + assert isinstance(response, JSONResponse) + assert response.status_code == 500 + assert response.headers["x-should-retry"] == "false" + payload = bytes(response.body).decode("utf-8") + assert '"type":"api_error"' in payload + assert '"message":"no"' in payload + + +def test_parse_cli_event_error_logs_metadata_by_default(): + """CLI parser must not log raw error text unless LOG_RAW_CLI_DIAGNOSTICS is on.""" + from free_claude_code.messaging.event_parser import parse_cli_event + + secret = "user-secret-parser-leak-xyz" + with patch("free_claude_code.messaging.event_parser.logger.info") as log_info: + parse_cli_event( + {"type": "error", "error": {"message": secret}}, log_raw_cli=False + ) + flat = " ".join(str(c) for c in log_info.call_args_list) + assert secret not in flat + assert "message_chars" in flat + + +def test_parse_cli_event_error_logs_text_when_log_raw_cli_enabled(): + from free_claude_code.messaging.event_parser import parse_cli_event + + secret = "visible-cli-parser-msg" + with patch("free_claude_code.messaging.event_parser.logger.info") as log_info: + parse_cli_event( + {"type": "error", "error": {"message": secret}}, log_raw_cli=True + ) + flat = " ".join(str(c) for c in log_info.call_args_list) + assert secret in flat + + +def test_count_tokens_unexpected_error_default_logs_exclude_exception_text(): + settings = Settings() + assert settings.log_api_error_tracebacks is False + secret = "count-tokens-leak-xyz" + + def boom(*_a, **_kw): + raise ValueError(secret) + + service = TokenCountHandler( + settings, + token_counter=boom, + ) + from free_claude_code.core.anthropic.models import TokenCountRequest + + req = TokenCountRequest( + model="claude-3-haiku-20240307", + messages=[Message(role="user", content="x")], + ) + + with ( + patch.object(request_errors.logger, "error") as log_err, + pytest.raises(HTTPException), + ): + service.count(req) + + blob = _flatten_log_calls(log_err) + assert secret not in blob + assert "ValueError" in blob diff --git a/tests/api/test_validation_log.py b/tests/api/test_validation_log.py new file mode 100644 index 0000000..7d26967 --- /dev/null +++ b/tests/api/test_validation_log.py @@ -0,0 +1,33 @@ +"""Tests for validation log summaries (metadata only).""" + +from free_claude_code.api.validation_log import summarize_request_validation_body + + +def test_summarize_lists_block_metadata_without_echoing_string_content(): + body = { + "messages": [ + { + "role": "user", + "content": "secret user phrase", + } + ], + "tools": [{"name": "web_search", "type": "web_search_20250305"}], + } + summary, tool_names = summarize_request_validation_body(body) + assert summary == [ + { + "role": "user", + "content_kind": "str", + "content_length": 18, + } + ] + assert tool_names == ["web_search"] + blob = repr(summary) + repr(tool_names) + assert "secret" not in blob + + +def test_summarize_handles_non_dict_messages_and_missing_tools(): + body = {"messages": ["not_a_dict"]} + summary, tool_names = summarize_request_validation_body(body) + assert summary == [{"message_kind": "str"}] + assert tool_names == [] diff --git a/tests/api/test_web_server_tools.py b/tests/api/test_web_server_tools.py new file mode 100644 index 0000000..d660440 --- /dev/null +++ b/tests/api/test_web_server_tools.py @@ -0,0 +1,770 @@ +import json +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest +from fastapi.responses import JSONResponse, StreamingResponse + +import free_claude_code.api.web_tools.constants as web_tool_constants +from free_claude_code.api.handlers import MessagesHandler +from free_claude_code.api.web_tools import egress as web_egress +from free_claude_code.api.web_tools.egress import ( + WebFetchEgressPolicy, + WebFetchEgressViolation, + enforce_web_fetch_egress, +) +from free_claude_code.api.web_tools.outbound import ( + _drain_response_body_capped, + _read_response_body_capped, + _run_web_fetch, +) +from free_claude_code.api.web_tools.request import is_web_server_tool_request +from free_claude_code.api.web_tools.streaming import stream_web_server_tool_response +from free_claude_code.application.errors import InvalidRequestError +from free_claude_code.application.routing import ( + ModelRouter, + ResolvedModel, + RoutedMessagesRequest, +) +from free_claude_code.config.provider_catalog import PROVIDER_CATALOG +from free_claude_code.config.settings import Settings +from free_claude_code.core.anthropic.models import Message, MessagesRequest, Tool +from free_claude_code.core.anthropic.stream_contracts import ( + assert_anthropic_stream_contract, + parse_sse_text, + text_content, +) +from free_claude_code.core.version import package_version +from free_claude_code.messaging.event_parser import parse_cli_event + +_STRICT_EGRESS = WebFetchEgressPolicy( + allow_private_network_targets=False, + allowed_schemes=frozenset({"http", "https"}), +) +_PROVIDER_IDS = tuple(PROVIDER_CATALOG) + + +def test_web_tool_user_agent_reports_installed_package_version() -> None: + assert { + "User-Agent": (f"Mozilla/5.0 compatible; free-claude-code/{package_version()}") + } == web_tool_constants._WEB_TOOL_HTTP_HEADERS + + +class FixedProviderModelRouter(ModelRouter): + """Test double that pins provider identity.""" + + def __init__(self, settings: Settings, provider_id: str) -> None: + super().__init__(settings) + self._fixed_provider_id = provider_id + + def resolve_messages_request( + self, request: MessagesRequest + ) -> RoutedMessagesRequest: + resolved = ResolvedModel( + original_model=request.model, + provider_id=self._fixed_provider_id, + provider_model=request.model, + provider_model_ref=f"{self._fixed_provider_id}/{request.model}", + thinking_enabled=False, + ) + routed = request.model_copy(deep=True) + routed.model = resolved.provider_model + return RoutedMessagesRequest(request=routed, resolved=resolved) + + +def test_web_server_tool_not_detected_when_tool_only_listed(): + """Listing web_search without forcing it must not skip the upstream provider.""" + request = MessagesRequest( + model="claude-haiku-4-5-20251001", + max_tokens=100, + messages=[Message(role="user", content="search")], + tools=[Tool(name="web_search", type="web_search_20250305")], + ) + + assert not is_web_server_tool_request(request) + + +def test_web_server_tool_detected_when_tool_choice_forces_it(): + request = MessagesRequest( + model="claude-haiku-4-5-20251001", + max_tokens=100, + messages=[Message(role="user", content="search")], + tools=[Tool(name="web_search", type="web_search_20250305")], + tool_choice={"type": "tool", "name": "web_search"}, + ) + + assert is_web_server_tool_request(request) + + +def test_web_server_tool_not_detected_when_forced_name_missing_from_tools(): + request = MessagesRequest( + model="claude-haiku-4-5-20251001", + max_tokens=100, + messages=[Message(role="user", content="hi")], + tools=[Tool(name="other", type="function")], + tool_choice={"type": "tool", "name": "web_search"}, + ) + + assert not is_web_server_tool_request(request) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("provider_id", _PROVIDER_IDS) +async def test_service_rejects_forced_server_tool_when_local_handler_is_disabled( + provider_id: str, +): + """Every provider needs FCC's local handler for forced server tools.""" + settings = Settings() + assert settings.enable_web_server_tools is False + service = MessagesHandler( + settings, + provider_resolver=lambda _: MagicMock(), + model_router=FixedProviderModelRouter(settings, provider_id), + ) + request = MessagesRequest( + model="claude-haiku-4-5-20251001", + max_tokens=100, + messages=[ + Message( + role="user", + content="Perform a web search for the query: DeepSeek V4 model release 2026", + ) + ], + tools=[Tool(name="web_search", type="web_search_20250305")], + tool_choice={"type": "tool", "name": "web_search"}, + ) + with pytest.raises(InvalidRequestError, match="ENABLE_WEB_SERVER_TOOLS"): + await service.create(request) + + +@pytest.mark.parametrize( + "url", + [ + "http://127.0.0.1/", + "http://192.168.1.1/", + "http://10.0.0.1/", + "http://[::1]/", + "http://localhost/foo", + "http://mybox.local/", + "file:///etc/passwd", + "http://169.254.169.254/latest/meta-data/", + ], +) +def test_enforce_web_fetch_egress_blocks_internal_or_disallowed(url: str): + with pytest.raises(WebFetchEgressViolation): + enforce_web_fetch_egress(url, _STRICT_EGRESS) + + +def test_enforce_web_fetch_egress_allows_global_literal_ip(): + enforce_web_fetch_egress("http://8.8.8.8/", _STRICT_EGRESS) + + +def test_enforce_web_fetch_egress_skips_private_checks_when_opted_in(): + enforce_web_fetch_egress( + "http://127.0.0.1/", + WebFetchEgressPolicy( + allow_private_network_targets=True, + allowed_schemes=frozenset({"http", "https"}), + ), + ) + + +def _cm(mock_client: MagicMock) -> MagicMock: + cm = MagicMock() + cm.__aenter__ = AsyncMock(return_value=mock_client) + cm.__aexit__ = AsyncMock(return_value=None) + return cm + + +def _stream_cm(response: httpx.Response) -> MagicMock: + cm = MagicMock() + cm.__aenter__ = AsyncMock(return_value=response) + cm.__aexit__ = AsyncMock(return_value=None) + return cm + + +def _json_body(response: JSONResponse) -> dict[str, Any]: + payload = json.loads(bytes(response.body).decode("utf-8")) + assert isinstance(payload, dict) + return payload + + +async def _streaming_body_text(response: StreamingResponse) -> str: + parts = [ + chunk.decode("utf-8") if isinstance(chunk, bytes) else str(chunk) + async for chunk in response.body_iterator + ] + return "".join(parts) + + +def _aiohttp_response( + status: int, + *, + url: str = "http://8.8.8.8/", + location: str | None = None, + body: bytes = b"hello world", +) -> MagicMock: + r = MagicMock() + r.status = status + r.url = url + hdrs: dict[str, str] = {} + if location is not None: + hdrs["location"] = location + r.headers = hdrs + r.get_encoding = MagicMock(return_value="utf-8") + r.raise_for_status = MagicMock() + r.request_info = MagicMock() + r.history = () + + async def iter_chunked(_n: int) -> Any: + yield body + + r.content.iter_chunked = MagicMock(side_effect=iter_chunked) + return r + + +def _aiohttp_client_session_patch( + *responses: MagicMock, +) -> tuple[MagicMock, MagicMock]: + """Build ``ClientSession`` mock that serves ``responses`` to successive ``get`` calls.""" + queue = list(responses) + n = 0 + + def get_side(*_a: Any, **_k: Any) -> Any: + nonlocal n + resp = queue[n] if n < len(queue) else queue[-1] + n += 1 + cm = MagicMock() + cm.__aenter__ = AsyncMock(return_value=resp) + cm.__aexit__ = AsyncMock(return_value=None) + return cm + + session = MagicMock() + session.get = MagicMock(side_effect=get_side) + + client_cm = MagicMock() + client_cm.__aenter__ = AsyncMock(return_value=session) + client_cm.__aexit__ = AsyncMock(return_value=None) + return client_cm, session + + +def test_enforce_web_fetch_egress_documents_connect_time_pinning(): + assert enforce_web_fetch_egress.__doc__ and "resolved addresses" in ( + enforce_web_fetch_egress.__doc__ or "" + ) + assert ( + web_egress.get_validated_stream_addrinfos_for_egress.__doc__ + and "pinning" + in (web_egress.get_validated_stream_addrinfos_for_egress.__doc__ or "") + ) + assert "DNS-pinned" in (_run_web_fetch.__doc__ or "") + + +@pytest.mark.asyncio +async def test_run_web_fetch_follows_redirect_when_each_hop_is_allowed(): + res_redirect = _aiohttp_response( + 302, url="http://8.8.8.8/start", location="/final", body=b"" + ) + res_ok = _aiohttp_response(200, url="http://8.8.8.8/final", body=b"hello world") + client_cm, session = _aiohttp_client_session_patch(res_redirect, res_ok) + with patch( + "free_claude_code.api.web_tools.outbound.ClientSession", return_value=client_cm + ): + out = await _run_web_fetch("http://8.8.8.8/start", _STRICT_EGRESS) + + assert out["data"] == "hello world" + assert session.get.call_count == 2 + + +@pytest.mark.asyncio +async def test_run_web_fetch_truncates_large_body_to_byte_cap(monkeypatch): + huge = b"x" * 5000 + res_ok = _aiohttp_response(200, url="http://8.8.8.8/big", body=huge) + client_cm, _ = _aiohttp_client_session_patch(res_ok) + monkeypatch.setattr(web_tool_constants, "_MAX_WEB_FETCH_RESPONSE_BYTES", 100) + with patch( + "free_claude_code.api.web_tools.outbound.ClientSession", return_value=client_cm + ): + out = await _run_web_fetch("http://8.8.8.8/big", _STRICT_EGRESS) + + assert len(out["data"]) <= 100 + assert out["data"] == "x" * 100 + + +@pytest.mark.asyncio +async def test_run_web_fetch_redirect_to_blocked_host_raises(): + res_redirect = _aiohttp_response( + 302, + url="http://8.8.8.8/start", + location="http://127.0.0.1/secret", + body=b"", + ) + client_cm, session = _aiohttp_client_session_patch(res_redirect) + with ( + patch( + "free_claude_code.api.web_tools.outbound.ClientSession", + return_value=client_cm, + ), + pytest.raises(WebFetchEgressViolation), + ): + await _run_web_fetch("http://8.8.8.8/start", _STRICT_EGRESS) + + session.get.assert_called_once() + + +@pytest.mark.asyncio +async def test_run_web_fetch_redirect_without_location_raises(): + res_bad = _aiohttp_response(302, url="http://8.8.8.8/here", body=b"") + client_cm, _ = _aiohttp_client_session_patch(res_bad) + with ( + patch( + "free_claude_code.api.web_tools.outbound.ClientSession", + return_value=client_cm, + ), + pytest.raises(WebFetchEgressViolation, match="missing Location"), + ): + await _run_web_fetch("http://8.8.8.8/here", _STRICT_EGRESS) + + +@pytest.mark.asyncio +async def test_run_web_fetch_excess_redirects_raises(): + res1 = _aiohttp_response(302, url="http://8.8.8.8/a", location="/b", body=b"") + res2 = _aiohttp_response(302, url="http://8.8.8.8/b", location="/c", body=b"") + client_cm, _ = _aiohttp_client_session_patch(res1, res2) + with ( + patch("free_claude_code.api.web_tools.constants._MAX_WEB_FETCH_REDIRECTS", 1), + patch( + "free_claude_code.api.web_tools.outbound.ClientSession", + return_value=client_cm, + ), + pytest.raises(WebFetchEgressViolation, match="exceeded maximum redirects"), + ): + await _run_web_fetch("http://8.8.8.8/a", _STRICT_EGRESS) + + +@pytest.mark.asyncio +async def test_streams_web_search_server_tool_result(monkeypatch): + async def fake_search(query: str) -> list[dict[str, str]]: + assert query == "DeepSeek V4 model release 2026" + return [{"title": "DeepSeek V4 Released", "url": "https://example.com/v4"}] + + monkeypatch.setattr( + "free_claude_code.api.web_tools.outbound._run_web_search", fake_search + ) + request = MessagesRequest( + model="claude-haiku-4-5-20251001", + max_tokens=100, + messages=[ + Message( + role="user", + content=( + "Perform a web search for the query: DeepSeek V4 model release 2026" + ), + ) + ], + tools=[Tool(name="web_search", type="web_search_20250305")], + tool_choice={"type": "tool", "name": "web_search"}, + ) + + raw = "".join( + [ + event + async for event in stream_web_server_tool_response( + request, input_tokens=42, web_fetch_egress=_STRICT_EGRESS + ) + ] + ) + events = parse_sse_text(raw) + assert_anthropic_stream_contract(events) + starts = [e for e in events if e.event == "content_block_start"] + assert starts[0].data["content_block"]["type"] == "server_tool_use" + assert starts[0].data["content_block"]["name"] == "web_search" + tool_use_id = starts[0].data["content_block"]["id"] + assert starts[1].data["content_block"]["type"] == "web_search_tool_result" + assert starts[1].data["content_block"]["tool_use_id"] == tool_use_id + assert starts[1].data["content_block"]["content"][0]["url"] == ( + "https://example.com/v4" + ) + text_deltas = [ + e + for e in events + if e.event == "content_block_delta" + and e.data.get("delta", {}).get("type") == "text_delta" + ] + assert text_deltas, "summary must be streamed as text_delta" + assert "example.com" in text_content(events) + cli_text: list[str] = [] + for ev in events: + cli_text.extend( + str(p.get("text", "")) + for p in parse_cli_event(ev.data) + if p.get("type") == "text_delta" + ) + assert "example.com" in "".join(cli_text) + deltas = [e for e in events if e.event == "message_delta"] + assert deltas[-1].data["usage"]["server_tool_use"] == {"web_search_requests": 1} + + +@pytest.mark.asyncio +async def test_service_streams_forced_web_search_by_default(monkeypatch): + async def fake_search(_query: str) -> list[dict[str, str]]: + return [{"title": "DeepSeek V4 Released", "url": "https://example.com/v4"}] + + monkeypatch.setattr( + "free_claude_code.api.web_tools.outbound._run_web_search", fake_search + ) + settings = Settings.model_validate({"ENABLE_WEB_SERVER_TOOLS": True}) + provider_resolver = MagicMock() + service = MessagesHandler( + settings, + provider_resolver=provider_resolver, + model_router=FixedProviderModelRouter(settings, _PROVIDER_IDS[0]), + ) + request = MessagesRequest( + model="claude-haiku-4-5-20251001", + max_tokens=100, + messages=[Message(role="user", content="Search for DeepSeek V4")], + tools=[Tool(name="web_search", type="web_search_20250305")], + tool_choice={"type": "tool", "name": "web_search"}, + ) + + response = await service.create(request) + + assert isinstance(response, StreamingResponse) + assert response.media_type == "text/event-stream" + raw = await _streaming_body_text(response) + assert "event: message_start" in raw + assert "DeepSeek V4 Released" in raw + provider_resolver.assert_not_called() + + +@pytest.mark.asyncio +async def test_service_aggregates_forced_web_search_when_stream_false(monkeypatch): + async def fake_search(_query: str) -> list[dict[str, str]]: + return [{"title": "DeepSeek V4 Released", "url": "https://example.com/v4"}] + + monkeypatch.setattr( + "free_claude_code.api.web_tools.outbound._run_web_search", fake_search + ) + settings = Settings.model_validate({"ENABLE_WEB_SERVER_TOOLS": True}) + provider_resolver = MagicMock() + service = MessagesHandler( + settings, + provider_resolver=provider_resolver, + model_router=FixedProviderModelRouter(settings, _PROVIDER_IDS[0]), + ) + request = MessagesRequest( + model="claude-haiku-4-5-20251001", + max_tokens=100, + messages=[Message(role="user", content="Search for DeepSeek V4")], + stream=False, + tools=[Tool(name="web_search", type="web_search_20250305")], + tool_choice={"type": "tool", "name": "web_search"}, + ) + + response = await service.create(request) + + assert isinstance(response, JSONResponse) + assert response.headers["content-type"].startswith("application/json") + body = _json_body(response) + assert [block["type"] for block in body["content"]] == [ + "server_tool_use", + "web_search_tool_result", + "text", + ] + assert body["content"][1]["content"][0]["url"] == "https://example.com/v4" + assert "DeepSeek V4 Released" in body["content"][2]["text"] + assert body["usage"]["server_tool_use"] == {"web_search_requests": 1} + provider_resolver.assert_not_called() + + +@pytest.mark.asyncio +async def test_forced_web_fetch_ignores_stale_url_from_prior_user_turns(monkeypatch): + """Only the latest user message supplies the URL (not earlier transcript text).""" + target = "https://new-only.example.com/page" + + async def fake_fetch(url: str, _egress: WebFetchEgressPolicy) -> dict[str, str]: + assert url == target + return { + "url": url, + "title": "T", + "media_type": "text/plain", + "data": "x", + } + + monkeypatch.setattr( + "free_claude_code.api.web_tools.outbound._run_web_fetch", fake_fetch + ) + request = MessagesRequest( + model="claude-haiku-4-5-20251001", + max_tokens=100, + messages=[ + Message( + role="user", + content="Earlier turn https://stale.com/old-article ignore this", + ), + Message(role="assistant", content="ok"), + Message( + role="user", + content=f"Please fetch {target} for the summary", + ), + ], + tools=[Tool(name="web_fetch", type="web_fetch_20250910")], + tool_choice={"type": "tool", "name": "web_fetch"}, + ) + + raw = "".join( + [ + event + async for event in stream_web_server_tool_response( + request, input_tokens=1, web_fetch_egress=_STRICT_EGRESS + ) + ] + ) + assert target in raw + + +@pytest.mark.asyncio +async def test_service_aggregates_forced_web_fetch_when_stream_false(monkeypatch): + async def fake_fetch(url: str, _egress: WebFetchEgressPolicy) -> dict[str, str]: + return { + "url": url, + "title": "Example Article", + "media_type": "text/plain", + "data": "Article body", + } + + monkeypatch.setattr( + "free_claude_code.api.web_tools.outbound._run_web_fetch", fake_fetch + ) + settings = Settings.model_validate({"ENABLE_WEB_SERVER_TOOLS": True}) + provider_resolver = MagicMock() + service = MessagesHandler( + settings, + provider_resolver=provider_resolver, + model_router=FixedProviderModelRouter(settings, _PROVIDER_IDS[0]), + ) + request = MessagesRequest( + model="claude-haiku-4-5-20251001", + max_tokens=100, + messages=[Message(role="user", content="Fetch https://example.com/article")], + stream=False, + tools=[Tool(name="web_fetch", type="web_fetch_20250910")], + tool_choice={"type": "tool", "name": "web_fetch"}, + ) + + response = await service.create(request) + + assert isinstance(response, JSONResponse) + assert response.headers["content-type"].startswith("application/json") + body = _json_body(response) + assert [block["type"] for block in body["content"]] == [ + "server_tool_use", + "web_fetch_tool_result", + "text", + ] + assert body["content"][1]["content"]["content"]["title"] == "Example Article" + assert body["content"][2]["text"] == "Article body" + assert body["usage"]["server_tool_use"] == {"web_fetch_requests": 1} + provider_resolver.assert_not_called() + + +@pytest.mark.asyncio +async def test_streams_web_fetch_server_tool_result(monkeypatch): + async def fake_fetch(url: str, _egress: WebFetchEgressPolicy) -> dict[str, str]: + assert url == "https://example.com/article" + return { + "url": url, + "title": "Example Article", + "media_type": "text/plain", + "data": "Article body", + } + + monkeypatch.setattr( + "free_claude_code.api.web_tools.outbound._run_web_fetch", fake_fetch + ) + request = MessagesRequest( + model="claude-haiku-4-5-20251001", + max_tokens=100, + messages=[ + Message(role="user", content="Fetch https://example.com/article please") + ], + tools=[Tool(name="web_fetch", type="web_fetch_20250910")], + tool_choice={"type": "tool", "name": "web_fetch"}, + ) + + raw = "".join( + [ + event + async for event in stream_web_server_tool_response( + request, input_tokens=42, web_fetch_egress=_STRICT_EGRESS + ) + ] + ) + events = parse_sse_text(raw) + assert_anthropic_stream_contract(events) + starts = [e for e in events if e.event == "content_block_start"] + assert starts[0].data["content_block"]["type"] == "server_tool_use" + tool_use_id = starts[0].data["content_block"]["id"] + assert starts[1].data["content_block"]["type"] == "web_fetch_tool_result" + assert starts[1].data["content_block"]["tool_use_id"] == tool_use_id + assert starts[1].data["content_block"]["content"]["content"]["title"] == ( + "Example Article" + ) + assert any( + e.event == "content_block_delta" + and e.data.get("delta", {}).get("type") == "text_delta" + for e in events + ) + assert "Article body" in text_content(events) + cli_text: list[str] = [] + for ev in events: + cli_text.extend( + str(p.get("text", "")) + for p in parse_cli_event(ev.data) + if p.get("type") == "text_delta" + ) + assert "Article body" in "".join(cli_text) + deltas = [e for e in events if e.event == "message_delta"] + assert deltas[-1].data["usage"]["server_tool_use"] == {"web_fetch_requests": 1} + + +@pytest.mark.asyncio +async def test_streams_web_fetch_error_summary_generic_by_default(monkeypatch): + secret = "sensitive-upstream-token" + + async def boom(_url: str, _egress: WebFetchEgressPolicy) -> dict[str, str]: + raise ValueError(secret) + + monkeypatch.setattr("free_claude_code.api.web_tools.outbound._run_web_fetch", boom) + request = MessagesRequest( + model="claude-haiku-4-5-20251001", + max_tokens=100, + messages=[ + Message( + role="user", + content="Fetch https://example.com/sensitive-path?x=1 please", + ) + ], + tools=[Tool(name="web_fetch", type="web_fetch_20250910")], + tool_choice={"type": "tool", "name": "web_fetch"}, + ) + + with patch("free_claude_code.api.web_tools.outbound.logger.warning") as log_warn: + raw = "".join( + [ + event + async for event in stream_web_server_tool_response( + request, + input_tokens=1, + web_fetch_egress=_STRICT_EGRESS, + verbose_client_errors=False, + ) + ] + ) + + assert secret not in raw + assert "ValueError" not in raw + assert "Web tool request failed." in raw + err_events = parse_sse_text(raw) + assert_anthropic_stream_contract(err_events) + assert any( + e.event == "content_block_delta" + and e.data.get("delta", {}).get("type") == "text_delta" + for e in err_events + ) + cli_err_text: list[str] = [] + for ev in err_events: + cli_err_text.extend( + str(p.get("text", "")) + for p in parse_cli_event(ev.data) + if p.get("type") == "text_delta" + ) + assert "Web tool request failed." in "".join(cli_err_text) + log_blob = " ".join(str(a) for c in log_warn.call_args_list for a in c.args) + assert secret not in log_blob + assert "example.com" in log_blob + assert "/sensitive-path" not in log_blob + + +@pytest.mark.asyncio +async def test_streams_web_fetch_error_summary_verbose_includes_exception_class( + monkeypatch, +): + async def boom(_url: str, _egress: WebFetchEgressPolicy) -> dict[str, str]: + raise OSError(5, "oops") + + monkeypatch.setattr("free_claude_code.api.web_tools.outbound._run_web_fetch", boom) + request = MessagesRequest( + model="claude-haiku-4-5-20251001", + max_tokens=100, + messages=[Message(role="user", content="Fetch https://example.com/x")], + tools=[Tool(name="web_fetch", type="web_fetch_20250910")], + tool_choice={"type": "tool", "name": "web_fetch"}, + ) + + raw = "".join( + [ + event + async for event in stream_web_server_tool_response( + request, + input_tokens=1, + web_fetch_egress=_STRICT_EGRESS, + verbose_client_errors=True, + ) + ] + ) + assert "OSError" in raw + + +@pytest.mark.asyncio +async def test_read_response_body_capped_truncates_single_oversized_chunk(): + cap = 500 + + async def aiter_bytes(chunk_size=None): + yield b"z" * (cap * 20) + + response = MagicMock() + response.aiter_bytes = aiter_bytes + + out = await _read_response_body_capped(response, cap) + assert len(out) == cap + assert out == b"z" * cap + + +@pytest.mark.asyncio +async def test_drain_response_body_capped_stops_after_first_chunk_when_oversized(): + cap = 300 + chunk_calls = {"n": 0} + + async def aiter_bytes(chunk_size=None): + chunk_calls["n"] += 1 + yield b"y" * (cap * 10) + + response = MagicMock() + response.aiter_bytes = aiter_bytes + + await _drain_response_body_capped(response, cap) + assert chunk_calls["n"] == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("provider_id", _PROVIDER_IDS) +async def test_service_rejects_listed_server_tools_for_every_provider( + provider_id: str, +) -> None: + settings = Settings() + service = MessagesHandler( + settings, + provider_resolver=lambda _: MagicMock(), + model_router=FixedProviderModelRouter(settings, provider_id), + ) + request = MessagesRequest( + model="m", + max_tokens=20, + messages=[Message(role="user", content="q")], + tools=[Tool(name="web_search", type="web_search_20250305")], + ) + with pytest.raises(InvalidRequestError, match="cannot pass listed Anthropic"): + await service.create(request) diff --git a/tests/application/test_execution.py b/tests/application/test_execution.py new file mode 100644 index 0000000..7e87927 --- /dev/null +++ b/tests/application/test_execution.py @@ -0,0 +1,181 @@ +"""Application-owned provider execution contracts.""" + +from collections.abc import AsyncIterator +from unittest.mock import MagicMock + +import pytest + +from free_claude_code.application.execution import ProviderExecutor +from free_claude_code.application.routing import ResolvedModel, RoutedMessagesRequest +from free_claude_code.core.anthropic.models import Message, MessagesRequest +from free_claude_code.core.async_iterators import AsyncCloseable + + +class FakeProvider: + def __init__(self) -> None: + self.preflight_calls: list[tuple[MessagesRequest, bool]] = [] + self.stream_calls: list[dict[str, object]] = [] + self.stream_close_calls = 0 + + def preflight_stream( + self, + request: MessagesRequest, + *, + thinking_enabled: bool, + ) -> None: + self.preflight_calls.append((request, thinking_enabled)) + + async def stream_response( + self, + request: MessagesRequest, + input_tokens: int = 0, + *, + request_id: str | None = None, + thinking_enabled: bool | None = None, + ) -> AsyncIterator[str]: + self.stream_calls.append( + { + "request": request, + "input_tokens": input_tokens, + "request_id": request_id, + "thinking_enabled": thinking_enabled, + } + ) + try: + yield "event: message_stop\ndata: {}\n\n" + finally: + self.stream_close_calls += 1 + + +class FailingPreflightProvider(FakeProvider): + def preflight_stream( + self, + request: MessagesRequest, + *, + thinking_enabled: bool, + ) -> None: + raise ValueError("invalid provider request") + + +class FailingStreamConstructionProvider(FakeProvider): + def stream_response( + self, + request: MessagesRequest, + input_tokens: int = 0, + *, + request_id: str | None = None, + thinking_enabled: bool | None = None, + ) -> AsyncIterator[str]: + raise RuntimeError("stream construction failed") + + +def _routed_request() -> RoutedMessagesRequest: + request = MessagesRequest( + model="provider-model", + messages=[Message(role="user", content="hello")], + ) + return RoutedMessagesRequest( + request=request, + resolved=ResolvedModel( + original_model="gateway-model", + provider_id="provider", + provider_model="provider-model", + provider_model_ref="provider/provider-model", + thinking_enabled=True, + ), + ) + + +@pytest.mark.asyncio +async def test_executor_uses_structural_provider_port_and_preflights_eagerly() -> None: + provider = FakeProvider() + routed = _routed_request() + request = routed.request + executor = ProviderExecutor( + lambda _provider_id: provider, + token_counter=lambda _messages, _system, _tools: 17, + ) + + stream = executor.stream( + routed, + wire_api="messages", + raw_log_label="FULL_PAYLOAD", + raw_log_payload=request.model_dump(), + request_id="req_application", + ) + + assert provider.preflight_calls == [(request, True)] + assert [chunk async for chunk in stream] == ["event: message_stop\ndata: {}\n\n"] + assert provider.stream_calls == [ + { + "request": request, + "input_tokens": 17, + "request_id": "req_application", + "thinking_enabled": True, + } + ] + assert provider.stream_close_calls == 1 + + +@pytest.mark.asyncio +async def test_closing_executor_stream_closes_provider_stream_once() -> None: + provider = FakeProvider() + routed = _routed_request() + executor = ProviderExecutor( + lambda _provider_id: provider, + token_counter=lambda _messages, _system, _tools: 17, + ) + stream = executor.stream( + routed, + wire_api="messages", + raw_log_label="FULL_PAYLOAD", + raw_log_payload={}, + request_id="req_early_close", + ) + + assert await anext(stream) == "event: message_stop\ndata: {}\n\n" + assert isinstance(stream, AsyncCloseable) + await stream.aclose() + + assert provider.stream_close_calls == 1 + + +@pytest.mark.asyncio +async def test_stream_construction_failure_remains_deferred_to_iteration() -> None: + provider = FailingStreamConstructionProvider() + executor = ProviderExecutor( + lambda _provider_id: provider, + token_counter=lambda _messages, _system, _tools: 17, + ) + + stream = executor.stream( + _routed_request(), + wire_api="messages", + raw_log_label="FULL_PAYLOAD", + raw_log_payload={}, + request_id="req_deferred_construction", + ) + + with pytest.raises(RuntimeError, match="stream construction failed"): + await anext(stream) + + +def test_executor_preflight_failure_stays_before_token_count_and_stream() -> None: + provider = FailingPreflightProvider() + token_counter = MagicMock(return_value=17) + executor = ProviderExecutor( + lambda _provider_id: provider, + token_counter=token_counter, + ) + + with pytest.raises(ValueError, match="invalid provider request"): + executor.stream( + _routed_request(), + wire_api="messages", + raw_log_label="FULL_PAYLOAD", + raw_log_payload={}, + request_id="req_application", + ) + + token_counter.assert_not_called() + assert provider.stream_calls == [] diff --git a/tests/application/test_routing.py b/tests/application/test_routing.py new file mode 100644 index 0000000..a343525 --- /dev/null +++ b/tests/application/test_routing.py @@ -0,0 +1,235 @@ +from unittest.mock import patch + +import pytest + +from free_claude_code.application.errors import UnknownProviderError +from free_claude_code.application.routing import ModelRouter +from free_claude_code.config.provider_catalog import PROVIDER_CATALOG +from free_claude_code.config.settings import Settings +from free_claude_code.core.anthropic.models import ( + Message, + MessagesRequest, + TokenCountRequest, +) + + +@pytest.fixture +def settings(): + settings = Settings() + settings.model = "nvidia_nim/fallback-model" + settings.model_opus = None + settings.model_sonnet = None + settings.model_haiku = None + settings.enable_model_thinking = True + settings.enable_opus_thinking = None + settings.enable_sonnet_thinking = None + settings.enable_haiku_thinking = None + return settings + + +def test_model_router_resolves_default_model(settings): + resolved = ModelRouter(settings).resolve("claude-3-opus") + + assert resolved.original_model == "claude-3-opus" + assert resolved.provider_id == "nvidia_nim" + assert resolved.provider_model == "fallback-model" + assert resolved.provider_model_ref == "nvidia_nim/fallback-model" + assert resolved.thinking_enabled is True + + +def test_model_router_applies_opus_override(settings): + settings.model_opus = "open_router/deepseek/deepseek-r1" + + request = MessagesRequest( + model="claude-opus-4-20250514", + max_tokens=100, + messages=[Message(role="user", content="hello")], + ) + routed = ModelRouter(settings).resolve_messages_request(request) + + assert routed.request.model == "deepseek/deepseek-r1" + assert routed.resolved.provider_model_ref == "open_router/deepseek/deepseek-r1" + assert routed.resolved.original_model == "claude-opus-4-20250514" + assert routed.resolved.thinking_enabled is True + assert request.model == "claude-opus-4-20250514" + + +def test_model_router_resolves_per_model_thinking(settings): + settings.enable_model_thinking = False + settings.enable_opus_thinking = True + settings.enable_haiku_thinking = False + + router = ModelRouter(settings) + + assert router.resolve("claude-opus-4-20250514").thinking_enabled is True + assert router.resolve("claude-sonnet-4-20250514").thinking_enabled is False + assert router.resolve("claude-3-haiku-20240307").thinking_enabled is False + assert router.resolve("claude-2.1").thinking_enabled is False + + +def test_model_router_applies_haiku_override(settings): + settings.model_haiku = "lmstudio/qwen2.5-7b" + + routed = ModelRouter(settings).resolve_messages_request( + MessagesRequest( + model="claude-3-haiku-20240307", + max_tokens=100, + messages=[Message(role="user", content="hello")], + ) + ) + + assert routed.request.model == "qwen2.5-7b" + assert routed.resolved.provider_model_ref == "lmstudio/qwen2.5-7b" + + +def test_model_router_applies_sonnet_override(settings): + settings.model_sonnet = "nvidia_nim/meta/llama-3.3-70b-instruct" + + routed = ModelRouter(settings).resolve_messages_request( + MessagesRequest( + model="claude-sonnet-4-20250514", + max_tokens=100, + messages=[Message(role="user", content="hello")], + ) + ) + + assert routed.request.model == "meta/llama-3.3-70b-instruct" + assert ( + routed.resolved.provider_model_ref == "nvidia_nim/meta/llama-3.3-70b-instruct" + ) + + +def test_model_router_routes_prefixed_provider_model_directly(settings): + routed = ModelRouter(settings).resolve_messages_request( + MessagesRequest( + model="deepseek/deepseek-chat", + max_tokens=100, + messages=[Message(role="user", content="hello")], + ) + ) + + assert routed.request.model == "deepseek-chat" + assert routed.resolved.original_model == "deepseek/deepseek-chat" + assert routed.resolved.provider_id == "deepseek" + assert routed.resolved.provider_model == "deepseek-chat" + assert routed.resolved.provider_model_ref == "deepseek/deepseek-chat" + + +def test_model_router_routes_wafer_provider_model_directly(settings): + routed = ModelRouter(settings).resolve_messages_request( + MessagesRequest( + model="wafer/DeepSeek-V4-Pro", + max_tokens=100, + messages=[Message(role="user", content="hello")], + ) + ) + + assert routed.request.model == "DeepSeek-V4-Pro" + assert routed.resolved.provider_id == "wafer" + assert routed.resolved.provider_model == "DeepSeek-V4-Pro" + assert routed.resolved.provider_model_ref == "wafer/DeepSeek-V4-Pro" + + +def test_model_router_routes_minimax_provider_model_directly(settings): + routed = ModelRouter(settings).resolve_messages_request( + MessagesRequest( + model="minimax/MiniMax-M3", + max_tokens=100, + messages=[Message(role="user", content="hello")], + ) + ) + + assert routed.request.model == "MiniMax-M3" + assert routed.resolved.provider_id == "minimax" + assert routed.resolved.provider_model == "MiniMax-M3" + assert routed.resolved.provider_model_ref == "minimax/MiniMax-M3" + + +def test_model_router_routes_gateway_encoded_provider_model_directly(settings): + routed = ModelRouter(settings).resolve_messages_request( + MessagesRequest( + model="anthropic/nvidia_nim/deepseek-ai/deepseek-v4-pro", + max_tokens=100, + messages=[Message(role="user", content="hello")], + ) + ) + + assert routed.request.model == "deepseek-ai/deepseek-v4-pro" + assert ( + routed.resolved.original_model + == "anthropic/nvidia_nim/deepseek-ai/deepseek-v4-pro" + ) + assert routed.resolved.provider_id == "nvidia_nim" + assert routed.resolved.provider_model == "deepseek-ai/deepseek-v4-pro" + assert ( + routed.resolved.provider_model_ref + == "anthropic/nvidia_nim/deepseek-ai/deepseek-v4-pro" + ) + + +def test_model_router_routes_no_thinking_gateway_model_directly(settings): + settings.enable_model_thinking = True + + routed = ModelRouter(settings).resolve_messages_request( + MessagesRequest( + model="claude-3-freecc-no-thinking/nvidia_nim/deepseek-ai/deepseek-v4-pro", + max_tokens=100, + messages=[Message(role="user", content="hello")], + ) + ) + + assert routed.request.model == "deepseek-ai/deepseek-v4-pro" + assert ( + routed.resolved.original_model + == "claude-3-freecc-no-thinking/nvidia_nim/deepseek-ai/deepseek-v4-pro" + ) + assert routed.resolved.provider_id == "nvidia_nim" + assert routed.resolved.provider_model == "deepseek-ai/deepseek-v4-pro" + assert routed.resolved.thinking_enabled is False + + +def test_model_router_direct_prefixed_model_uses_provider_model_for_thinking(settings): + settings.enable_model_thinking = False + settings.enable_opus_thinking = True + + resolved = ModelRouter(settings).resolve("open_router/anthropic/claude-opus-4") + + assert resolved.provider_id == "open_router" + assert resolved.provider_model == "anthropic/claude-opus-4" + assert resolved.thinking_enabled is True + + +def test_model_router_routes_token_count_request(settings): + settings.model_haiku = "lmstudio/qwen2.5-7b" + + request = TokenCountRequest( + model="claude-3-haiku-20240307", + messages=[Message(role="user", content="hello")], + ) + routed = ModelRouter(settings).resolve_token_count_request(request) + + assert routed.request.model == "qwen2.5-7b" + assert request.model == "claude-3-haiku-20240307" + + +def test_model_router_logs_mapping(settings): + with patch("free_claude_code.application.routing.logger.debug") as mock_log: + ModelRouter(settings).resolve("claude-2.1") + + mock_log.assert_called() + args = mock_log.call_args[0] + assert "MODEL MAPPING" in args[0] + assert args[1] == "claude-2.1" + assert args[2] == "fallback-model" + + +def test_model_router_preserves_typed_error_for_unknown_mapped_provider(settings): + settings.model = "unknown/model" + + with pytest.raises(UnknownProviderError) as exc_info: + ModelRouter(settings).resolve("claude-2.1") + + supported = "', '".join(PROVIDER_CATALOG) + assert str(exc_info.value) == ( + f"Unknown provider_type: 'unknown'. Supported: '{supported}'" + ) diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py new file mode 100644 index 0000000..8bcdb22 --- /dev/null +++ b/tests/cli/test_cli.py @@ -0,0 +1,768 @@ +"""Tests for cli/ module.""" + +import asyncio +import os +from typing import cast +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.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 an error only.""" + event = {"type": "exit", "code": 1} + result = parse_cli_event(event) + assert len(result) == 1 + assert result[0] == { + "type": "error", + "message": "Process exited with code 1", + "source": "exit", + "exit_code": 1, + } + assert ( + "exit" in result[0]["message"].lower() + or "code" in result[0]["message"].lower() + ) + + 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 TestManagedClaudeSession: + """Test ManagedClaudeSession.""" + + def test_session_init(self): + """Test ManagedClaudeSession initialization.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession( + workspace_path="/tmp/test", + proxy_root_url="http://localhost:8082", + allowed_dirs=["/home/user/projects"], + ) + assert session.workspace == os.path.normpath(os.path.abspath("/tmp/test")) + assert session.proxy_root_url == "http://localhost:8082" + assert not session.is_busy + + def test_session_extract_session_id(self): + """Test session ID extraction from various event formats.""" + from free_claude_code.cli.managed.claude import ( + extract_managed_claude_session_id, + ) + + # Direct session_id field + assert extract_managed_claude_session_id({"session_id": "abc123"}) == "abc123" + assert extract_managed_claude_session_id({"sessionId": "abc123"}) == "abc123" + + # Nested in init + assert ( + extract_managed_claude_session_id({"init": {"session_id": "nested123"}}) + == "nested123" + ) + + # Nested in result + assert ( + extract_managed_claude_session_id({"result": {"session_id": "res123"}}) + == "res123" + ) + + # Conversation id + assert ( + extract_managed_claude_session_id({"conversation": {"id": "conv123"}}) + == "conv123" + ) + + # No session ID + assert extract_managed_claude_session_id({"type": "message"}) is None + assert extract_managed_claude_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 free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + # 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 free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + 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 free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + 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 free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + mock_process = AsyncMock() + mock_process.stdout.read.side_effect = [b""] # No stdout + mock_process.stderr.read.side_effect = [b"Fatal error", b""] + 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_start_task_stderr_while_stdout_streams(self): + """Stderr is drained concurrently so stdout streaming is not blocked.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + mock_process = AsyncMock() + mock_process.stdout.read.side_effect = [ + b'{"type": "message", "content": "Hi"}\n', + b"", + ] + mock_process.stderr.read.side_effect = [b"warning on stderr\n", 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("Hello")] + + assert mock_process.stderr.read.await_count >= 2 + err_events = [e for e in events if e.get("type") == "error"] + assert len(err_events) == 1 + assert "warning on stderr" in err_events[0]["error"]["message"] + assert events[-1]["type"] == "exit" + assert events[-1]["code"] == 0 + + @pytest.mark.asyncio + async def test_start_task_ignores_benign_claude_connectors_stderr(self): + """Known Claude diagnostics on stderr are not surfaced as task failures.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + mock_process = AsyncMock() + mock_process.stdout.read.side_effect = [ + b'{"type": "message", "content": "Hi"}\n', + b"", + ] + mock_process.stderr.read.side_effect = [ + b"claude.ai connectors are disabled in this environment\n", + 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("Hello")] + + assert [e for e in events if e.get("type") == "error"] == [] + assert events[-1] == {"type": "exit", "code": 0, "stderr": None} + + @pytest.mark.asyncio + async def test_start_task_mixed_stderr_reports_only_fatal_lines(self): + """Benign stderr diagnostics are filtered without hiding real failures.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + mock_process = AsyncMock() + mock_process.stdout.read.side_effect = [b""] + mock_process.stderr.read.side_effect = [ + (b"claude.ai connectors are disabled in this environment\nFatal error\n"), + b"", + ] + 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")] + + assert len(events) == 2 + assert events[0] == {"type": "error", "error": {"message": "Fatal error"}} + assert events[1] == {"type": "exit", "code": 1, "stderr": "Fatal error"} + + @pytest.mark.asyncio + async def test_start_task_nonzero_with_only_benign_stderr_has_no_stderr_error( + self, + ): + """A benign stderr line is not duplicated as the process failure reason.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + mock_process = AsyncMock() + mock_process.stdout.read.side_effect = [b""] + mock_process.stderr.read.side_effect = [ + b"claude.ai connectors are disabled in this environment\n", + b"", + ] + 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")] + + assert events == [{"type": "exit", "code": 1, "stderr": None}] + + @pytest.mark.asyncio + async def test_drain_stderr_bounded_retains_cap_but_drains_to_eof(self): + """Oversized stderr is fully drained so the pipe cannot deadlock; capture is bounded.""" + from free_claude_code.cli.managed.session import ( + _MAX_STDERR_CAPTURE_BYTES, + ManagedClaudeSession, + ) + + total_len = _MAX_STDERR_CAPTURE_BYTES + 100_000 + remaining: dict[str, int] = {"n": total_len} + + class _FakeStderr: + async def read(self, n: int = 65536) -> bytes: + left = remaining["n"] + if left <= 0: + return b"" + take = min(n, left) + remaining["n"] = left - take + return b"y" * take + + class _FakeProcess: + stderr = _FakeStderr() + + out = await ManagedClaudeSession._drain_stderr_bounded( + cast(asyncio.subprocess.Process, _FakeProcess()) + ) + assert len(out) == _MAX_STDERR_CAPTURE_BYTES + assert out == b"y" * _MAX_STDERR_CAPTURE_BYTES + assert remaining["n"] == 0 + + @pytest.mark.asyncio + async def test_stop_session(self): + """Test stopping the session process.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + 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 + + with patch( + "free_claude_code.cli.managed.session.kill_pid_tree_best_effort" + ) as kill_tree: + stopped = await session.stop() + + assert stopped is True + kill_tree.assert_called_once_with(mock_process.pid) + 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 free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + 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 + + with patch( + "free_claude_code.cli.managed.session.kill_pid_tree_best_effort" + ) as kill_tree: + stopped = await session.stop() + + assert stopped is True + kill_tree.assert_called_once_with(mock_process.pid) + mock_process.kill.assert_called() + + @pytest.mark.asyncio + async def test_start_task_split_buffer(self): + """Test handling of JSON split across chunks.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + 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 free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + 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_targets_proxy_root(self): + """Test start_task passes the configured proxy root to Claude Code.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/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_sets_proxy_auth_token(self): + """Test start_task forwards configured proxy auth to Claude Code.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession( + "/tmp", "http://localhost:8082", auth_token="proxy-token" + ) + + 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.dict(os.environ, {"ANTHROPIC_API_KEY": "official-key"}, clear=False), + 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 + + env = mock_exec.call_args.kwargs["env"] + assert env["ANTHROPIC_AUTH_TOKEN"] == "proxy-token" + assert env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "1" + assert env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] == "190000" + assert "ANTHROPIC_API_KEY" not in env + + @pytest.mark.asyncio + async def test_start_task_uses_sentinel_when_proxy_auth_blank(self): + """Test start_task does not leak inherited Claude auth into proxy calls.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082", auth_token="") + + 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.dict(os.environ, {"ANTHROPIC_AUTH_TOKEN": "stale"}, clear=False), + 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 + + env = mock_exec.call_args.kwargs["env"] + assert env["ANTHROPIC_AUTH_TOKEN"] == "fcc-no-auth" + + @pytest.mark.asyncio + async def test_start_task_allowed_dirs(self): + """Test start_task includes allowed dirs in command.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession( + "/tmp", "http://localhost:8082", 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_json_error(self): + """Test handling of non-JSON output from free_claude_code.cli.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + 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 free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + mock_process = MagicMock() + mock_process.returncode = None + + session.process = mock_process + + with patch( + "free_claude_code.cli.managed.session.kill_pid_tree_best_effort", + side_effect=RuntimeError("Permission denied"), + ): + stopped = await session.stop() + assert stopped is False + + +class TestManagedClaudeSessionManager: + """Test ManagedClaudeSessionManager.""" + + @pytest.mark.asyncio + async def test_manager_create_session(self): + """Test creating a new session.""" + from free_claude_code.cli.managed.manager import ManagedClaudeSessionManager + + manager = ManagedClaudeSessionManager( + workspace_path="/tmp/test", + proxy_root_url="http://localhost:8082", + ) + + 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 free_claude_code.cli.managed.manager import ManagedClaudeSessionManager + + manager = ManagedClaudeSessionManager( + workspace_path="/tmp/test", + proxy_root_url="http://localhost:8082", + ) + + # 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 free_claude_code.cli.managed.manager import ManagedClaudeSessionManager + + manager = ManagedClaudeSessionManager( + workspace_path="/tmp/test", + proxy_root_url="http://localhost:8082", + ) + + stats = manager.get_stats() + assert stats["active_sessions"] == 0 + assert stats["pending_sessions"] == 0 diff --git a/tests/cli/test_cli_manager_edge_cases.py b/tests/cli/test_cli_manager_edge_cases.py new file mode 100644 index 0000000..a06762f --- /dev/null +++ b/tests/cli/test_cli_manager_edge_cases.py @@ -0,0 +1,126 @@ +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 free_claude_code.cli.managed.manager import ManagedClaudeSessionManager + + with patch( + "free_claude_code.cli.managed.manager.ManagedClaudeSession" + ) 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 = ManagedClaudeSessionManager( + workspace_path="/tmp", + proxy_root_url="http://x", + auth_token="proxy-token", + ) + session, temp_id, is_new = await manager.get_or_create_session() + assert session is mock_session + assert is_new is True + mock_session_cls.assert_called_once() + assert mock_session_cls.call_args.kwargs["auth_token"] == "proxy-token" + + 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 free_claude_code.cli.managed.manager import ManagedClaudeSessionManager + + manager = ManagedClaudeSessionManager( + workspace_path="/tmp", proxy_root_url="http://x" + ) + 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 free_claude_code.cli.managed.manager import ManagedClaudeSessionManager + + with patch( + "free_claude_code.cli.managed.manager.ManagedClaudeSession" + ) 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 = ManagedClaudeSessionManager( + workspace_path="/tmp", proxy_root_url="http://x" + ) + _, 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 free_claude_code.cli.managed.manager import ManagedClaudeSessionManager + + with patch( + "free_claude_code.cli.managed.manager.ManagedClaudeSession" + ) 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 = ManagedClaudeSessionManager( + workspace_path="/tmp", proxy_root_url="http://x" + ) + _, 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_reports_and_retains_stop_exceptions(): + from free_claude_code.cli.managed.manager import ManagedClaudeSessionManager + + manager = ManagedClaudeSessionManager( + workspace_path="/tmp", proxy_root_url="http://x" + ) + + 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 + + with pytest.raises( + RuntimeError, + match=r"^Managed Claude session shutdown failures: 1\.$", + ): + await manager.stop_all() + s1.stop.assert_awaited_once() + s2.stop.assert_awaited_once() + assert manager.get_stats()["active_sessions"] == 1 + assert manager.get_stats()["pending_sessions"] == 0 diff --git a/tests/cli/test_cli_ownership.py b/tests/cli/test_cli_ownership.py new file mode 100644 index 0000000..03a099f --- /dev/null +++ b/tests/cli/test_cli_ownership.py @@ -0,0 +1,17 @@ +from pathlib import Path + +from free_claude_code.cli.managed.session import ManagedClaudeSession + + +def test_cli_session_owns_typed_runner_config(tmp_path: Path) -> None: + session = ManagedClaudeSession( + workspace_path=str(tmp_path), + proxy_root_url="http://127.0.0.1:8082", + allowed_dirs=[str(tmp_path)], + claude_bin="claude-test", + ) + + assert session.config.workspace_path == str(tmp_path) + assert session.config.proxy_root_url == "http://127.0.0.1:8082" + assert session.config.allowed_dirs == [str(tmp_path)] + assert session.config.claude_bin == "claude-test" diff --git a/tests/cli/test_codex_model_catalog.py b/tests/cli/test_codex_model_catalog.py new file mode 100644 index 0000000..d23eaeb --- /dev/null +++ b/tests/cli/test_codex_model_catalog.py @@ -0,0 +1,184 @@ +import json +import os +import shutil +import subprocess +from collections.abc import Mapping +from pathlib import Path +from typing import Any, cast + +import pytest + +from free_claude_code.cli.launchers.codex_model_catalog import ( + build_codex_model_catalog, + write_codex_model_catalog, +) + + +def _models_payload(*model_ids: str) -> dict[str, Any]: + return { + "data": [ + { + "id": model_id, + "display_name": model_id.replace("anthropic/", ""), + } + for model_id in model_ids + ] + } + + +def _catalog_models(catalog: Mapping[str, Any]) -> list[Mapping[str, Any]]: + models = catalog["models"] + assert isinstance(models, list) + catalog_models: list[Mapping[str, Any]] = [] + for model in models: + assert isinstance(model, Mapping) + catalog_models.append(cast(Mapping[str, Any], model)) + return catalog_models + + +def _slugs(catalog: Mapping[str, Any]) -> list[str]: + slugs: list[str] = [] + for model in _catalog_models(catalog): + slug = model["slug"] + assert isinstance(slug, str) + slugs.append(slug) + return slugs + + +def test_codex_catalog_converts_configured_and_cached_models_to_direct_slugs() -> None: + catalog = build_codex_model_catalog( + _models_payload( + "anthropic/nvidia_nim/nvidia/nemotron-3-super", + "claude-3-freecc-no-thinking/nvidia_nim/nvidia/nemotron-3-super", + "anthropic/open_router/meta-llama/llama-3.3-70b", + "claude-3-freecc-no-thinking/open_router/meta-llama/llama-3.3-70b", + ) + ) + + assert _slugs(catalog) == [ + "nvidia_nim/nvidia/nemotron-3-super", + "open_router/meta-llama/llama-3.3-70b", + ] + model = _catalog_models(catalog)[0] + assert { + "slug", + "display_name", + "description", + "default_reasoning_level", + "supported_reasoning_levels", + "shell_type", + "visibility", + "supported_in_api", + "priority", + "additional_speed_tiers", + "service_tiers", + } <= set(model) + + +def test_codex_catalog_excludes_claude_compatibility_model_ids() -> None: + catalog = build_codex_model_catalog( + _models_payload( + "claude-opus-4-20250514", + "claude-3-haiku-20240307", + "anthropic/nvidia_nim/provider-model", + ) + ) + + assert _slugs(catalog) == ["nvidia_nim/provider-model"] + + +def test_codex_catalog_skips_no_thinking_duplicate_when_normal_slug_exists() -> None: + catalog = build_codex_model_catalog( + _models_payload( + "claude-3-freecc-no-thinking/nvidia_nim/provider-model", + "anthropic/nvidia_nim/provider-model", + ) + ) + + assert _slugs(catalog) == ["nvidia_nim/provider-model"] + + +def test_codex_catalog_preserves_no_thinking_only_entries_for_routing() -> None: + catalog = build_codex_model_catalog( + _models_payload("claude-3-freecc-no-thinking/open_router/plain-model") + ) + + assert _slugs(catalog) == ["claude-3-freecc-no-thinking/open_router/plain-model"] + + +def test_codex_catalog_ordering_and_priorities_are_deterministic() -> None: + catalog = build_codex_model_catalog( + _models_payload( + "anthropic/gemini/models/gemini-test", + "anthropic/nvidia_nim/nvidia/test", + "anthropic/gemini/models/gemini-test", + "anthropic/open_router/provider/test", + ) + ) + + models = _catalog_models(catalog) + assert _slugs(catalog) == [ + "gemini/models/gemini-test", + "nvidia_nim/nvidia/test", + "open_router/provider/test", + ] + assert [model["priority"] for model in models] == [0, 1, 2] + + +def test_codex_catalog_accepts_future_direct_provider_slugs() -> None: + catalog = build_codex_model_catalog( + _models_payload( + "nvidia_nim/provider-model", + "anthropic/open_router/provider-model", + ) + ) + + assert _slugs(catalog) == [ + "nvidia_nim/provider-model", + "open_router/provider-model", + ] + + +def test_generated_catalog_schema_is_accepted_by_installed_codex( + tmp_path: Path, +) -> None: + codex_binary = shutil.which("codex") + if codex_binary is None: + pytest.skip("Codex CLI is not installed") + + catalog_path = tmp_path / "codex-model-catalog.json" + write_codex_model_catalog( + catalog_path, + build_codex_model_catalog(_models_payload("anthropic/nvidia_nim/test-model")), + ) + codex_home = tmp_path / "codex-home" + codex_home.mkdir() + codex_env = os.environ.copy() + for key in ( + "CODEX_THREAD_ID", + "CODEX_INTERNAL_ORIGINATOR_OVERRIDE", + "CODEX_SHELL", + "CODEX_PERMISSION_PROFILE", + ): + codex_env.pop(key, None) + codex_env["CODEX_HOME"] = str(codex_home) + + result = subprocess.run( + [ + codex_binary, + "debug", + "models", + "-c", + f"model_catalog_json={json.dumps(str(catalog_path))}", + ], + capture_output=True, + check=False, + encoding="utf-8", + env=codex_env, + errors="replace", + text=True, + timeout=10, + ) + + assert result.returncode == 0, result.stderr + assert "nvidia_nim/test-model" in result.stdout diff --git a/tests/cli/test_entrypoints.py b/tests/cli/test_entrypoints.py new file mode 100644 index 0000000..2de74b2 --- /dev/null +++ b/tests/cli/test_entrypoints.py @@ -0,0 +1,1002 @@ +"""Tests for cli/entrypoints.py — fcc-init scaffolding logic.""" + +import json +import tomllib +from collections.abc import Callable +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch +from urllib.error import URLError +from urllib.request import Request + +import pytest + +from free_claude_code.config.settings import Settings + + +def _launcher_settings( + *, + port: int = 8082, + token: str = "freecc", +) -> Settings: + return Settings.model_construct( + host="0.0.0.0", + port=port, + anthropic_auth_token=token, + model="nvidia_nim/test-model", + ) + + +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 free_claude_code.cli.entrypoints import init + + env_file = tmp_home / ".fcc" / ".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 + + +class _JsonResponse: + def __init__(self, payload: dict[str, object]) -> None: + self._payload = payload + + def __enter__(self) -> _JsonResponse: + return self + + def __exit__(self, *_args: object) -> None: + return None + + def read(self) -> bytes: + return json.dumps(self._payload).encode("utf-8") + + +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 canonical root env.example content, not an empty file.""" + template = (Path(__file__).resolve().parents[2] / ".env.example").read_text( + encoding="utf-8" + ) + _, env_file = _run_init(tmp_path) + + assert env_file.read_text("utf-8") == template + + +def test_init_migrates_home_checkout_env_before_template(tmp_path: Path) -> None: + """init() preserves users who kept config in ~/free-claude-code/.env.""" + legacy_env = tmp_path / "free-claude-code" / ".env" + legacy_env.parent.mkdir(parents=True) + legacy_env.write_text("MODEL=deepseek/deepseek-chat\n", encoding="utf-8") + + output, env_file = _run_init(tmp_path) + + assert env_file.read_text("utf-8") == "MODEL=deepseek/deepseek-chat\n" + assert f"Config migrated from {legacy_env}" in output + + +def test_init_migrates_legacy_xdg_env_before_template(tmp_path: Path) -> None: + """init() preserves users who kept config in ~/.config/free-claude-code/.env.""" + legacy_env = tmp_path / ".config" / "free-claude-code" / ".env" + legacy_env.parent.mkdir(parents=True) + legacy_env.write_text("MODEL=open_router/free-model\n", encoding="utf-8") + + output, env_file = _run_init(tmp_path) + + assert env_file.read_text("utf-8") == "MODEL=open_router/free-model\n" + assert f"Config migrated from {legacy_env}" in output + + +def test_legacy_env_migration_does_not_overwrite_managed_env( + tmp_path: Path, +) -> None: + """Legacy migration never overwrites an existing ~/.fcc/.env.""" + from free_claude_code.cli.entrypoints import _migrate_legacy_env_if_missing + + managed_env = tmp_path / ".fcc" / ".env" + managed_env.parent.mkdir(parents=True) + managed_env.write_text("MODEL=nvidia_nim/current\n", encoding="utf-8") + legacy_env = tmp_path / "free-claude-code" / ".env" + legacy_env.parent.mkdir(parents=True) + legacy_env.write_text("MODEL=deepseek/legacy\n", encoding="utf-8") + + with patch("pathlib.Path.home", return_value=tmp_path): + migrated_from = _migrate_legacy_env_if_missing() + + assert migrated_from is None + assert managed_env.read_text("utf-8") == "MODEL=nvidia_nim/current\n" + + +def test_env_template_loader_uses_root_template_in_source_checkout() -> None: + """Source checkout fallback uses the root .env.example as the single source.""" + from free_claude_code.config.env_template import load_env_template + + template = (Path(__file__).resolve().parents[2] / ".env.example").read_text( + encoding="utf-8" + ) + + assert load_env_template() == template + + +def test_init_creates_parent_directories(tmp_path: Path) -> None: + """init() creates ~/.fcc/ even if it doesn't exist.""" + config_dir = tmp_path / ".fcc" + 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 / ".fcc" / ".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 fcc-server after editing .env.""" + output, _ = _run_init(tmp_path) + + assert "fcc-server" in output + + +def test_cli_scripts_are_registered() -> None: + pyproject = tomllib.loads( + (Path(__file__).resolve().parents[2] / "pyproject.toml").read_text( + encoding="utf-8" + ) + ) + + scripts = pyproject["project"]["scripts"] + assert scripts["fcc-server"] == "free_claude_code.cli.entrypoints:serve" + assert scripts["free-claude-code"] == "free_claude_code.cli.entrypoints:serve" + assert scripts["fcc-claude"] == "free_claude_code.cli.launchers.claude:launch" + assert scripts["fcc-codex"] == "free_claude_code.cli.launchers.codex:launch" + assert scripts["fcc-pi"] == "free_claude_code.cli.launchers.pi:launch" + + +@pytest.mark.parametrize("entrypoint_name", ["serve", "init"]) +@pytest.mark.parametrize( + "argv", + [("--version",), ("--version", "--help"), ("--help", "--version")], +) +def test_fcc_owned_entrypoints_report_version_without_side_effects( + entrypoint_name: str, + argv: tuple[str, ...], + capsys: pytest.CaptureFixture[str], +) -> None: + from free_claude_code.cli import entrypoints + + with ( + patch.object(entrypoints, "package_version", return_value="9.8.7"), + patch.object(entrypoints, "_migrate_legacy_env_if_missing") as migrate_legacy, + patch.object(entrypoints, "_migrate_config_env_keys") as migrate_keys, + patch.object(entrypoints, "get_settings") as get_settings, + patch.object( + entrypoints, "_run_supervised_server", return_value=False + ) as run_server, + patch.object(entrypoints, "kill_all_best_effort") as kill_all, + patch.object(entrypoints, "config_dir_path") as config_dir, + patch.object(entrypoints, "managed_env_path") as managed_env, + patch.object(entrypoints, "load_env_template") as load_template, + ): + getattr(entrypoints, entrypoint_name)(argv) + + assert capsys.readouterr() == ("free-claude-code 9.8.7\n", "") + for side_effect in { + migrate_legacy, + migrate_keys, + get_settings, + run_server, + kill_all, + config_dir, + managed_env, + load_template, + }: + side_effect.assert_not_called() + + +def test_schedule_open_admin_browser_opens_when_health_ready( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Opening /admin runs after /health preflight succeeds.""" + monkeypatch.delenv("FCC_OPEN_BROWSER", raising=False) + from free_claude_code.cli import entrypoints + from free_claude_code.config.server_urls import local_admin_url + + settings = _launcher_settings(port=31337) + opened_urls: list[str] = [] + + class ImmediateThread: + def __init__(self, target=None, **_kwargs: object) -> None: + self._target = target + + def start(self) -> None: + assert self._target is not None + self._target() + + with ( + patch.object(entrypoints.threading, "Thread", ImmediateThread), + patch.object(entrypoints, "preflight_proxy", return_value=None), + patch.object( + entrypoints.webbrowser, + "open", + side_effect=lambda url: opened_urls.append(url), + ), + patch.object(entrypoints.time, "sleep"), + ): + entrypoints._schedule_open_admin_browser(settings) + + assert opened_urls == [local_admin_url(settings)] + + +def test_schedule_open_admin_browser_skips_when_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("FCC_OPEN_BROWSER", "0") + from free_claude_code.cli import entrypoints + + settings = _launcher_settings() + + with patch.object(entrypoints.threading, "Thread") as thread_cls: + entrypoints._schedule_open_admin_browser(settings) + + thread_cls.assert_not_called() + + +def test_serve_supervisor_restarts_when_app_requests_restart() -> None: + from free_claude_code.cli import entrypoints + + settings = _launcher_settings() + get_settings = MagicMock(side_effect=[settings, settings]) + get_settings.cache_clear = MagicMock() + servers: list[object] = [] + restart_callbacks: list[Callable[[], None]] = [] + + apps: list[SimpleNamespace] = [] + + def build_asgi_app(_settings: Settings, restart_callback: Callable[[], None]): + restart_callbacks.append(restart_callback) + app = SimpleNamespace(runtime=SimpleNamespace(is_closed=False)) + apps.append(app) + return app + + class FakeServer: + def __init__(self, config): + self.config = config + self.should_exit = False + servers.append(self) + + def run(self): + if len(servers) == 1: + restart_callbacks[-1]() + assert self.should_exit is True + self.config.app.runtime.is_closed = True + + def fake_config(app, **kwargs): + return SimpleNamespace(app=app, kwargs=kwargs) + + with ( + patch.object(entrypoints, "get_settings", get_settings), + patch.object(entrypoints.uvicorn, "Config", side_effect=fake_config), + patch.object(entrypoints.uvicorn, "Server", side_effect=FakeServer), + patch.object(entrypoints, "build_asgi_app", side_effect=build_asgi_app), + patch.object(entrypoints, "_schedule_open_admin_browser"), + patch.object(entrypoints, "kill_all_best_effort") as kill_all, + ): + entrypoints.serve() + + assert len(servers) == 2 + get_settings.cache_clear.assert_called_once() + kill_all.assert_called_once() + + +def test_serve_supervisor_refuses_restart_after_incomplete_shutdown() -> None: + from free_claude_code.cli import entrypoints + + settings = _launcher_settings() + get_settings = MagicMock(return_value=settings) + get_settings.cache_clear = MagicMock() + servers: list[object] = [] + restart_callbacks: list[Callable[[], None]] = [] + + def build_asgi_app(_settings: Settings, restart_callback: Callable[[], None]): + restart_callbacks.append(restart_callback) + return SimpleNamespace(runtime=SimpleNamespace(is_closed=False)) + + class FakeServer: + def __init__(self, config): + self.config = config + self.should_exit = False + servers.append(self) + + def run(self): + restart_callbacks[-1]() + assert self.should_exit is True + + def fake_config(app, **kwargs): + return SimpleNamespace(app=app, kwargs=kwargs) + + with ( + patch.object(entrypoints, "get_settings", get_settings), + patch.object(entrypoints.uvicorn, "Config", side_effect=fake_config), + patch.object(entrypoints.uvicorn, "Server", side_effect=FakeServer), + patch.object(entrypoints, "build_asgi_app", side_effect=build_asgi_app), + patch.object(entrypoints, "_schedule_open_admin_browser"), + patch.object(entrypoints, "kill_all_best_effort") as kill_all, + ): + entrypoints.serve() + + assert len(servers) == 1 + get_settings.cache_clear.assert_not_called() + kill_all.assert_called_once() + + +def test_serve_migrates_legacy_env_before_loading_settings(tmp_path: Path) -> None: + from free_claude_code.cli import entrypoints + + legacy_env = tmp_path / "free-claude-code" / ".env" + legacy_env.parent.mkdir(parents=True) + legacy_env.write_text("MODEL=deepseek/deepseek-chat\n", encoding="utf-8") + settings = _launcher_settings() + get_settings = MagicMock(return_value=settings) + get_settings.cache_clear = MagicMock() + + with ( + patch("pathlib.Path.home", return_value=tmp_path), + patch.object(entrypoints, "get_settings", get_settings), + patch.object(entrypoints, "_run_supervised_server", return_value=False), + patch.object(entrypoints, "kill_all_best_effort"), + ): + entrypoints.serve() + + assert (tmp_path / ".fcc" / ".env").read_text("utf-8") == ( + "MODEL=deepseek/deepseek-chat\n" + ) + get_settings.assert_called_once_with() + + +def test_serve_migrates_hf_token_before_loading_settings( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from free_claude_code.cli import entrypoints + + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".env").write_text("HF_TOKEN=legacy-hf\n", encoding="utf-8") + settings = _launcher_settings() + get_settings = MagicMock(return_value=settings) + get_settings.cache_clear = MagicMock() + monkeypatch.chdir(repo) + + with ( + patch("pathlib.Path.home", return_value=tmp_path), + patch.object(entrypoints, "get_settings", get_settings), + patch.object(entrypoints, "_run_supervised_server", return_value=False), + patch.object(entrypoints, "kill_all_best_effort"), + patch.object(entrypoints, "explicit_env_file_huggingface_warning"), + ): + entrypoints.serve() + + assert (repo / ".env").read_text(encoding="utf-8") == ( + "HUGGINGFACE_API_KEY=legacy-hf\n" + ) + get_settings.assert_called_once_with() + + +def test_config_env_key_migration_warns_for_explicit_env_file( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + from free_claude_code.cli import entrypoints + + explicit = tmp_path / "custom.env" + explicit.write_text("HF_TOKEN=legacy-hf\n", encoding="utf-8") + + with patch.dict(entrypoints.os.environ, {"FCC_ENV_FILE": str(explicit)}): + migrated = entrypoints._migrate_config_env_keys() + + assert migrated == () + assert "HF_TOKEN" in capsys.readouterr().err + assert explicit.read_text(encoding="utf-8") == "HF_TOKEN=legacy-hf\n" + + +def test_serve_handles_keyboard_interrupt_without_traceback() -> None: + from free_claude_code.cli import entrypoints + + settings = _launcher_settings() + get_settings = MagicMock(return_value=settings) + get_settings.cache_clear = MagicMock() + + with ( + patch.object(entrypoints, "get_settings", get_settings), + patch.object( + entrypoints, + "_run_supervised_server", + side_effect=KeyboardInterrupt, + ), + patch.object(entrypoints, "kill_all_best_effort") as kill_all, + ): + entrypoints.serve() + + get_settings.cache_clear.assert_not_called() + kill_all.assert_called_once() + + +def test_claude_child_env_targets_current_proxy_config() -> None: + from free_claude_code.cli.claude_env import build_claude_proxy_env + + env = build_claude_proxy_env( + proxy_root_url="http://127.0.0.1:9090", + auth_token=" proxy-token ", + base_env={ + "PATH": "keep", + "ANTHROPIC_API_URL": "https://api.anthropic.com/v1", + "ANTHROPIC_BASE_URL": "https://api.anthropic.com", + "ANTHROPIC_AUTH_TOKEN": "old-token", + "ANTHROPIC_API_KEY": "official-key", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "0", + }, + ) + + assert env["PATH"] == "keep" + assert env["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:9090" + assert env["ANTHROPIC_AUTH_TOKEN"] == "proxy-token" + assert env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "1" + assert env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] == "190000" + assert env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] == "1" + assert "ANTHROPIC_API_URL" not in env + assert "ANTHROPIC_API_KEY" not in env + + +def test_claude_child_env_uses_sentinel_for_blank_configured_auth_token() -> None: + from free_claude_code.cli.claude_env import build_claude_proxy_env + + env = build_claude_proxy_env( + proxy_root_url="http://127.0.0.1:8082", + auth_token="", + base_env={ + "ANTHROPIC_AUTH_TOKEN": "inherited-token", + "ANTHROPIC_API_KEY": "official-key", + }, + ) + + assert env["ANTHROPIC_AUTH_TOKEN"] == "fcc-no-auth" + assert "ANTHROPIC_API_KEY" not in env + + +def test_launch_claude_passes_args_and_child_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from free_claude_code.cli.launchers.claude import launch + + monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://api.anthropic.com") + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "old-token") + monkeypatch.setenv("KEEP_ME", "yes") + settings = _launcher_settings(port=9191, token="proxy-token") + + with ( + patch( + "free_claude_code.cli.launchers.claude.get_settings", return_value=settings + ), + patch( + "free_claude_code.cli.launchers.claude.preflight_proxy", return_value=None + ), + patch( + "free_claude_code.cli.launchers.common.shutil.which", + return_value="resolved-claude.cmd", + ), + patch("free_claude_code.cli.launchers.common.subprocess.Popen") as popen, + patch("free_claude_code.cli.launchers.common.register_pid") as register_pid, + patch("free_claude_code.cli.launchers.common.unregister_pid") as unregister_pid, + pytest.raises(SystemExit) as exc_info, + ): + process = popen.return_value + process.pid = 12345 + process.wait.return_value = 7 + launch(["--model", "sonnet"]) + + assert exc_info.value.code == 7 + popen.assert_called_once() + assert popen.call_args.args[0] == ["resolved-claude.cmd", "--model", "sonnet"] + child_env = popen.call_args.kwargs["env"] + assert child_env["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:9191" + assert child_env["ANTHROPIC_AUTH_TOKEN"] == "proxy-token" + assert child_env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "1" + assert child_env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] == "190000" + assert child_env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] == "1" + assert child_env["KEEP_ME"] == "yes" + register_pid.assert_called_once_with(12345) + unregister_pid.assert_called_once_with(12345) + + +def test_launch_codex_passes_responses_config_and_child_env( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from free_claude_code.cli.launchers.codex import launch + + monkeypatch.setenv("OPENAI_API_KEY", "official-key") + monkeypatch.setenv("OPENAI_BASE_URL", "https://api.openai.com/v1") + monkeypatch.setenv("CODEX_HOME", "keep-home") + monkeypatch.setenv("CODEX_INTERNAL_ORIGINATOR_OVERRIDE", "Codex Desktop") + monkeypatch.setenv("CODEX_PERMISSION_PROFILE", "danger-full-access") + monkeypatch.setenv("CODEX_SHELL", "1") + monkeypatch.setenv("CODEX_THREAD_ID", "parent-thread") + settings = _launcher_settings(port=9191, token="proxy-token") + catalog_path = tmp_path / "codex-model-catalog.json" + requests: list[Request] = [] + + def fake_urlopen(request: Request, *, timeout: float) -> _JsonResponse: + requests.append(request) + assert timeout == 1.5 + return _JsonResponse( + { + "data": [ + { + "id": "anthropic/nvidia_nim/provider-model", + "display_name": "NVIDIA model", + }, + { + "id": ("claude-3-freecc-no-thinking/nvidia_nim/provider-model"), + "display_name": "NVIDIA model (no thinking)", + }, + { + "id": "claude-opus-4-20250514", + "display_name": "Claude Opus 4", + }, + ] + } + ) + + with ( + patch( + "free_claude_code.cli.launchers.codex.get_settings", return_value=settings + ), + patch( + "free_claude_code.cli.launchers.codex.preflight_proxy", return_value=None + ), + patch( + "free_claude_code.cli.launchers.common.shutil.which", + return_value="resolved-codex.cmd", + ), + patch( + "free_claude_code.cli.launchers.codex.codex_model_catalog_path", + return_value=catalog_path, + ), + patch("free_claude_code.cli.launchers.codex.urlopen", side_effect=fake_urlopen), + patch("free_claude_code.cli.launchers.common.subprocess.Popen") as popen, + patch("free_claude_code.cli.launchers.common.register_pid") as register_pid, + patch("free_claude_code.cli.launchers.common.unregister_pid") as unregister_pid, + pytest.raises(SystemExit) as exc_info, + ): + process = popen.return_value + process.pid = 12345 + process.wait.return_value = 0 + launch(["exec", "hello"]) + + assert exc_info.value.code == 0 + command = popen.call_args.args[0] + assert command[0] == "resolved-codex.cmd" + assert 'model_provider="fcc"' in command + assert 'model_providers.fcc.base_url="http://127.0.0.1:9191/v1"' in command + assert 'model_providers.fcc.wire_api="responses"' in command + assert f"model_catalog_json={json.dumps(str(catalog_path))}" in command + assert command[-2:] == ["exec", "hello"] + assert len(requests) == 1 + request = requests[0] + assert request.full_url == "http://127.0.0.1:9191/v1/models" + headers = {key.lower(): value for key, value in request.header_items()} + assert headers["x-api-key"] == "proxy-token" + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + assert [model["slug"] for model in catalog["models"]] == [ + "nvidia_nim/provider-model" + ] + child_env = popen.call_args.kwargs["env"] + assert child_env["FCC_CODEX_API_KEY"] == "proxy-token" + assert child_env["CODEX_HOME"] == "keep-home" + assert "CODEX_INTERNAL_ORIGINATOR_OVERRIDE" not in child_env + assert "CODEX_PERMISSION_PROFILE" not in child_env + assert "CODEX_SHELL" not in child_env + assert "CODEX_THREAD_ID" not in child_env + assert "OPENAI_API_KEY" not in child_env + assert "OPENAI_BASE_URL" not in child_env + register_pid.assert_called_once_with(12345) + unregister_pid.assert_called_once_with(12345) + + +def test_launch_codex_catalog_failure_warns_and_continues( + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + from free_claude_code.cli.launchers.codex import launch + + settings = _launcher_settings(port=9191, token="proxy-token") + + with ( + patch( + "free_claude_code.cli.launchers.codex.get_settings", return_value=settings + ), + patch( + "free_claude_code.cli.launchers.codex.preflight_proxy", return_value=None + ), + patch( + "free_claude_code.cli.launchers.common.shutil.which", + return_value="resolved-codex.cmd", + ), + patch( + "free_claude_code.cli.launchers.codex.codex_model_catalog_path", + return_value=tmp_path / "codex-model-catalog.json", + ), + patch( + "free_claude_code.cli.launchers.codex.urlopen", side_effect=URLError("boom") + ), + patch("free_claude_code.cli.launchers.common.subprocess.Popen") as popen, + patch("free_claude_code.cli.launchers.common.register_pid"), + patch("free_claude_code.cli.launchers.common.unregister_pid"), + pytest.raises(SystemExit) as exc_info, + ): + process = popen.return_value + process.pid = 12345 + process.wait.return_value = 0 + launch(["exec", "hello"]) + + assert exc_info.value.code == 0 + command = popen.call_args.args[0] + assert not any("model_catalog_json=" in arg for arg in command) + captured = capsys.readouterr() + assert "could not prepare Codex model catalog" in captured.err + assert "launching without model picker catalog" in captured.err + + +def test_pi_launcher_builds_scoped_session_command_and_proxy_env( + tmp_path: Path, +) -> None: + from free_claude_code.cli.launchers.pi import ( + build_pi_launcher_command, + build_pi_launcher_env, + ) + + extension = tmp_path / "pi_extension.ts" + env = build_pi_launcher_env( + proxy_root_url="http://127.0.0.1:9191/", + auth_token=" proxy-token ", + base_env={ + "PATH": "keep", + "ANTHROPIC_API_KEY": "native-pi-credential", + "FCC_PI_API_KEY": "stale-key", + "FCC_PI_BASE_URL": "https://stale.invalid", + }, + ) + + assert build_pi_launcher_command( + binary_path="resolved-pi.cmd", + extension_path=extension, + argv=["--print", "hello"], + ) == [ + "resolved-pi.cmd", + "-e", + str(extension), + "--models", + "free-claude-code/**", + "--print", + "hello", + ] + assert env == { + "PATH": "keep", + "ANTHROPIC_API_KEY": "native-pi-credential", + "FCC_PI_BASE_URL": "http://127.0.0.1:9191", + "FCC_PI_API_KEY": "proxy-token", + } + + +def test_pi_launcher_uses_no_auth_sentinel_for_blank_token() -> None: + from free_claude_code.cli.launchers.pi import build_pi_launcher_env + + env = build_pi_launcher_env( + proxy_root_url="http://127.0.0.1:8082", + auth_token="", + base_env={}, + ) + + assert env["FCC_PI_API_KEY"] == "fcc-no-auth" + + +def test_launch_pi_registers_bundled_extension_for_sessions( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from free_claude_code.cli.launchers.pi import launch + + monkeypatch.setenv("KEEP_ME", "yes") + monkeypatch.setenv("FCC_PI_API_KEY", "stale-key") + extension = tmp_path / "pi_extension.ts" + extension.write_text("export default () => {};", encoding="utf-8") + settings = _launcher_settings(port=9191, token="proxy-token") + + with ( + patch("free_claude_code.cli.launchers.pi.get_settings", return_value=settings), + patch("free_claude_code.cli.launchers.pi.preflight_proxy", return_value=None), + patch( + "free_claude_code.cli.launchers.pi.pi_extension_path", + return_value=extension, + ), + patch( + "free_claude_code.cli.launchers.common.shutil.which", + return_value="resolved-pi.cmd", + ), + patch( + "free_claude_code.cli.launchers.pi.pi_binary_is_compatible", + return_value=True, + ), + patch("free_claude_code.cli.launchers.common.subprocess.Popen") as popen, + patch("free_claude_code.cli.launchers.common.register_pid"), + patch("free_claude_code.cli.launchers.common.unregister_pid"), + pytest.raises(SystemExit) as exc_info, + ): + process = popen.return_value + process.pid = 12345 + process.wait.return_value = 0 + launch(["--print", "hello"]) + + assert exc_info.value.code == 0 + assert popen.call_args.args[0] == [ + "resolved-pi.cmd", + "-e", + str(extension), + "--models", + "free-claude-code/**", + "--print", + "hello", + ] + child_env = popen.call_args.kwargs["env"] + assert child_env["FCC_PI_BASE_URL"] == "http://127.0.0.1:9191" + assert child_env["FCC_PI_API_KEY"] == "proxy-token" + assert child_env["KEEP_ME"] == "yes" + + +@pytest.mark.parametrize( + "argv", + [ + ["--help"], + ["--version"], + ["config", "set", "theme", "dark"], + ["install", "npm:example"], + ["list"], + ["remove", "npm:example"], + ["uninstall", "npm:example"], + ["update"], + ], +) +def test_launch_pi_passes_management_commands_through_without_proxy( + argv: list[str], +) -> None: + from free_claude_code.cli.launchers.pi import launch + + with ( + patch("free_claude_code.cli.launchers.pi.get_settings") as get_settings, + patch("free_claude_code.cli.launchers.pi.preflight_proxy") as preflight, + patch( + "free_claude_code.cli.launchers.common.shutil.which", + return_value="resolved-pi", + ), + patch( + "free_claude_code.cli.launchers.pi.pi_binary_is_compatible", + return_value=True, + ), + patch("free_claude_code.cli.launchers.common.subprocess.Popen") as popen, + patch("free_claude_code.cli.launchers.common.register_pid"), + patch("free_claude_code.cli.launchers.common.unregister_pid"), + pytest.raises(SystemExit) as exc_info, + ): + process = popen.return_value + process.pid = 12345 + process.wait.return_value = 0 + launch(argv) + + assert exc_info.value.code == 0 + assert popen.call_args.args[0] == ["resolved-pi", *argv] + get_settings.assert_not_called() + preflight.assert_not_called() + + +def test_launch_pi_fails_closed_when_bundled_extension_is_missing( + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + from free_claude_code.cli.launchers.pi import launch + + settings = _launcher_settings(port=9191) + with ( + patch("free_claude_code.cli.launchers.pi.get_settings", return_value=settings), + patch("free_claude_code.cli.launchers.pi.preflight_proxy", return_value=None), + patch( + "free_claude_code.cli.launchers.pi.pi_extension_path", + return_value=tmp_path / "missing.ts", + ), + patch( + "free_claude_code.cli.launchers.common.shutil.which", + return_value="resolved-pi", + ), + patch( + "free_claude_code.cli.launchers.pi.pi_binary_is_compatible", + return_value=True, + ), + patch("free_claude_code.cli.launchers.common.subprocess.Popen") as popen, + pytest.raises(SystemExit) as exc_info, + ): + launch([]) + + assert exc_info.value.code == 1 + popen.assert_not_called() + assert "bundled Pi extension is missing" in capsys.readouterr().err + + +def test_pi_install_hints_use_official_platform_installers() -> None: + from free_claude_code.cli.launchers.pi import pi_install_hint + + assert "https://pi.dev/install.ps1" in pi_install_hint("win32") + assert "https://pi.dev/install.sh" in pi_install_hint("darwin") + + +@pytest.mark.parametrize( + ("help_output", "return_code", "expected"), + [ + ("--extension \n--models \n", 0, True), + ("--models \n", 0, False), + ("--extension \n", 0, False), + ("--extension \n--models \n", 1, False), + ], +) +def test_pi_binary_compatibility_requires_both_launcher_capabilities( + help_output: str, + return_code: int, + expected: bool, +) -> None: + from free_claude_code.cli.launchers.pi import pi_binary_is_compatible + + with patch( + "free_claude_code.cli.launchers.pi.subprocess.run", + return_value=SimpleNamespace(returncode=return_code, stdout=help_output), + ): + assert pi_binary_is_compatible("resolved-pi") is expected + + +def test_launch_pi_rejects_unrelated_pi_binary( + capsys: pytest.CaptureFixture[str], +) -> None: + from free_claude_code.cli.launchers.pi import launch + + with ( + patch( + "free_claude_code.cli.launchers.common.shutil.which", + return_value="unrelated-pi", + ), + patch( + "free_claude_code.cli.launchers.pi.pi_binary_is_compatible", + return_value=False, + ), + patch("free_claude_code.cli.launchers.pi.get_settings") as get_settings, + patch("free_claude_code.cli.launchers.common.subprocess.Popen") as popen, + pytest.raises(SystemExit) as exc_info, + ): + launch([]) + + assert exc_info.value.code == 126 + get_settings.assert_not_called() + popen.assert_not_called() + captured = capsys.readouterr() + assert "not a compatible Pi Coding Agent" in captured.err + assert "https://pi.dev/install." in captured.err + + +def test_launch_claude_keyboard_interrupt_kills_child_tree() -> None: + from free_claude_code.cli.launchers.claude import launch + + settings = _launcher_settings(port=9191, token="proxy-token") + + with ( + patch( + "free_claude_code.cli.launchers.claude.get_settings", return_value=settings + ), + patch( + "free_claude_code.cli.launchers.claude.preflight_proxy", return_value=None + ), + patch( + "free_claude_code.cli.launchers.common.shutil.which", + return_value="resolved-claude.cmd", + ), + patch("free_claude_code.cli.launchers.common.subprocess.Popen") as popen, + patch("free_claude_code.cli.launchers.common.register_pid"), + patch( + "free_claude_code.cli.launchers.common.kill_pid_tree_best_effort" + ) as kill_tree, + patch("free_claude_code.cli.launchers.common.unregister_pid") as unregister_pid, + pytest.raises(KeyboardInterrupt), + ): + process = popen.return_value + process.pid = 12345 + process.wait.side_effect = [KeyboardInterrupt, 0] + + launch([]) + + kill_tree.assert_called_once_with(12345) + unregister_pid.assert_called_once_with(12345) + + +def test_launch_claude_exits_when_command_cannot_be_resolved( + capsys: pytest.CaptureFixture[str], +) -> None: + from free_claude_code.cli.launchers.claude import launch + + settings = _launcher_settings() + with ( + patch( + "free_claude_code.cli.launchers.claude.get_settings", return_value=settings + ), + patch( + "free_claude_code.cli.launchers.claude.preflight_proxy", return_value=None + ), + patch("free_claude_code.cli.launchers.common.shutil.which", return_value=None), + patch("free_claude_code.cli.launchers.common.subprocess.Popen") as popen, + pytest.raises(SystemExit) as exc_info, + ): + launch([]) + + assert exc_info.value.code == 127 + popen.assert_not_called() + captured = capsys.readouterr() + assert "Could not find Claude Code command: claude" in captured.err + assert "npm install -g @anthropic-ai/claude-code" in captured.err + + +def test_launch_claude_unreachable_proxy_exits_with_hint( + capsys: pytest.CaptureFixture[str], +) -> None: + from free_claude_code.cli.launchers.claude import launch + + settings = _launcher_settings(port=9393) + with ( + patch( + "free_claude_code.cli.launchers.claude.get_settings", return_value=settings + ), + patch( + "free_claude_code.cli.launchers.claude.preflight_proxy", + return_value="connection refused", + ), + patch("free_claude_code.cli.launchers.common.subprocess.Popen") as popen, + pytest.raises(SystemExit) as exc_info, + ): + launch([]) + + assert exc_info.value.code == 1 + popen.assert_not_called() + captured = capsys.readouterr() + assert "http://127.0.0.1:9393" in captured.err + assert "fcc-server" in captured.err diff --git a/tests/cli/test_managed_claude.py b/tests/cli/test_managed_claude.py new file mode 100644 index 0000000..409ce22 --- /dev/null +++ b/tests/cli/test_managed_claude.py @@ -0,0 +1,213 @@ +import os + +from free_claude_code.cli.claude_env import build_claude_proxy_env +from free_claude_code.cli.managed.claude import ( + MANAGED_CLAUDE_MODEL_TIER, + ManagedClaudeConfig, + ManagedClaudeParseState, + ManagedClaudeTaskRequest, + build_managed_claude_env, + build_managed_claude_invocation, + extract_managed_claude_session_id, + parse_managed_claude_stdout_line, +) +from free_claude_code.cli.managed.diagnostics import classify_managed_claude_stderr + + +def _config(**overrides: object) -> ManagedClaudeConfig: + workspace_path = overrides.get("workspace_path", os.path.normpath("/tmp/workspace")) + proxy_root_url = overrides.get("proxy_root_url", "http://localhost:8082") + raw_allowed_dirs = overrides.get("allowed_dirs") + allowed_dirs: list[str] = [] + if raw_allowed_dirs is not None: + assert isinstance(raw_allowed_dirs, list) + for directory in raw_allowed_dirs: + assert isinstance(directory, str) + allowed_dirs.append(directory) + claude_bin = overrides.get("claude_bin", "claude") + auth_token = overrides.get("auth_token", "proxy-token") + + assert isinstance(workspace_path, str) + assert isinstance(proxy_root_url, str) + assert isinstance(claude_bin, str) + assert isinstance(auth_token, str) + return ManagedClaudeConfig( + workspace_path=workspace_path, + proxy_root_url=proxy_root_url, + allowed_dirs=allowed_dirs, + claude_bin=claude_bin, + auth_token=auth_token, + ) + + +def test_managed_claude_builds_new_task_command_and_env() -> None: + invocation = build_managed_claude_invocation( + config=_config(allowed_dirs=[os.path.normpath("/tmp/extra")]), + request=ManagedClaudeTaskRequest(prompt="hello"), + base_env={"PATH": "keep", "ANTHROPIC_API_KEY": "official"}, + ) + + assert invocation.argv[:4] == ( + "claude", + "--model", + MANAGED_CLAUDE_MODEL_TIER, + "-p", + ) + assert "hello" in invocation.argv + assert "--output-format" in invocation.argv + assert "stream-json" in invocation.argv + assert "--add-dir" in invocation.argv + assert os.path.normpath("/tmp/extra") in invocation.argv + assert "--settings" not in invocation.argv + assert invocation.env["PATH"] == "keep" + assert invocation.env["ANTHROPIC_BASE_URL"] == "http://localhost:8082" + assert invocation.env["ANTHROPIC_AUTH_TOKEN"] == "proxy-token" + assert invocation.env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "1" + assert invocation.env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] == "190000" + assert invocation.env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] == "1" + assert "ANTHROPIC_API_URL" not in invocation.env + assert "ANTHROPIC_API_KEY" not in invocation.env + assert invocation.trace_metadata["client_cli_id"] == "claude" + assert invocation.trace_metadata["claude_binary"] == "claude" + assert invocation.trace_metadata["managed_model_tier"] == MANAGED_CLAUDE_MODEL_TIER + + +def test_managed_claude_builds_resume_and_fork_commands() -> None: + resume = build_managed_claude_invocation( + config=_config(), + request=ManagedClaudeTaskRequest(prompt="again", session_id="sess_1"), + base_env={}, + ) + fork = build_managed_claude_invocation( + config=_config(), + request=ManagedClaudeTaskRequest( + prompt="branch", session_id="sess_1", fork_session=True + ), + base_env={}, + ) + + assert resume.argv[:6] == ( + "claude", + "--resume", + "sess_1", + "--model", + MANAGED_CLAUDE_MODEL_TIER, + "-p", + ) + assert "--fork-session" not in resume.argv + assert fork.argv[:7] == ( + "claude", + "--resume", + "sess_1", + "--fork-session", + "--model", + MANAGED_CLAUDE_MODEL_TIER, + "-p", + ) + assert "--fork-session" in fork.argv + + +def test_managed_claude_uses_native_plan_storage() -> None: + invocation = build_managed_claude_invocation( + config=_config(), + request=ManagedClaudeTaskRequest(prompt="hello"), + base_env={}, + ) + + assert "--settings" not in invocation.argv + + +def test_managed_claude_env_uses_sentinel_when_proxy_auth_blank() -> None: + env = build_managed_claude_env( + proxy_root_url="http://localhost:8082", + auth_token="", + base_env={"ANTHROPIC_AUTH_TOKEN": "stale"}, + ) + + assert env["ANTHROPIC_AUTH_TOKEN"] == "fcc-no-auth" + + +def test_managed_claude_env_only_adds_noninteractive_process_settings() -> None: + base_env = { + "PATH": "keep", + "ANTHROPIC_API_URL": "https://api.anthropic.com/v1", + "ANTHROPIC_API_KEY": "official-key", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "0", + } + proxy_env = build_claude_proxy_env( + proxy_root_url="http://localhost:8082", + auth_token="proxy-token", + base_env=base_env, + ) + + managed_env = build_managed_claude_env( + proxy_root_url="http://localhost:8082", + auth_token="proxy-token", + base_env=base_env, + ) + + assert managed_env == { + **proxy_env, + "TERM": "dumb", + "PYTHONIOENCODING": "utf-8", + } + + +def test_managed_claude_stderr_classifier_filters_known_benign_notice() -> None: + diagnostics = classify_managed_claude_stderr( + "claude.ai connectors are disabled in this environment" + ) + + assert diagnostics.has_benign + assert diagnostics.benign_lines == ( + "claude.ai connectors are disabled in this environment", + ) + assert diagnostics.fatal_text is None + + +def test_managed_claude_stderr_classifier_preserves_unknown_lines() -> None: + diagnostics = classify_managed_claude_stderr( + "claude.ai connectors are disabled in this environment\nFatal error" + ) + + assert diagnostics.has_benign + assert diagnostics.fatal_text == "Fatal error" + + +def test_managed_claude_extracts_session_ids() -> None: + assert extract_managed_claude_session_id({"session_id": "direct"}) == "direct" + assert extract_managed_claude_session_id({"sessionId": "camel"}) == "camel" + assert ( + extract_managed_claude_session_id({"init": {"session_id": "nested"}}) + == "nested" + ) + assert ( + extract_managed_claude_session_id({"result": {"sessionId": "result"}}) + == "result" + ) + assert extract_managed_claude_session_id({"conversation": {"id": "conv"}}) == "conv" + assert extract_managed_claude_session_id({"type": "message"}) is None + assert extract_managed_claude_session_id("not a dict") is None + + +def test_managed_claude_parser_emits_session_info_once() -> None: + state = ManagedClaudeParseState() + + first = list(parse_managed_claude_stdout_line('{"session_id": "sess_1"}', state)) + second = list(parse_managed_claude_stdout_line('{"session_id": "sess_2"}', state)) + + assert first == [ + {"type": "session_info", "session_id": "sess_1"}, + {"session_id": "sess_1"}, + ] + assert second == [{"session_id": "sess_2"}] + + +def test_managed_claude_parser_returns_raw_for_non_json() -> None: + events = list( + parse_managed_claude_stdout_line( + "not json", ManagedClaudeParseState(log_raw_cli_diagnostics=False) + ) + ) + + assert events == [{"type": "raw", "content": "not json"}] diff --git a/tests/cli/test_managed_session_shutdown.py b/tests/cli/test_managed_session_shutdown.py new file mode 100644 index 0000000..465f4f7 --- /dev/null +++ b/tests/cli/test_managed_session_shutdown.py @@ -0,0 +1,471 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.cli.managed.manager import ManagedClaudeSessionManager +from free_claude_code.cli.managed.session import ManagedClaudeSession + + +def _manager() -> ManagedClaudeSessionManager: + return ManagedClaudeSessionManager( + workspace_path="/tmp", + proxy_root_url="http://127.0.0.1:8082", + ) + + +def _mock_session(*stop_results: object) -> MagicMock: + session = MagicMock(spec=ManagedClaudeSession) + session.is_busy = False + session.stop = AsyncMock(side_effect=list(stop_results)) + return session + + +def _completed_process(pid: int) -> MagicMock: + process = MagicMock() + process.pid = pid + process.returncode = 0 + process.stdout.read = AsyncMock(return_value=b"") + process.stderr = None + process.wait = AsyncMock(return_value=0) + return process + + +@pytest.mark.asyncio +async def test_stop_is_idempotent_without_a_live_process() -> None: + session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082") + + assert await session.stop() is True + + process = MagicMock() + process.pid = 101 + process.returncode = 0 + process.wait = AsyncMock() + session.process = process + + with ( + patch( + "free_claude_code.cli.managed.session.kill_pid_tree_best_effort" + ) as kill_tree, + patch("free_claude_code.cli.managed.session.unregister_pid") as unregister, + ): + assert await session.stop() is True + + kill_tree.assert_not_called() + process.wait.assert_not_awaited() + unregister.assert_called_once_with(101) + + +@pytest.mark.asyncio +async def test_stopped_session_reference_cannot_launch_a_new_process() -> None: + session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082") + + assert await session.stop() is True + + with ( + patch( + "free_claude_code.cli.managed.session.asyncio.create_subprocess_exec", + new=AsyncMock(return_value=_completed_process(100)), + ) as create_process, + patch("free_claude_code.cli.managed.session.trace_event") as trace, + pytest.raises(RuntimeError, match="closed"), + ): + async for _ in session.start_task("must not launch"): + pass + + create_process.assert_not_awaited() + trace.assert_not_called() + assert session.is_busy is False + + +@pytest.mark.asyncio +async def test_launch_publication_wins_before_concurrent_stop() -> None: + session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082") + launch_entered = asyncio.Event() + release_launch = asyncio.Event() + release_stream = asyncio.Event() + process = MagicMock() + process.pid = 102 + process.returncode = None + process.stderr = None + + async def create_process(*_args: object, **_kwargs: object) -> MagicMock: + launch_entered.set() + await release_launch.wait() + return process + + async def read_stdout(*_args: object, **_kwargs: object) -> bytes: + await release_stream.wait() + return b"" + + async def wait_process() -> int: + process.returncode = 0 + return 0 + + process.stdout.read = AsyncMock(side_effect=read_stdout) + process.wait = AsyncMock(side_effect=wait_process) + + with ( + patch( + "free_claude_code.cli.managed.session.asyncio.create_subprocess_exec", + side_effect=create_process, + ), + patch("free_claude_code.cli.managed.session.kill_pid_tree_best_effort"), + patch("free_claude_code.cli.managed.session.register_pid"), + patch("free_claude_code.cli.managed.session.unregister_pid"), + ): + stream_task = asyncio.create_task( + _collect_session_events(session, "launch wins") + ) + await launch_entered.wait() + stop_task = asyncio.create_task(session.stop()) + await asyncio.sleep(0) + stopped_before_publication = stop_task.done() + + release_launch.set() + assert await asyncio.wait_for(stop_task, timeout=1) is True + release_stream.set() + events = await asyncio.wait_for(stream_task, timeout=1) + + with pytest.raises(RuntimeError, match="closed"): + async for _ in session.start_task("cannot relaunch"): + pass + + assert events == [{"type": "exit", "code": 0, "stderr": None}] + assert stopped_before_publication is False + + +@pytest.mark.asyncio +async def test_concurrent_stop_wins_before_launch_publication() -> None: + session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082") + stop_entered = asyncio.Event() + release_stop = asyncio.Event() + process = MagicMock() + process.pid = 103 + process.returncode = None + + async def wait_process() -> int: + stop_entered.set() + await release_stop.wait() + process.returncode = 0 + return 0 + + process.wait = AsyncMock(side_effect=wait_process) + session.process = process + unexpected_process = _completed_process(106) + + with ( + patch( + "free_claude_code.cli.managed.session.asyncio.create_subprocess_exec", + new=AsyncMock(return_value=unexpected_process), + ) as create_process, + patch("free_claude_code.cli.managed.session.kill_pid_tree_best_effort"), + patch("free_claude_code.cli.managed.session.unregister_pid"), + patch("free_claude_code.cli.managed.session.trace_event") as trace, + ): + stop_task = asyncio.create_task(session.stop()) + await stop_entered.wait() + stream_task = asyncio.create_task(_collect_session_events(session, "stop wins")) + await asyncio.sleep(0) + + release_stop.set() + assert await asyncio.wait_for(stop_task, timeout=1) is True + with pytest.raises(RuntimeError, match="closed"): + await asyncio.wait_for(stream_task, timeout=1) + + create_process.assert_not_awaited() + trace.assert_not_called() + + +@pytest.mark.asyncio +async def test_normal_sequential_starts_remain_allowed_before_stop() -> None: + session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082") + processes = [_completed_process(104), _completed_process(105)] + + with ( + patch( + "free_claude_code.cli.managed.session.asyncio.create_subprocess_exec", + new=AsyncMock(side_effect=processes), + ) as create_process, + patch("free_claude_code.cli.managed.session.register_pid"), + patch("free_claude_code.cli.managed.session.unregister_pid"), + ): + first = await _collect_session_events(session, "first") + second = await _collect_session_events(session, "second") + + assert first == [{"type": "exit", "code": 0, "stderr": None}] + assert second == [{"type": "exit", "code": 0, "stderr": None}] + assert create_process.await_count == 2 + + +async def _collect_session_events( + session: ManagedClaudeSession, + prompt: str, +) -> list[dict]: + return [event async for event in session.start_task(prompt)] + + +@pytest.mark.asyncio +async def test_failed_stop_keeps_pid_registered_until_retry_confirms_exit() -> None: + session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082") + process = MagicMock() + process.pid = 202 + process.returncode = None + process.wait = AsyncMock(side_effect=[RuntimeError("wait failed"), 0]) + session.process = process + + with ( + patch("free_claude_code.cli.managed.session.kill_pid_tree_best_effort"), + patch("free_claude_code.cli.managed.session.unregister_pid") as unregister, + ): + assert await session.stop() is False + unregister.assert_not_called() + + assert await session.stop() is True + + unregister.assert_called_once_with(202) + + +@pytest.mark.asyncio +async def test_cancelled_stop_keeps_pid_registered_and_can_be_retried() -> None: + session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082") + process = MagicMock() + process.pid = 303 + process.returncode = None + wait_entered = asyncio.Event() + + async def wait_until_cancelled() -> int: + wait_entered.set() + await asyncio.Event().wait() + return 0 + + process.wait = AsyncMock(side_effect=wait_until_cancelled) + session.process = process + + with ( + patch("free_claude_code.cli.managed.session.kill_pid_tree_best_effort"), + patch("free_claude_code.cli.managed.session.unregister_pid") as unregister, + ): + stopping = asyncio.create_task(session.stop()) + await wait_entered.wait() + stopping.cancel() + with pytest.raises(asyncio.CancelledError): + await stopping + unregister.assert_not_called() + + process.wait = AsyncMock(return_value=0) + assert await session.stop() is True + + unregister.assert_called_once_with(303) + + +@pytest.mark.asyncio +async def test_task_failure_keeps_unconfirmed_process_pid_registered() -> None: + session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082") + process = MagicMock() + process.pid = 404 + process.returncode = None + process.stdout.read = AsyncMock(side_effect=RuntimeError("read failed")) + process.stderr = None + + with ( + patch( + "free_claude_code.cli.managed.session.asyncio.create_subprocess_exec", + new=AsyncMock(return_value=process), + ), + patch("free_claude_code.cli.managed.session.register_pid") as register, + patch("free_claude_code.cli.managed.session.unregister_pid") as unregister, + pytest.raises(RuntimeError, match="read failed"), + ): + async for _ in session.start_task("hello"): + pass + + register.assert_called_once_with(404) + unregister.assert_not_called() + + +@pytest.mark.asyncio +async def test_completed_task_wait_unregisters_confirmed_process_pid() -> None: + session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082") + process = MagicMock() + process.pid = 405 + process.returncode = None + process.stdout.read = AsyncMock(return_value=b"") + process.stderr = None + process.wait = AsyncMock(return_value=0) + + with ( + patch( + "free_claude_code.cli.managed.session.asyncio.create_subprocess_exec", + new=AsyncMock(return_value=process), + ), + patch("free_claude_code.cli.managed.session.register_pid") as register, + patch("free_claude_code.cli.managed.session.unregister_pid") as unregister, + ): + events = [event async for event in session.start_task("hello")] + + assert events == [{"type": "exit", "code": 0, "stderr": None}] + register.assert_called_once_with(405) + unregister.assert_called_once_with(405) + + +@pytest.mark.parametrize( + ("first_stop", "raised"), + [ + (False, None), + (RuntimeError("stop failed"), RuntimeError), + (asyncio.CancelledError(), asyncio.CancelledError), + ], +) +@pytest.mark.asyncio +async def test_remove_failure_retains_exact_aliases_and_closes_session_for_reuse( + first_stop: object, + raised: type[BaseException] | None, +) -> None: + manager = _manager() + session = _mock_session(first_stop, True) + manager._sessions["real_1"] = session + manager._temp_to_real["temp_1"] = "real_1" + manager._real_to_temp["real_1"] = "temp_1" + expected_sessions = dict(manager._sessions) + expected_temp_to_real = dict(manager._temp_to_real) + expected_real_to_temp = dict(manager._real_to_temp) + + if raised is None: + assert await manager.remove_session("real_1") is False + else: + with pytest.raises(raised): + await manager.remove_session("real_1") + + assert manager._sessions == expected_sessions + assert manager._temp_to_real == expected_temp_to_real + assert manager._real_to_temp == expected_real_to_temp + with pytest.raises(RuntimeError, match="closing"): + await manager.get_or_create_session("real_1") + with pytest.raises(RuntimeError, match="closing"): + await manager.get_or_create_session("temp_1") + + assert await manager.remove_session("temp_1") is True + assert manager._sessions == {} + assert manager._temp_to_real == {} + assert manager._real_to_temp == {} + assert session.stop.await_count == 2 + + +@pytest.mark.asyncio +async def test_register_rejects_a_pending_session_after_stop_failure() -> None: + manager = _manager() + session = _mock_session(False) + manager._pending_sessions["temp_1"] = session + + assert await manager.remove_session("temp_1") is False + assert await manager.register_real_session_id("temp_1", "real_1") is False + assert manager._pending_sessions == {"temp_1": session} + assert manager._sessions == {} + assert manager._temp_to_real == {} + assert manager._real_to_temp == {} + + +@pytest.mark.asyncio +async def test_register_rejects_real_id_owned_by_a_different_session() -> None: + manager = _manager() + existing = _mock_session(False) + pending = _mock_session(True) + manager._sessions["real_1"] = existing + manager._pending_sessions["temp_2"] = pending + + assert await manager.register_real_session_id("temp_2", "real_1") is False + + assert manager._sessions == {"real_1": existing} + assert manager._pending_sessions == {"temp_2": pending} + assert manager._temp_to_real == {} + assert manager._real_to_temp == {} + + +@pytest.mark.asyncio +async def test_stop_all_never_loses_a_closing_owner_outside_identity_maps() -> None: + manager = _manager() + orphaned_owner = _mock_session(False) + mapped_owner = _mock_session(True) + manager._closing_sessions.add(orphaned_owner) + manager._sessions["real_1"] = mapped_owner + + with pytest.raises( + RuntimeError, + match=r"^Managed Claude session shutdown failures: 1\.$", + ): + await manager.stop_all() + + orphaned_owner.stop.assert_awaited_once() + mapped_owner.stop.assert_awaited_once() + assert manager._sessions == {} + assert manager._closing_sessions == {orphaned_owner} + + orphaned_owner.stop = AsyncMock(return_value=True) + await manager.stop_all() + + orphaned_owner.stop.assert_awaited_once() + assert manager._closing_sessions == set() + + +@pytest.mark.asyncio +async def test_stop_all_attempts_every_session_and_retains_only_failures() -> None: + manager = _manager() + returned_false = _mock_session(False) + raised_error = _mock_session(RuntimeError("secret failure detail")) + stopped = _mock_session(True) + manager._sessions.update( + { + "real_error": raised_error, + "real_false": returned_false, + "real_stopped": stopped, + } + ) + manager._temp_to_real.update( + { + "temp_error": "real_error", + "temp_false": "real_false", + "temp_stopped": "real_stopped", + } + ) + manager._real_to_temp.update( + { + "real_error": "temp_error", + "real_false": "temp_false", + "real_stopped": "temp_stopped", + } + ) + + with pytest.raises(RuntimeError) as exc_info: + await manager.stop_all() + + assert str(exc_info.value) == "Managed Claude session shutdown failures: 2." + returned_false.stop.assert_awaited_once() + raised_error.stop.assert_awaited_once() + stopped.stop.assert_awaited_once() + assert manager._sessions == { + "real_error": raised_error, + "real_false": returned_false, + } + assert manager._pending_sessions == {} + assert manager._temp_to_real == { + "temp_error": "real_error", + "temp_false": "real_false", + } + assert manager._real_to_temp == { + "real_error": "temp_error", + "real_false": "temp_false", + } + with pytest.raises(RuntimeError, match="closing"): + await manager.get_or_create_session("temp_false") + with pytest.raises(RuntimeError, match="closing"): + await manager.get_or_create_session("temp_error") + + returned_false.stop = AsyncMock(return_value=True) + raised_error.stop = AsyncMock(return_value=True) + await manager.stop_all() + + assert manager._sessions == {} + assert manager._pending_sessions == {} + assert manager._temp_to_real == {} + assert manager._real_to_temp == {} diff --git a/tests/cli/test_process_registry.py b/tests/cli/test_process_registry.py new file mode 100644 index 0000000..cfe6612 --- /dev/null +++ b/tests/cli/test_process_registry.py @@ -0,0 +1,92 @@ +import os +import subprocess +from unittest.mock import patch + + +def test_process_registry_register_pid_zero_noop(): + """register_pid(0) is a no-op (early return).""" + from free_claude_code.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 free_claude_code.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 free_claude_code.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 free_claude_code.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 free_claude_code.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 free_claude_code.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 free_claude_code.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() + + +def test_process_registry_kill_pid_tree_windows_uses_taskkill(monkeypatch): + from free_claude_code.cli import process_registry as pr + + calls = [] + monkeypatch.setattr(os, "name", "nt", raising=False) + monkeypatch.setattr(subprocess, "run", lambda *a, **kw: calls.append((a, kw))) + + pr.kill_pid_tree_best_effort(12345) + + assert calls + assert calls[0][0][0] == ["taskkill", "/PID", "12345", "/T", "/F"] diff --git a/tests/config/test_config.py b/tests/config/test_config.py new file mode 100644 index 0000000..b848b5e --- /dev/null +++ b/tests/config/test_config.py @@ -0,0 +1,1041 @@ +"""Tests for config/settings.py and config/nim.py""" + +from typing import Any, cast + +import pytest +from pydantic import ValidationError + +from free_claude_code.config.constants import ( + ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + HTTP_CONNECT_TIMEOUT_DEFAULT, +) +from free_claude_code.config.env_files import ( + ANTHROPIC_AUTH_TOKEN_ENV, + process_env_key_is_effective, +) +from free_claude_code.config.model_refs import ( + configured_chat_model_refs, + parse_model_name, + parse_provider_type, +) +from free_claude_code.config.nim import NimSettings +from free_claude_code.config.paths import messaging_state_dir_path + + +class TestSettings: + """Test Settings configuration.""" + + def test_settings_loads(self): + """Ensure Settings can be instantiated.""" + from free_claude_code.config.settings import Settings + + settings = Settings() + assert settings is not None + + def test_default_values(self, monkeypatch): + """Test default values are set and have correct types.""" + from free_claude_code.config.settings import Settings + + monkeypatch.delenv("CLAUDE_WORKSPACE", raising=False) + monkeypatch.delenv("MODEL", raising=False) + monkeypatch.delenv("HTTP_READ_TIMEOUT", raising=False) + monkeypatch.delenv("HTTP_CONNECT_TIMEOUT", raising=False) + monkeypatch.setitem(Settings.model_config, "env_file", ()) + settings = Settings() + assert settings.model == "nvidia_nim/nvidia/nemotron-3-super-120b-a12b" + 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) + assert isinstance(settings.enable_model_thinking, bool) + assert settings.http_read_timeout == 120.0 + assert settings.http_connect_timeout == HTTP_CONNECT_TIMEOUT_DEFAULT + assert settings.enable_web_server_tools is False + assert settings.log_raw_api_payloads is False + assert settings.log_raw_sse_events is False + assert settings.debug_platform_edits is False + assert settings.debug_subagent_stack is False + + def test_default_claude_workspace_uses_fcc_home(self, monkeypatch, tmp_path): + """Unset CLAUDE_WORKSPACE stores agent data under the fixed path helper.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("CLAUDE_WORKSPACE", raising=False) + monkeypatch.setitem(Settings.model_config, "env_file", ()) + + settings = Settings() + + assert messaging_state_dir_path() == tmp_path / ".fcc" / "agent_workspace" + assert not hasattr(settings, "claude_workspace") + + def test_server_log_path_uses_fcc_home(self, monkeypatch, tmp_path): + """The server log location is fixed under ~/.fcc.""" + from free_claude_code.config.paths import server_log_path + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + + assert server_log_path() == tmp_path / ".fcc" / "logs" / "server.log" + + def test_removed_log_file_env_is_ignored(self, monkeypatch): + """Legacy LOG_FILE values do not affect Settings or block startup.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("LOG_FILE", "custom/server.log") + monkeypatch.setitem(Settings.model_config, "env_file", ()) + + settings = Settings() + + assert not hasattr(settings, "log_file") + + def test_stale_zai_base_url_env_is_ignored(self, monkeypatch): + """Cloud Z.ai endpoint is fixed in provider metadata, not settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("ZAI_BASE_URL", "https://custom.zai.invalid/v1") + monkeypatch.setitem(Settings.model_config, "env_file", ()) + + settings = Settings() + + assert not hasattr(settings, "zai_base_url") + + def test_blank_claude_workspace_uses_fcc_home(self, monkeypatch, tmp_path): + """An explicit blank env value does not affect the fixed workspace helper.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.setenv("CLAUDE_WORKSPACE", "") + monkeypatch.setitem(Settings.model_config, "env_file", ()) + + settings = Settings() + + assert messaging_state_dir_path() == tmp_path / ".fcc" / "agent_workspace" + assert not hasattr(settings, "claude_workspace") + + def test_explicit_claude_workspace_is_ignored(self, monkeypatch, tmp_path): + """Custom CLAUDE_WORKSPACE values do not override the fixed workspace helper.""" + from free_claude_code.config.settings import Settings + + workspace = tmp_path / "custom-workspace" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.setenv("CLAUDE_WORKSPACE", str(workspace)) + monkeypatch.setitem(Settings.model_config, "env_file", ()) + + settings = Settings() + + assert messaging_state_dir_path() == tmp_path / ".fcc" / "agent_workspace" + assert not hasattr(settings, "claude_workspace") + + def test_explicit_claude_cli_bin_is_ignored(self, monkeypatch): + """Custom CLAUDE_CLI_BIN values do not become Settings fields.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("CLAUDE_CLI_BIN", "claude-custom") + monkeypatch.setitem(Settings.model_config, "env_file", ()) + + settings = Settings() + + assert not hasattr(settings, "claude_cli_bin") + assert not hasattr(settings, "codex_cli_bin") + + def test_direct_claude_runtime_overrides_are_ignored(self, monkeypatch, tmp_path): + """Constructor extras cannot add fixed Claude runtime settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.setitem(Settings.model_config, "env_file", ()) + + settings = Settings( + **cast( + Any, + { + "claude_workspace": str(tmp_path / "custom-workspace"), + "claude_cli_bin": "claude-custom", + }, + ) + ) + + assert messaging_state_dir_path() == tmp_path / ".fcc" / "agent_workspace" + assert not hasattr(settings, "claude_workspace") + assert not hasattr(settings, "claude_cli_bin") + + def test_get_settings_cached(self): + """Test get_settings returns cached instance.""" + from free_claude_code.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 free_claude_code.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 free_claude_code.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_DEFAULT_BASE is a constant.""" + from free_claude_code.config.provider_catalog import NVIDIA_NIM_DEFAULT_BASE + + assert NVIDIA_NIM_DEFAULT_BASE == "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 free_claude_code.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_ollama_base_url_defaults_to_root(self, monkeypatch): + """OLLAMA_BASE_URL keeps the customer-facing Ollama root default.""" + from free_claude_code.config.settings import Settings + + monkeypatch.delenv("OLLAMA_BASE_URL", raising=False) + monkeypatch.setitem(Settings.model_config, "env_file", ()) + settings = Settings() + assert settings.ollama_base_url == "http://localhost:11434" + + def test_ollama_base_url_accepts_v1_suffix(self, monkeypatch): + """The adapter accepts either the root URL or the explicit OpenAI path.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("OLLAMA_BASE_URL", "http://localhost:11434/v1") + assert Settings().ollama_base_url == "http://localhost:11434/v1" + + def test_provider_rate_limit_from_env(self, monkeypatch): + """PROVIDER_RATE_LIMIT env var is loaded into settings.""" + from free_claude_code.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 free_claude_code.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 free_claude_code.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 free_claude_code.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 free_claude_code.config.settings import Settings + + monkeypatch.setenv("HTTP_CONNECT_TIMEOUT", "5") + settings = Settings() + assert settings.http_connect_timeout == 5.0 + + def test_http_connect_timeout_default_matches_shared_constant( + self, monkeypatch + ) -> None: + """Default must match config.constants (and README / .env.example).""" + from free_claude_code.config.settings import Settings + + monkeypatch.delenv("HTTP_CONNECT_TIMEOUT", raising=False) + monkeypatch.setitem(Settings.model_config, "env_file", ()) + settings = Settings() + assert settings.http_connect_timeout == HTTP_CONNECT_TIMEOUT_DEFAULT + assert HTTP_CONNECT_TIMEOUT_DEFAULT == 10.0 + + def test_enable_model_thinking_from_env(self, monkeypatch): + """ENABLE_MODEL_THINKING env var is loaded into settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("ENABLE_MODEL_THINKING", "false") + settings = Settings() + assert settings.enable_model_thinking is False + + def test_wafer_api_key_from_env(self, monkeypatch): + """WAFER_API_KEY env var is loaded into settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("WAFER_API_KEY", "wafer-key") + settings = Settings() + assert settings.wafer_api_key == "wafer-key" + + def test_minimax_settings_from_env(self, monkeypatch): + """MiniMax key and proxy env vars load into settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("MINIMAX_API_KEY", "minimax-key") + monkeypatch.setenv("MINIMAX_PROXY", "http://proxy.test:8080") + settings = Settings() + assert settings.minimax_api_key == "minimax-key" + assert settings.minimax_proxy == "http://proxy.test:8080" + + def test_cloudflare_settings_from_env(self, monkeypatch): + """Cloudflare token, account, and proxy env vars load into settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("CLOUDFLARE_API_TOKEN", "cf-token") + monkeypatch.setenv("CLOUDFLARE_ACCOUNT_ID", "cf-account") + monkeypatch.setenv("CLOUDFLARE_PROXY", "http://proxy.test:8080") + settings = Settings() + assert settings.cloudflare_api_token == "cf-token" + assert settings.cloudflare_account_id == "cf-account" + assert settings.cloudflare_proxy == "http://proxy.test:8080" + + def test_vercel_settings_from_env(self, monkeypatch): + """Vercel AI Gateway key and proxy env vars load into settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("AI_GATEWAY_API_KEY", "vercel-key") + monkeypatch.setenv("VERCEL_AI_GATEWAY_PROXY", "http://proxy.test:8080") + settings = Settings() + assert settings.vercel_ai_gateway_api_key == "vercel-key" + assert settings.vercel_ai_gateway_proxy == "http://proxy.test:8080" + + def test_huggingface_settings_from_env(self, monkeypatch): + """Hugging Face key and proxy env vars load into settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("HUGGINGFACE_API_KEY", "hf-key") + monkeypatch.setenv("HUGGINGFACE_PROXY", "http://proxy.test:8080") + settings = Settings() + assert settings.huggingface_api_key == "hf-key" + assert settings.huggingface_proxy == "http://proxy.test:8080" + assert not hasattr(settings, "hf_token") + + def test_cohere_settings_from_env(self, monkeypatch): + """Cohere key and proxy env vars load into settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("COHERE_API_KEY", "cohere-key") + monkeypatch.setenv("COHERE_PROXY", "http://proxy.test:8080") + settings = Settings() + assert settings.cohere_api_key == "cohere-key" + assert settings.cohere_proxy == "http://proxy.test:8080" + + def test_github_models_settings_from_env(self, monkeypatch): + """GitHub Models token and proxy env vars load into settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("GITHUB_MODELS_TOKEN", "github-token") + monkeypatch.setenv("GITHUB_MODELS_PROXY", "http://proxy.test:8080") + settings = Settings() + assert settings.github_models_token == "github-token" + assert settings.github_models_proxy == "http://proxy.test:8080" + + def test_sambanova_settings_from_env(self, monkeypatch): + """SambaNova key and proxy env vars load into settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("SAMBANOVA_API_KEY", "sambanova-key") + monkeypatch.setenv("SAMBANOVA_PROXY", "http://proxy.test:8080") + settings = Settings() + assert settings.sambanova_api_key == "sambanova-key" + assert settings.sambanova_proxy == "http://proxy.test:8080" + + def test_legacy_hf_token_env_is_ignored(self, monkeypatch): + """HF_TOKEN is migrated by startup config migration, not read by Settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("HF_TOKEN", "legacy-token") + monkeypatch.delenv("HUGGINGFACE_API_KEY", raising=False) + settings = Settings() + assert settings.huggingface_api_key == "" + assert not hasattr(settings, "hf_token") + + def test_per_model_thinking_from_env(self, monkeypatch): + """Per-model thinking env vars are loaded into settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("ENABLE_OPUS_THINKING", "true") + monkeypatch.setenv("ENABLE_SONNET_THINKING", "false") + monkeypatch.setenv("ENABLE_HAIKU_THINKING", "false") + settings = Settings() + assert settings.enable_opus_thinking is True + assert settings.enable_sonnet_thinking is False + assert settings.enable_haiku_thinking is False + + def test_empty_per_model_thinking_inherits_model_default(self, monkeypatch): + """Blank per-model thinking env vars are treated as unset.""" + from free_claude_code.application.routing import ModelRouter + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("ENABLE_MODEL_THINKING", "false") + monkeypatch.setenv("ENABLE_OPUS_THINKING", "") + settings = Settings() + assert settings.enable_opus_thinking is None + assert ( + ModelRouter(settings).resolve("claude-opus-4-20250514").thinking_enabled + is False + ) + + def test_resolve_thinking_uses_model_tiers(self, monkeypatch): + """ModelRouter applies tier thinking override then fallback.""" + from free_claude_code.application.routing import ModelRouter + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("ENABLE_MODEL_THINKING", "false") + monkeypatch.setenv("ENABLE_OPUS_THINKING", "true") + monkeypatch.setenv("ENABLE_HAIKU_THINKING", "false") + settings = Settings() + router = ModelRouter(settings) + assert router.resolve("claude-opus-4-20250514").thinking_enabled is True + assert router.resolve("claude-sonnet-4-20250514").thinking_enabled is False + assert router.resolve("claude-haiku-4-20250514").thinking_enabled is False + assert router.resolve("unknown-model").thinking_enabled is False + + def test_anthropic_auth_token_from_env_without_dotenv_key(self, monkeypatch): + """ANTHROPIC_AUTH_TOKEN env var is loaded when dotenv does not define it.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "process-token") + monkeypatch.setitem(Settings.model_config, "env_file", ()) + settings = Settings() + assert settings.anthropic_auth_token == "process-token" + assert ( + process_env_key_is_effective( + Settings.model_config, ANTHROPIC_AUTH_TOKEN_ENV + ) + is True + ) + + def test_empty_dotenv_anthropic_auth_token_overrides_process_env( + self, monkeypatch, tmp_path + ): + """An explicit empty .env token disables auth despite stale shell tokens.""" + from free_claude_code.config.settings import Settings + + env_file = tmp_path / ".env" + env_file.write_text("ANTHROPIC_AUTH_TOKEN=\n", encoding="utf-8") + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "stale-client-token") + monkeypatch.setitem(Settings.model_config, "env_file", (env_file,)) + + settings = Settings() + assert settings.anthropic_auth_token == "" + assert ( + process_env_key_is_effective( + Settings.model_config, ANTHROPIC_AUTH_TOKEN_ENV + ) + is False + ) + + def test_dotenv_anthropic_auth_token_overrides_process_env( + self, monkeypatch, tmp_path + ): + """A configured .env token is the server token even with a stale shell token.""" + from free_claude_code.config.settings import Settings + + env_file = tmp_path / ".env" + env_file.write_text( + 'ANTHROPIC_AUTH_TOKEN="server-token"\n', + encoding="utf-8", + ) + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "stale-client-token") + monkeypatch.setitem(Settings.model_config, "env_file", (env_file,)) + + settings = Settings() + assert settings.anthropic_auth_token == "server-token" + assert ( + process_env_key_is_effective( + Settings.model_config, ANTHROPIC_AUTH_TOKEN_ENV + ) + is False + ) + + @pytest.mark.parametrize("removed_key", ["NIM_ENABLE_THINKING", "ENABLE_THINKING"]) + def test_removed_thinking_env_keys_are_ignored(self, monkeypatch, removed_key): + """Stale thinking env keys do not block startup or affect settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv(removed_key, "false") + monkeypatch.setitem(Settings.model_config, "env_file", ()) + + settings = Settings() + + assert settings.enable_model_thinking is True + + @pytest.mark.parametrize("removed_key", ["NIM_ENABLE_THINKING", "ENABLE_THINKING"]) + @pytest.mark.parametrize("value", ["false", ""]) + def test_removed_thinking_dotenv_keys_are_ignored( + self, monkeypatch, tmp_path, removed_key, value + ): + """Stale thinking dotenv keys do not block startup or affect settings.""" + from free_claude_code.config.settings import Settings + + env_file = tmp_path / ".env" + env_file.write_text(f"{removed_key}={value}\n", encoding="utf-8") + monkeypatch.delenv(removed_key, raising=False) + monkeypatch.setitem(Settings.model_config, "env_file", (env_file,)) + + settings = Settings() + + assert settings.enable_model_thinking is True + + +# --- 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.""" + + def test_default_max_tokens_matches_shared_constant(self): + assert NimSettings().max_tokens == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS + + @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"})) + + def test_enable_thinking_field_removed(self): + """NimSettings no longer accepts the removed thinking toggle.""" + from typing import Any, cast + + with pytest.raises(ValidationError): + NimSettings(**cast(Any, {"enable_thinking": True})) + + +class TestSettingsOptionalStr: + """Test Settings parse_optional_str validator.""" + + def test_empty_telegram_token_to_none(self, monkeypatch): + from free_claude_code.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 free_claude_code.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 free_claude_code.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 free_claude_code.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 free_claude_code.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 free_claude_code.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 free_claude_code.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 free_claude_code.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 free_claude_code.config.settings import Settings + + monkeypatch.setenv("WHISPER_DEVICE", device) + s = Settings() + assert s.whisper_device == device + + +class TestPerModelMapping: + """Test per-model settings and model-ref helpers.""" + + def test_model_fields_default_none(self): + """Per-model fields default to None.""" + from free_claude_code.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 free_claude_code.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_var", ["MODEL_OPUS", "MODEL_SONNET", "MODEL_HAIKU"]) + def test_empty_model_override_env_is_unset(self, monkeypatch, env_var): + """Empty per-model override env vars are treated as unset.""" + from free_claude_code.application.routing import ModelRouter + from free_claude_code.config.settings import Settings + + monkeypatch.setenv(env_var, "") + s = Settings() + assert getattr(s, env_var.lower()) is None + assert ( + ModelRouter(s) + .resolve(f"claude-{env_var.removeprefix('MODEL_').lower()}-4") + .provider_model_ref + == s.model + ) + + @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": "deepseek/deepseek-chat"}, "deepseek/deepseek-chat", None), + ({"MODEL": "wafer/DeepSeek-V4-Pro"}, "wafer/DeepSeek-V4-Pro", None), + ( + {"MODEL": "cloudflare/@cf/moonshotai/kimi-k2.6"}, + "cloudflare/@cf/moonshotai/kimi-k2.6", + None, + ), + ( + {"MODEL": "github_models/openai/gpt-4.1"}, + "github_models/openai/gpt-4.1", + None, + ), + ( + {"MODEL": "sambanova/Meta-Llama-3.3-70B-Instruct"}, + "sambanova/Meta-Llama-3.3-70B-Instruct", + None, + ), + ({"MODEL": "lmstudio/qwen2.5-7b"}, "lmstudio/qwen2.5-7b", None), + ({"MODEL": "llamacpp/local-model"}, "llamacpp/local-model", None), + ({"MODEL": "ollama/llama3.1"}, "ollama/llama3.1", None), + ], + ) + def test_settings_models_from_env( + self, env_vars, expected_model, expected_haiku, monkeypatch + ): + """Test environment variables override model defaults.""" + from free_claude_code.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 free_claude_code.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 free_claude_code.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 free_claude_code.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 free_claude_code.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 free_claude_code.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): + """ModelRouter returns model_opus for opus model names.""" + from free_claude_code.application.routing import ModelRouter + from free_claude_code.config.settings import Settings + + s = Settings() + s.model_opus = "open_router/deepseek/deepseek-r1" + router = ModelRouter(s) + assert ( + router.resolve("claude-opus-4-20250514").provider_model_ref + == "open_router/deepseek/deepseek-r1" + ) + assert ( + router.resolve("claude-3-opus").provider_model_ref + == "open_router/deepseek/deepseek-r1" + ) + assert ( + router.resolve("claude-3-opus-20240229").provider_model_ref + == "open_router/deepseek/deepseek-r1" + ) + + def test_resolve_model_sonnet_override(self): + """ModelRouter returns model_sonnet for sonnet model names.""" + from free_claude_code.application.routing import ModelRouter + from free_claude_code.config.settings import Settings + + s = Settings() + s.model_sonnet = "nvidia_nim/meta/llama-3.3-70b-instruct" + router = ModelRouter(s) + assert ( + router.resolve("claude-sonnet-4-20250514").provider_model_ref + == "nvidia_nim/meta/llama-3.3-70b-instruct" + ) + assert ( + router.resolve("claude-3-5-sonnet-20241022").provider_model_ref + == "nvidia_nim/meta/llama-3.3-70b-instruct" + ) + + def test_resolve_model_haiku_override(self): + """ModelRouter returns model_haiku for haiku model names.""" + from free_claude_code.application.routing import ModelRouter + from free_claude_code.config.settings import Settings + + s = Settings() + s.model_haiku = "lmstudio/qwen2.5-7b" + router = ModelRouter(s) + assert ( + router.resolve("claude-3-haiku-20240307").provider_model_ref + == "lmstudio/qwen2.5-7b" + ) + assert ( + router.resolve("claude-3-5-haiku-20241022").provider_model_ref + == "lmstudio/qwen2.5-7b" + ) + assert ( + router.resolve("claude-haiku-4-20250514").provider_model_ref + == "lmstudio/qwen2.5-7b" + ) + + def test_resolve_model_fallback_when_override_not_set(self): + """ModelRouter falls back to MODEL when model override is None.""" + from free_claude_code.application.routing import ModelRouter + from free_claude_code.config.settings import Settings + + s = Settings() + s.model = "nvidia_nim/fallback-model" + router = ModelRouter(s) + assert ( + router.resolve("claude-opus-4-20250514").provider_model_ref + == "nvidia_nim/fallback-model" + ) + assert ( + router.resolve("claude-sonnet-4-20250514").provider_model_ref + == "nvidia_nim/fallback-model" + ) + assert ( + router.resolve("claude-3-haiku-20240307").provider_model_ref + == "nvidia_nim/fallback-model" + ) + + def test_resolve_model_unknown_model_falls_back(self): + """ModelRouter falls back to MODEL for unrecognized model names.""" + from free_claude_code.application.routing import ModelRouter + from free_claude_code.config.settings import Settings + + s = Settings() + s.model = "nvidia_nim/fallback-model" + s.model_opus = "open_router/opus-model" + router = ModelRouter(s) + assert router.resolve("claude-2.1").provider_model_ref == ( + "nvidia_nim/fallback-model" + ) + assert router.resolve("some-unknown-model").provider_model_ref == ( + "nvidia_nim/fallback-model" + ) + + def test_resolve_model_case_insensitive(self): + """Model classification is case-insensitive.""" + from free_claude_code.application.routing import ModelRouter + from free_claude_code.config.settings import Settings + + s = Settings() + s.model_opus = "open_router/opus-model" + assert ( + ModelRouter(s).resolve("Claude-OPUS-4").provider_model_ref + == "open_router/opus-model" + ) + + def test_parse_provider_type(self): + """parse_provider_type extracts provider from model string.""" + + assert parse_provider_type("nvidia_nim/meta/llama") == "nvidia_nim" + assert parse_provider_type("open_router/deepseek/r1") == "open_router" + assert parse_provider_type("mistral/devstral-small-latest") == "mistral" + assert ( + parse_provider_type("mistral_codestral/codestral-latest") + == "mistral_codestral" + ) + assert parse_provider_type("deepseek/deepseek-chat") == "deepseek" + assert parse_provider_type("lmstudio/qwen") == "lmstudio" + assert parse_provider_type("llamacpp/model") == "llamacpp" + assert parse_provider_type("ollama/llama3.1") == "ollama" + assert parse_provider_type("wafer/DeepSeek-V4-Pro") == "wafer" + assert parse_provider_type("minimax/MiniMax-M3") == "minimax" + assert ( + parse_provider_type("cloudflare/@cf/moonshotai/kimi-k2.6") == "cloudflare" + ) + assert parse_provider_type("vercel/openai/gpt-5.5") == "vercel" + assert ( + parse_provider_type("huggingface/openai/gpt-oss-120b:fastest") + == "huggingface" + ) + assert parse_provider_type("cohere/command-a-plus-05-2026") == "cohere" + assert parse_provider_type("github_models/openai/gpt-4.1") == ("github_models") + assert parse_provider_type("gemini/models/gemini-3.1-flash-lite") == "gemini" + assert parse_provider_type("groq/llama-3.3-70b-versatile") == "groq" + assert ( + parse_provider_type("sambanova/Meta-Llama-3.3-70B-Instruct") == "sambanova" + ) + assert parse_provider_type("cerebras/llama3.1-8b") == "cerebras" + + def test_parse_model_name(self): + """parse_model_name extracts model name from model string.""" + + assert parse_model_name("nvidia_nim/meta/llama") == "meta/llama" + assert parse_model_name("mistral/devstral-small-latest") == ( + "devstral-small-latest" + ) + assert ( + parse_model_name("mistral_codestral/codestral-latest") == "codestral-latest" + ) + assert parse_model_name("deepseek/deepseek-chat") == "deepseek-chat" + assert parse_model_name("lmstudio/qwen") == "qwen" + assert parse_model_name("llamacpp/model") == "model" + assert parse_model_name("ollama/llama3.1") == "llama3.1" + assert parse_model_name("wafer/DeepSeek-V4-Pro") == "DeepSeek-V4-Pro" + assert parse_model_name("minimax/MiniMax-M3") == "MiniMax-M3" + assert ( + parse_model_name("cloudflare/@cf/moonshotai/kimi-k2.6") + == "@cf/moonshotai/kimi-k2.6" + ) + assert parse_model_name("vercel/openai/gpt-5.5") == "openai/gpt-5.5" + assert ( + parse_model_name("huggingface/openai/gpt-oss-120b:fastest") + == "openai/gpt-oss-120b:fastest" + ) + assert parse_model_name("cohere/command-a-plus-05-2026") == ( + "command-a-plus-05-2026" + ) + assert parse_model_name("github_models/openai/gpt-4.1") == "openai/gpt-4.1" + assert ( + parse_model_name("gemini/models/gemini-3.1-flash-lite") + == "models/gemini-3.1-flash-lite" + ) + assert ( + parse_model_name("groq/llama-3.3-70b-versatile") + == "llama-3.3-70b-versatile" + ) + assert ( + parse_model_name("sambanova/Meta-Llama-3.3-70B-Instruct") + == "Meta-Llama-3.3-70B-Instruct" + ) + assert parse_model_name("cerebras/llama3.1-8b") == "llama3.1-8b" + + def test_configured_chat_model_refs_collects_unique_models_with_sources( + self, monkeypatch + ): + """Startup validation model collection is limited to configured chat refs.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("FCC_SMOKE_MODEL_NVIDIA_NIM", "nvidia_nim/smoke") + monkeypatch.setenv("WHISPER_MODEL", "openai/whisper-large-v3") + s = Settings() + s.model = "nvidia_nim/fallback" + s.model_opus = "open_router/anthropic/claude-opus" + s.model_sonnet = "nvidia_nim/fallback" + s.model_haiku = None + + refs = configured_chat_model_refs(s) + + assert [ref.model_ref for ref in refs] == [ + "nvidia_nim/fallback", + "open_router/anthropic/claude-opus", + ] + assert refs[0].provider_id == "nvidia_nim" + assert refs[0].model_id == "fallback" + assert refs[0].sources == ("MODEL", "MODEL_SONNET") + assert refs[1].provider_id == "open_router" + assert refs[1].model_id == "anthropic/claude-opus" + assert refs[1].sources == ("MODEL_OPUS",) diff --git a/tests/config/test_env_migrations.py b/tests/config/test_env_migrations.py new file mode 100644 index 0000000..5e23a4d --- /dev/null +++ b/tests/config/test_env_migrations.py @@ -0,0 +1,92 @@ +from pathlib import Path + +from free_claude_code.config.env_migrations import ( + HUGGINGFACE_API_KEY_ENV, + HUGGINGFACE_TOKEN_MIGRATION, + LEGACY_HUGGINGFACE_TOKEN_ENV, + env_text_needs_migration, + explicit_env_file_huggingface_warning, + migrate_env_key_in_file, + migrate_env_key_in_text, + migrate_owned_env_files, +) + + +def test_migrate_env_key_in_text_renames_legacy_hf_token() -> None: + text = "# comment\nHF_TOKEN=old-token\nMODEL=nvidia_nim/model\n" + + migrated, changed = migrate_env_key_in_text(text, HUGGINGFACE_TOKEN_MIGRATION) + + assert changed is True + assert migrated == ( + "# comment\nHUGGINGFACE_API_KEY=old-token\nMODEL=nvidia_nim/model\n" + ) + + +def test_migrate_env_key_in_text_preserves_existing_huggingface_api_key() -> None: + text = "HF_TOKEN=old-token\nHUGGINGFACE_API_KEY=new-token\n" + + migrated, changed = migrate_env_key_in_text(text, HUGGINGFACE_TOKEN_MIGRATION) + + assert changed is False + assert migrated == text + + +def test_migrate_env_key_in_text_ignores_comments() -> None: + text = "# HF_TOKEN=old-token\n" + + migrated, changed = migrate_env_key_in_text(text, HUGGINGFACE_TOKEN_MIGRATION) + + assert changed is False + assert migrated == text + assert not env_text_needs_migration(text, HUGGINGFACE_TOKEN_MIGRATION) + + +def test_migrate_env_key_in_file_rewrites_dotenv(tmp_path: Path) -> None: + env_file = tmp_path / ".env" + env_file.write_text("export HF_TOKEN = quoted-token\n", encoding="utf-8") + + assert migrate_env_key_in_file(env_file, HUGGINGFACE_TOKEN_MIGRATION) is True + + assert env_file.read_text(encoding="utf-8") == ( + "export HUGGINGFACE_API_KEY = quoted-token\n" + ) + + +def test_migrate_owned_env_files_rewrites_repo_and_managed_env( + monkeypatch, tmp_path: Path +) -> None: + repo = tmp_path / "repo" + repo.mkdir() + managed = tmp_path / ".fcc" / ".env" + managed.parent.mkdir() + (repo / ".env").write_text("HF_TOKEN=repo-token\n", encoding="utf-8") + managed.write_text("HF_TOKEN=managed-token\n", encoding="utf-8") + monkeypatch.chdir(repo) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + + migrated = migrate_owned_env_files() + + assert migrated == (repo / ".env", managed) + assert (repo / ".env").read_text(encoding="utf-8") == ( + "HUGGINGFACE_API_KEY=repo-token\n" + ) + assert managed.read_text(encoding="utf-8") == ( + "HUGGINGFACE_API_KEY=managed-token\n" + ) + + +def test_explicit_env_file_huggingface_warning_does_not_rewrite( + tmp_path: Path, +) -> None: + explicit = tmp_path / "custom.env" + explicit.write_text("HF_TOKEN=explicit-token\n", encoding="utf-8") + + warning = explicit_env_file_huggingface_warning({"FCC_ENV_FILE": str(explicit)}) + + assert warning is not None + assert str(explicit) in warning + assert LEGACY_HUGGINGFACE_TOKEN_ENV in warning + assert HUGGINGFACE_API_KEY_ENV in warning + assert explicit.read_text(encoding="utf-8") == "HF_TOKEN=explicit-token\n" diff --git a/tests/config/test_logging_config.py b/tests/config/test_logging_config.py new file mode 100644 index 0000000..5bdc0ee --- /dev/null +++ b/tests/config/test_logging_config.py @@ -0,0 +1,103 @@ +"""Tests for config/logging_config.py.""" + +import json +import logging +from pathlib import Path + +from loguru import logger + +from free_claude_code.config.logging_config import configure_logging + + +def test_configure_logging_creates_parent_directories(tmp_path) -> None: + """Nested log path: parent directories are created before truncating.""" + log_file = tmp_path / "nested" / "dir" / "app.log" + configure_logging(str(log_file), force=True) + assert log_file.is_file() + + +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" + ) + + +def test_telegram_bot_token_redacted_in_message_field(tmp_path) -> None: + log_file = str(tmp_path / "redact.log") + configure_logging(log_file, force=True, verbose_third_party=False) + token = "123456:ABCDEF-ghij-klm" + logger.info("Calling {}", f"https://api.telegram.org/bot{token}/getMe") + logger.complete() + text = Path(log_file).read_text(encoding="utf-8") + assert token not in text + assert "bot/" in text or "redacted" in text + + +def test_bearer_substring_redacted_in_log_file(tmp_path) -> None: + log_file = str(tmp_path / "bearer.log") + configure_logging(log_file, force=True, verbose_third_party=False) + secret = "ya29.secret-token-abc" + logger.info("Request headers: Authorization: Bearer {}", secret) + logger.complete() + text = Path(log_file).read_text(encoding="utf-8") + assert secret not in text + assert "Bearer" in text + + +def test_httpx_logger_quieted_when_not_verbose_third_party(tmp_path) -> None: + log_file = str(tmp_path / "quiet.log") + configure_logging(log_file, force=True, verbose_third_party=False) + assert logging.getLogger("httpx").level >= logging.WARNING + assert logging.getLogger("httpcore").level >= logging.WARNING + + +def test_httpx_resets_to_notset_when_verbose_third_party(tmp_path) -> None: + log_file = str(tmp_path / "verbose.log") + configure_logging(log_file, force=True, verbose_third_party=True) + assert logging.getLogger("httpx").level == logging.NOTSET diff --git a/tests/config/test_provider_catalog.py b/tests/config/test_provider_catalog.py new file mode 100644 index 0000000..fe4ca71 --- /dev/null +++ b/tests/config/test_provider_catalog.py @@ -0,0 +1,34 @@ +from dataclasses import FrozenInstanceError + +import pytest + +from free_claude_code.config.provider_catalog import ( + PROVIDER_CATALOG, + ProviderDescriptor, +) + + +def test_provider_descriptors_are_immutable_values() -> None: + descriptor = ProviderDescriptor( + provider_id="local", + display_name="Local", + local=True, + ) + + assert descriptor.local is True + assert not hasattr(descriptor, "__dict__") + with pytest.raises(FrozenInstanceError): + descriptor.__setattr__("local", False) + + +def test_catalog_has_no_transport_metadata() -> None: + assert "transport_type" not in ProviderDescriptor.__slots__ + assert "capabilities" not in ProviderDescriptor.__slots__ + + +def test_catalog_local_assignments_are_exact() -> None: + assert { + provider_id + for provider_id, descriptor in PROVIDER_CATALOG.items() + if descriptor.local + } == {"lmstudio", "llamacpp", "ollama"} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..813e978 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,219 @@ +import asyncio +import contextlib +import logging +import os +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from free_claude_code.config.settings import Settings +from tests.providers.support import passthrough_rate_limiter + +# 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"] = "" + +Settings.model_config = {**Settings.model_config, "env_file": None} + + +@pytest.fixture(autouse=True) +def _isolate_from_dotenv(monkeypatch): + """Prevent Pydantic BaseSettings from reading the .env file during tests.""" + monkeypatch.setattr( + Settings, "model_config", {**Settings.model_config, "env_file": None} + ) + + +@pytest.fixture +def provider_config(): + from free_claude_code.providers.base import ProviderConfig + + 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): + from free_claude_code.config.nim import NimSettings + from free_claude_code.providers.nvidia_nim import NvidiaNimProvider + + return NvidiaNimProvider( + provider_config, + nim_settings=NimSettings(), + rate_limiter=passthrough_rate_limiter(), + ) + + +@pytest.fixture +def open_router_provider(provider_config): + from free_claude_code.providers.open_router import OpenRouterProvider + + return OpenRouterProvider(provider_config, rate_limiter=passthrough_rate_limiter()) + + +@pytest.fixture +def lmstudio_provider(provider_config): + from free_claude_code.providers.base import ProviderConfig + from free_claude_code.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, rate_limiter=passthrough_rate_limiter()) + + +@pytest.fixture +def llamacpp_provider(provider_config): + from free_claude_code.providers.base import ProviderConfig + from free_claude_code.providers.openai_chat import create_openai_chat_provider + + llamacpp_config = ProviderConfig( + api_key="llamacpp", + base_url="http://localhost:8080/v1", + rate_limit=10, + rate_window=60, + ) + return create_openai_chat_provider( + "llamacpp", + llamacpp_config, + passthrough_rate_limiter(), + ) + + +@pytest.fixture +def mock_cli_session(): + from free_claude_code.messaging.managed_protocols import ( + ManagedClaudeSessionProtocol, + ) + + session = MagicMock(spec=ManagedClaudeSessionProtocol) + session.start_task = MagicMock() # This will return an async generator + session.is_busy = False + return session + + +@pytest.fixture +def mock_cli_manager(): + from free_claude_code.messaging.managed_protocols import ( + ManagedClaudeSessionManagerProtocol, + ) + + manager = MagicMock(spec=ManagedClaudeSessionManagerProtocol) + 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(): + from free_claude_code.messaging.platforms.ports import OutboundMessenger + + platform = MagicMock(spec=OutboundMessenger) + 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_messages = AsyncMock() + platform.cancel_pending_voice = AsyncMock(return_value=None) + platform.cancel_all_pending_voices = AsyncMock(return_value=()) + platform.cancel_pending_voices_in_scope = AsyncMock(return_value=()) + + 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(): + from free_claude_code.messaging.session import SessionStore + + store = MagicMock(spec=SessionStore) + store.save_tree = MagicMock() + store.get_tree = MagicMock(return_value=None) + store.register_node = MagicMock() + store.record_message_id = MagicMock() + store.get_tracked_message_ids_for_chat = MagicMock(return_value=[]) + store.forget_tracked_message_ids = MagicMock() + store.clear_scope = MagicMock() + 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): + from free_claude_code.messaging.models import IncomingMessage + + 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/tests/contracts/test_admin_provider_manifest.py b/tests/contracts/test_admin_provider_manifest.py new file mode 100644 index 0000000..36ba118 --- /dev/null +++ b/tests/contracts/test_admin_provider_manifest.py @@ -0,0 +1,119 @@ +"""Ensure admin UI manifest exposes every catalog credential/proxy binding.""" + +from free_claude_code.config.admin.manifest import FIELD_BY_KEY +from free_claude_code.config.provider_catalog import PROVIDER_CATALOG +from free_claude_code.config.settings import Settings + + +def test_provider_catalog_remote_credentials_in_admin_manifest() -> None: + missing: list[str] = [] + wrong_attr: list[str] = [] + + for provider_id, desc in PROVIDER_CATALOG.items(): + if desc.credential_env is None: + continue + if desc.credential_attr is None: + missing.append( + f"{provider_id}: credential_env set but credential_attr missing" + ) + continue + entry = FIELD_BY_KEY.get(desc.credential_env) + if entry is None: + missing.append( + f"{provider_id}: {desc.credential_env} not in admin FIELD_BY_KEY" + ) + continue + if entry.settings_attr != desc.credential_attr: + wrong_attr.append( + f"{provider_id}: {desc.credential_env} maps settings_attr=" + f"{entry.settings_attr!r}, catalog expects " + f"{desc.credential_attr!r}" + ) + + assert not missing and not wrong_attr, "\n".join(missing + wrong_attr) + + +def test_provider_catalog_local_base_urls_in_admin_manifest() -> None: + missing_key: list[str] = [] + wrong_attr: list[str] = [] + + for provider_id, desc in PROVIDER_CATALOG.items(): + if desc.base_url_attr is None: + continue + mf = Settings.model_fields[desc.base_url_attr] + alias = mf.validation_alias + if alias is None: + missing_key.append( + f"{provider_id}: {desc.base_url_attr} has no validation_alias " + "(admin manifest expects env-backed base URL)" + ) + continue + env_key = str(alias) + entry = FIELD_BY_KEY.get(env_key) + if entry is None: + missing_key.append( + f"{provider_id}: base URL env {env_key} not in FIELD_BY_KEY" + ) + continue + if entry.settings_attr != desc.base_url_attr: + wrong_attr.append( + f"{provider_id}: {env_key} maps settings_attr=" + f"{entry.settings_attr!r}, catalog expects {desc.base_url_attr!r}" + ) + + assert not missing_key and not wrong_attr, "\n".join(missing_key + wrong_attr) + + +def test_provider_catalog_proxy_attrs_in_admin_manifest() -> None: + missing_key: list[str] = [] + wrong_attr: list[str] = [] + + for provider_id, desc in PROVIDER_CATALOG.items(): + if desc.proxy_attr is None: + continue + mf = Settings.model_fields[desc.proxy_attr] + alias = mf.validation_alias + if alias is None: + missing_key.append( + f"{provider_id}: {desc.proxy_attr} has no validation_alias " + "(admin manifest expects env-backed proxy)" + ) + continue + env_key = str(alias) + entry = FIELD_BY_KEY.get(env_key) + if entry is None: + missing_key.append( + f"{provider_id}: proxy env {env_key} not in FIELD_BY_KEY" + ) + continue + if entry.settings_attr != desc.proxy_attr: + wrong_attr.append( + f"{provider_id}: {env_key} maps settings_attr=" + f"{entry.settings_attr!r}, catalog expects {desc.proxy_attr!r}" + ) + + assert not missing_key and not wrong_attr, "\n".join(missing_key + wrong_attr) + + +def test_provider_catalog_display_names_are_admin_status_source() -> None: + from free_claude_code.config.admin.status import provider_config_status + from free_claude_code.config.admin.values import load_value_state + + status_by_provider = { + entry["provider_id"]: entry + for entry in provider_config_status(load_value_state()) + } + + assert set(status_by_provider) == set(PROVIDER_CATALOG) + for provider_id, desc in PROVIDER_CATALOG.items(): + assert status_by_provider[provider_id]["display_name"] == desc.display_name + expected_kind = "local" if desc.local else "remote" + assert status_by_provider[provider_id]["kind"] == expected_kind + + +def test_cloudflare_account_id_is_admin_provider_field() -> None: + entry = FIELD_BY_KEY["CLOUDFLARE_ACCOUNT_ID"] + + assert entry.settings_attr == "cloudflare_account_id" + assert entry.section_id == "providers" + assert entry.secret is False diff --git a/tests/contracts/test_architecture_contracts.py b/tests/contracts/test_architecture_contracts.py new file mode 100644 index 0000000..1d7d01f --- /dev/null +++ b/tests/contracts/test_architecture_contracts.py @@ -0,0 +1,64 @@ +import re +import tomllib +from pathlib import Path +from urllib.parse import unquote, urlsplit + + +def test_architecture_document_exists() -> None: + repo_root = Path(__file__).resolve().parents[2] + + assert (repo_root / "ARCHITECTURE.md").is_file() + + +def test_architecture_document_relative_links_resolve() -> None: + repo_root = Path(__file__).resolve().parents[2] + architecture = repo_root / "ARCHITECTURE.md" + text = architecture.read_text(encoding="utf-8") + + missing: list[str] = [] + for match in re.finditer(r"(? None: + repo_root = Path(__file__).resolve().parents[2] + root_example = repo_root / ".env.example" + duplicate_example = ( + repo_root / "src" / "free_claude_code" / "config" / "env.example" + ) + + assert root_example.is_file() + assert not duplicate_example.exists() + + +def test_root_env_example_is_packaged_for_config_template_loader() -> None: + repo_root = Path(__file__).resolve().parents[2] + pyproject = tomllib.loads((repo_root / "pyproject.toml").read_text("utf-8")) + + force_include = pyproject["tool"]["hatch"]["build"]["targets"]["wheel"][ + "force-include" + ] + + assert force_include[".env.example"] == "free_claude_code/config/env.example" + + +def test_pyproject_first_party_packages_match_packaged_roots() -> None: + repo_root = Path(__file__).resolve().parents[2] + pyproject = (repo_root / "pyproject.toml").read_text(encoding="utf-8") + match = re.search(r"known-first-party = \[(?P[^\]]+)\]", pyproject) + + assert match is not None + configured = { + item.strip().strip('"') + for item in match.group("items").split(",") + if item.strip() + } + expected = {"free_claude_code", "smoke"} + assert configured == expected diff --git a/tests/contracts/test_capability_map.py b/tests/contracts/test_capability_map.py new file mode 100644 index 0000000..6d8036b --- /dev/null +++ b/tests/contracts/test_capability_map.py @@ -0,0 +1,57 @@ +from smoke.capabilities import ( + CAPABILITY_CONTRACTS, + capability_names, + contracted_feature_ids, +) +from smoke.features import FEATURE_INVENTORY, feature_ids + +EXPECTED_CAPABILITIES = { + "api_compatibility", + "auth", + "cli", + "config", + "extensibility", + "local_providers", + "messaging", + "openrouter", + "persistence", + "provider_routing", + "provider_runtime", + "request_behavior", + "streaming_conversion", + "voice", +} + + +def test_capability_map_covers_every_public_feature() -> None: + assert contracted_feature_ids() == feature_ids() + + +def test_capability_map_has_expected_top_level_groups() -> None: + assert capability_names() == EXPECTED_CAPABILITIES + + +def test_capability_contracts_are_decision_complete() -> None: + known_features = {feature.feature_id: feature for feature in FEATURE_INVENTORY} + + for contract in CAPABILITY_CONTRACTS: + assert contract.feature_id in known_features, contract + assert contract.capability.strip(), contract + assert contract.subfeature.strip(), contract + assert contract.owner.strip(), contract + assert contract.inputs.strip(), contract + assert contract.outputs.strip(), contract + assert contract.failure.strip(), contract + + assert contract.pytest_tests or contract.smoke_tests, contract + + +def test_capability_contract_owners_do_not_reference_removed_request_pipeline() -> None: + stale = [ + contract + for contract in CAPABILITY_CONTRACTS + if "free_claude_code.api.request_pipeline" in contract.owner + or "ApiRequestPipeline" in contract.owner + ] + + assert stale == [] diff --git a/tests/contracts/test_feature_manifest.py b/tests/contracts/test_feature_manifest.py new file mode 100644 index 0000000..3048622 --- /dev/null +++ b/tests/contracts/test_feature_manifest.py @@ -0,0 +1,130 @@ +import re +from pathlib import Path + +from free_claude_code.config.provider_catalog import PROVIDER_CATALOG +from free_claude_code.messaging.platforms.factory import create_messaging_components +from free_claude_code.providers.base import BaseProvider +from free_claude_code.providers.cloudflare import CloudflareProvider +from free_claude_code.providers.deepseek import DeepSeekProvider +from free_claude_code.providers.gemini import GeminiProvider +from free_claude_code.providers.github_models import GitHubModelsProvider +from free_claude_code.providers.lmstudio import LMStudioProvider +from free_claude_code.providers.mistral import MistralProvider +from free_claude_code.providers.nvidia_nim import NvidiaNimProvider +from free_claude_code.providers.open_router import OpenRouterProvider +from free_claude_code.providers.openai_chat import ( + OPENAI_CHAT_PROFILES, + OpenAIChatProvider, +) +from smoke.features import FEATURE_INVENTORY, README_FEATURES, feature_ids + +VALID_SOURCE = {"readme", "public_surface"} + + +def test_every_readme_feature_has_inventory_entry() -> None: + missing = sorted(set(README_FEATURES) - feature_ids(source="readme")) + extra_readme = sorted(feature_ids(source="readme") - set(README_FEATURES)) + assert not missing, f"README features missing inventory entries: {missing}" + assert not extra_readme, ( + f"README inventory entries not in README_FEATURES: {extra_readme}" + ) + + +def test_readme_provider_table_covers_full_catalog() -> None: + repo_root = Path(__file__).resolve().parents[2] + readme = (repo_root / "README.md").read_text(encoding="utf-8") + provider_section = readme.split("## Choose A Provider", 1)[1].split("\n## ", 1)[0] + rows = [line for line in provider_section.splitlines() if line.startswith("| [")] + + prefixes: list[str] = [] + for row in rows: + example_cell = row.split("|")[3] + match = re.search(r"`([a-z0-9_]+)/", example_cell) + assert match is not None, row + prefixes.append(match.group(1)) + + assert len(rows) == len(PROVIDER_CATALOG) + assert len(prefixes) == len(set(prefixes)) + assert set(prefixes) == set(PROVIDER_CATALOG) + + +def test_feature_inventory_is_unique_and_decision_complete() -> None: + ids = [feature.feature_id for feature in FEATURE_INVENTORY] + assert len(ids) == len(set(ids)) + assert "claude_pick" not in ids + + for feature in FEATURE_INVENTORY: + assert feature.source in VALID_SOURCE, feature + assert feature.title.strip(), feature + assert feature.skip_policy.strip(), feature + assert feature.pytest_contract_tests, feature + assert feature.has_pytest_coverage, feature + if feature.product_e2e_tests: + assert feature.smoke_targets, feature + assert not feature.product_e2e_reason, feature + else: + assert feature.product_e2e_reason.strip(), feature + if feature.live_prereq_tests: + assert feature.smoke_targets, feature + + +def test_feature_inventory_test_owners_exist() -> None: + repo_root = Path(__file__).resolve().parents[2] + pytest_names = _collect_test_names(repo_root / "tests") + smoke_names = _collect_test_names(repo_root / "smoke") + + for feature in FEATURE_INVENTORY: + for owner in feature.pytest_contract_tests: + _assert_owner_exists(owner, repo_root, pytest_names) + for owner in feature.live_prereq_tests + feature.product_e2e_tests: + assert owner in smoke_names or owner in pytest_names, (feature, owner) + + +def test_product_coverage_is_not_satisfied_by_prereq_probes() -> None: + for feature in FEATURE_INVENTORY: + overlap = set(feature.live_prereq_tests) & set(feature.product_e2e_tests) + assert not overlap, (feature.feature_id, sorted(overlap)) + if feature.product_e2e_tests: + assert all("_e2e" in name for name in feature.product_e2e_tests), feature + + +def test_provider_and_platform_registries_include_advertised_builtins() -> None: + specialized_provider_classes = { + "nvidia_nim": NvidiaNimProvider, + "open_router": OpenRouterProvider, + "mistral": MistralProvider, + "deepseek": DeepSeekProvider, + "cloudflare": CloudflareProvider, + "lmstudio": LMStudioProvider, + "github_models": GitHubModelsProvider, + "gemini": GeminiProvider, + } + assert set(OPENAI_CHAT_PROFILES).isdisjoint(specialized_provider_classes) + assert set(PROVIDER_CATALOG) == ( + set(OPENAI_CHAT_PROFILES) | set(specialized_provider_classes) + ) + assert issubclass(OpenAIChatProvider, BaseProvider) + for provider_class in specialized_provider_classes.values(): + assert issubclass(provider_class, BaseProvider) + + assert create_messaging_components("not-a-platform") is None + + +def _collect_test_names(root: Path) -> set[str]: + names: set[str] = set() + for path in root.rglob("test_*.py"): + text = path.read_text(encoding="utf-8") + names.update(re.findall(r"^\s*(?:async\s+)?def (test_[^(]+)", text, re.M)) + return names + + +def _assert_owner_exists(owner: str, repo_root: Path, test_names: set[str]) -> None: + if owner.startswith("test_"): + assert owner in test_names, owner + return + + path_part, _, node_name = owner.partition("::") + path = repo_root / path_part + assert path.exists(), owner + if node_name: + assert node_name in test_names, owner diff --git a/tests/contracts/test_import_boundaries.py b/tests/contracts/test_import_boundaries.py new file mode 100644 index 0000000..11f859c --- /dev/null +++ b/tests/contracts/test_import_boundaries.py @@ -0,0 +1,740 @@ +"""Declarative static import contracts; dynamic imports are deliberately unscanned.""" + +import ast +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_PACKAGE_ROOT = _REPO_ROOT / "src" / "free_claude_code" +_PACKAGE_NAME = "free_claude_code" + +ALLOWED_PACKAGE_DEPENDENCIES: dict[str, set[str]] = { + "config": set(), + "core": set(), + "application": {"config", "core"}, + "messaging": {"core"}, + "providers": {"application", "config", "core"}, + "api": {"application", "config", "core"}, + "cli": {"config", "core"}, + "runtime": { + "api", + "application", + "cli", + "config", + "core", + "messaging", + "providers", + }, +} + +IMPORT_EXCEPTIONS: dict[tuple[str, str], str] = { + ( + "free_claude_code.cli.entrypoints", + "free_claude_code.runtime.bootstrap", + ): ( + "Owner: installed server entrypoint. " + "Reason: the executable delegates construction to the process composition root." + ), +} + +FACADE_ONLY_BOUNDARIES = { + "free_claude_code.core.openai_responses", + "free_claude_code.messaging.trees", + "free_claude_code.providers.openai_chat", +} + +OPTIONAL_IMPORT_OWNERS = { + "librosa": "free_claude_code.messaging.transcription", + "torch": "free_claude_code.messaging.transcription", + "transformers": "free_claude_code.messaging.transcription", + "riva": "free_claude_code.providers.nvidia_nim.voice", +} + + +@dataclass(frozen=True, slots=True) +class ImportRecord: + importer: str + imported: str + path: str + line: int + inside_function: bool + + def describe(self) -> str: + return f"{self.path}:{self.line}: {self.importer} -> {self.imported}" + + +class _ImportVisitor(ast.NodeVisitor): + def __init__( + self, + *, + importer: str, + importer_is_package: bool, + modules: set[str], + path: str, + ) -> None: + self._importer = importer + self._importer_is_package = importer_is_package + self._modules = modules + self._path = path + self._function_depth = 0 + self.records: list[ImportRecord] = [] + + def visit_Import(self, node: ast.Import) -> None: + for alias in node.names: + self._record(alias.name, node.lineno) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + base = _resolve_import_from_base( + importer=self._importer, + importer_is_package=self._importer_is_package, + level=node.level, + module=node.module, + ) + if base is None: + return + for alias in node.names: + candidate = f"{base}.{alias.name}" + imported = candidate if candidate in self._modules else base + self._record(imported, node.lineno) + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self._visit_function(node) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + self._visit_function(node) + + def _visit_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: + self._function_depth += 1 + self.generic_visit(node) + self._function_depth -= 1 + + def _record(self, imported: str, line: int) -> None: + self.records.append( + ImportRecord( + importer=self._importer, + imported=imported, + path=self._path, + line=line, + inside_function=self._function_depth > 0, + ) + ) + + +def test_package_dependencies_follow_declarative_policy() -> None: + modules = _module_paths(_PACKAGE_ROOT) + records = _scan_imports(_PACKAGE_ROOT) + actual_packages = _ownership_roots(set(modules), _PACKAGE_NAME) + + assert set(ALLOWED_PACKAGE_DEPENDENCIES) == actual_packages + assert all( + (_PACKAGE_ROOT / package / "__init__.py").is_file() + for package in actual_packages + ) + assert all( + dependency in actual_packages and dependency != package + for package, dependencies in ALLOWED_PACKAGE_DEPENDENCIES.items() + for dependency in dependencies + ) + + observed_dependencies: set[tuple[str, str]] = set() + observed_exceptions: set[tuple[str, str]] = set() + offenders: list[str] = [] + for record in records: + source_package = _top_level_package(record.importer) + target_package = _top_level_package(record.imported) + if source_package is None or target_package is None: + continue + if source_package == target_package: + continue + exact_edge = (record.importer, record.imported) + if exact_edge in IMPORT_EXCEPTIONS: + observed_exceptions.add(exact_edge) + continue + if target_package in ALLOWED_PACKAGE_DEPENDENCIES[source_package]: + observed_dependencies.add((source_package, target_package)) + continue + offenders.append(record.describe()) + + declared_dependencies = { + (package, dependency) + for package, dependencies in ALLOWED_PACKAGE_DEPENDENCIES.items() + for dependency in dependencies + } + assert sorted(offenders) == [] + assert declared_dependencies - observed_dependencies == set() + assert set(IMPORT_EXCEPTIONS) - observed_exceptions == set() + assert all( + "Owner:" in reason and "Reason:" in reason + for reason in IMPORT_EXCEPTIONS.values() + ) + for source, target in IMPORT_EXCEPTIONS: + source_package = _top_level_package(source) + target_package = _top_level_package(target) + assert source_package is not None + assert target_package is not None + assert target_package not in ALLOWED_PACKAGE_DEPENDENCIES[source_package] + expected_package_modules = { + _PACKAGE_NAME, + *(f"{_PACKAGE_NAME}.{package}" for package in actual_packages), + } + assert set(modules) >= expected_package_modules + + +def test_first_party_imports_use_the_installable_namespace() -> None: + offenders = _legacy_first_party_import_offenders( + _scan_imports(_PACKAGE_ROOT), + set(ALLOWED_PACKAGE_DEPENDENCIES), + ) + + assert offenders == [] + + +def test_openai_chat_collaborators_have_explicit_ownership_boundaries() -> None: + provider_root = _PACKAGE_ROOT / "providers" / "openai_chat" + + assert _provider_backchannel_offenders(provider_root) == [] + + +def test_provider_backchannel_detector_reports_untyped_private_access( + tmp_path: Path, +) -> None: + provider_root = tmp_path / "openai_chat" + _write_module( + provider_root / "sample" / "runner.py", + "from typing import Any\n" + "\n" + "class Runner:\n" + " def __init__(self, provider: object | Any) -> None:\n" + " self._provider = provider\n" + "\n" + " def run(self) -> object:\n" + " return self._provider._send()\n", + ) + + assert _provider_backchannel_offenders(provider_root) == [ + "sample/runner.py:4: untyped provider collaborator", + "sample/runner.py:8: private provider member _send outside provider.py", + ] + + +def test_legacy_first_party_import_detector_rejects_bare_owner_names() -> None: + record = ImportRecord( + importer="free_claude_code.api.routes", + imported="core.anthropic", + path="free_claude_code/api/routes.py", + line=7, + inside_function=False, + ) + + assert _legacy_first_party_import_offenders([record], {"api", "core"}) == [ + "free_claude_code/api/routes.py:7: " + "free_claude_code.api.routes -> core.anthropic" + ] + + +def test_ownership_root_discovery_includes_modules_and_namespace_directories( + tmp_path: Path, +) -> None: + package_root = tmp_path / "sample" + _write_module(package_root / "__init__.py") + _write_module(package_root / "declared" / "__init__.py") + _write_module(package_root / "namespace" / "module.py") + _write_module(package_root / "root_module.py") + + roots = _ownership_roots(set(_module_paths(package_root)), "sample") + + assert roots == {"declared", "namespace", "root_module"} + + +def test_descendants_do_not_import_ancestor_package_facades() -> None: + modules = _module_paths(_PACKAGE_ROOT) + packages = { + module for module, path in modules.items() if path.name == "__init__.py" + } + offenders = _ancestor_facade_offenders(_scan_imports(_PACKAGE_ROOT), packages) + + assert offenders == [] + + +def test_static_first_party_import_graph_is_acyclic() -> None: + modules = set(_module_paths(_PACKAGE_ROOT)) + graph = {module: set() for module in modules} + for record in _scan_imports(_PACKAGE_ROOT): + if record.imported in modules: + graph[record.importer].add(record.imported) + + assert _cyclic_components(graph) == [] + + +def test_cycle_detector_reports_exact_strongly_connected_components() -> None: + graph = { + "package.a": {"package.b"}, + "package.b": {"package.a"}, + "package.c": set(), + "package.self": {"package.self"}, + } + + assert _cyclic_components(graph) == [ + ("package.a", "package.b"), + ("package.self",), + ] + + +def test_external_consumers_use_owned_package_facades() -> None: + offenders: list[str] = [] + for record in _scan_imports(_PACKAGE_ROOT): + for facade in FACADE_ONLY_BOUNDARIES: + if record.importer == facade or record.importer.startswith(f"{facade}."): + continue + if record.imported.startswith(f"{facade}."): + offenders.append(record.describe()) + + assert sorted(offenders) == [] + + +def test_import_scanner_resolves_absolute_relative_and_lazy_imports( + tmp_path: Path, +) -> None: + package_root = tmp_path / "sample" + _write_module(package_root / "__init__.py") + _write_module(package_root / "alpha" / "__init__.py", "PUBLIC = object()\n") + _write_module(package_root / "alpha" / "sibling.py") + _write_module(package_root / "beta" / "__init__.py") + _write_module(package_root / "beta" / "absolute.py") + _write_module(package_root / "beta" / "relative.py") + _write_module(package_root / "beta" / "lazy.py") + _write_module( + package_root / "alpha" / "consumer.py", + "\n".join( + ( + "import sample.beta.absolute", + "from ..beta import relative", + "from . import sibling", + "from . import PUBLIC", + "def load():", + " import sample.beta.lazy", + "", + ) + ), + ) + + records = _scan_imports(package_root) + resolved = { + (record.imported, record.inside_function) + for record in records + if record.importer == "sample.alpha.consumer" + } + + assert resolved == { + ("sample.alpha", False), + ("sample.alpha.sibling", False), + ("sample.beta.absolute", False), + ("sample.beta.relative", False), + ("sample.beta.lazy", True), + } + + +def test_import_scanner_distinguishes_facade_symbols_from_sibling_modules( + tmp_path: Path, +) -> None: + package_root = tmp_path / "sample" + _write_module(package_root / "__init__.py") + _write_module(package_root / "owner" / "__init__.py", "PUBLIC = object()\n") + _write_module(package_root / "owner" / "sibling.py") + _write_module( + package_root / "owner" / "consumer.py", + "from . import PUBLIC, sibling\n", + ) + modules = _module_paths(package_root) + packages = { + module for module, path in modules.items() if path.name == "__init__.py" + } + + offenders = _ancestor_facade_offenders(_scan_imports(package_root), packages) + + assert offenders == [ + "sample/owner/consumer.py:1: sample.owner.consumer -> sample.owner" + ] + + +def test_anthropic_request_boundaries_use_the_protocol_model() -> None: + """Known Messages fields must not cross core/provider boundaries by duck typing.""" + roots = [ + _PACKAGE_ROOT / "core" / "anthropic", + _PACKAGE_ROOT / "providers", + ] + request_names = {"request", "request_data"} + protocol_fields = { + "extra_body", + "max_tokens", + "messages", + "model", + "stop_sequences", + "system", + "temperature", + "thinking", + "tool_choice", + "tools", + "top_k", + "top_p", + } + offenders: list[str] = [] + + for root in roots: + for path in root.rglob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + relative = path.relative_to(_REPO_ROOT).as_posix() + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): + arguments = [ + *node.args.posonlyargs, + *node.args.args, + *node.args.kwonlyargs, + ] + for argument in arguments: + if argument.arg.lstrip("_") not in request_names: + continue + annotation_names = ( + { + child.id + for child in ast.walk(argument.annotation) + if isinstance(child, ast.Name) + } + if argument.annotation is not None + else set() + ) + if argument.annotation is None or annotation_names & { + "Any", + "Mapping", + }: + offenders.append( + f"{relative}:{argument.lineno}: " + f"{node.name}({argument.arg}) is not concrete" + ) + if not isinstance(node, ast.Call) or not ( + isinstance(node.func, ast.Name) + and node.func.id == "getattr" + and len(node.args) >= 2 + and isinstance(node.args[0], ast.Name) + and node.args[0].id.lstrip("_") in request_names + and isinstance(node.args[1], ast.Constant) + and node.args[1].value in protocol_fields + ): + continue + offenders.append( + f"{relative}:{node.lineno}: " + f"getattr({node.args[0].id}, {node.args[1].value!r})" + ) + + assert sorted(offenders) == [] + + +def test_core_does_not_import_provider_transport_sdks() -> None: + forbidden_roots = {"aiohttp", "httpx", "openai"} + offenders = [ + record.describe() + for record in _scan_imports(_PACKAGE_ROOT) + if ( + record.importer == "free_claude_code.core" + or record.importer.startswith("free_claude_code.core.") + ) + and record.imported.split(".", 1)[0] in forbidden_roots + ] + + assert sorted(offenders) == [] + + +def test_providers_do_not_own_wire_error_type_literals() -> None: + wire_types = { + "api_error", + "authentication_error", + "billing_error", + "invalid_request_error", + "not_found_error", + "overloaded_error", + "permission_error", + "rate_limit_error", + "request_too_large", + "timeout_error", + } + offenders: list[str] = [] + for path in (_PACKAGE_ROOT / "providers").rglob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + offenders.extend( + f"{path.relative_to(_REPO_ROOT).as_posix()}:{node.lineno}: {node.value}" + for node in ast.walk(tree) + if isinstance(node, ast.Constant) and node.value in wire_types + ) + + assert sorted(offenders) == [] + + +def test_optional_dependencies_have_one_lazy_owner() -> None: + seen: set[str] = set() + offenders: list[str] = [] + for record in _scan_imports(_PACKAGE_ROOT): + dependency = record.imported.split(".", 1)[0] + owner = OPTIONAL_IMPORT_OWNERS.get(dependency) + if owner is None: + continue + seen.add(dependency) + if record.importer != owner or not record.inside_function: + offenders.append(record.describe()) + + assert seen == set(OPTIONAL_IMPORT_OWNERS) + assert sorted(offenders) == [] + + +def test_runtime_imports_without_optional_voice_dependencies() -> None: + blocked = sorted(OPTIONAL_IMPORT_OWNERS) + script = "\n".join( + ( + "import importlib.abc", + "import sys", + f"BLOCKED = {blocked!r}", + "class Blocker(importlib.abc.MetaPathFinder):", + " def find_spec(self, fullname, path=None, target=None):", + " if fullname.split('.', 1)[0] in BLOCKED:", + " raise ModuleNotFoundError(fullname)", + " return None", + "sys.meta_path.insert(0, Blocker())", + "import free_claude_code.runtime.bootstrap", + "import free_claude_code.api.app", + ) + ) + + completed = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + check=False, + text=True, + ) + + assert completed.returncode == 0, completed.stderr or completed.stdout + + +def test_supported_messaging_facade_is_explicit() -> None: + import free_claude_code.messaging as facade + from free_claude_code.messaging.managed_protocols import ( + ManagedClaudeSessionManagerProtocol, + ManagedClaudeSessionProtocol, + ) + from free_claude_code.messaging.models import IncomingMessage, MessageScope + from free_claude_code.messaging.platforms.ports import OutboundMessenger + + expected = { + "IncomingMessage": IncomingMessage, + "ManagedClaudeSessionManagerProtocol": ManagedClaudeSessionManagerProtocol, + "ManagedClaudeSessionProtocol": ManagedClaudeSessionProtocol, + "MessageScope": MessageScope, + "OutboundMessenger": OutboundMessenger, + } + + assert set(facade.__all__) == set(expected) + assert all(getattr(facade, name) is value for name, value in expected.items()) + + +def test_message_tree_mutability_stays_behind_its_facade() -> None: + import free_claude_code.messaging.trees as facade + + for internal_owner in { + "MessageNode", + "MessageTree", + "TreeQueueProcessor", + "TreeRepository", + }: + assert internal_owner not in facade.__all__ + assert not hasattr(facade, internal_owner) + + +def _module_paths(package_root: Path) -> dict[str, Path]: + return { + _module_name(package_root, path): path for path in package_root.rglob("*.py") + } + + +def _module_name(package_root: Path, path: Path) -> str: + relative = path.relative_to(package_root) + module_parts = ( + relative.parent.parts + if path.name == "__init__.py" + else relative.with_suffix("").parts + ) + return ".".join((package_root.name, *module_parts)) + + +def _scan_imports(package_root: Path) -> list[ImportRecord]: + module_paths = _module_paths(package_root) + modules = set(module_paths) + records: list[ImportRecord] = [] + for importer, path in sorted(module_paths.items()): + visitor = _ImportVisitor( + importer=importer, + importer_is_package=path.name == "__init__.py", + modules=modules, + path=path.relative_to(package_root.parent).as_posix(), + ) + visitor.visit(ast.parse(path.read_text(encoding="utf-8"), filename=str(path))) + records.extend(visitor.records) + return records + + +def _resolve_import_from_base( + *, + importer: str, + importer_is_package: bool, + level: int, + module: str | None, +) -> str | None: + if level == 0: + return module + package_parts = importer.split(".") + if not importer_is_package: + package_parts.pop() + parents_to_remove = level - 1 + if parents_to_remove > len(package_parts): + return None + if parents_to_remove: + del package_parts[-parents_to_remove:] + if module is not None: + package_parts.extend(module.split(".")) + return ".".join(package_parts) or None + + +def _top_level_package(module: str) -> str | None: + parts = module.split(".") + if len(parts) < 2 or parts[0] != _PACKAGE_NAME: + return None + return parts[1] + + +def _ownership_roots(modules: set[str], package_name: str) -> set[str]: + roots: set[str] = set() + for module in modules: + parts = module.split(".") + if len(parts) >= 2 and parts[0] == package_name: + roots.add(parts[1]) + return roots + + +def _legacy_first_party_import_offenders( + records: list[ImportRecord], + owner_names: set[str], +) -> list[str]: + offenders = [ + record.describe() + for record in records + if record.imported.split(".", 1)[0] in owner_names + ] + return sorted(offenders) + + +def _provider_backchannel_offenders(provider_root: Path) -> list[str]: + offenders: list[str] = [] + for path in sorted(provider_root.rglob("*.py")): + relative_path = path.relative_to(provider_root).as_posix() + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): + arguments = ( + *node.args.posonlyargs, + *node.args.args, + *node.args.kwonlyargs, + ) + offenders.extend( + f"{relative_path}:{argument.lineno}: untyped provider collaborator" + for argument in arguments + if argument.arg == "provider" + and _annotation_is_any(argument.annotation) + ) + if ( + path.name != "provider.py" + and isinstance(node, ast.Attribute) + and node.attr.startswith("_") + and _is_provider_reference(node.value) + ): + offenders.append( + f"{relative_path}:{node.lineno}: private provider member " + f"{node.attr} outside provider.py" + ) + return sorted(offenders) + + +def _annotation_is_any(annotation: ast.expr | None) -> bool: + if annotation is None: + return False + return any( + (isinstance(node, ast.Name) and node.id == "Any") + or (isinstance(node, ast.Attribute) and node.attr == "Any") + for node in ast.walk(annotation) + ) + + +def _is_provider_reference(expression: ast.expr) -> bool: + return (isinstance(expression, ast.Name) and expression.id == "provider") or ( + isinstance(expression, ast.Attribute) + and isinstance(expression.value, ast.Name) + and expression.value.id == "self" + and expression.attr == "_provider" + ) + + +def _ancestor_facade_offenders( + records: list[ImportRecord], + packages: set[str], +) -> list[str]: + offenders = [ + record.describe() + for record in records + if record.imported in packages + and record.importer.startswith(f"{record.imported}.") + ] + return sorted(offenders) + + +def _cyclic_components(graph: dict[str, set[str]]) -> list[tuple[str, ...]]: + index = 0 + indices: dict[str, int] = {} + lowlinks: dict[str, int] = {} + stack: list[str] = [] + on_stack: set[str] = set() + components: list[tuple[str, ...]] = [] + + def visit(node: str) -> None: + nonlocal index + indices[node] = index + lowlinks[node] = index + index += 1 + stack.append(node) + on_stack.add(node) + + for successor in sorted(graph[node]): + if successor not in indices: + visit(successor) + lowlinks[node] = min(lowlinks[node], lowlinks[successor]) + elif successor in on_stack: + lowlinks[node] = min(lowlinks[node], indices[successor]) + + if lowlinks[node] != indices[node]: + return + component: list[str] = [] + while True: + member = stack.pop() + on_stack.remove(member) + component.append(member) + if member == node: + break + if len(component) > 1 or node in graph[node]: + components.append(tuple(sorted(component))) + + for node in sorted(graph): + if node not in indices: + visit(node) + return sorted(components) + + +def _write_module(path: Path, text: str = "") -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") diff --git a/tests/contracts/test_local_provider_smoke.py b/tests/contracts/test_local_provider_smoke.py new file mode 100644 index 0000000..d126b19 --- /dev/null +++ b/tests/contracts/test_local_provider_smoke.py @@ -0,0 +1,96 @@ +from dataclasses import dataclass + +import httpx +import pytest + +from smoke.lib.local_providers import first_local_provider_model_id + + +@dataclass(slots=True) +class FakeResponse: + status_code: int + payload: dict + text: str = "" + + def json(self) -> dict: + return self.payload + + +def test_local_provider_openai_models_returns_first_id(monkeypatch) -> None: + calls: list[tuple[str, float]] = [] + + def fake_get(url: str, *, timeout: float) -> FakeResponse: + calls.append((url, timeout)) + return FakeResponse(200, {"data": [{"id": "local-model"}]}) + + monkeypatch.setattr("smoke.lib.local_providers.httpx.get", fake_get) + + model = first_local_provider_model_id( + "lmstudio", + "http://127.0.0.1:1234/v1", + timeout_s=45, + ) + + assert model == "local-model" + assert calls == [("http://127.0.0.1:1234/v1/models", 1.5)] + + +@pytest.mark.parametrize( + ("provider", "port", "model_id"), + [ + ("llamacpp", 8080, "local-model"), + ("ollama", 11434, "llama3.1"), + ], +) +def test_local_provider_root_url_targets_openai_v1_models( + monkeypatch, provider: str, port: int, model_id: str +) -> None: + def fake_get(url: str, *, timeout: float) -> FakeResponse: + assert url == f"http://127.0.0.1:{port}/v1/models" + assert timeout == 1.5 + return FakeResponse(200, {"data": [{"id": model_id}]}) + + monkeypatch.setattr("smoke.lib.local_providers.httpx.get", fake_get) + + assert ( + first_local_provider_model_id( + provider, + f"http://127.0.0.1:{port}", + timeout_s=45, + ) + == model_id + ) + + +def test_local_provider_not_running_is_missing_env_skip(monkeypatch) -> None: + def fake_get(url: str, *, timeout: float) -> FakeResponse: + raise httpx.ConnectError("refused") + + monkeypatch.setattr("smoke.lib.local_providers.httpx.get", fake_get) + + with pytest.raises(pytest.skip.Exception) as excinfo: + first_local_provider_model_id( + "llamacpp", + "http://127.0.0.1:8080/v1", + timeout_s=45, + ) + + assert "missing_env: llamacpp local server is not running" in str(excinfo.value) + + +def test_local_provider_empty_model_list_is_missing_env_skip(monkeypatch) -> None: + def fake_get(url: str, *, timeout: float) -> FakeResponse: + return FakeResponse(200, {"data": []}) + + monkeypatch.setattr("smoke.lib.local_providers.httpx.get", fake_get) + + with pytest.raises(pytest.skip.Exception) as excinfo: + first_local_provider_model_id( + "lmstudio", + "http://127.0.0.1:1234/v1", + timeout_s=45, + ) + + assert "missing_env: lmstudio local server has no loaded models" in str( + excinfo.value + ) diff --git a/tests/contracts/test_nvidia_nim_cli_matrix.py b/tests/contracts/test_nvidia_nim_cli_matrix.py new file mode 100644 index 0000000..8030b0a --- /dev/null +++ b/tests/contracts/test_nvidia_nim_cli_matrix.py @@ -0,0 +1,555 @@ +import json +import subprocess +from pathlib import Path +from typing import cast + +from free_claude_code.config.settings import Settings +from smoke.lib.claude_cli_matrix import ( + ClaudeCliRun, + _build_claude_cli_command, + _subagent_probe_options, + make_outcome, + regression_failures, + run_claude_cli, + write_matrix_report, +) +from smoke.lib.config import DEFAULT_TARGETS, SmokeConfig +from smoke.lib.server import RunningServer + + +def _smoke_config(tmp_path: Path) -> SmokeConfig: + return SmokeConfig( + root=tmp_path, + results_dir=tmp_path / ".smoke-results", + live=False, + interactive=False, + targets=DEFAULT_TARGETS, + provider_matrix=frozenset(), + timeout_s=45.0, + prompt="Reply with exactly: FCC_SMOKE_PONG", + claude_bin="claude", + worker_id="test-worker", + settings=Settings.model_construct(anthropic_auth_token=""), + ) + + +def test_nvidia_nim_cli_matrix_report_shape_and_redaction( + tmp_path: Path, monkeypatch +) -> None: + monkeypatch.setenv("NVIDIA_NIM_API_KEY", "secret-nim-key") + run = ClaudeCliRun( + command=("claude", "-p", "redacted"), + returncode=0, + stdout="FCC_NIM_BASIC secret-nim-key", + stderr="", + duration_s=1.25, + ) + outcome = make_outcome( + model="z-ai/glm-5.2", + full_model="nvidia_nim/z-ai/glm-5.2", + source="nvidia_nim_cli_default", + feature="basic_text", + marker="FCC_NIM_BASIC", + run=run, + log_delta='POST /v1/messages HTTP/1.1" 200 OK secret-nim-key', + log_path=tmp_path / "server.log", + ) + + path = write_matrix_report( + _smoke_config(tmp_path), + [outcome], + target="nvidia_nim_cli", + filename_prefix="nvidia-nim-cli", + ) + payload = json.loads(path.read_text(encoding="utf-8")) + + assert path.name.startswith("nvidia-nim-cli-matrix-test-worker-") + assert payload["target"] == "nvidia_nim_cli" + assert payload["models"] == ["nvidia_nim/z-ai/glm-5.2"] + saved = payload["outcomes"][0] + assert saved["feature"] == "basic_text" + assert saved["classification"] == "passed" + assert saved["request_count"] == 1 + assert saved["token_evidence"]["marker_present"] is True + assert saved["token_evidence"]["agent_catalog_present"] is False + assert saved["token_evidence"]["agent_tool_count"] == 0 + assert saved["token_evidence"]["agent_result_count"] == 0 + assert "secret-nim-key" not in path.read_text(encoding="utf-8") + + +def test_cli_matrix_normalizes_missing_captured_output( + tmp_path: Path, monkeypatch +) -> None: + def fake_run_captured_text( + command: list[str], + **_kwargs: object, + ) -> subprocess.CompletedProcess[str]: + return cast( + subprocess.CompletedProcess[str], + subprocess.CompletedProcess( + args=command, + returncode=0, + stdout=None, + stderr=None, + ), + ) + + monkeypatch.setattr( + "smoke.lib.claude_cli_matrix.run_captured_text", + fake_run_captured_text, + ) + server = RunningServer( + base_url="http://127.0.0.1:9999", + port=9999, + log_path=tmp_path / "server.log", + process=cast(subprocess.Popen[bytes], object()), + ) + + run = run_claude_cli( + claude_bin="claude", + server=server, + config=_smoke_config(tmp_path), + cwd=tmp_path / "workspace", + prompt="hello", + tools="", + ) + outcome = make_outcome( + model="z-ai/glm-5.2", + full_model="nvidia_nim/z-ai/glm-5.2", + source="nvidia_nim_cli_default", + feature="basic_text", + marker="FCC_NIM_BASIC", + run=run, + log_delta='POST /v1/messages HTTP/1.1" 200 OK', + log_path=tmp_path / "server.log", + ) + + assert run.stdout == "" + assert run.stderr == "" + assert outcome.stdout_excerpt == "" + assert outcome.stderr_excerpt == "" + + +def test_openrouter_free_cli_matrix_report_shape_and_redaction( + tmp_path: Path, monkeypatch +) -> None: + monkeypatch.setenv("OPENROUTER_API_KEY", "secret-openrouter-key") + run = ClaudeCliRun( + command=("claude", "-p", "redacted"), + returncode=0, + stdout="FCC_OPENROUTER_FREE_BASIC secret-openrouter-key", + stderr="", + duration_s=1.25, + ) + outcome = make_outcome( + model="openai/gpt-oss-120b:free", + full_model="open_router/openai/gpt-oss-120b:free", + source="openrouter_free_cli_default", + feature="basic_text", + marker="FCC_OPENROUTER_FREE_BASIC", + run=run, + log_delta='POST /v1/messages HTTP/1.1" 200 OK secret-openrouter-key', + log_path=tmp_path / "server.log", + ) + + path = write_matrix_report( + _smoke_config(tmp_path), + [outcome], + target="openrouter_free_cli", + filename_prefix="openrouter-free-cli", + ) + payload = json.loads(path.read_text(encoding="utf-8")) + + assert path.name.startswith("openrouter-free-cli-matrix-test-worker-") + assert payload["target"] == "openrouter_free_cli" + assert payload["models"] == ["open_router/openai/gpt-oss-120b:free"] + saved = payload["outcomes"][0] + assert saved["feature"] == "basic_text" + assert saved["classification"] == "passed" + assert saved["request_count"] == 1 + assert saved["token_evidence"]["marker_present"] is True + assert saved["token_evidence"]["agent_catalog_present"] is False + assert saved["token_evidence"]["agent_tool_count"] == 0 + assert saved["token_evidence"]["agent_result_count"] == 0 + assert "secret-openrouter-key" not in path.read_text(encoding="utf-8") + + +def test_nvidia_nim_cli_matrix_regression_detection(tmp_path: Path) -> None: + run = ClaudeCliRun( + command=("claude", "-p", "x"), + returncode=0, + stdout="", + stderr="", + duration_s=0.1, + ) + outcome = make_outcome( + model="z-ai/glm-5.2", + full_model="nvidia_nim/z-ai/glm-5.2", + source="nvidia_nim_cli_default", + feature="basic_text", + marker="FCC_NIM_BASIC", + run=run, + log_delta='POST /v1/messages HTTP/1.1" 500 Internal Server Error', + log_path=tmp_path / "server.log", + ) + + assert outcome.classification == "product_failure" + assert regression_failures([outcome]) == [ + "nvidia_nim/z-ai/glm-5.2 basic_text: product_failure" + ] + + +def test_nvidia_nim_cli_matrix_model_feature_failures_do_not_regress( + tmp_path: Path, +) -> None: + run = ClaudeCliRun( + command=("claude", "-p", "x"), + returncode=0, + stdout="ordinary answer", + stderr="", + duration_s=0.1, + ) + outcome = make_outcome( + model="z-ai/glm-5.2", + full_model="nvidia_nim/z-ai/glm-5.2", + source="nvidia_nim_cli_default", + feature="tool_use_roundtrip", + marker="FCC_NIM_TOOL", + run=run, + log_delta='POST /v1/messages HTTP/1.1" 200 OK', + log_path=tmp_path / "server.log", + requires_tool_result=True, + ) + + assert outcome.classification == "model_feature_failure" + assert regression_failures([outcome]) == [] + + +def test_nvidia_nim_cli_raw_payload_log_counts_as_proxy_request( + tmp_path: Path, +) -> None: + run = ClaudeCliRun( + command=("claude", "-p", "x"), + returncode=0, + stdout="ordinary answer", + stderr="", + duration_s=0.1, + ) + outcome = make_outcome( + model="z-ai/glm-5.2", + full_model="nvidia_nim/z-ai/glm-5.2", + source="nvidia_nim_cli_default", + feature="subagent_task", + marker="FCC_NIM_TASK", + run=run, + log_delta="API_REQUEST: request_id=req_1 model=z-ai/glm-5.2 messages=2", + log_path=tmp_path / "server.log", + requires_task=True, + ) + + assert outcome.classification == "model_feature_failure" + assert outcome.request_count == 1 + assert regression_failures([outcome]) == [] + + +def test_cli_matrix_missing_agent_catalog_is_harness_bug(tmp_path: Path) -> None: + run = ClaudeCliRun( + command=("claude", "-p", "x"), + returncode=0, + stdout="ordinary answer", + stderr="", + duration_s=0.1, + ) + outcome = make_outcome( + model="openai/gpt-oss-120b:free", + full_model="open_router/openai/gpt-oss-120b:free", + source="openrouter_free_cli_default", + feature="subagent_task", + marker="FCC_OPENROUTER_FREE_TASK", + run=run, + log_delta=( + "API_REQUEST: request_id=req_1 model=openai/gpt-oss-120b:free " + "messages=1\n" + "FULL_PAYLOAD [req_1]: {'messages': [], 'tools': [{'name': 'Read'}], " + "'tool_choice': None}" + ), + log_path=tmp_path / "server.log", + requires_agent=True, + ) + + assert outcome.classification == "harness_bug" + assert outcome.token_evidence["agent_catalog_present"] is False + + +def test_cli_matrix_agent_catalog_without_agent_use_is_model_feature_failure( + tmp_path: Path, +) -> None: + marker = "FCC_OPENROUTER_FREE_TASK" + run = ClaudeCliRun( + command=("claude", "-p", "x"), + returncode=0, + stdout=( + f'{marker}\n{{"type":"tool_use","name":"Read"}}\n{{"type":"tool_result"}}' + ), + stderr="", + duration_s=0.1, + ) + outcome = make_outcome( + model="openai/gpt-oss-120b:free", + full_model="open_router/openai/gpt-oss-120b:free", + source="openrouter_free_cli_default", + feature="subagent_task", + marker=marker, + run=run, + log_delta=( + "API_REQUEST: request_id=req_1 model=openai/gpt-oss-120b:free " + "messages=1\n" + "FULL_PAYLOAD [req_1]: {'messages': [], 'tools': " + "[{'name': 'Agent'}, {'name': 'Read'}], 'tool_choice': None}" + ), + log_path=tmp_path / "server.log", + requires_tool_result=True, + requires_agent=True, + ) + + assert outcome.classification == "model_feature_failure" + assert outcome.token_evidence["agent_catalog_present"] is True + assert outcome.token_evidence["agent_tool_count"] == 0 + + +def test_cli_matrix_agent_use_result_and_marker_pass(tmp_path: Path) -> None: + marker = "FCC_OPENROUTER_FREE_TASK" + run = ClaudeCliRun( + command=("claude", "-p", "x"), + returncode=0, + stdout=( + f'{marker}\n{{"type":"tool_use","name":"Agent"}}\n' + '{"type":"tool_result","content":"agentId: abc123"}' + ), + stderr="", + duration_s=0.1, + ) + outcome = make_outcome( + model="openai/gpt-oss-120b:free", + full_model="open_router/openai/gpt-oss-120b:free", + source="openrouter_free_cli_default", + feature="subagent_task", + marker=marker, + run=run, + log_delta=( + "API_REQUEST: request_id=req_1 model=openai/gpt-oss-120b:free " + "messages=1\n" + "FULL_PAYLOAD [req_1]: {'messages': [], 'tools': " + "[{'name': 'Agent'}, {'name': 'Read'}], 'tool_choice': None}" + ), + log_path=tmp_path / "server.log", + requires_tool_result=True, + requires_agent=True, + ) + + assert outcome.classification == "passed" + assert outcome.token_evidence["agent_catalog_present"] is True + assert outcome.token_evidence["agent_tool_count"] == 1 + assert outcome.token_evidence["agent_result_count"] == 1 + + +def test_cli_matrix_agent_prompt_text_without_tool_evidence_does_not_pass( + tmp_path: Path, +) -> None: + marker = "FCC_OPENROUTER_FREE_TASK" + run = ClaudeCliRun( + command=("claude", "-p", "x"), + returncode=0, + stdout=f"{marker}\nAgent should read the file.", + stderr="", + duration_s=0.1, + ) + outcome = make_outcome( + model="openai/gpt-oss-120b:free", + full_model="open_router/openai/gpt-oss-120b:free", + source="openrouter_free_cli_default", + feature="subagent_task", + marker=marker, + run=run, + log_delta=( + "API_REQUEST: request_id=req_1 model=openai/gpt-oss-120b:free " + "messages=1\n" + "FULL_PAYLOAD [req_1]: {'messages': [], 'tools': " + "[{'name': 'Agent'}, {'name': 'Read'}], 'tool_choice': None}" + ), + log_path=tmp_path / "server.log", + requires_agent=True, + ) + + assert outcome.classification == "model_feature_failure" + assert outcome.token_evidence["agent_catalog_present"] is True + assert outcome.token_evidence["agent_tool_count"] == 0 + + +def test_cli_matrix_structured_provider_error_is_upstream_unavailable( + tmp_path: Path, +) -> None: + run = ClaudeCliRun( + command=("claude", "-p", "x"), + returncode=0, + stdout="Provider API request failed. (request_id=req_123)", + stderr="", + duration_s=0.1, + ) + outcome = make_outcome( + model="poolside/laguna-m.1:free", + full_model="open_router/poolside/laguna-m.1:free", + source="openrouter_free_cli_default", + feature="tool_use_roundtrip", + marker="FCC_OPENROUTER_FREE_TOOL", + run=run, + log_delta=( + '{"event": "free_claude_code.api.request.received", "http_method": "POST", ' + '"http_path": "/v1/messages"}\n' + '{"event": "provider.response.error", "exc_type": "HTTPStatusError"}' + ), + log_path=tmp_path / "server.log", + requires_tool_result=True, + ) + + assert outcome.classification == "upstream_unavailable" + assert outcome.request_count == 1 + + +def test_nvidia_nim_cli_timeout_is_not_model_missing( + tmp_path: Path, +) -> None: + run = ClaudeCliRun( + command=("claude", "-p", "x"), + returncode=None, + stdout='{"type":"assistant","content":[{"type":"text","text":"FCC_NIM_TOOL"}]}', + stderr="", + duration_s=45.0, + timed_out=True, + ) + outcome = make_outcome( + model="z-ai/glm-5.2", + full_model="nvidia_nim/z-ai/glm-5.2", + source="nvidia_nim_cli_default", + feature="tool_use_roundtrip", + marker="FCC_NIM_TOOL", + run=run, + log_delta="API_REQUEST: request_id=req_1 model=z-ai/glm-5.2 messages=2", + log_path=tmp_path / "server.log", + ) + + assert outcome.classification == "probe_timeout" + assert outcome.token_evidence["timed_out"] is True + assert regression_failures([outcome]) == [] + + +def test_nvidia_nim_cli_success_beats_verbose_timeout_words(tmp_path: Path) -> None: + run = ClaudeCliRun( + command=("claude", "-p", "x"), + returncode=0, + stdout="FCC_NIM_THINK", + stderr="", + duration_s=0.1, + ) + outcome = make_outcome( + model="z-ai/glm-5.2", + full_model="nvidia_nim/z-ai/glm-5.2", + source="nvidia_nim_cli_default", + feature="thinking", + marker="FCC_NIM_THINK", + run=run, + log_delta=( + "API_REQUEST: request_id=req_1 model=z-ai/glm-5.2 messages=1 " + "read_timeout_s=300" + ), + log_path=tmp_path / "server.log", + ) + + assert outcome.classification == "passed" + assert outcome.request_count == 1 + + +def test_cli_matrix_uuid_429_does_not_count_as_upstream_unavailable( + tmp_path: Path, +) -> None: + run = ClaudeCliRun( + command=("claude", "-p", "x"), + returncode=0, + stdout='{"uuid":"d3c76eea-3634-4299-aec0-e7634b3716da"}', + stderr="", + duration_s=0.1, + ) + outcome = make_outcome( + model="openai/gpt-oss-120b:free", + full_model="open_router/openai/gpt-oss-120b:free", + source="openrouter_free_cli_default", + feature="subagent_task", + marker="FCC_OPENROUTER_FREE_TASK", + run=run, + log_delta="API_REQUEST: request_id=req_1 model=openai/gpt-oss-120b:free messages=2", + log_path=tmp_path / "server.log", + requires_task=True, + ) + + assert outcome.classification == "model_feature_failure" + + +def test_cli_matrix_real_http_429_counts_as_upstream_unavailable( + tmp_path: Path, +) -> None: + run = ClaudeCliRun( + command=("claude", "-p", "x"), + returncode=0, + stdout="ordinary answer", + stderr="", + duration_s=0.1, + ) + outcome = make_outcome( + model="openai/gpt-oss-120b:free", + full_model="open_router/openai/gpt-oss-120b:free", + source="openrouter_free_cli_default", + feature="subagent_task", + marker="FCC_OPENROUTER_FREE_TASK", + run=run, + log_delta=( + "API_REQUEST: request_id=req_1 model=openai/gpt-oss-120b:free " + 'messages=2 upstream HTTP/1.1" 429 Too Many Requests' + ), + log_path=tmp_path / "server.log", + requires_task=True, + ) + + assert outcome.classification == "upstream_unavailable" + + +def test_cli_matrix_default_command_uses_bare_mode() -> None: + command = _build_claude_cli_command( + claude_bin="claude", + prompt="hello", + tools="Read", + ) + + assert command[:2] == ("claude", "--bare") + assert "--tools" in command + assert "Read" in command + + +def test_cli_matrix_subagent_command_uses_agent_without_bare_or_task() -> None: + bare, tools, pre_tool_args, extra_args = _subagent_probe_options("{}") + command = _build_claude_cli_command( + claude_bin="claude", + prompt="hello", + tools=tools, + bare=bare, + pre_tool_args=pre_tool_args, + extra_args=extra_args, + ) + + assert "--bare" not in command + assert command[command.index("--setting-sources") + 1] == "local" + assert "--strict-mcp-config" in command + assert command[command.index("--mcp-config") + 1] == '{"mcpServers":{}}' + assert command[command.index("--tools") + 1] == "Agent,Read" + assert command[command.index("--allowedTools") + 1] == "Agent,Read" + assert command[command.index("--agents") + 1] == "{}" + assert "Task,Read" not in command diff --git a/tests/contracts/test_provider_catalog_order.py b/tests/contracts/test_provider_catalog_order.py new file mode 100644 index 0000000..4da5c9b --- /dev/null +++ b/tests/contracts/test_provider_catalog_order.py @@ -0,0 +1,40 @@ +"""Freeze ``PROVIDER_CATALOG`` insertion order used as canonical provider ranking.""" + +from free_claude_code.config.provider_catalog import ( + PROVIDER_CATALOG, + SUPPORTED_PROVIDER_IDS, +) + +_EXPECTED_PROVIDER_ORDER: tuple[str, ...] = ( + "nvidia_nim", + "open_router", + "gemini", + "deepseek", + "mistral", + "mistral_codestral", + "opencode", + "opencode_go", + "vercel", + "huggingface", + "cohere", + "github_models", + "wafer", + "kimi", + "minimax", + "cerebras", + "groq", + "sambanova", + "fireworks", + "cloudflare", + "zai", + "lmstudio", + "llamacpp", + "ollama", +) + + +def test_provider_catalog_key_order_matches_canonical_plan() -> None: + """NIM first; OpenCode pair stays adjacent; gateways precede native remotes.""" + + assert tuple(PROVIDER_CATALOG.keys()) == _EXPECTED_PROVIDER_ORDER + assert SUPPORTED_PROVIDER_IDS == _EXPECTED_PROVIDER_ORDER diff --git a/tests/contracts/test_smoke_child_process.py b/tests/contracts/test_smoke_child_process.py new file mode 100644 index 0000000..bee5cb9 --- /dev/null +++ b/tests/contracts/test_smoke_child_process.py @@ -0,0 +1,116 @@ +import subprocess +from pathlib import Path + +from free_claude_code.config.settings import Settings +from smoke.lib import child_process +from smoke.lib import server as smoke_server +from smoke.lib.child_process import ( + cmd_free_claude_code_serve, + cmd_python_c, + run_captured_text, +) +from smoke.lib.config import SmokeConfig + + +def test_free_claude_code_serve_command_uses_cli_entrypoint() -> None: + assert cmd_free_claude_code_serve() == [ + child_process.python_exe(), + "-c", + "from free_claude_code.cli.entrypoints import serve; serve()", + ] + + +def test_start_server_disables_cli_admin_browser(monkeypatch, tmp_path: Path) -> None: + captured: dict[str, object] = {} + + class FakeProcess: + def __init__(self, command: list[str], **kwargs: object) -> None: + captured["command"] = command + captured.update(kwargs) + + def poll(self) -> int | None: + return None + + config = SmokeConfig( + root=tmp_path, + results_dir=tmp_path / "results", + live=True, + interactive=False, + targets=frozenset(), + provider_matrix=frozenset(), + timeout_s=1.0, + prompt="", + claude_bin="claude", + worker_id="test", + settings=Settings(), + ) + + monkeypatch.setattr(smoke_server, "find_free_port", lambda: 4567) + monkeypatch.setattr(smoke_server.subprocess, "Popen", FakeProcess) + monkeypatch.setattr( + smoke_server, "_wait_for_health", lambda _server, *, timeout_s: None + ) + monkeypatch.setattr(smoke_server, "_stop_process", lambda _process: None) + + with smoke_server.start_server(config): + pass + + env_obj = captured["env"] + assert isinstance(env_obj, dict) + env = {str(key): value for key, value in env_obj.items()} + assert env["FCC_OPEN_BROWSER"] == "0" + assert env["HOST"] == "127.0.0.1" + assert env["PORT"] == "4567" + + +def test_run_captured_text_uses_utf8_replacement(monkeypatch, tmp_path: Path) -> None: + calls: dict[str, object] = {} + + def fake_run( + command: list[str], + **kwargs: object, + ) -> subprocess.CompletedProcess[str]: + calls["command"] = command + calls.update(kwargs) + return subprocess.CompletedProcess( + args=command, + returncode=0, + stdout="ok", + stderr="", + ) + + monkeypatch.setattr(child_process.subprocess, "run", fake_run) + + result = run_captured_text( + ("cmd", "arg"), + cwd=tmp_path, + env={"FCC_TEST": "1"}, + timeout=1.0, + ) + + assert result.stdout == "ok" + assert calls["command"] == ["cmd", "arg"] + assert calls["cwd"] == tmp_path + assert calls["env"] == {"FCC_TEST": "1"} + assert calls["capture_output"] is True + assert calls["text"] is True + assert calls["encoding"] == "utf-8" + assert calls["errors"] == "replace" + assert calls["timeout"] == 1.0 + assert calls["check"] is False + + +def test_run_captured_text_replaces_invalid_utf8_bytes(tmp_path: Path) -> None: + result = run_captured_text( + cmd_python_c( + "import sys; " + "sys.stdout.buffer.write(bytes([0x8f])); " + "sys.stderr.buffer.write(bytes([0x8f]))" + ), + cwd=tmp_path, + timeout=10.0, + ) + + assert result.returncode == 0 + assert result.stdout == "\ufffd" + assert result.stderr == "\ufffd" diff --git a/tests/contracts/test_smoke_config.py b/tests/contracts/test_smoke_config.py new file mode 100644 index 0000000..bda5569 --- /dev/null +++ b/tests/contracts/test_smoke_config.py @@ -0,0 +1,582 @@ +from pathlib import Path +from types import SimpleNamespace + +from smoke.conftest import ( + DISABLED_PROVIDER_MODEL, + provider_model_params, + provider_xdist_group, +) +from smoke.lib.config import ( + ALL_TARGETS, + DEFAULT_TARGETS, + MISTRAL_REASONING_SMOKE_DEFAULT_MODEL, + NVIDIA_NIM_CLI_DEFAULT_MODELS, + OPENROUTER_FREE_CLI_DEFAULT_MODELS, + OPT_IN_TARGETS, + PROVIDER_SMOKE_DEFAULT_MODELS, + TARGET_REQUIRED_ENV, + SmokeConfig, + nvidia_nim_cli_model_refs, + openrouter_free_cli_model_refs, +) + + +def _settings(**overrides): + values = { + "model": "ollama/llama3.1", + "model_opus": None, + "model_sonnet": None, + "model_haiku": None, + "nvidia_nim_api_key": "", + "open_router_api_key": "", + "mistral_api_key": "", + "codestral_api_key": "", + "deepseek_api_key": "", + "kimi_api_key": "", + "wafer_api_key": "", + "minimax_api_key": "", + "opencode_api_key": "", + "vercel_ai_gateway_api_key": "", + "huggingface_api_key": "", + "cohere_api_key": "", + "github_models_token": "", + "zai_api_key": "", + "gemini_api_key": "", + "groq_api_key": "", + "sambanova_api_key": "", + "cerebras_api_key": "", + "fireworks_api_key": "", + "cloudflare_api_token": "", + "cloudflare_account_id": "", + "lm_studio_base_url": "", + "llamacpp_base_url": "", + "ollama_base_url": "http://localhost:11434", + } + values.update(overrides) + return SimpleNamespace(**values) + + +def _smoke_config(**overrides) -> SmokeConfig: + values = { + "root": Path("."), + "results_dir": Path(".smoke-results"), + "live": False, + "interactive": False, + "targets": DEFAULT_TARGETS, + "provider_matrix": frozenset(), + "timeout_s": 45.0, + "prompt": "Reply with exactly: FCC_SMOKE_PONG", + "claude_bin": "claude", + "worker_id": "main", + "settings": _settings(), + } + values.update(overrides) + return SmokeConfig(**values) + + +def test_ollama_is_default_smoke_target() -> None: + assert "ollama" in DEFAULT_TARGETS + assert "ollama" in TARGET_REQUIRED_ENV + + +def test_nvidia_nim_cli_is_opt_in_smoke_target() -> None: + assert "nvidia_nim_cli" not in DEFAULT_TARGETS + assert "nvidia_nim_cli" in OPT_IN_TARGETS + assert "nvidia_nim_cli" in ALL_TARGETS + assert "nvidia_nim_cli" in TARGET_REQUIRED_ENV + assert "openrouter_free_cli" not in DEFAULT_TARGETS + assert "openrouter_free_cli" in OPT_IN_TARGETS + assert "openrouter_free_cli" in ALL_TARGETS + assert "openrouter_free_cli" in TARGET_REQUIRED_ENV + + +def test_ollama_provider_configuration_uses_base_url() -> None: + config = _smoke_config() + + assert config.has_provider_configuration("ollama") + assert config.provider_models()[0].full_model == "ollama/llama3.1" + + +def test_ollama_provider_matrix_filters_models() -> None: + config = _smoke_config(provider_matrix=frozenset({"ollama"})) + + assert [model.provider for model in config.provider_models()] == ["ollama"] + + +def test_provider_smoke_models_cover_configured_providers_independent_of_model_mapping( + monkeypatch, +) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_DEEPSEEK", raising=False) + config = _smoke_config( + settings=_settings( + model="ollama/llama3.1", + deepseek_api_key="deepseek-key", + ollama_base_url="", + ) + ) + + models = config.provider_smoke_models() + + assert [model.provider for model in models] == ["deepseek"] + assert models[0].full_model == PROVIDER_SMOKE_DEFAULT_MODELS["deepseek"] + assert models[0].source == "provider_default" + + +def test_openrouter_provider_smoke_uses_concrete_free_model(monkeypatch) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_OPEN_ROUTER", raising=False) + config = _smoke_config( + settings=_settings(open_router_api_key="openrouter-key", ollama_base_url="") + ) + + models = config.provider_smoke_models() + + assert [model.provider for model in models] == ["open_router"] + assert models[0].full_model == "open_router/moonshotai/kimi-k2.6:free" + assert models[0].source == "provider_default" + + +def test_wafer_provider_configuration_uses_api_key(monkeypatch) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_WAFER", raising=False) + config = _smoke_config( + settings=_settings( + model="ollama/llama3.1", + ollama_base_url="", + wafer_api_key="wafer-key", + ) + ) + + assert config.has_provider_configuration("wafer") + models = config.provider_smoke_models() + assert models[0].provider == "wafer" + assert models[0].full_model == PROVIDER_SMOKE_DEFAULT_MODELS["wafer"] + + +def test_minimax_provider_configuration_uses_api_key(monkeypatch) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_MINIMAX", raising=False) + config = _smoke_config( + settings=_settings( + model="ollama/llama3.1", + ollama_base_url="", + minimax_api_key="minimax-key", + ) + ) + + assert config.has_provider_configuration("minimax") + models = config.provider_smoke_models() + assert models[0].provider == "minimax" + assert models[0].full_model == PROVIDER_SMOKE_DEFAULT_MODELS["minimax"] + + +def test_cloudflare_provider_configuration_requires_token_and_account( + monkeypatch, +) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_CLOUDFLARE", raising=False) + config = _smoke_config( + settings=_settings( + model="ollama/llama3.1", + ollama_base_url="", + cloudflare_api_token="cf-token", + cloudflare_account_id="cf-account", + ) + ) + + assert config.has_provider_configuration("cloudflare") + models = config.provider_smoke_models() + assert models[0].provider == "cloudflare" + assert models[0].full_model == PROVIDER_SMOKE_DEFAULT_MODELS["cloudflare"] + + +def test_cloudflare_provider_configuration_missing_account_is_unconfigured() -> None: + config = _smoke_config( + settings=_settings( + ollama_base_url="", + cloudflare_api_token="cf-token", + cloudflare_account_id="", + ) + ) + + assert not config.has_provider_configuration("cloudflare") + + +def test_vercel_provider_configuration_uses_api_key(monkeypatch) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_VERCEL", raising=False) + config = _smoke_config( + settings=_settings( + model="ollama/llama3.1", + ollama_base_url="", + vercel_ai_gateway_api_key="vercel-key", + ) + ) + + assert config.has_provider_configuration("vercel") + models = config.provider_smoke_models() + assert models[0].provider == "vercel" + assert models[0].full_model == PROVIDER_SMOKE_DEFAULT_MODELS["vercel"] + + +def test_huggingface_provider_configuration_uses_api_key(monkeypatch) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_HUGGINGFACE", raising=False) + config = _smoke_config( + settings=_settings( + model="ollama/llama3.1", + ollama_base_url="", + huggingface_api_key="hf-key", + ) + ) + + assert config.has_provider_configuration("huggingface") + models = config.provider_smoke_models() + assert models[0].provider == "huggingface" + assert models[0].full_model == PROVIDER_SMOKE_DEFAULT_MODELS["huggingface"] + + +def test_cohere_provider_configuration_uses_api_key(monkeypatch) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_COHERE", raising=False) + config = _smoke_config( + settings=_settings( + model="ollama/llama3.1", + ollama_base_url="", + cohere_api_key="cohere-key", + ) + ) + + assert config.has_provider_configuration("cohere") + models = config.provider_smoke_models() + assert models[0].provider == "cohere" + assert models[0].full_model == PROVIDER_SMOKE_DEFAULT_MODELS["cohere"] + + +def test_github_models_provider_configuration_uses_token(monkeypatch) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_GITHUB_MODELS", raising=False) + config = _smoke_config( + settings=_settings( + model="ollama/llama3.1", + ollama_base_url="", + github_models_token="github-token", + ) + ) + + assert config.has_provider_configuration("github_models") + models = config.provider_smoke_models() + assert models[0].provider == "github_models" + assert models[0].full_model == PROVIDER_SMOKE_DEFAULT_MODELS["github_models"] + + +def test_sambanova_provider_configuration_uses_api_key(monkeypatch) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_SAMBANOVA", raising=False) + config = _smoke_config( + settings=_settings( + model="ollama/llama3.1", + ollama_base_url="", + sambanova_api_key="sambanova-key", + ) + ) + + assert config.has_provider_configuration("sambanova") + models = config.provider_smoke_models() + assert models[0].provider == "sambanova" + assert models[0].full_model == PROVIDER_SMOKE_DEFAULT_MODELS["sambanova"] + + +def test_provider_smoke_model_override_accepts_model_name_without_prefix( + monkeypatch, +) -> None: + monkeypatch.setenv("FCC_SMOKE_MODEL_DEEPSEEK", "deepseek-reasoner") + config = _smoke_config( + settings=_settings( + deepseek_api_key="deepseek-key", + ollama_base_url="", + ), + provider_matrix=frozenset({"deepseek"}), + ) + + models = config.provider_smoke_models() + + assert models[0].full_model == "deepseek/deepseek-reasoner" + assert models[0].source == "FCC_SMOKE_MODEL_DEEPSEEK" + + +def test_provider_smoke_model_override_accepts_owner_model_name( + monkeypatch, +) -> None: + monkeypatch.setenv( + "FCC_SMOKE_MODEL_NVIDIA_NIM", "nvidia/nemotron-3-super-120b-a12b" + ) + config = _smoke_config( + settings=_settings( + model="deepseek/deepseek-chat", + deepseek_api_key="", + nvidia_nim_api_key="nim-key", + ollama_base_url="", + ), + provider_matrix=frozenset({"nvidia_nim"}), + ) + + models = config.provider_smoke_models() + + assert models[0].full_model == "nvidia_nim/nvidia/nemotron-3-super-120b-a12b" + assert models[0].source == "FCC_SMOKE_MODEL_NVIDIA_NIM" + + +def test_provider_smoke_model_override_rejects_wrong_provider_prefix( + monkeypatch, +) -> None: + monkeypatch.setenv("FCC_SMOKE_MODEL_DEEPSEEK", "ollama/llama3.1") + config = _smoke_config( + settings=_settings( + deepseek_api_key="deepseek-key", + ollama_base_url="", + ), + provider_matrix=frozenset({"deepseek"}), + ) + + try: + config.provider_smoke_models() + except ValueError as exc: + assert "FCC_SMOKE_MODEL_DEEPSEEK" in str(exc) + else: + raise AssertionError("expected wrong provider prefix to fail") + + +def test_mistral_reasoning_smoke_uses_reasoning_default(monkeypatch) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_MISTRAL_REASONING", raising=False) + config = _smoke_config( + settings=_settings(mistral_api_key="mistral-key", ollama_base_url="") + ) + + model = config.mistral_reasoning_smoke_model() + + assert model is not None + assert model.provider == "mistral" + assert model.full_model == MISTRAL_REASONING_SMOKE_DEFAULT_MODEL + assert model.source == "mistral_reasoning_default" + + +def test_mistral_reasoning_smoke_accepts_override(monkeypatch) -> None: + monkeypatch.setenv("FCC_SMOKE_MODEL_MISTRAL_REASONING", "mistral-medium-3-5") + config = _smoke_config( + settings=_settings(mistral_api_key="mistral-key", ollama_base_url="") + ) + + model = config.mistral_reasoning_smoke_model() + + assert model is not None + assert model.full_model == "mistral/mistral-medium-3-5" + assert model.source == "FCC_SMOKE_MODEL_MISTRAL_REASONING" + + +def test_mistral_reasoning_smoke_respects_provider_matrix(monkeypatch) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_MISTRAL_REASONING", raising=False) + config = _smoke_config( + settings=_settings(mistral_api_key="mistral-key", ollama_base_url=""), + provider_matrix=frozenset({"deepseek"}), + ) + + assert config.mistral_reasoning_smoke_model() is None + + +def test_provider_smoke_matrix_filters_provider_catalog(monkeypatch) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_DEEPSEEK", raising=False) + config = _smoke_config( + settings=_settings( + deepseek_api_key="deepseek-key", + nvidia_nim_api_key="nim-key", + ollama_base_url="", + ), + provider_matrix=frozenset({"nvidia_nim"}), + ) + + assert [model.provider for model in config.provider_smoke_models()] == [ + "nvidia_nim" + ] + + +def test_provider_smoke_collection_params_are_grouped_by_provider( + monkeypatch, +) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_DEEPSEEK", raising=False) + monkeypatch.delenv("FCC_SMOKE_MODEL_NVIDIA_NIM", raising=False) + config = _smoke_config( + live=True, + settings=_settings( + deepseek_api_key="deepseek-key", + nvidia_nim_api_key="nim-key", + ollama_base_url="", + ), + ) + + params = provider_model_params(config) + + assert [param.id for param in params] == ["nvidia_nim", "deepseek"] + groups = [ + mark.args[0] + for param in params + for mark in param.marks + if mark.name == "xdist_group" + ] + assert groups == ["provider:nvidia_nim", "provider:deepseek"] + + +def test_provider_smoke_collection_uses_disabled_placeholder_when_not_live() -> None: + config = _smoke_config(live=False, settings=_settings(ollama_base_url="")) + + params = provider_model_params(config) + + assert [param.values[0] for param in params] == [DISABLED_PROVIDER_MODEL] + assert provider_xdist_group(DISABLED_PROVIDER_MODEL) == "provider:smoke_disabled" + + +def test_provider_smoke_includes_local_provider_when_model_mapping_uses_it( + monkeypatch, +) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_OLLAMA", raising=False) + config = _smoke_config() + + assert [model.provider for model in config.provider_smoke_models()] == ["ollama"] + + +def test_provider_smoke_does_not_include_default_local_urls_when_unmapped( + monkeypatch, +) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_OLLAMA", raising=False) + config = _smoke_config(settings=_settings(model="nvidia_nim/test")) + + assert config.provider_smoke_models() == [] + + +def test_nvidia_nim_cli_default_models_are_normalized() -> None: + refs = nvidia_nim_cli_model_refs({}) + + assert tuple(refs) == tuple( + f"nvidia_nim/{model}" for model in NVIDIA_NIM_CLI_DEFAULT_MODELS + ) + assert "nvidia_nim/deepseek-ai/deepseek-v4-pro" in refs + assert "nvidia_nim/deepseek-ai/deepseek-v4-flash" in refs + assert set(refs.values()) == {"nvidia_nim_cli_default"} + + +def test_nvidia_nim_cli_models_override_and_append() -> None: + refs = nvidia_nim_cli_model_refs( + { + "FCC_SMOKE_NIM_MODELS": "z-ai/glm-5.2,nvidia_nim/custom/model", + "FCC_SMOKE_NIM_EXTRA_MODELS": "moonshotai/kimi-k2.6,z-ai/glm-5.2", + } + ) + + assert tuple(refs) == ( + "nvidia_nim/z-ai/glm-5.2", + "nvidia_nim/custom/model", + "nvidia_nim/moonshotai/kimi-k2.6", + ) + assert refs["nvidia_nim/z-ai/glm-5.2"] == "FCC_SMOKE_NIM_MODELS" + assert refs["nvidia_nim/moonshotai/kimi-k2.6"] == ("FCC_SMOKE_NIM_EXTRA_MODELS") + + +def test_nvidia_nim_cli_models_reject_empty_override() -> None: + try: + nvidia_nim_cli_model_refs({"FCC_SMOKE_NIM_MODELS": " , "}) + except ValueError as exc: + assert "FCC_SMOKE_NIM_MODELS" in str(exc) + else: + raise AssertionError("expected empty NVIDIA NIM CLI model override to fail") + + +def test_nvidia_nim_cli_models_reject_wrong_provider_prefix() -> None: + try: + nvidia_nim_cli_model_refs({"FCC_SMOKE_NIM_MODELS": "open_router/model"}) + except ValueError as exc: + assert "nvidia_nim" in str(exc) + else: + raise AssertionError("expected wrong provider prefix to fail") + + +def test_smoke_config_returns_nvidia_nim_cli_provider_models(monkeypatch) -> None: + monkeypatch.delenv("FCC_SMOKE_NIM_MODELS", raising=False) + monkeypatch.delenv("FCC_SMOKE_NIM_EXTRA_MODELS", raising=False) + config = _smoke_config( + settings=_settings( + model="nvidia_nim/z-ai/glm-5.2", + nvidia_nim_api_key="nim-key", + ollama_base_url="", + ) + ) + + models = config.nvidia_nim_cli_models() + + assert models[0].provider == "nvidia_nim" + assert models[0].full_model == "nvidia_nim/z-ai/glm-5.2" + assert models[0].source == "nvidia_nim_cli_default" + + +def test_openrouter_free_cli_default_models_are_normalized() -> None: + refs = openrouter_free_cli_model_refs({}) + + assert tuple(refs) == tuple( + f"open_router/{model}" for model in OPENROUTER_FREE_CLI_DEFAULT_MODELS + ) + assert "open_router/nvidia/nemotron-3-super-120b-a12b:free" in refs + assert "open_router/poolside/laguna-m.1:free" in refs + assert set(refs.values()) == {"openrouter_free_cli_default"} + + +def test_openrouter_free_cli_models_override_and_append() -> None: + refs = openrouter_free_cli_model_refs( + { + "FCC_SMOKE_OPENROUTER_FREE_MODELS": ( + "openai/gpt-oss-120b:free,open_router/custom/model:free" + ), + "FCC_SMOKE_OPENROUTER_FREE_EXTRA_MODELS": ( + "poolside/laguna-m.1:free,openai/gpt-oss-120b:free" + ), + } + ) + + assert tuple(refs) == ( + "open_router/openai/gpt-oss-120b:free", + "open_router/custom/model:free", + "open_router/poolside/laguna-m.1:free", + ) + assert refs["open_router/openai/gpt-oss-120b:free"] == ( + "FCC_SMOKE_OPENROUTER_FREE_MODELS" + ) + assert refs["open_router/poolside/laguna-m.1:free"] == ( + "FCC_SMOKE_OPENROUTER_FREE_EXTRA_MODELS" + ) + + +def test_openrouter_free_cli_models_reject_empty_override() -> None: + try: + openrouter_free_cli_model_refs({"FCC_SMOKE_OPENROUTER_FREE_MODELS": " , "}) + except ValueError as exc: + assert "FCC_SMOKE_OPENROUTER_FREE_MODELS" in str(exc) + else: + raise AssertionError("expected empty OpenRouter free CLI override to fail") + + +def test_openrouter_free_cli_models_reject_wrong_provider_prefix() -> None: + try: + openrouter_free_cli_model_refs( + {"FCC_SMOKE_OPENROUTER_FREE_MODELS": "nvidia_nim/model"} + ) + except ValueError as exc: + assert "open_router" in str(exc) + else: + raise AssertionError("expected wrong provider prefix to fail") + + +def test_smoke_config_returns_openrouter_free_cli_provider_models(monkeypatch) -> None: + monkeypatch.delenv("FCC_SMOKE_OPENROUTER_FREE_MODELS", raising=False) + monkeypatch.delenv("FCC_SMOKE_OPENROUTER_FREE_EXTRA_MODELS", raising=False) + config = _smoke_config( + settings=_settings( + model="open_router/openai/gpt-oss-120b:free", + open_router_api_key="openrouter-key", + ollama_base_url="", + ) + ) + + models = config.openrouter_free_cli_models() + + assert models[0].provider == "open_router" + assert models[0].full_model == "open_router/nvidia/nemotron-3-super-120b-a12b:free" + assert models[0].source == "openrouter_free_cli_default" diff --git a/tests/contracts/test_smoke_tiers.py b/tests/contracts/test_smoke_tiers.py new file mode 100644 index 0000000..801cf9d --- /dev/null +++ b/tests/contracts/test_smoke_tiers.py @@ -0,0 +1,88 @@ +import json +from pathlib import Path + +import pytest + +from free_claude_code.core.anthropic.stream_contracts import SSEEvent +from smoke.lib.report import classify_outcome +from smoke.lib.report_summary import format_summary, summarize_reports +from smoke.lib.skips import skip_if_upstream_unavailable_events + + +def test_smoke_readme_uses_env_gated_serial_commands() -> None: + repo_root = Path(__file__).resolve().parents[2] + text = (repo_root / "smoke" / "README.md").read_text(encoding="utf-8") + + assert "FCC_LIVE_SMOKE=1" in text + assert "-n 0" in text + assert "-m live" not in text + + +def test_smoke_report_summary_counts_regression_classes(tmp_path: Path) -> None: + report = { + "outcomes": [ + {"classification": "missing_env"}, + {"classification": "product_failure"}, + {"classification": "upstream_unavailable"}, + ] + } + (tmp_path / "report-one.json").write_text(json.dumps(report), encoding="utf-8") + + summary = summarize_reports(tmp_path) + + assert summary.reports == 1 + assert summary.outcomes == 3 + assert summary.classifications["product_failure"] == 1 + assert summary.has_regression + assert "status=regression" in format_summary(summary) + + +def test_target_disabled_skip_is_not_missing_env() -> None: + classification = classify_outcome( + nodeid="smoke/product/test_api_product_live.py::test_api_basic_conversation_e2e", + outcome="skipped", + detail="Skipped: smoke target disabled: api", + ) + + assert classification == "target_disabled" + + +def test_explicit_missing_env_skip_wins_over_network_words() -> None: + classification = classify_outcome( + nodeid="smoke/prereq/test_local_provider_endpoints_prereq_live.py::" + "test_ollama_endpoint_prereq_live", + outcome="skipped", + detail="Skipped: missing_env: ollama local server is not reachable: timed out", + ) + + assert classification == "missing_env" + + +def test_provider_error_text_stream_is_upstream_unavailable_skip() -> None: + events = [ + SSEEvent("message_start", {"message": {"content": []}}, ""), + SSEEvent( + "content_block_start", + {"index": 0, "content_block": {"type": "text", "text": ""}}, + "", + ), + SSEEvent( + "content_block_delta", + { + "index": 0, + "delta": { + "type": "text_delta", + "text": "Upstream provider OPENROUTER returned HTTP 429.", + }, + }, + "", + ), + SSEEvent("content_block_stop", {"index": 0}, ""), + SSEEvent("message_delta", {"delta": {"stop_reason": "end_turn"}}, ""), + SSEEvent("message_stop", {}, ""), + ] + + with pytest.raises(pytest.skip.Exception) as excinfo: + skip_if_upstream_unavailable_events(events) + + assert "upstream_unavailable" in str(excinfo.value) diff --git a/tests/contracts/test_stream_contracts.py b/tests/contracts/test_stream_contracts.py new file mode 100644 index 0000000..4c16455 --- /dev/null +++ b/tests/contracts/test_stream_contracts.py @@ -0,0 +1,194 @@ +"""Stream/SSE contract tests. Strict transcript *ordering* is covered here for +``AnthropicStreamLedger`` output; for integration ordering, add messaging or API +integration tests. +""" + +from collections.abc import Iterable + +from free_claude_code.core.anthropic import ( + AnthropicStreamLedger, + ContentType, + HeuristicToolParser, + ThinkTagParser, +) +from free_claude_code.core.anthropic.stream_contracts import ( + assert_anthropic_stream_contract, + event_names, + parse_sse_text, + text_content, + thinking_content, +) +from free_claude_code.core.anthropic.streaming import format_sse_event + + +def test_interleaved_thinking_text_blocks_are_valid() -> None: + events = _parse_builder_events( + _interleaved_thinking_text_events( + ("first thought", "first answer", "second thought", "final answer") + ) + ) + assert_anthropic_stream_contract(events) + assert event_names(events).count("content_block_start") == 4 + assert thinking_content(events) == "first thoughtsecond thought" + assert text_content(events) == "first answerfinal answer" + + +def test_split_think_tags_preserve_text_and_thinking() -> None: + events = _parse_builder_events( + _events_from_text_chunks(["before hidden", " after"]) + ) + assert_anthropic_stream_contract(events) + assert thinking_content(events) == "hidden" + assert text_content(events) == "before after" + + +def test_mixed_reasoning_content_and_think_tags_keep_order() -> None: + builder = AnthropicStreamLedger("msg_contract", "contract-model") + chunks = [builder.message_start()] + chunks.extend(builder.ensure_thinking_block()) + chunks.append(builder.emit_thinking_delta("reasoning field")) + chunks.extend( + _events_from_text_chunks([" visible tagged done"], builder) + ) + chunks.extend(builder.close_all_blocks()) + chunks.append(builder.message_delta("end_turn", 10)) + chunks.append(builder.message_stop()) + + events = parse_sse_text("".join(chunks)) + assert_anthropic_stream_contract(events) + assert thinking_content(events) == "reasoning fieldtagged" + assert text_content(events) == " visible done" + + +def test_redacted_thinking_block_start_stop_is_valid() -> None: + """Native redacted_thinking uses start/stop only (no deltas).""" + chunks = [ + format_sse_event( + "message_start", + { + "type": "message_start", + "message": { + "id": "msg_r", + "type": "message", + "role": "assistant", + "content": [], + "model": "m", + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + }, + ), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "redacted_thinking", "data": "opaque"}, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ), + format_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"input_tokens": 1, "output_tokens": 2}, + }, + ), + format_sse_event("message_stop", {"type": "message_stop"}), + ] + events = parse_sse_text("".join(chunks)) + assert_anthropic_stream_contract(events) + + +def test_enable_thinking_false_suppresses_reasoning_only() -> None: + events = _parse_builder_events( + _events_from_text_chunks( + ["hello secret world"], enable_thinking=False + ) + ) + assert_anthropic_stream_contract(events) + assert "secret" not in thinking_content(events) + assert text_content(events) == "hello world" + + +def test_task_tool_arguments_force_foreground_execution() -> None: + parser = HeuristicToolParser() + filtered, detected = parser.feed( + "● Inspect" + "true trailing" + ) + detected.extend(parser.flush()) + assert "trailing" in filtered + task = detected[0] + assert task["name"] == "Task" + if isinstance(task.get("input"), dict): + task["input"]["run_in_background"] = False + assert task["input"]["run_in_background"] is False + + +def _interleaved_thinking_text_events( + parts: tuple[str, str, str, str], +) -> Iterable[str]: + builder = AnthropicStreamLedger("msg_contract", "contract-model") + yield builder.message_start() + yield from builder.ensure_thinking_block() + yield builder.emit_thinking_delta(parts[0]) + yield from builder.ensure_text_block() + yield builder.emit_text_delta(parts[1]) + yield from builder.ensure_thinking_block() + yield builder.emit_thinking_delta(parts[2]) + yield from builder.ensure_text_block() + yield builder.emit_text_delta(parts[3]) + yield from builder.close_all_blocks() + yield builder.message_delta("end_turn", 20) + yield builder.message_stop() + + +def _events_from_text_chunks( + chunks: list[str], + builder: AnthropicStreamLedger | None = None, + *, + enable_thinking: bool = True, +) -> list[str]: + sse = builder or AnthropicStreamLedger("msg_contract", "contract-model") + out: list[str] = [] if builder else [sse.message_start()] + parser = ThinkTagParser() + + for chunk in chunks: + out.extend(_emit_parser_parts(sse, parser.feed(chunk), enable_thinking)) + + remaining = parser.flush() + if remaining is not None: + out.extend(_emit_parser_parts(sse, [remaining], enable_thinking)) + + if builder is None: + out.extend(sse.close_all_blocks()) + out.append(sse.message_delta("end_turn", 20)) + out.append(sse.message_stop()) + return out + + +def _emit_parser_parts( + builder: AnthropicStreamLedger, + parts: Iterable, + enable_thinking: bool, +) -> list[str]: + out: list[str] = [] + for part in parts: + if part.type == ContentType.THINKING: + if enable_thinking: + out.extend(builder.ensure_thinking_block()) + out.append(builder.emit_thinking_delta(part.content)) + continue + out.extend(builder.ensure_text_block()) + out.append(builder.emit_text_delta(part.content)) + return out + + +def _parse_builder_events(chunks: Iterable[str]): + return parse_sse_text("".join(chunks)) diff --git a/tests/core/anthropic/test_errors.py b/tests/core/anthropic/test_errors.py new file mode 100644 index 0000000..21e1974 --- /dev/null +++ b/tests/core/anthropic/test_errors.py @@ -0,0 +1,43 @@ +import pytest + +from free_claude_code.core.anthropic import ( + anthropic_error_payload, + anthropic_status_for_error_type, +) + + +@pytest.mark.parametrize( + ("error_type", "status_code"), + [ + ("invalid_request_error", 400), + ("authentication_error", 401), + ("billing_error", 402), + ("permission_error", 403), + ("not_found_error", 404), + ("request_too_large", 413), + ("rate_limit_error", 429), + ("api_error", 500), + ("timeout_error", 504), + ("overloaded_error", 529), + ("future_error", 500), + ], +) +def test_anthropic_status_for_error_type(error_type: str, status_code: int) -> None: + assert anthropic_status_for_error_type(error_type) == status_code + + +def test_anthropic_error_payload_adds_request_id_and_redacts_credentials() -> None: + payload = anthropic_error_payload( + error_type="api_error", + message="failed token=SECRET authorization: Bearer ALSO_SECRET", + request_id="req_test", + ) + + assert payload == { + "type": "error", + "error": { + "type": "api_error", + "message": "failed token= authorization: ", + }, + "request_id": "req_test", + } diff --git a/tests/core/anthropic/test_models.py b/tests/core/anthropic/test_models.py new file mode 100644 index 0000000..10ff809 --- /dev/null +++ b/tests/core/anthropic/test_models.py @@ -0,0 +1,331 @@ +import pytest + +from free_claude_code.core.anthropic.conversion import ( + OpenAIConversionError, + build_base_request_body, +) +from free_claude_code.core.anthropic.models import ( + ContentBlockDocument, + ContentBlockWebFetchToolResult, + Message, + MessagesRequest, + TokenCountRequest, +) + + +def test_messages_request_parses_without_model_mapping_side_effects(): + request = MessagesRequest( + model="claude-3-opus", + max_tokens=100, + messages=[Message(role="user", content="hello")], + ) + + assert request.model == "claude-3-opus" + + +def test_messages_request_normalizes_system_role_messages(): + request = MessagesRequest.model_validate( + { + "model": "claude-3-opus", + "max_tokens": 100, + "messages": [ + {"role": "user", "content": "first"}, + {"role": "system", "content": "system prompt"}, + {"role": "user", "content": "second"}, + ], + } + ) + + assert [message.role for message in request.messages] == ["user", "user"] + assert request.system == "system prompt" + + +def test_messages_request_merges_system_role_messages_with_existing_system(): + request = MessagesRequest.model_validate( + { + "model": "claude-3-opus", + "max_tokens": 100, + "system": "existing system", + "messages": [ + {"role": "system", "content": "message system"}, + {"role": "user", "content": "hello"}, + ], + } + ) + + assert len(request.messages) == 1 + assert request.system == "existing system\n\nmessage system" + + +def test_messages_request_preserves_system_block_cache_control_when_normalizing(): + request = MessagesRequest.model_validate( + { + "model": "claude-3-opus", + "max_tokens": 100, + "system": [ + { + "type": "text", + "text": "existing system", + "cache_control": {"type": "ephemeral"}, + } + ], + "messages": [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": "message system", + "cache_control": {"type": "ephemeral"}, + } + ], + }, + {"role": "user", "content": "hello"}, + ], + } + ) + + assert len(request.messages) == 1 + assert isinstance(request.system, list) + assert [block.text for block in request.system] == [ + "existing system", + "message system", + ] + assert request.system[0].model_dump()["cache_control"] == {"type": "ephemeral"} + assert request.system[1].model_dump()["cache_control"] == {"type": "ephemeral"} + + +def test_messages_request_ignores_internal_routing_fields_when_supplied(): + request = MessagesRequest.model_validate( + { + "model": "target-model", + "original_model": "claude-3-opus", + "resolved_provider_model": "nvidia_nim/target-model", + "max_tokens": 100, + "messages": [{"role": "user", "content": "hello"}], + } + ) + + assert request.model == "target-model" + assert "original_model" not in request.model_dump() + assert "resolved_provider_model" not in request.model_dump() + + +def test_token_count_request_parses_without_model_mapping_side_effects(): + request = TokenCountRequest( + model="claude-3-sonnet", messages=[Message(role="user", content="hello")] + ) + + assert request.model == "claude-3-sonnet" + + +def test_token_count_request_normalizes_system_role_messages(): + request = TokenCountRequest.model_validate( + { + "model": "claude-3-sonnet", + "messages": [ + {"role": "system", "content": "counting system"}, + {"role": "user", "content": "hello"}, + ], + } + ) + + assert len(request.messages) == 1 + assert request.messages[0].role == "user" + assert request.system == "counting system" + + +def test_messages_request_preserves_thinking_signature(): + request = MessagesRequest.model_validate( + { + "model": "claude-3-opus", + "max_tokens": 100, + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "signed thought", + "signature": "sig_123", + } + ], + } + ], + } + ) + + dumped = request.model_dump(exclude_none=True) + + assert dumped["messages"][0]["content"][0]["signature"] == "sig_123" + + +def test_messages_request_preserves_native_thinking_budget(): + request = MessagesRequest.model_validate( + { + "model": "claude-3-opus", + "max_tokens": 100, + "messages": [{"role": "user", "content": "think hard"}], + "thinking": {"type": "enabled", "budget_tokens": 4096}, + } + ) + + dumped = request.model_dump(exclude_none=True) + + assert dumped["thinking"]["type"] == "enabled" + assert dumped["thinking"]["budget_tokens"] == 4096 + + +def test_messages_request_accepts_adaptive_thinking_type(): + request = MessagesRequest.model_validate( + { + "model": "claude-3-opus", + "max_tokens": 100, + "messages": [{"role": "user", "content": "hello"}], + "thinking": {"type": "adaptive"}, + } + ) + + dumped = request.model_dump(exclude_none=True) + + assert dumped["thinking"]["type"] == "adaptive" + + +def test_messages_request_accepts_anthropic_server_tool_without_input_schema(): + request = MessagesRequest.model_validate( + { + "model": "claude-opus-4-7", + "max_tokens": 100, + "messages": [{"role": "user", "content": "search"}], + "tools": [{"type": "web_search_20250305", "name": "web_search"}], + } + ) + + dumped = request.model_dump(exclude_none=True) + + assert dumped["tools"] == [{"name": "web_search", "type": "web_search_20250305"}] + + +def test_messages_request_accepts_redacted_thinking_blocks(): + request = MessagesRequest.model_validate( + { + "model": "claude-3-opus", + "max_tokens": 100, + "messages": [ + { + "role": "assistant", + "content": [{"type": "redacted_thinking", "data": "opaque"}], + } + ], + } + ) + + dumped = request.model_dump(exclude_none=True) + + assert dumped["messages"][0]["content"][0] == { + "type": "redacted_thinking", + "data": "opaque", + } + + +def test_document_and_web_fetch_blocks_preserve_protocol_extensions() -> None: + request = MessagesRequest.model_validate( + { + "model": "model", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "document", + "source": {"type": "base64", "data": "encoded"}, + "cache_control": {"type": "ephemeral"}, + }, + { + "type": "web_fetch_tool_result", + "tool_use_id": "srvtoolu_1", + "content": {"url": "https://example.com"}, + "provider_extension": True, + }, + ], + } + ], + } + ) + + content = request.messages[0].content + assert isinstance(content, list) + assert isinstance(content[0], ContentBlockDocument) + assert content[0].model_dump()["cache_control"] == {"type": "ephemeral"} + assert isinstance(content[1], ContentBlockWebFetchToolResult) + assert content[1].model_dump()["provider_extension"] is True + + +def test_content_block_descriptions_remain_in_the_public_schema() -> None: + definitions = MessagesRequest.model_json_schema()["$defs"] + + assert definitions["ContentBlockDocument"]["description"] == ( + "Anthropic document block (e.g. PDF files via the Files API)." + ) + assert definitions["ContentBlockServerToolUse"]["description"] == ( + "Anthropic server-side tool invocation (e.g. ``web_search``, ``web_fetch``)." + ) + + +def test_messages_request_dump_preserves_public_defaults_and_excludes_internal_fields() -> ( + None +): + request = MessagesRequest.model_validate( + { + "model": "model", + "messages": [{"role": "user", "content": "hello"}], + "thinking": {"type": "adaptive"}, + "original_model": "original", + "resolved_provider_model": "provider/model", + "betas": ["feature-beta"], + "client_extension": {"enabled": True}, + } + ) + + dumped = request.model_dump(exclude_none=True) + + assert dumped == { + "model": "model", + "messages": [{"role": "user", "content": "hello"}], + "stream": True, + "thinking": {"enabled": True, "type": "adaptive"}, + "client_extension": {"enabled": True}, + } + + +def test_token_count_request_accepts_extras_but_excludes_internal_fields() -> None: + request = TokenCountRequest.model_validate( + { + "model": "model", + "messages": [{"role": "user", "content": "hello"}], + "original_model": "original", + "resolved_provider_model": "provider/model", + "betas": ["feature-beta"], + "client_extension": "accepted", + } + ) + + assert request.model_extra == {"client_extension": "accepted"} + assert request.model_dump(exclude_none=True) == { + "model": "model", + "messages": [{"role": "user", "content": "hello"}], + "client_extension": "accepted", + } + + +def test_openai_conversion_rejects_unknown_top_level_anthropic_extensions() -> None: + request = MessagesRequest.model_validate( + { + "model": "model", + "messages": [{"role": "user", "content": "hello"}], + "client_extension": True, + } + ) + + with pytest.raises(OpenAIConversionError, match="client_extension"): + build_base_request_body(request) diff --git a/tests/core/anthropic/test_request_serialization.py b/tests/core/anthropic/test_request_serialization.py new file mode 100644 index 0000000..3a9ffbb --- /dev/null +++ b/tests/core/anthropic/test_request_serialization.py @@ -0,0 +1,104 @@ +"""Anthropic request parsing and public-field serialization.""" + +from free_claude_code.core.anthropic import dump_messages_request +from free_claude_code.core.anthropic.models import ( + ContentBlockServerToolUse, + ContentBlockText, + ContentBlockWebSearchToolResult, + Message, + MessagesRequest, +) + + +def test_dump_preserves_public_fields_and_nested_extensions() -> None: + request = MessagesRequest.model_validate( + { + "model": "m", + "max_tokens": 20, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "hi", + "cache_control": {"type": "ephemeral"}, + } + ], + } + ], + "context_management": {"edits": [{"type": "clear"}]}, + "output_config": {"some": "hint"}, + } + ) + + body = dump_messages_request(request) + + assert body["messages"][0]["content"][0]["cache_control"] == {"type": "ephemeral"} + assert body["context_management"] == {"edits": [{"type": "clear"}]} + assert body["output_config"] == {"some": "hint"} + + +def test_dump_excludes_unknown_client_hints_and_fcc_routing_state() -> None: + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "x"}], + "reasoning_effort": "none", + "unknown_client_hint": {"mode": "local"}, + } + ) + request.original_model = "claude" + request.resolved_provider_model = "upstream" + + body = dump_messages_request(request) + + assert "reasoning_effort" not in body + assert "unknown_client_hint" not in body + assert "original_model" not in body + assert "resolved_provider_model" not in body + + +def test_pydantic_discriminator_still_distinguishes_blocks() -> None: + message = Message.model_validate( + { + "role": "user", + "content": [{"type": "text", "text": "a", "z": 1}], + } + ) + + block = message.content[0] + + assert isinstance(block, ContentBlockText) + assert block.model_dump()["z"] == 1 + + +def test_server_tool_history_remains_valid_anthropic_input() -> None: + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "server_tool_use", + "id": "srvtoolu_1", + "name": "web_search", + "input": {"query": "q"}, + }, + { + "type": "web_search_tool_result", + "tool_use_id": "srvtoolu_1", + "content": [], + }, + ], + } + ], + } + ) + + blocks = request.messages[0].content + assert isinstance(blocks, list) + assert isinstance(blocks[0], ContentBlockServerToolUse) + assert isinstance(blocks[1], ContentBlockWebSearchToolResult) diff --git a/tests/core/anthropic/test_response_models.py b/tests/core/anthropic/test_response_models.py new file mode 100644 index 0000000..4889411 --- /dev/null +++ b/tests/core/anthropic/test_response_models.py @@ -0,0 +1,201 @@ +"""Tests for Anthropic protocol response models.""" + +from free_claude_code.core.anthropic.models import ( + ContentBlockText, + ContentBlockThinking, + ContentBlockToolUse, + 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/tests/core/anthropic/test_stream_ledger.py b/tests/core/anthropic/test_stream_ledger.py new file mode 100644 index 0000000..2ebdb9b --- /dev/null +++ b/tests/core/anthropic/test_stream_ledger.py @@ -0,0 +1,126 @@ +"""Tests for the provider-neutral Anthropic stream ledger.""" + +import json +from unittest.mock import patch + +import pytest + +from free_claude_code.core.anthropic.streaming import ( + AnthropicStreamLedger, + StreamBlockLedger, + ToolSchema, + map_stop_reason, +) + + +def _payload(event: str) -> dict: + return json.loads( + next(line[6:] for line in event.splitlines() if line.startswith("data:")) + ) + + +@pytest.mark.parametrize( + ("upstream", "anthropic"), + [ + ("stop", "end_turn"), + ("length", "max_tokens"), + ("tool_calls", "tool_use"), + ("content_filter", "end_turn"), + (None, "end_turn"), + ], +) +def test_map_stop_reason(upstream: str | None, anthropic: str) -> None: + assert map_stop_reason(upstream) == anthropic + + +def test_stream_block_ledger_allocates_monotonic_indexes() -> None: + blocks = StreamBlockLedger() + + assert (blocks.allocate_index(), blocks.allocate_index()) == (0, 1) + + +def test_message_lifecycle() -> None: + ledger = AnthropicStreamLedger("msg_1", "model", input_tokens=7) + + start = _payload(ledger.message_start()) + delta = _payload(ledger.message_delta("end_turn", 3)) + stop = _payload(ledger.message_stop()) + + assert start["message"]["id"] == "msg_1" + assert start["message"]["usage"]["input_tokens"] == 7 + assert delta["delta"]["stop_reason"] == "end_turn" + assert delta["usage"]["output_tokens"] == 3 + assert stop == {"type": "message_stop"} + assert ledger.has_terminal_message() + + +def test_text_and_thinking_blocks_accumulate_content() -> None: + ledger = AnthropicStreamLedger("msg_1", "model") + + ledger.start_thinking_block() + ledger.emit_thinking_delta("step") + ledger.stop_thinking_block() + ledger.start_text_block() + ledger.emit_text_delta("answer") + ledger.stop_text_block() + + assert ledger.accumulated_reasoning == "step" + assert ledger.accumulated_text == "answer" + + +def test_ensure_block_switches_close_the_previous_kind() -> None: + ledger = AnthropicStreamLedger("msg_1", "model") + ledger.start_thinking_block() + + events = list(ledger.ensure_text_block()) + + assert [_payload(event)["type"] for event in events] == [ + "content_block_stop", + "content_block_start", + ] + assert not ledger.blocks.thinking_started + assert ledger.blocks.text_started + + +def test_tool_blocks_drive_stop_reason_and_salvage() -> None: + ledger = AnthropicStreamLedger("msg_1", "model") + ledger.start_tool_block(0, "toolu_1", "Read") + ledger.emit_tool_delta(0, '{"path":"test.py"}') + + assert ledger.final_stop_reason("end_turn") == "tool_use" + assert ledger.can_salvage_tool_use( + { + "Read": ToolSchema( + name="Read", + input_schema={ + "type": "object", + "properties": {"path": {"type": "string"}}, + }, + ) + } + ) + + +def test_close_unclosed_blocks_closes_each_block_once() -> None: + ledger = AnthropicStreamLedger("msg_1", "model") + ledger.start_text_block() + ledger.start_tool_block(0, "toolu_1", "Read") + + events = list(ledger.close_unclosed_blocks()) + + assert len(events) == 2 + assert all(_payload(event)["type"] == "content_block_stop" for event in events) + assert list(ledger.close_unclosed_blocks()) == [] + + +def test_output_token_estimate_uses_encoder_when_available() -> None: + ledger = AnthropicStreamLedger("msg_1", "model") + ledger.start_text_block() + ledger.emit_text_delta("abcd") + + class Encoder: + def encode(self, text: str) -> list[int]: + return list(range(len(text))) + + with patch("free_claude_code.core.anthropic.streaming.ledger.ENCODER", Encoder()): + assert ledger.estimate_output_tokens() == 8 diff --git a/tests/core/anthropic/test_stream_repair.py b/tests/core/anthropic/test_stream_repair.py new file mode 100644 index 0000000..1f8c7e3 --- /dev/null +++ b/tests/core/anthropic/test_stream_repair.py @@ -0,0 +1,47 @@ +"""Neutral Anthropic continuation and tool-repair helpers.""" + +from free_claude_code.core.anthropic.streaming import ( + ToolSchema, + accept_tool_json_repair, + continuation_suffix, +) + + +def test_continuation_suffix_trims_overlap() -> None: + assert continuation_suffix("hello wor", "world") == "ld" + assert continuation_suffix("alpha", "alpha beta") == " beta" + assert continuation_suffix("", "fresh") == "fresh" + + +def test_tool_json_repair_requires_append_only_schema_valid_json() -> None: + schemas = { + "Echo": ToolSchema( + name="Echo", + input_schema={ + "type": "object", + "properties": {"message": {"type": "string"}}, + "required": ["message"], + "additionalProperties": False, + }, + ) + } + + accepted = accept_tool_json_repair( + '{"message":', + '"ok"}', + tool_name="Echo", + schemas=schemas, + ) + assert accepted is not None + assert accepted.suffix == '"ok"}' + assert accepted.parsed_input == {"message": "ok"} + + assert ( + accept_tool_json_repair( + '{"message":', + "1}", + tool_name="Echo", + schemas=schemas, + ) + is None + ) diff --git a/tests/core/openai_responses/test_conversion.py b/tests/core/openai_responses/test_conversion.py new file mode 100644 index 0000000..60619e1 --- /dev/null +++ b/tests/core/openai_responses/test_conversion.py @@ -0,0 +1,629 @@ +from typing import Any + +import pytest + +from free_claude_code.core.openai_responses import ( + OpenAIResponsesAdapter, + OpenAIResponsesRequest, +) + +_ADAPTER = OpenAIResponsesAdapter() +_CONVERSION_ERROR = OpenAIResponsesAdapter.ConversionError + + +def _to_anthropic_payload(request: dict[str, Any]) -> dict[str, Any]: + return _ADAPTER.to_anthropic_payload(OpenAIResponsesRequest.model_validate(request)) + + +def test_responses_string_input_converts_to_anthropic_message() -> None: + payload = _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "instructions": "System instructions", + "input": "Hello", + "max_output_tokens": 64, + "temperature": 0.2, + "top_p": 0.9, + "metadata": {"trace": "abc"}, + } + ) + + assert payload["model"] == "nvidia_nim/test-model" + assert payload["system"] == "System instructions" + assert payload["messages"] == [{"role": "user", "content": "Hello"}] + assert payload["max_tokens"] == 64 + assert payload["temperature"] == 0.2 + assert payload["top_p"] == 0.9 + assert payload["metadata"] == {"trace": "abc"} + + +def test_responses_messages_tools_and_tool_results_convert() -> None: + payload = _to_anthropic_payload( + { + "model": "deepseek/deepseek-chat", + "input": [ + { + "type": "message", + "role": "developer", + "content": [{"type": "input_text", "text": "Developer rules"}], + }, + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Use the tool"}], + }, + { + "type": "function_call", + "call_id": "call_1", + "name": "echo", + "arguments": '{"value":"FCC"}', + }, + { + "type": "function_call_output", + "call_id": "call_1", + "output": "FCC", + }, + ], + "tools": [ + { + "type": "function", + "name": "echo", + "description": "Echo a value", + "parameters": { + "type": "object", + "properties": {"value": {"type": "string"}}, + }, + } + ], + "tool_choice": {"type": "function", "name": "echo"}, + } + ) + + assert payload["system"] == "Developer rules" + assert payload["messages"] == [ + {"role": "user", "content": [{"type": "text", "text": "Use the tool"}]}, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "call_1", + "name": "echo", + "input": {"value": "FCC"}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "call_1", + "content": "FCC", + } + ], + }, + ] + assert payload["tools"] == [ + { + "name": "echo", + "description": "Echo a value", + "input_schema": { + "type": "object", + "properties": {"value": {"type": "string"}}, + }, + } + ] + assert payload["tool_choice"] == {"type": "tool", "name": "echo"} + + +def test_responses_tool_choice_none_disables_forwarded_tools() -> None: + payload = _to_anthropic_payload( + { + "model": "deepseek/deepseek-chat", + "input": "Reply without tools", + "tools": [ + { + "type": "function", + "name": "echo", + "parameters": { + "type": "object", + "properties": {"value": {"type": "string"}}, + }, + } + ], + "tool_choice": "none", + } + ) + + assert "tools" not in payload + assert "tool_choice" not in payload + + +def test_responses_namespace_tools_flatten_for_anthropic() -> None: + payload = _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": "Use JS", + "tools": [ + { + "type": "namespace", + "name": "mcp__node_repl", + "description": "Node tools", + "tools": [ + { + "type": "function", + "name": "js", + "description": "Run JavaScript", + "parameters": { + "type": "object", + "properties": {"code": {"type": "string"}}, + "required": ["code"], + }, + } + ], + } + ], + "tool_choice": { + "type": "function", + "namespace": "mcp__node_repl", + "name": "js", + }, + } + ) + + assert payload["tools"] == [ + { + "name": "mcp__node_repl__js", + "description": "Run JavaScript", + "input_schema": { + "type": "object", + "properties": {"code": {"type": "string"}}, + "required": ["code"], + }, + } + ] + assert payload["tool_choice"] == {"type": "tool", "name": "mcp__node_repl__js"} + + +def test_responses_namespaced_tool_choice_type_tool_flattens_for_anthropic() -> None: + payload = _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": "Use JS", + "tools": [ + { + "type": "namespace", + "name": "mcp__node_repl", + "tools": [ + { + "type": "function", + "name": "js", + "parameters": {"type": "object", "properties": {}}, + } + ], + } + ], + "tool_choice": { + "type": "tool", + "namespace": "mcp__node_repl", + "name": "js", + }, + } + ) + + assert payload["tool_choice"] == {"type": "tool", "name": "mcp__node_repl__js"} + + +def test_responses_custom_tool_converts_to_anthropic_string_tool() -> None: + payload = _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": "Use apply_patch", + "tools": [ + { + "type": "custom", + "name": "apply_patch", + "description": "Apply a repo patch", + "format": { + "type": "grammar", + "syntax": "lark", + "definition": "start: /.+/", + }, + } + ], + "tool_choice": {"type": "custom", "name": "apply_patch"}, + } + ) + + assert payload["tools"] == [ + { + "name": "apply_patch", + "description": ( + "Apply a repo patch\n\n" + "Custom tool input format: grammar (lark): start: /.+/" + ), + "input_schema": { + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "Free-form input for the custom tool.", + } + }, + "required": ["input"], + }, + } + ] + assert payload["tool_choice"] == {"type": "tool", "name": "apply_patch"} + + +def test_responses_namespaced_custom_tool_flattens_for_anthropic() -> None: + payload = _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": "Use shell", + "tools": [ + { + "type": "namespace", + "name": "mcp__shell", + "tools": [ + { + "type": "custom", + "name": "exec", + "description": "Run shell text", + "format": {"type": "text"}, + } + ], + } + ], + "tool_choice": { + "type": "custom", + "namespace": "mcp__shell", + "custom": {"name": "exec"}, + }, + } + ) + + assert payload["tools"][0]["name"] == "mcp__shell__exec" + assert payload["tools"][0]["description"] == ( + "Run shell text\n\nCustom tool input format: unconstrained text." + ) + assert payload["tool_choice"] == {"type": "tool", "name": "mcp__shell__exec"} + + +def test_responses_passive_codex_built_in_tools_are_ignored() -> None: + payload = _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": "Hello", + "tools": [ + {"type": "web_search", "external_web_access": True}, + {"type": "image_generation", "output_format": "png"}, + {"type": "tool_search"}, + { + "type": "function", + "name": "echo", + "parameters": {"type": "object", "properties": {}}, + }, + ], + } + ) + + assert payload["tools"] == [ + {"name": "echo", "input_schema": {"type": "object", "properties": {}}} + ] + + +def test_responses_namespaced_prior_function_call_flattens_tool_use_name() -> None: + payload = _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": [ + { + "type": "function_call", + "call_id": "call_1", + "namespace": "mcp__node_repl", + "name": "js", + "arguments": '{"code":"1+1"}', + }, + { + "type": "function_call_output", + "call_id": "call_1", + "output": "2", + }, + ], + } + ) + + assert payload["messages"][0]["content"][0]["name"] == "mcp__node_repl__js" + + +def test_responses_prior_custom_tool_call_flattens_tool_use_name() -> None: + payload = _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": [ + { + "type": "custom_tool_call", + "call_id": "call_1", + "namespace": "mcp__shell", + "name": "exec", + "input": "printf FCC", + }, + { + "type": "custom_tool_call_output", + "call_id": "call_1", + "output": "FCC", + }, + ], + } + ) + + assert payload["messages"] == [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "call_1", + "name": "mcp__shell__exec", + "input": {"input": "printf FCC"}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "call_1", + "content": "FCC", + } + ], + }, + ] + + +def test_responses_groups_consecutive_prior_tool_calls() -> None: + payload = _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": [ + { + "type": "function_call", + "call_id": "call_1", + "name": "echo", + "arguments": '{"value":"FCC"}', + }, + { + "type": "custom_tool_call", + "call_id": "call_2", + "name": "apply_patch", + "input": "*** Begin Patch", + }, + ], + } + ) + + assert payload["messages"] == [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "call_1", + "name": "echo", + "input": {"value": "FCC"}, + }, + { + "type": "tool_use", + "id": "call_2", + "name": "apply_patch", + "input": {"input": "*** Begin Patch"}, + }, + ], + } + ] + + +def test_responses_groups_consecutive_prior_tool_outputs() -> None: + payload = _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": [ + { + "type": "function_call", + "call_id": "call_1", + "name": "echo", + "arguments": '{"value":"FCC"}', + }, + { + "type": "function_call", + "call_id": "call_2", + "name": "echo", + "arguments": '{"value":"Codex"}', + }, + { + "type": "function_call_output", + "call_id": "call_1", + "output": "FCC", + }, + { + "type": "function_call_output", + "call_id": "call_2", + "output": "Codex", + }, + ], + } + ) + + assert len(payload["messages"]) == 2 + assert payload["messages"][0]["role"] == "assistant" + assert len(payload["messages"][0]["content"]) == 2 + assert payload["messages"][1] == { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "call_1", + "content": "FCC", + }, + { + "type": "tool_result", + "tool_use_id": "call_2", + "content": "Codex", + }, + ], + } + + +def test_responses_reasoning_between_tool_call_and_output_attaches_to_tool_message() -> ( + None +): + payload = _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": [ + { + "type": "function_call", + "call_id": "call_1", + "name": "echo", + "arguments": '{"value":"FCC"}', + }, + { + "type": "reasoning", + "content": [{"type": "reasoning_text", "text": "Need the result."}], + }, + { + "type": "function_call_output", + "call_id": "call_1", + "output": "FCC", + }, + ], + } + ) + + assert payload["messages"][0]["reasoning_content"] == "Need the result." + assert payload["messages"][0]["content"][0]["id"] == "call_1" + assert payload["messages"][1]["content"][0]["tool_use_id"] == "call_1" + + +def test_responses_empty_reasoning_attaches_to_prior_tool_call() -> None: + payload = _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": [ + { + "type": "function_call", + "call_id": "call_1", + "name": "echo", + "arguments": '{"value":"FCC"}', + }, + { + "type": "reasoning", + "content": [{"type": "reasoning_text", "text": ""}], + }, + { + "type": "function_call_output", + "call_id": "call_1", + "output": "FCC", + }, + ], + } + ) + + assert payload["messages"][0]["reasoning_content"] == "" + + +def test_responses_unsupported_tool_type_is_clear() -> None: + with pytest.raises(_CONVERSION_ERROR, match="Unsupported Responses tool type"): + _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": "Hello", + "tools": [{"type": "web_search_preview"}], + } + ) + + +def test_responses_malformed_prior_function_call_is_quarantined() -> None: + payload = _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": [ + {"role": "user", "content": "hello"}, + { + "type": "function_call", + "call_id": "call_bad", + "name": "echo", + "arguments": "{", + }, + { + "type": "function_call_output", + "call_id": "call_bad", + "output": "stale output", + }, + { + "type": "function_call", + "call_id": "call_good", + "name": "echo", + "arguments": '{"value":"ok"}', + }, + { + "type": "function_call_output", + "call_id": "call_good", + "output": "ok", + }, + {"role": "user", "content": "continue"}, + ], + } + ) + + assert payload["messages"] == [ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "call_good", + "name": "echo", + "input": {"value": "ok"}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "call_good", + "content": "ok", + } + ], + }, + {"role": "user", "content": "continue"}, + ] + + +def test_responses_malformed_only_function_call_still_has_no_routable_message() -> None: + with pytest.raises(_CONVERSION_ERROR, match="must contain a message"): + _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": [ + { + "type": "function_call", + "call_id": "call_bad", + "name": "echo", + "arguments": "{", + }, + { + "type": "function_call_output", + "call_id": "call_bad", + "output": "stale output", + }, + ], + } + ) diff --git a/tests/core/openai_responses/test_openai_responses_models.py b/tests/core/openai_responses/test_openai_responses_models.py new file mode 100644 index 0000000..354555e --- /dev/null +++ b/tests/core/openai_responses/test_openai_responses_models.py @@ -0,0 +1,94 @@ +"""Wire-compatibility tests for core-owned OpenAI Responses models.""" + +import pytest +from pydantic import ValidationError + +from free_claude_code.core.openai_responses import OpenAIResponsesRequest + + +def test_responses_request_preserves_defaults_and_unknown_extensions() -> None: + request = OpenAIResponsesRequest.model_validate( + { + "model": "provider/model", + "input": "hello", + "provider_extension": {"enabled": True}, + } + ) + + assert request.stream is True + assert request.model_extra == {"provider_extension": {"enabled": True}} + assert request.model_dump(mode="json", exclude_none=True) == { + "model": "provider/model", + "input": "hello", + "stream": True, + "provider_extension": {"enabled": True}, + } + + +@pytest.mark.parametrize(("stream", "expected"), [(False, False), (None, None)]) +def test_responses_request_preserves_explicit_stream_values( + stream: bool | None, + expected: bool | None, +) -> None: + request = OpenAIResponsesRequest.model_validate( + {"model": "provider/model", "input": "hello", "stream": stream} + ) + + assert request.stream is expected + + +def test_responses_request_keeps_permissive_nested_protocol_shapes() -> None: + payload = { + "model": "provider/model", + "input": [ + {"role": "user", "content": [{"type": "input_text", "text": "hi"}]}, + { + "type": "function_call", + "call_id": "call_1", + "name": "lookup", + "arguments": "{}", + }, + ], + "tools": [ + { + "type": "namespace", + "name": "mcp__tools", + "tools": [{"type": "custom", "name": "apply_patch"}], + } + ], + "tool_choice": {"type": "custom", "name": "apply_patch"}, + "metadata": {"trace": ["a", 1]}, + "reasoning": {"effort": "high", "provider_hint": {"mode": "extended"}}, + } + + request = OpenAIResponsesRequest.model_validate(payload) + + dumped = request.model_dump(mode="json", exclude_none=True) + for field_name, value in payload.items(): + assert dumped[field_name] == value + + +def test_responses_request_preserves_existing_pydantic_coercion() -> None: + request = OpenAIResponsesRequest.model_validate( + { + "model": "provider/model", + "stream": "true", + "temperature": "0.2", + "max_output_tokens": "32", + } + ) + + assert request.stream is True + assert request.temperature == 0.2 + assert request.max_output_tokens == 32 + + +def test_responses_request_still_requires_model() -> None: + with pytest.raises(ValidationError): + OpenAIResponsesRequest.model_validate({"input": "hello"}) + + +def test_responses_request_keeps_openapi_component_name() -> None: + assert OpenAIResponsesRequest.model_json_schema()["title"] == ( + "OpenAIResponsesRequest" + ) diff --git a/tests/core/openai_responses/test_sse.py b/tests/core/openai_responses/test_sse.py new file mode 100644 index 0000000..ededbfe --- /dev/null +++ b/tests/core/openai_responses/test_sse.py @@ -0,0 +1,970 @@ +from collections.abc import AsyncIterator +from typing import Any +from unittest.mock import patch + +import pytest + +from free_claude_code.core.anthropic.stream_contracts import parse_sse_text +from free_claude_code.core.anthropic.streaming import format_sse_event +from free_claude_code.core.async_iterators import AsyncCloseable +from free_claude_code.core.failures import ExecutionFailure, FailureKind +from free_claude_code.core.openai_responses import ( + OpenAIResponsesAdapter, + OpenAIResponsesRequest, +) +from free_claude_code.core.openai_responses.anthropic_sse import ( + AnthropicSseEvent, + iter_sse_events, +) + +_ADAPTER = OpenAIResponsesAdapter() + + +class _CloseTrackingAsyncIterator: + def __init__( + self, + values: list[Any], + *, + iteration_error: Exception | None = None, + close_error: Exception | None = None, + ) -> None: + self._values = iter(values) + self._iteration_error = iteration_error + self._close_error = close_error + self.close_calls = 0 + + def __aiter__(self) -> _CloseTrackingAsyncIterator: + return self + + async def __anext__(self) -> Any: + try: + return next(self._values) + except StopIteration: + if self._iteration_error is not None: + error = self._iteration_error + self._iteration_error = None + raise error from None + raise StopAsyncIteration from None + + async def aclose(self) -> None: + self.close_calls += 1 + if self._close_error is not None: + raise self._close_error + + +def _responses_sse( + chunks: AsyncIterator[str], request: dict[str, Any] +) -> AsyncIterator[str]: + return _ADAPTER.iter_sse_from_anthropic( + chunks, + OpenAIResponsesRequest.model_validate(request), + ) + + +@pytest.mark.asyncio +async def test_anthropic_sse_parser_closes_source_after_normal_completion() -> None: + source = _CloseTrackingAsyncIterator( + [format_sse_event("message_start", {"type": "message_start"})] + ) + + events = [event async for event in iter_sse_events(source)] + + assert [event.event for event in events] == ["message_start"] + assert source.close_calls == 1 + + +@pytest.mark.asyncio +async def test_anthropic_sse_parser_closes_source_on_early_close() -> None: + source = _CloseTrackingAsyncIterator( + [ + format_sse_event("message_start", {"type": "message_start"}), + format_sse_event("message_stop", {"type": "message_stop"}), + ] + ) + events = iter_sse_events(source) + + assert (await anext(events)).event == "message_start" + assert isinstance(events, AsyncCloseable) + await events.aclose() + + assert source.close_calls == 1 + + +@pytest.mark.asyncio +async def test_anthropic_sse_parser_preserves_source_failure() -> None: + source_failure = RuntimeError("source failed") + source = _CloseTrackingAsyncIterator( + [], + iteration_error=source_failure, + close_error=RuntimeError("close failed"), + ) + + with pytest.raises(RuntimeError) as exc_info: + [event async for event in iter_sse_events(source)] + + assert exc_info.value is source_failure + assert source.close_calls == 1 + + +@pytest.mark.asyncio +async def test_responses_transform_closes_direct_event_source_on_early_close() -> None: + events = _CloseTrackingAsyncIterator( + [ + AnthropicSseEvent( + event="message_start", + data={"type": "message_start", "message": {}}, + ), + AnthropicSseEvent( + event="message_stop", + data={"type": "message_stop"}, + ), + ] + ) + with patch( + "free_claude_code.core.openai_responses.stream.iter_sse_events", + return_value=events, + ): + stream = _responses_sse( + _aiter([]), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + assert parse_sse_text(await anext(stream))[0].event == "response.created" + assert isinstance(stream, AsyncCloseable) + await stream.aclose() + + assert events.close_calls == 1 + + +@pytest.mark.asyncio +async def test_responses_transform_preserves_direct_source_failure() -> None: + source_failure = RuntimeError("event source failed") + events = _CloseTrackingAsyncIterator( + [], + iteration_error=source_failure, + close_error=RuntimeError("event close failed"), + ) + with ( + patch( + "free_claude_code.core.openai_responses.stream.iter_sse_events", + return_value=events, + ), + pytest.raises(RuntimeError) as exc_info, + ): + [ + chunk + async for chunk in _responses_sse( + _aiter([]), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + ] + + assert exc_info.value is source_failure + assert events.close_calls == 1 + + +@pytest.mark.asyncio +async def test_anthropic_text_stream_converts_to_responses_sse() -> None: + text = await _collect_sse( + _responses_sse( + _aiter(_anthropic_text_stream("Hello Codex")), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + ) + + events = parse_sse_text(text) + event_names = [event.event for event in events] + assert event_names[:3] == [ + "response.created", + "response.output_item.added", + "response.content_part.added", + ] + assert "response.output_text.delta" in event_names + assert events[-1].event == "response.completed" + completed = events[-1].data["response"] + assert completed["output"][0]["content"][0]["text"] == "Hello Codex" + assert completed["parallel_tool_calls"] is True + assert completed["tool_choice"] == "auto" + + +@pytest.mark.asyncio +async def test_response_payload_preserves_explicit_request_options() -> None: + response = await _completed_response_from_sse( + _aiter(_anthropic_text_stream("done")), + { + "model": "nvidia_nim/test-model", + "parallel_tool_calls": False, + "tool_choice": "none", + "temperature": 0.0, + "top_p": 0.0, + "max_output_tokens": 0, + }, + ) + + assert response["parallel_tool_calls"] is False + assert response["tool_choice"] == "none" + assert response["temperature"] == 0.0 + assert response["top_p"] == 0.0 + assert response["max_output_tokens"] == 0 + + +@pytest.mark.asyncio +async def test_response_payload_maps_explicit_null_options_to_wire_defaults() -> None: + response = await _completed_response_from_sse( + _aiter(_anthropic_text_stream("done")), + { + "model": "nvidia_nim/test-model", + "parallel_tool_calls": None, + "tool_choice": None, + }, + ) + + assert response["parallel_tool_calls"] is True + assert response["tool_choice"] == "auto" + + +@pytest.mark.asyncio +async def test_anthropic_tool_stream_converts_to_function_call_item() -> None: + text = await _collect_sse( + _responses_sse( + _aiter(_anthropic_tool_stream()), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + ) + + events = parse_sse_text(text) + names = [event.event for event in events] + assert "response.function_call_arguments.delta" in names + assert "response.function_call_arguments.done" in names + completed = events[-1].data["response"] + function_call = completed["output"][0] + assert function_call["type"] == "function_call" + assert function_call["call_id"] == "toolu_1" + assert function_call["name"] == "echo" + assert function_call["arguments"] == '{"value":"FCC"}' + + +@pytest.mark.asyncio +async def test_anthropic_function_tool_arguments_are_normalized() -> None: + response = await _completed_response_from_sse( + _aiter(_anthropic_tool_stream(partial_json='{ "value" : "FCC" }')), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + + assert response["output"][0]["arguments"] == '{"value":"FCC"}' + + +@pytest.mark.asyncio +async def test_anthropic_malformed_function_tool_arguments_fail_response() -> None: + text = await _collect_sse( + _responses_sse( + _aiter(_anthropic_tool_stream(partial_json='{"value":"FCC" "bad"}')), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + ) + + events = parse_sse_text(text) + assert events[-1].event == "response.failed" + assert "response.function_call_arguments.done" not in [ + event.event for event in events + ] + assert "response.output_item.done" not in [event.event for event in events] + failed = events[-1].data["response"] + assert failed["status"] == "failed" + assert failed["output"] == [] + assert failed["error"]["type"] == "api_error" + assert "replay-unsafe Responses output" in failed["error"]["message"] + + +@pytest.mark.asyncio +async def test_anthropic_malformed_function_tool_arguments_fail_on_eof() -> None: + stream = _anthropic_tool_stream( + partial_json='{"value":"FCC" "bad"}', + include_block_stop=False, + ) + text = await _collect_sse( + _responses_sse( + _aiter(stream[:-1]), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + ) + + events = parse_sse_text(text) + assert events[-1].event == "response.failed" + assert events[-1].data["response"]["output"] == [] + + +@pytest.mark.asyncio +async def test_provider_failure_outranks_provisional_incomplete_function_call() -> None: + failure = ExecutionFailure( + kind=FailureKind.RATE_LIMIT, + status_code=429, + message="provider is busy\n\nRequest ID: req_failure_precedence", + retryable=True, + ) + incomplete_tool = _anthropic_tool_stream(partial_json='{"value":')[:4] + + text = await _collect_sse( + _responses_sse( + _aiter_then_raise(incomplete_tool, failure), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + ) + + events = parse_sse_text(text) + assert [event.event for event in events].count("response.failed") == 1 + assert events[-1].event == "response.failed" + failed = events[-1].data["response"] + assert failed["id"] == events[0].data["response"]["id"] + assert failed["error"] == { + "message": "provider is busy\n\nRequest ID: req_failure_precedence", + "type": "rate_limit_error", + "param": None, + "code": None, + } + + +@pytest.mark.asyncio +async def test_post_start_failure_observer_runs_before_terminal_failure_event() -> None: + failure = RuntimeError("socket closed") + timeline: list[tuple[str, object]] = [] + + def observe_terminal_failure(exc: BaseException) -> None: + timeline.append(("observed", exc)) + + stream = _ADAPTER.iter_sse_from_anthropic( + _aiter_then_raise(_anthropic_text_stream("partial")[:3], failure), + OpenAIResponsesRequest.model_validate( + {"model": "nvidia_nim/test-model", "stream": True} + ), + on_post_start_terminal_failure=observe_terminal_failure, + ) + parts: list[str] = [] + async for chunk in stream: + parts.append(chunk) + event = parse_sse_text(chunk) + assert len(event) == 1 + timeline.append(("emitted", event[0].event)) + + events = parse_sse_text("".join(parts)) + assert timeline.count(("observed", failure)) == 1 + assert timeline.index(("observed", failure)) < timeline.index( + ("emitted", "response.failed") + ) + assert events[-1].data["response"]["error"]["message"] == "socket closed" + + +@pytest.mark.asyncio +async def test_unrelated_post_start_exception_group_remains_unexpected() -> None: + grouped = ExceptionGroup("stream failed", [RuntimeError("socket closed")]) + observed: list[BaseException] = [] + stream = _ADAPTER.iter_sse_from_anthropic( + _aiter_then_raise(_anthropic_text_stream("partial")[:3], grouped), + OpenAIResponsesRequest.model_validate( + {"model": "nvidia_nim/test-model", "stream": True} + ), + on_post_start_terminal_failure=observed.append, + ) + + events = parse_sse_text(await _collect_sse(stream)) + + assert observed == [grouped] + assert events[-1].event == "response.failed" + assert events[-1].data["response"]["error"]["type"] == "api_error" + + +@pytest.mark.asyncio +async def test_namespaced_anthropic_tool_stream_restores_responses_namespace() -> None: + text = await _collect_sse( + _responses_sse( + _aiter(_anthropic_tool_stream(tool_name="mcp__node_repl__js")), + { + "model": "nvidia_nim/test-model", + "stream": True, + "tools": [ + { + "type": "namespace", + "name": "mcp__node_repl", + "tools": [ + { + "type": "function", + "name": "js", + "parameters": {"type": "object", "properties": {}}, + } + ], + } + ], + }, + ) + ) + + events = parse_sse_text(text) + completed = events[-1].data["response"] + function_call = completed["output"][0] + assert function_call["type"] == "function_call" + assert function_call["namespace"] == "mcp__node_repl" + assert function_call["name"] == "js" + + +@pytest.mark.asyncio +async def test_anthropic_custom_tool_stream_converts_to_custom_tool_call() -> None: + text = await _collect_sse( + _responses_sse( + _aiter( + _anthropic_tool_stream( + tool_name="apply_patch", + partial_json='{"input":"*** Begin Patch"}', + ) + ), + { + "model": "nvidia_nim/test-model", + "stream": True, + "tools": [ + { + "type": "custom", + "name": "apply_patch", + "format": {"type": "text"}, + } + ], + }, + ) + ) + + events = parse_sse_text(text) + names = [event.event for event in events] + assert "response.custom_tool_call_input.delta" in names + assert "response.custom_tool_call_input.done" in names + assert "response.function_call_arguments.delta" not in names + completed = events[-1].data["response"] + custom_call = completed["output"][0] + assert custom_call["type"] == "custom_tool_call" + assert custom_call["call_id"] == "toolu_1" + assert custom_call["name"] == "apply_patch" + assert custom_call["input"] == "*** Begin Patch" + + +@pytest.mark.asyncio +async def test_custom_tool_input_remains_free_form_when_not_json() -> None: + response = await _completed_response_from_sse( + _aiter( + _anthropic_tool_stream( + tool_name="apply_patch", + partial_json="*** Begin Patch", + ) + ), + { + "model": "nvidia_nim/test-model", + "stream": True, + "tools": [{"type": "custom", "name": "apply_patch"}], + }, + ) + + custom_call = response["output"][0] + assert custom_call["type"] == "custom_tool_call" + assert custom_call["input"] == "*** Begin Patch" + + +@pytest.mark.asyncio +async def test_anthropic_error_stream_converts_to_response_failed_event() -> None: + text = await _collect_sse( + _responses_sse( + _aiter( + [ + format_sse_event( + "error", + { + "type": "error", + "error": { + "type": "api_error", + "message": "upstream failed", + }, + }, + ) + ] + ), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + ) + + events = parse_sse_text(text) + assert events[0].event == "response.created" + assert events[1].event == "response.failed" + failed = events[1].data["response"] + assert failed["status"] == "failed" + assert failed["error"]["message"] == "upstream failed" + + +@pytest.mark.asyncio +async def test_split_usage_deltas_are_accumulated() -> None: + response = await _completed_response_from_sse( + _aiter( + [ + *_anthropic_text_stream("usage")[:-2], + format_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn"}, + "usage": {"input_tokens": 11}, + }, + ), + format_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {}, + "usage": {"output_tokens": 7}, + }, + ), + format_sse_event("message_stop", {"type": "message_stop"}), + ] + ), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + + assert response["usage"] == { + "input_tokens": 11, + "output_tokens": 7, + "total_tokens": 18, + } + assert "stop_reason" not in response + + +@pytest.mark.asyncio +async def test_reasoning_stream_reports_reasoning_usage_detail() -> None: + response = await _completed_response_from_sse( + _aiter(_anthropic_reasoning_stream("inspect the code before answering")), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + + usage = response["usage"] + assert usage["input_tokens"] == 3 + assert usage["output_tokens"] == 20 + assert usage["total_tokens"] == 23 + assert usage["output_tokens_details"]["reasoning_tokens"] > 0 + + +@pytest.mark.asyncio +async def test_reasoning_usage_detail_is_capped_at_output_tokens() -> None: + response = await _completed_response_from_sse( + _aiter( + _anthropic_reasoning_stream( + "this reasoning text is intentionally long enough to exceed one token", + output_tokens=1, + ) + ), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + + assert response["usage"]["output_tokens"] == 1 + assert response["usage"]["output_tokens_details"]["reasoning_tokens"] == 1 + + +@pytest.mark.asyncio +async def test_reasoning_usage_detail_omits_zero_capped_count() -> None: + response = await _completed_response_from_sse( + _aiter( + _anthropic_reasoning_stream( + "reasoning text exists without reported output tokens", + output_tokens=None, + ) + ), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + + assert response["usage"] == { + "input_tokens": 3, + "output_tokens": 0, + "total_tokens": 3, + } + + +@pytest.mark.asyncio +async def test_text_only_usage_omits_reasoning_usage_detail() -> None: + response = await _completed_response_from_sse( + _aiter(_anthropic_text_stream("plain text only")), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + + assert response["usage"] == { + "input_tokens": 3, + "output_tokens": 4, + "total_tokens": 7, + } + + +@pytest.mark.parametrize( + ("request_payload", "tool_name", "partial_json", "expected_type", "expected_field"), + [ + ( + {"model": "nvidia_nim/test-model", "stream": True}, + "echo", + '{"value":"FCC"}', + "function_call", + ("arguments", '{"value":"FCC"}'), + ), + ( + { + "model": "nvidia_nim/test-model", + "stream": True, + "tools": [{"type": "custom", "name": "apply_patch"}], + }, + "apply_patch", + '{"input":"*** Begin Patch"}', + "custom_tool_call", + ("input", "*** Begin Patch"), + ), + ], +) +@pytest.mark.asyncio +async def test_pending_tool_blocks_flush_on_message_stop_and_eof( + request_payload: dict[str, object], + tool_name: str, + partial_json: str, + expected_type: str, + expected_field: tuple[str, str], +) -> None: + stream = _anthropic_tool_stream( + tool_name=tool_name, partial_json=partial_json, include_block_stop=False + ) + message_stop_response = await _completed_response_from_sse( + _aiter(stream), request_payload + ) + eof_response = await _completed_response_from_sse( + _aiter(stream[:-1]), request_payload + ) + + for response in (message_stop_response, eof_response): + call = response["output"][0] + assert call["type"] == expected_type + assert call[expected_field[0]] == expected_field[1] + + +@pytest.mark.asyncio +async def test_overlapping_text_and_tool_blocks_keep_reserved_output_indexes() -> None: + text = await _collect_sse( + _responses_sse( + _aiter(_overlapping_text_tool_stream()), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + ) + + events = parse_sse_text(text) + item_events = [ + (event.event, event.data["output_index"]) + for event in events + if event.event in {"response.output_item.added", "response.output_item.done"} + ] + assert item_events == [ + ("response.output_item.added", 0), + ("response.output_item.added", 1), + ("response.output_item.done", 1), + ("response.output_item.done", 0), + ] + completed = events[-1].data["response"] + assert [item["type"] for item in completed["output"]] == [ + "message", + "function_call", + ] + assert completed["output"][0]["content"][0]["text"] == "text" + assert completed["output"][1]["arguments"] == '{"value":"FCC"}' + + +@pytest.mark.asyncio +async def test_overlapping_text_blocks_do_not_merge_content_by_index() -> None: + response = await _completed_response_from_sse( + _aiter(_overlapping_text_stream()), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + + assert [item["content"][0]["text"] for item in response["output"]] == [ + "A1-A2", + "B1-B2", + ] + + +async def _collect_sse(chunks: AsyncIterator[str]) -> str: + parts = [chunk async for chunk in chunks] + return "".join(parts) + + +async def _completed_response_from_sse( + chunks: AsyncIterator[str], + request: dict[str, object], +) -> dict[str, Any]: + text = await _collect_sse(_responses_sse(chunks, request)) + events = parse_sse_text(text) + assert events[-1].event == "response.completed" + return events[-1].data["response"] + + +async def _aiter(chunks: list[str]) -> AsyncIterator[str]: + for chunk in chunks: + yield chunk + + +async def _aiter_then_raise( + chunks: list[str], failure: BaseException +) -> AsyncIterator[str]: + for chunk in chunks: + yield chunk + raise failure + + +def _anthropic_text_stream(text: str) -> list[str]: + return [ + format_sse_event("message_start", {"type": "message_start", "message": {}}), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": text}, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ), + format_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"input_tokens": 3, "output_tokens": 4}, + }, + ), + format_sse_event("message_stop", {"type": "message_stop"}), + ] + + +def _anthropic_tool_stream( + tool_name: str = "echo", + partial_json: str = '{"value":"FCC"}', + *, + include_block_stop: bool = True, +) -> list[str]: + chunks = [ + format_sse_event("message_start", {"type": "message_start", "message": {}}), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "tool_use", + "id": "toolu_1", + "name": tool_name, + "input": {}, + }, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "input_json_delta", + "partial_json": partial_json, + }, + }, + ), + ] + if include_block_stop: + chunks.append( + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ) + ) + chunks.extend( + [ + format_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "tool_use", "stop_sequence": None}, + "usage": {"input_tokens": 3, "output_tokens": 4}, + }, + ), + format_sse_event("message_stop", {"type": "message_stop"}), + ] + ) + return chunks + + +def _anthropic_reasoning_stream( + reasoning: str, + *, + output_tokens: int | None = 20, +) -> list[str]: + usage = {"input_tokens": 3} + if output_tokens is not None: + usage["output_tokens"] = output_tokens + + return [ + format_sse_event("message_start", {"type": "message_start", "message": {}}), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "thinking", "thinking": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "thinking_delta", "thinking": reasoning}, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ), + format_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": usage, + }, + ), + format_sse_event("message_stop", {"type": "message_stop"}), + ] + + +def _overlapping_text_tool_stream() -> list[str]: + return [ + format_sse_event("message_start", {"type": "message_start", "message": {}}), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "text"}, + }, + ), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 1, + "content_block": { + "type": "tool_use", + "id": "toolu_1", + "name": "echo", + "input": {}, + }, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 1, + "delta": { + "type": "input_json_delta", + "partial_json": '{"value":"FCC"}', + }, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 1}, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ), + format_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "tool_use", "stop_sequence": None}, + "usage": {"input_tokens": 3, "output_tokens": 4}, + }, + ), + format_sse_event("message_stop", {"type": "message_stop"}), + ] + + +def _overlapping_text_stream() -> list[str]: + return [ + format_sse_event("message_start", {"type": "message_start", "message": {}}), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "A1-"}, + }, + ), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 1, + "content_block": {"type": "text", "text": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 1, + "delta": {"type": "text_delta", "text": "B1-"}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "A2"}, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 1, + "delta": {"type": "text_delta", "text": "B2"}, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 1}, + ), + format_sse_event("message_stop", {"type": "message_stop"}), + ] diff --git a/tests/core/test_async_iterators.py b/tests/core/test_async_iterators.py new file mode 100644 index 0000000..2a1d825 --- /dev/null +++ b/tests/core/test_async_iterators.py @@ -0,0 +1,54 @@ +"""Async iterator lifecycle helper contracts.""" + +import asyncio + +import pytest + +from free_claude_code.core.async_iterators import ( + AsyncCloseable, + try_close_async_iterator, +) + + +class _Closeable: + def __init__(self, error: BaseException | None = None) -> None: + self._error = error + self.close_calls = 0 + + async def aclose(self) -> None: + self.close_calls += 1 + if self._error is not None: + raise self._error + + +@pytest.mark.asyncio +async def test_try_close_async_iterator_closes_supported_value_once() -> None: + value = _Closeable() + + assert isinstance(value, AsyncCloseable) + assert await try_close_async_iterator(value) is None + assert value.close_calls == 1 + + +@pytest.mark.asyncio +async def test_try_close_async_iterator_ignores_non_closeable_value() -> None: + assert await try_close_async_iterator(object()) is None + + +@pytest.mark.asyncio +async def test_try_close_async_iterator_returns_ordinary_close_failure() -> None: + failure = RuntimeError("close failed") + value = _Closeable(failure) + + assert await try_close_async_iterator(value) is failure + assert value.close_calls == 1 + + +@pytest.mark.asyncio +async def test_try_close_async_iterator_propagates_cancellation() -> None: + value = _Closeable(asyncio.CancelledError()) + + with pytest.raises(asyncio.CancelledError): + await try_close_async_iterator(value) + + assert value.close_calls == 1 diff --git a/tests/core/test_diagnostics.py b/tests/core/test_diagnostics.py new file mode 100644 index 0000000..5654f20 --- /dev/null +++ b/tests/core/test_diagnostics.py @@ -0,0 +1,152 @@ +"""Protocol-neutral diagnostic redaction and detail contracts.""" + +from httpx import ConnectError, HTTPStatusError, Request, Response + +from free_claude_code.core.diagnostics import ( + ERROR_DETAIL_DISPLAY_CAP_BYTES, + UpstreamErrorDetail, + attach_upstream_error_body, + exception_cause_types, + extract_upstream_error_detail, + format_execution_failure_message, + format_user_error_preview, + redact_sensitive_error_text, + safe_exception_message, +) +from free_claude_code.core.failures import ExecutionFailure, FailureKind + + +def test_redaction_preserves_context_and_covers_recognizable_credentials() -> None: + sanitized = redact_sensitive_error_text( + '{"authorization":"Bearer AUTH_TOKEN","api_key":"sk-live-secret-key",' + '"client_secret":"CLIENT_SECRET"} raw=nvapi-standalone-secret ' + "token=PLAIN_TOKEN" + ) + + assert sanitized == ( + '{"authorization":"","api_key":"",' + '"client_secret":""} raw= token=' + ) + + +def test_safe_exception_message_is_detailed_redacted_and_non_empty() -> None: + assert ( + safe_exception_message( + RuntimeError("gateway failed api_key=SECRET useful detail") + ) + == "gateway failed api_key= useful detail" + ) + assert safe_exception_message(RuntimeError()) == ( + "Provider request failed unexpectedly." + ) + assert format_user_error_preview(ValueError("x" * 500), max_len=20) == "x" * 20 + + +def test_extract_upstream_error_detail_compacts_json_and_redacts_secrets() -> None: + response = Response( + status_code=400, + request=Request("POST", "https://provider.test/v1/messages"), + json={ + "error": { + "type": "BadRequest", + "message": "bad field api_key=SECRET authorization: Bearer TOKEN", + } + }, + ) + error = HTTPStatusError( + "Bad Request", + request=response.request, + response=response, + ) + + detail = extract_upstream_error_detail(error) + + assert isinstance(detail, UpstreamErrorDetail) + assert detail.status_code == 400 + assert detail.body_text == ( + '{"error":{"type":"BadRequest","message":' + '"bad field api_key= authorization: "}}' + ) + assert detail.exception_text == "Bad Request" + assert detail.cause_chain_text is None + assert detail.category_hint == "BadRequest" + assert not detail.body_truncated + assert "SECRET" not in repr(detail) + assert "TOKEN" not in repr(detail) + + +def test_attached_upstream_body_is_capped_after_redaction() -> None: + assert ERROR_DETAIL_DISPLAY_CAP_BYTES == 16_384 + response = Response( + status_code=500, + request=Request("POST", "https://provider.test/v1/messages"), + content=b"", + ) + error = HTTPStatusError( + "Server Error", + request=response.request, + response=response, + ) + attach_upstream_error_body( + error, + "token=SECRET " + "x" * ERROR_DETAIL_DISPLAY_CAP_BYTES, + ) + + detail = extract_upstream_error_detail(error) + + assert detail.body_text is not None + assert detail.body_truncated + assert "SECRET" not in detail.body_text + assert "token=" in detail.body_text + assert f"truncated after {ERROR_DETAIL_DISPLAY_CAP_BYTES} bytes" in detail.body_text + + +def test_cause_chain_is_redacted_capped_and_has_safe_type_metadata() -> None: + request = Request("POST", "https://provider.test/v1/messages") + error = RuntimeError("provider connection failed") + error.__cause__ = ConnectError( + "connect failed authorization: Bearer SECRET token=ALSO_SECRET " + + "x" * ERROR_DETAIL_DISPLAY_CAP_BYTES, + request=request, + ) + + detail = extract_upstream_error_detail(error) + + assert exception_cause_types(error) == ("ConnectError",) + assert detail.cause_chain_text is not None + assert "ConnectError: connect failed authorization: " in ( + detail.cause_chain_text + ) + assert "SECRET" not in detail.cause_chain_text + assert f"truncated after {ERROR_DETAIL_DISPLAY_CAP_BYTES} bytes" in ( + detail.cause_chain_text + ) + + +def test_execution_failure_format_uses_semantic_category_and_request_id() -> None: + failure = ExecutionFailure( + kind=FailureKind.INVALID_REQUEST, + status_code=400, + message="Invalid request sent to provider.", + retryable=False, + ) + detail = UpstreamErrorDetail( + status_code=400, + body_text='{"error":{"message":"bad field token="}}', + exception_text="Bad Request", + category_hint=None, + ) + + message = format_execution_failure_message( + failure, + detail, + upstream_name="ACME", + request_id="req_diagnostic", + ) + + assert "Upstream provider ACME returned HTTP 400." in message + assert "Category: invalid_request" in message + assert "Mapped message: Invalid request sent to provider." in message + assert '{"error":{"message":"bad field token="}}' in message + assert "Request ID: req_diagnostic" in message + assert "invalid_request_error" not in message diff --git a/tests/core/test_failure_protocol_mapping.py b/tests/core/test_failure_protocol_mapping.py new file mode 100644 index 0000000..dbf7032 --- /dev/null +++ b/tests/core/test_failure_protocol_mapping.py @@ -0,0 +1,49 @@ +"""Canonical execution failures and their protocol-owned wire mappings.""" + +import pytest + +from free_claude_code.core.anthropic.errors import ( + anthropic_error_type_for_failure, + anthropic_failure_payload, +) +from free_claude_code.core.failures import ExecutionFailure, FailureKind +from free_claude_code.core.openai_responses.errors import ( + openai_error_type_for_failure, + openai_failure_payload, +) + + +@pytest.mark.parametrize( + ("kind", "anthropic_type", "openai_type"), + [ + (FailureKind.INVALID_REQUEST, "invalid_request_error", "invalid_request_error"), + (FailureKind.AUTHENTICATION, "authentication_error", "authentication_error"), + (FailureKind.PERMISSION, "permission_error", "permission_error"), + (FailureKind.RATE_LIMIT, "rate_limit_error", "rate_limit_error"), + (FailureKind.OVERLOADED, "overloaded_error", "overloaded_error"), + # Existing finalized transport timeouts are exposed as api_error; the + # semantic kind becomes more precise without changing that wire contract. + (FailureKind.TIMEOUT, "api_error", "api_error"), + (FailureKind.UPSTREAM, "api_error", "api_error"), + (FailureKind.UNAVAILABLE, "api_error", "api_error"), + ], +) +def test_each_protocol_owns_failure_kind_to_wire_type_mapping( + kind: FailureKind, + anthropic_type: str, + openai_type: str, +) -> None: + assert anthropic_error_type_for_failure(kind) == anthropic_type + assert openai_error_type_for_failure(kind) == openai_type + + +def test_finalized_status_502_timeout_keeps_existing_api_error_wire_type() -> None: + failure = ExecutionFailure( + kind=FailureKind.TIMEOUT, + status_code=502, + message="Provider request timed out.", + retryable=True, + ) + + assert anthropic_failure_payload(failure)["error"]["type"] == "api_error" + assert openai_failure_payload(failure)["error"]["type"] == "api_error" diff --git a/tests/core/test_failures.py b/tests/core/test_failures.py new file mode 100644 index 0000000..1a2672b --- /dev/null +++ b/tests/core/test_failures.py @@ -0,0 +1,112 @@ +"""Canonical, protocol-neutral execution failure contracts.""" + +from dataclasses import FrozenInstanceError, fields, is_dataclass + +import pytest + +from free_claude_code.core.failures import ( + ExecutionFailure, + FailureKind, + find_execution_failure, +) + + +def test_failure_kind_has_only_protocol_neutral_semantics() -> None: + assert tuple(FailureKind) == ( + FailureKind.INVALID_REQUEST, + FailureKind.AUTHENTICATION, + FailureKind.PERMISSION, + FailureKind.RATE_LIMIT, + FailureKind.OVERLOADED, + FailureKind.TIMEOUT, + FailureKind.UPSTREAM, + FailureKind.UNAVAILABLE, + ) + assert tuple(kind.value for kind in FailureKind) == ( + "invalid_request", + "authentication", + "permission", + "rate_limit", + "overloaded", + "timeout", + "upstream", + "unavailable", + ) + + +def test_execution_failure_is_the_direct_frozen_slotted_exception() -> None: + failure = ExecutionFailure( + kind=FailureKind.RATE_LIMIT, + status_code=429, + message="Provider rate limit reached.", + retryable=True, + ) + + assert is_dataclass(failure) + assert tuple(field.name for field in fields(failure)) == ( + "kind", + "status_code", + "message", + "retryable", + ) + assert ExecutionFailure.__slots__ == ( + "kind", + "status_code", + "message", + "retryable", + ) + assert str(failure) == "Provider rate limit reached." + assert failure.args == ("Provider rate limit reached.",) + + with pytest.raises(ExecutionFailure) as raised: + raise failure + + assert raised.value is failure + with pytest.raises(FrozenInstanceError): + failure.status_code = 500 + + +def test_execution_failure_uses_exception_identity_not_value_equality() -> None: + first = ExecutionFailure( + kind=FailureKind.UPSTREAM, + status_code=500, + message="same", + retryable=True, + ) + second = ExecutionFailure( + kind=FailureKind.UPSTREAM, + status_code=500, + message="same", + retryable=True, + ) + + assert first is not second + assert first != second + + +def test_find_execution_failure_recurses_through_nested_groups() -> None: + failure = ExecutionFailure( + kind=FailureKind.RATE_LIMIT, + status_code=429, + message="provider is busy", + retryable=True, + ) + grouped = ExceptionGroup( + "stream and cleanup failed", + [ + RuntimeError("cleanup failed"), + ExceptionGroup("provider failed", [failure]), + ], + ) + + assert find_execution_failure(failure) is failure + assert find_execution_failure(grouped) is failure + + +def test_find_execution_failure_leaves_unrelated_groups_unclassified() -> None: + grouped = BaseExceptionGroup( + "unrelated failures", + [RuntimeError("socket closed"), KeyboardInterrupt()], + ) + + assert find_execution_failure(grouped) is None diff --git a/tests/core/test_protocol_model_ownership.py b/tests/core/test_protocol_model_ownership.py new file mode 100644 index 0000000..4b5921b --- /dev/null +++ b/tests/core/test_protocol_model_ownership.py @@ -0,0 +1,76 @@ +"""Protocol models live with the protocol logic that consumes them.""" + +import subprocess +import sys + +from free_claude_code.core.anthropic import ( + MessagesRequest as PublicMessagesRequest, +) +from free_claude_code.core.anthropic import ( + MessagesResponse, + TokenCountResponse, +) +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.openai_responses import ( + OpenAIResponsesRequest as PublicOpenAIResponsesRequest, +) +from free_claude_code.core.openai_responses.models import OpenAIResponsesRequest + + +def test_anthropic_request_model_is_core_owned_and_permissive() -> None: + request = MessagesRequest.model_validate( + { + "model": "provider-model", + "messages": [{"role": "user", "content": "hello"}], + "provider_extension": {"enabled": True}, + } + ) + + assert MessagesRequest.__module__ == "free_claude_code.core.anthropic.models" + assert PublicMessagesRequest is MessagesRequest + assert request.model_extra == {"provider_extension": {"enabled": True}} + + +def test_responses_request_model_is_core_owned_and_permissive() -> None: + request = OpenAIResponsesRequest.model_validate( + { + "model": "provider-model", + "input": "hello", + "provider_extension": {"enabled": True}, + } + ) + + assert ( + OpenAIResponsesRequest.__module__ + == "free_claude_code.core.openai_responses.models" + ) + assert PublicOpenAIResponsesRequest is OpenAIResponsesRequest + assert request.model_extra == {"provider_extension": {"enabled": True}} + + +def test_anthropic_response_models_are_protocol_owned() -> None: + assert MessagesResponse.__module__ == "free_claude_code.core.anthropic.models" + assert TokenCountResponse.__module__ == "free_claude_code.core.anthropic.models" + + +def test_protocol_facades_are_import_order_independent() -> None: + import_orders = ( + ( + "free_claude_code.core.anthropic", + "free_claude_code.core.openai_responses", + ), + ( + "free_claude_code.core.openai_responses", + "free_claude_code.core.anthropic", + ), + ) + + for modules in import_orders: + script = "; ".join(f"import {module}" for module in modules) + completed = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + check=False, + text=True, + ) + assert completed.returncode == 0, completed.stderr diff --git a/tests/core/test_strict_sliding_window.py b/tests/core/test_strict_sliding_window.py new file mode 100644 index 0000000..7622ba2 --- /dev/null +++ b/tests/core/test_strict_sliding_window.py @@ -0,0 +1,76 @@ +"""Direct tests for :class:`core.rate_limit.StrictSlidingWindowLimiter`.""" + +import asyncio +import time + +import pytest + +import free_claude_code.core.rate_limit as rate_limit_module +from free_claude_code.core.rate_limit import StrictSlidingWindowLimiter + + +@pytest.mark.asyncio +async def test_strict_window_allows_burst_then_blocks(): + lim = StrictSlidingWindowLimiter(rate_limit=2, rate_window=0.2) + await lim.acquire() + await lim.acquire() + start = time.monotonic() + await lim.acquire() + assert time.monotonic() - start >= 0.15 + + +@pytest.mark.asyncio +async def test_strict_window_async_context_manager(): + lim = StrictSlidingWindowLimiter(rate_limit=1, rate_window=0.15) + + async def run(): + async with lim: + pass + + await run() + start = time.monotonic() + await run() + assert time.monotonic() - start >= 0.1 + + +@pytest.mark.asyncio +async def test_rejected_conditional_acquisition_does_not_consume_capacity(): + lim = StrictSlidingWindowLimiter(rate_limit=1, rate_window=60) + + assert await lim.acquire_if(lambda: False) is False + + await asyncio.wait_for(lim.acquire(), timeout=0.1) + + +@pytest.mark.asyncio +async def test_conditional_acquisition_records_predicate_commit_time( + monkeypatch: pytest.MonkeyPatch, +) -> None: + now = 0.0 + sleep_delays: list[float] = [] + lim = StrictSlidingWindowLimiter(rate_limit=1, rate_window=10) + + def advance_during_condition() -> bool: + nonlocal now + now = 100.0 + return True + + async def advance_during_sleep(delay: float) -> None: + nonlocal now + sleep_delays.append(delay) + now += delay + + monkeypatch.setattr(rate_limit_module.time, "monotonic", lambda: now) + monkeypatch.setattr(rate_limit_module.asyncio, "sleep", advance_during_sleep) + + assert await lim.acquire_if(advance_during_condition) is True + await lim.acquire() + + assert sleep_delays == [10.0] + + +def test_strict_window_rejects_invalid_config(): + with pytest.raises(ValueError): + StrictSlidingWindowLimiter(rate_limit=0, rate_window=1.0) + with pytest.raises(ValueError): + StrictSlidingWindowLimiter(rate_limit=1, rate_window=0.0) diff --git a/tests/core/test_trace.py b/tests/core/test_trace.py new file mode 100644 index 0000000..60c0614 --- /dev/null +++ b/tests/core/test_trace.py @@ -0,0 +1,174 @@ +"""Structured TRACE logging assertions.""" + +import json +from pathlib import Path + +import pytest +from loguru import logger + +from free_claude_code.config.logging_config import configure_logging +from free_claude_code.core.trace import ( + TRACE_PAYLOAD_BINDING, + trace_event, + traced_async_stream, +) + + +class _CloseTrackingIterator: + def __init__( + self, + chunks: list[str], + *, + iteration_error: Exception | None = None, + close_error: Exception | None = None, + ) -> None: + self._chunks = iter(chunks) + self._iteration_error = iteration_error + self._close_error = close_error + self.close_calls = 0 + + def __aiter__(self) -> _CloseTrackingIterator: + return self + + async def __anext__(self) -> str: + try: + return next(self._chunks) + except StopIteration: + if self._iteration_error is not None: + error = self._iteration_error + self._iteration_error = None + raise error from None + raise StopAsyncIteration from None + + async def aclose(self) -> None: + self.close_calls += 1 + if self._close_error is not None: + raise self._close_error + + +def _json_log_rows(log_file: str) -> list[dict]: + logger.complete() + text = Path(log_file).read_text(encoding="utf-8").strip() + if not text: + return [] + return [json.loads(line) for line in text.split("\n")] + + +def test_trace_payload_merged_into_json_line(tmp_path) -> None: + log_file = str(tmp_path / "t.log") + configure_logging(log_file, force=True) + trace_event(stage="s", event="e.v1", source="unit", hello="world", n=42) + row = _json_log_rows(log_file)[-1] + assert row["trace"] is True + assert row["stage"] == "s" + assert row["event"] == "e.v1" + assert row["source"] == "unit" + assert row["hello"] == "world" + assert row["n"] == 42 + assert TRACE_PAYLOAD_BINDING == "trace_payload" + + +def test_sanitize_masks_nested_api_key_strings() -> None: + """Credential-shaped keys redact without touching normal message text.""" + from free_claude_code.core.trace import sanitize_trace_value + + out = sanitize_trace_value( + {"outer": {"api_key": "secret", "text": "visible"}}, + ) + assert out["outer"]["api_key"] == "" + assert out["outer"]["text"] == "visible" + + +@pytest.mark.asyncio +async def test_traced_async_stream_logs_completion(tmp_path) -> None: + log_file = str(tmp_path / "complete.log") + configure_logging(log_file, force=True) + + source = _CloseTrackingIterator(["hello", " world"]) + + chunks = [ + chunk + async for chunk in traced_async_stream( + source, + stage="egress", + source="unit", + complete_event="stream.completed", + interrupted_event="stream.interrupted", + extra={"request_id": "req_complete"}, + ) + ] + + assert chunks == ["hello", " world"] + assert source.close_calls == 1 + rows = _json_log_rows(log_file) + completed = [row for row in rows if row.get("event") == "stream.completed"] + assert len(completed) == 1 + assert completed[0]["request_id"] == "req_complete" + assert completed[0]["stream_chunks"] == 2 + assert completed[0]["outcome"] == "ok" + + +@pytest.mark.asyncio +async def test_traced_async_stream_logs_real_exception(tmp_path) -> None: + log_file = str(tmp_path / "error.log") + configure_logging(log_file, force=True) + + source = _CloseTrackingIterator( + ["before"], + iteration_error=RuntimeError("boom"), + close_error=RuntimeError("close boom"), + ) + + with pytest.raises(RuntimeError, match="boom"): + async for _chunk in traced_async_stream( + source, + stage="egress", + source="unit", + complete_event="stream.completed", + interrupted_event="stream.interrupted", + extra={"request_id": "req_error"}, + ): + pass + + assert source.close_calls == 1 + + rows = _json_log_rows(log_file) + interrupted = [row for row in rows if row.get("event") == "stream.interrupted"] + assert len(interrupted) == 1 + assert interrupted[0]["request_id"] == "req_error" + assert interrupted[0]["stream_chunks"] == 1 + assert interrupted[0]["outcome"] == "error" + assert interrupted[0]["exc_type"] == "RuntimeError" + close_failed = [ + row for row in rows if row.get("event") == "stream.input.close_failed" + ] + assert len(close_failed) == 1 + assert close_failed[0]["owner"] == "traced_async_stream" + assert close_failed[0]["close_exc_type"] == "RuntimeError" + assert close_failed[0]["preserved_exc_type"] == "RuntimeError" + + +@pytest.mark.asyncio +async def test_traced_async_stream_closes_quietly_on_generator_exit(tmp_path) -> None: + log_file = str(tmp_path / "generator_exit.log") + configure_logging(log_file, force=True) + + source = _CloseTrackingIterator(["first", "second"]) + + stream = traced_async_stream( + source, + stage="egress", + source="unit", + complete_event="stream.completed", + interrupted_event="stream.interrupted", + extra={"request_id": "req_closed"}, + ) + + assert await anext(stream) == "first" + await stream.aclose() + + assert source.close_calls == 1 + rows = _json_log_rows(log_file) + events = {row.get("event") for row in rows} + assert "stream.completed" not in events + assert "stream.interrupted" not in events diff --git a/tests/core/test_version.py b/tests/core/test_version.py new file mode 100644 index 0000000..084409b --- /dev/null +++ b/tests/core/test_version.py @@ -0,0 +1,42 @@ +import tomllib +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as distribution_version +from pathlib import Path + +import pytest + +import free_claude_code.core.version as version_module + + +def test_package_version_uses_installed_distribution_metadata() -> None: + assert version_module.package_version() == distribution_version("free-claude-code") + + +def test_package_version_has_explicit_uninstalled_source_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def missing(_distribution_name: str) -> str: + raise PackageNotFoundError("free-claude-code") + + monkeypatch.setattr(version_module, "distribution_version", missing) + + assert version_module.package_version() == "0+unknown" + + +def test_package_version_does_not_hide_invalid_installed_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def invalid(_distribution_name: str) -> str: + raise ValueError("invalid metadata") + + monkeypatch.setattr(version_module, "distribution_version", invalid) + + with pytest.raises(ValueError, match="invalid metadata"): + version_module.package_version() + + +def test_project_release_version_matches_installed_metadata() -> None: + repo_root = Path(__file__).resolve().parents[2] + pyproject = tomllib.loads((repo_root / "pyproject.toml").read_text("utf-8")) + + assert pyproject["project"]["version"] == distribution_version("free-claude-code") diff --git a/tests/messaging/test_discord_markdown.py b/tests/messaging/test_discord_markdown.py new file mode 100644 index 0000000..bb69efa --- /dev/null +++ b/tests/messaging/test_discord_markdown.py @@ -0,0 +1,203 @@ +"""Tests for messaging/rendering/discord_markdown.py.""" + +from free_claude_code.messaging.rendering.discord_markdown import ( + discord_bold, + discord_code_inline, + escape_discord, + escape_discord_code, + format_status, + format_status_discord, + render_markdown_to_discord, +) +from free_claude_code.messaging.rendering.markdown_tables import ( + _is_gfm_table_header_line, + normalize_gfm_tables, +) + + +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("![alt](https://img.png)") + assert "alt" in result + assert "https://img.png" in result + + def test_image_without_alt(self): + result = render_markdown_to_discord("![](https://img.png)") + 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/tests/messaging/test_discord_platform.py b/tests/messaging/test_discord_platform.py new file mode 100644 index 0000000..17c41a7 --- /dev/null +++ b/tests/messaging/test_discord_platform.py @@ -0,0 +1,598 @@ +"""Tests for Discord platform adapter.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.messaging.platforms.discord import ( + DISCORD_AVAILABLE, + DiscordRuntime, + _get_discord, +) +from free_claude_code.messaging.platforms.discord_inbound import ( + discord_text_message_from_event, + parse_allowed_channels, +) +from free_claude_code.messaging.platforms.discord_io import truncate_discord_message + + +def _limiter_mock() -> MagicMock: + limiter = MagicMock() + limiter.start = MagicMock() + limiter.shutdown = AsyncMock() + return limiter + + +def _discord_runtime(*args, limiter=None, transcriber=None, **kwargs) -> DiscordRuntime: + return DiscordRuntime( + *args, + limiter=limiter or _limiter_mock(), + transcriber=transcriber, + **kwargs, + ) + + +class TestGetDiscord: + """Tests for _get_discord helper.""" + + def test_raises_when_discord_not_available(self): + import free_claude_code.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"} + + +class TestDiscordInbound: + def test_text_message_normalizes_accepted_event(self): + msg = MagicMock() + msg.author.id = 456 + msg.author.display_name = "User" + msg.content = "hello" + msg.channel.id = 123 + msg.id = 789 + msg.reference.message_id = 555 + + incoming = discord_text_message_from_event( + msg, + log_raw_messaging_content=False, + ) + + assert incoming.text == "hello" + assert incoming.chat_id == "123" + assert incoming.message_id == "789" + assert incoming.platform == "discord" + assert incoming.reply_to_message_id == "555" + + +@pytest.mark.skipif(not DISCORD_AVAILABLE, reason="discord.py not installed") +class TestDiscordRuntime: + """Tests for Discord runtime and messenger behavior.""" + + def test_init_with_token(self): + platform = _discord_runtime( + 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 = _discord_runtime(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 = _discord_runtime(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): + long_text = "x" * 2500 + truncated = truncate_discord_message(long_text) + assert len(truncated) == 2000 + assert truncated.endswith("...") + + def test_truncate_short_message_unchanged(self): + short = "hello" + assert truncate_discord_message(short) == short + + def test_truncate_exactly_at_limit_unchanged(self): + exact = "x" * 2000 + assert truncate_discord_message(exact) == exact + + def test_truncate_one_over_limit_truncates(self): + over = "x" * 2001 + result = truncate_discord_message(over) + assert len(result) == 2000 + assert result.endswith("...") + + def test_truncate_empty_string(self): + assert truncate_discord_message("") == "" + + @pytest.mark.asyncio + async def test_send_message_returns_message_id(self): + platform = _discord_runtime(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.outbound.send_message("123", "Hello") + assert msg_id == "999" + + @pytest.mark.asyncio + async def test_edit_message(self): + platform = _discord_runtime(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.outbound.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 = _discord_runtime(bot_token="token") + platform._connected = True + with ( + patch.object(platform._client, "get_channel", MagicMock(return_value=None)), + pytest.raises(RuntimeError, match="Channel"), + ): + await platform.outbound.send_message("123", "Hello") + + @pytest.mark.asyncio + async def test_send_message_channel_no_send_raises(self): + platform = _discord_runtime(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.outbound.send_message("123", "Hello") + + @pytest.mark.asyncio + async def test_queue_send_message_uses_required_limiter(self): + platform = _discord_runtime(bot_token="token") + + async def enqueue(operation, dedup_key=None): + return await operation() + + platform._limiter.enqueue = AsyncMock(side_effect=enqueue) + 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.outbound.queue_send_message( + "123", "hi", fire_and_forget=False + ) + assert result == "42" + platform._limiter.enqueue.assert_awaited_once() + mock_channel.send.assert_awaited_once() + + @pytest.mark.asyncio + async def test_queue_edit_message_uses_required_limiter(self): + platform = _discord_runtime(bot_token="token") + + async def enqueue(operation, dedup_key=None): + return await operation() + + platform._limiter.enqueue = AsyncMock(side_effect=enqueue) + 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.outbound.queue_edit_message( + "123", "456", "Updated", fire_and_forget=False + ) + platform._limiter.enqueue.assert_awaited_once() + mock_msg.edit.assert_called_once_with(content="Updated") + + @pytest.mark.asyncio + async def test_on_discord_message_bot_ignored(self): + platform = _discord_runtime(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 = _discord_runtime(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 = _discord_runtime(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 = _discord_runtime(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.message_id == "789" + assert call.platform == "discord" + + @pytest.mark.asyncio + async def test_send_message_with_reply_to(self): + platform = _discord_runtime(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( + "free_claude_code.messaging.platforms.discord._get_discord" + ) as mock_get, + ): + mock_discord = MagicMock() + mock_get.return_value = mock_discord + platform.outbound._get_discord = mock_get + msg_id = await platform.outbound.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 = _discord_runtime(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.outbound.edit_message("123", "456", "Updated") + # Should not raise - NotFound is caught and we return + + @pytest.mark.asyncio + async def test_delete_message(self): + platform = _discord_runtime(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( + "free_claude_code.messaging.platforms.discord._get_discord" + ) as mock_get, + ): + mock_get.return_value = MagicMock() + platform.outbound._get_discord = mock_get + await platform.outbound.delete_message("123", "456") + mock_msg.delete.assert_awaited_once() + + @pytest.mark.asyncio + async def test_fire_and_forget_with_coroutine(self): + platform = _discord_runtime(bot_token="token") + completed = asyncio.Event() + + async def _task(): + completed.set() + + platform.outbound.fire_and_forget(_task()) + await completed.wait() + await platform.outbound.close() + assert platform.outbound._outbox._background_tasks == set() + + def test_on_message_registers_handler(self): + platform = _discord_runtime(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 = _discord_runtime(bot_token="") + with pytest.raises(ValueError, match="DISCORD_BOT_TOKEN"): + await platform.start() + + @pytest.mark.asyncio + async def test_start_connects(self): + limiter = _limiter_mock() + platform = _discord_runtime(bot_token="token", limiter=limiter) + keep_running = asyncio.Event() + + async def _fake_start(_token): + platform._mark_connected() + await keep_running.wait() + + with patch.object( + platform._client, + "start", + new_callable=AsyncMock, + side_effect=_fake_start, + ): + await platform.start() + assert platform.is_connected is True + limiter.start.assert_called_once_with() + await platform.quiesce() + await platform.close() + + @pytest.mark.asyncio + async def test_client_failure_after_readiness_marks_runtime_disconnected(self): + limiter = _limiter_mock() + platform = _discord_runtime(bot_token="token", limiter=limiter) + fail_client = asyncio.Event() + + async def _fake_start(_token): + platform._mark_connected() + await fail_client.wait() + raise RuntimeError("connection lost") + + with patch.object( + platform._client, + "start", + new_callable=AsyncMock, + side_effect=_fake_start, + ): + await platform.start() + assert platform.is_connected is True + + fail_client.set() + assert platform._start_task is not None + result = await asyncio.wait_for( + asyncio.gather(platform._start_task, return_exceptions=True), + timeout=1.0, + ) + assert isinstance(result[0], RuntimeError) + await asyncio.sleep(0) + + assert platform.is_connected is False + await platform.quiesce() + await platform.close() + + @pytest.mark.asyncio + async def test_quiesce_when_client_already_closed_then_close_delivery(self): + limiter = _limiter_mock() + platform = _discord_runtime(bot_token="token", limiter=limiter) + platform._connected = True + with patch.object( + platform._client, "is_closed", new_callable=MagicMock, return_value=True + ): + await platform.quiesce() + assert platform.is_connected is False + limiter.shutdown.assert_not_awaited() + + await platform.close() + + limiter.shutdown.assert_awaited_once_with() + + @pytest.mark.asyncio + async def test_quiesce_closes_client_without_closing_delivery(self): + limiter = _limiter_mock() + platform = _discord_runtime(bot_token="token", limiter=limiter) + 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.quiesce() + mock_close.assert_awaited_once() + assert platform.is_connected is False + limiter.shutdown.assert_not_awaited() + + await platform.close() + + limiter.shutdown.assert_awaited_once_with() + + @pytest.mark.asyncio + async def test_quiesce_drains_start_task_when_client_close_fails(self): + limiter = _limiter_mock() + platform = _discord_runtime(bot_token="token", limiter=limiter) + platform._connected = True + started = asyncio.Event() + + async def pending_start() -> None: + started.set() + await asyncio.Event().wait() + + platform._start_task = asyncio.create_task(pending_start()) + await started.wait() + with ( + patch.object( + platform._client, + "is_closed", + new_callable=MagicMock, + return_value=False, + ), + patch.object( + platform._client, + "close", + new_callable=AsyncMock, + side_effect=RuntimeError("close failed"), + ), + pytest.raises(RuntimeError, match="close failed"), + ): + await platform.quiesce() + + assert platform._start_task is None + limiter.shutdown.assert_not_awaited() + assert platform.is_connected is False + + await platform.close() + + limiter.shutdown.assert_awaited_once_with() + + @pytest.mark.asyncio + async def test_start_propagates_client_failure_immediately(self): + limiter = _limiter_mock() + platform = _discord_runtime(bot_token="token", limiter=limiter) + + with ( + patch.object( + platform._client, + "start", + new_callable=AsyncMock, + side_effect=RuntimeError("invalid token"), + ), + pytest.raises(RuntimeError, match="invalid token"), + ): + await platform.start() + + assert platform._start_task is not None + assert platform._start_task.done() + await platform.quiesce() + await platform.close() + + @pytest.mark.asyncio + async def test_quiesce_waits_for_tracked_inbound_handler(self): + platform = _discord_runtime(bot_token="token") + platform._accepting_messages = True + entered = asyncio.Event() + release = asyncio.Event() + + async def handle(_message) -> None: + entered.set() + await release.wait() + + with ( + patch.object(platform, "_on_discord_message", side_effect=handle), + patch.object( + platform._client, + "is_closed", + new_callable=MagicMock, + return_value=True, + ), + ): + handler_task = asyncio.create_task( + platform._handle_client_message(MagicMock()) + ) + await entered.wait() + quiesce_task = asyncio.create_task(platform.quiesce()) + await asyncio.sleep(0) + + assert not quiesce_task.done() + release.set() + await handler_task + await quiesce_task + + assert platform._inbound_tasks == set() + await platform.close() + + @pytest.mark.asyncio + async def test_quiesce_preserves_caller_cancellation(self): + platform = _discord_runtime(bot_token="token") + entered = asyncio.Event() + + async def close_client() -> None: + entered.set() + await asyncio.Event().wait() + + with ( + patch.object( + platform._client, + "is_closed", + new_callable=MagicMock, + return_value=False, + ), + patch.object(platform._client, "close", side_effect=close_client), + ): + quiesce_task = asyncio.create_task(platform.quiesce()) + await entered.wait() + quiesce_task.cancel() + with pytest.raises(asyncio.CancelledError): + await quiesce_task + + await platform.close() diff --git a/tests/messaging/test_event_parser.py b/tests/messaging/test_event_parser.py new file mode 100644 index 0000000..4efd475 --- /dev/null +++ b/tests/messaging/test_event_parser.py @@ -0,0 +1,197 @@ +from free_claude_code.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 results == [ + { + "type": "error", + "message": "fatal error", + "source": "exit", + "exit_code": 1, + } + ] + + +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/tests/messaging/test_extract_text.py b/tests/messaging/test_extract_text.py new file mode 100644 index 0000000..a62ed2e --- /dev/null +++ b/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 free_claude_code.core.anthropic import extract_text_from_content + + +class TestExtractTextFromContent: + """Tests for core.anthropic.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/tests/messaging/test_handler.py b/tests/messaging/test_handler.py new file mode 100644 index 0000000..4dac095 --- /dev/null +++ b/tests/messaging/test_handler.py @@ -0,0 +1,2499 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock, call, patch + +import pytest + +from free_claude_code.messaging.command_context import ReplyClearResult, StopOutcome +from free_claude_code.messaging.models import MessageScope +from free_claude_code.messaging.platforms.ports import MessagingStartupNotice +from free_claude_code.messaging.session import SessionStore +from free_claude_code.messaging.trees import ( + CancellationReason, + CancellationResult, + CancellationUiOwner, + FailureResult, + MessageReferenceKind, + MessageState, + MessageSubtreeRemovalResult, + NodeClaim, + NodeUiTarget, + QueueEntry, + ReplyTarget, + TreeIdentity, + TreeQueueManager, + TreeSnapshot, +) +from free_claude_code.messaging.trees.transitions import CancellationEffect +from free_claude_code.messaging.voice import VoiceCancellationResult +from free_claude_code.messaging.workflow import MessagingWorkflow + +_SCOPE = MessageScope(platform="telegram", chat_id="chat_1") +_OTHER_SCOPE = MessageScope(platform="telegram", chat_id="other_chat") + + +def _stop_outcome( + cancelled_count: int, + *, + scopes: frozenset[MessageScope] = frozenset({_SCOPE}), + fallback_required: bool = False, +) -> StopOutcome: + return StopOutcome(cancelled_count, scopes, fallback_required) + + +def _startup_notice(chat_id: str = _SCOPE.chat_id) -> MessagingStartupNotice: + return MessagingStartupNotice(chat_id=chat_id, transport_label="Bot API") + + +async def _event_stream(events): + for event in events: + await asyncio.sleep(0) + yield event + + +def _claim( + node_id: str = "node_1", + *, + prompt: str = "hello", + parent_session_id: str | None = None, +) -> NodeClaim: + return NodeClaim( + identity=TreeIdentity(scope=_SCOPE, root_id="root_1"), + claim_id="claim_1", + node=NodeUiTarget( + scope=_SCOPE, + node_id=node_id, + status_message_id="status_1", + ), + prompt=prompt, + parent_session_id=parent_session_id, + ) + + +def _snapshot(root_id: str = "root_1") -> TreeSnapshot: + return TreeSnapshot(scope=_SCOPE, root_id=root_id, nodes={}) + + +def _session(events) -> MagicMock: + session = MagicMock() + session.start_task.return_value = _event_stream(events) + return session + + +async def _wait_for_idle(workflow: MessagingWorkflow) -> None: + for _ in range(200): + if workflow.tree_queue.task_count() == 0: + await asyncio.sleep(0) + return + await asyncio.sleep(0.01) + raise AssertionError("messaging workflow did not become idle") + + +@pytest.fixture +def handler(mock_platform, mock_cli_manager, mock_session_store): + default_session = _session([{"type": "exit", "code": 0}]) + mock_cli_manager.get_or_create_session.return_value = ( + default_session, + "session_1", + False, + ) + return MessagingWorkflow( + mock_platform, + mock_cli_manager, + mock_session_store, + platform_name="telegram", + voice_cancellation=mock_platform, + ) + + +@pytest.mark.asyncio +async def test_handle_message_turn_trace_always_includes_full_message_text( + mock_platform, + mock_cli_manager, + mock_session_store, + incoming_message_factory, +): + text = "user-message-content-visible-in-trace" + workflow = MessagingWorkflow( + mock_platform, + mock_cli_manager, + mock_session_store, + ) + incoming = incoming_message_factory(text=text) + with ( + patch.object(workflow.turn_intake, "handle_message", new_callable=AsyncMock), + patch("free_claude_code.messaging.workflow.trace_event") as trace_mock, + ): + await workflow.handle_message(incoming) + + assert trace_mock.call_args.kwargs["event"] == "turn.received" + assert trace_mock.call_args.kwargs["message_text"] == text + + +@pytest.mark.asyncio +async def test_user_prompt_and_status_are_recorded_for_global_clear( + handler, + mock_session_store, + incoming_message_factory, +) -> None: + incoming = incoming_message_factory(text="keep me", message_id="user-prompt") + + await handler.handle_message(incoming) + await _wait_for_idle(handler) + + mock_session_store.record_message_id.assert_has_calls( + [ + call( + incoming.platform, + incoming.chat_id, + "user-prompt", + "in", + "prompt", + ), + call( + incoming.platform, + incoming.chat_id, + "msg_123", + "out", + "status", + ), + ] + ) + + +@pytest.mark.parametrize( + ("target", "expected"), + [ + (None, "Launching"), + ( + ReplyTarget( + node_id="parent", + reference_id="parent", + reference_kind=MessageReferenceKind.PROMPT, + queue_position=None, + ), + "Continuing", + ), + ( + ReplyTarget( + node_id="parent", + reference_id="status-parent", + reference_kind=MessageReferenceKind.STATUS, + queue_position=3, + ), + "position 3", + ), + ], +) +def test_initial_status_uses_immutable_reply_advice(handler, target, expected): + assert expected in handler.turn_intake._get_initial_status(target) + + +@pytest.mark.asyncio +async def test_global_stop_success_uses_existing_status_without_confirmation( + handler, mock_platform, incoming_message_factory +): + incoming = incoming_message_factory(text="/stop") + handler.stop_all_tasks = AsyncMock(return_value=_stop_outcome(5)) + + await handler.handle_message(incoming) + + handler.stop_all_tasks.assert_awaited_once() + mock_platform.queue_send_message.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "status_feedback_scopes", + ( + frozenset({_OTHER_SCOPE}), + frozenset({_SCOPE, _OTHER_SCOPE}), + ), + ids=("remote-only", "mixed-local-and-remote"), +) +async def test_global_stop_reports_cross_chat_work( + handler, + mock_platform, + incoming_message_factory, + status_feedback_scopes: frozenset[MessageScope], +) -> None: + incoming = incoming_message_factory(text="/stop") + handler.stop_all_tasks = AsyncMock( + return_value=_stop_outcome(2, scopes=status_feedback_scopes) + ) + + await handler.handle_message(incoming) + + assert ( + "Cancelled 2 pending or active requests" + in mock_platform.queue_send_message.call_args.args[1] + ) + + +@pytest.mark.asyncio +async def test_global_stop_noop_reports_nothing_to_stop( + handler, mock_platform, incoming_message_factory +) -> None: + incoming = incoming_message_factory(text="/stop") + handler.stop_all_tasks = AsyncMock( + return_value=_stop_outcome(0, scopes=frozenset()) + ) + + await handler.handle_message(incoming) + + mock_platform.queue_send_message.assert_awaited_once_with( + incoming.chat_id, + "⏹ *Stopped\\.* Nothing to stop\\.", + fire_and_forget=False, + message_thread_id=None, + ) + + +@pytest.mark.asyncio +async def test_global_stop_without_status_target_sends_fallback_confirmation( + handler, mock_platform, incoming_message_factory +) -> None: + incoming = incoming_message_factory(text="/stop") + handler.stop_all_tasks = AsyncMock( + return_value=_stop_outcome( + 1, + scopes=frozenset(), + fallback_required=True, + ) + ) + + await handler.handle_message(incoming) + + assert ( + "Cancelled 1 pending or active request" + in mock_platform.queue_send_message.call_args.args[1] + ) + + +@pytest.mark.asyncio +async def test_failed_global_stop_never_reports_success( + handler, mock_platform, incoming_message_factory +) -> None: + incoming = incoming_message_factory(text="/stop") + handler.stop_all_tasks = AsyncMock( + side_effect=RuntimeError("Failed to stop 1 managed Claude session.") + ) + + with pytest.raises(RuntimeError, match="Failed to stop"): + await handler.handle_message(incoming) + + mock_platform.queue_send_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_reply_stop_resolves_and_stops_only_target( + handler, mock_platform, mock_cli_manager, incoming_message_factory +): + handler.stop_reply = AsyncMock(return_value=_stop_outcome(1)) + handler.stop_all_tasks = AsyncMock(return_value=_stop_outcome(999)) + incoming = incoming_message_factory( + text="/stop", + message_id="stop_msg", + reply_to_message_id="status_root", + ) + + await handler.handle_message(incoming) + + handler.stop_reply.assert_awaited_once_with(incoming.scope, "status_root") + handler.stop_all_tasks.assert_not_awaited() + mock_cli_manager.stop_all.assert_not_awaited() + mock_platform.queue_send_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_reply_stop_unknown_does_not_stop_all( + handler, mock_platform, mock_cli_manager, incoming_message_factory +): + handler.stop_reply = AsyncMock(return_value=_stop_outcome(0, scopes=frozenset())) + handler.stop_all_tasks = AsyncMock(return_value=_stop_outcome(5)) + incoming = incoming_message_factory( + text="/stop", + message_id="stop_msg", + reply_to_message_id="unknown_msg", + ) + + await handler.handle_message(incoming) + + handler.stop_reply.assert_awaited_once_with(incoming.scope, "unknown_msg") + handler.stop_all_tasks.assert_not_awaited() + mock_cli_manager.stop_all.assert_not_awaited() + assert ( + "Nothing to stop for that message" + in mock_platform.queue_send_message.call_args.args[1] + ) + + +@pytest.mark.asyncio +async def test_reply_stop_cancels_voice_when_no_tree_was_admitted( + handler, + mock_platform, + incoming_message_factory, +) -> None: + mock_platform.cancel_pending_voice.return_value = VoiceCancellationResult( + scope=_SCOPE, + voice_message_id="voice", + status_message_id="voice_status", + delete_message_ids=frozenset({"voice", "voice_status"}), + ) + incoming = incoming_message_factory( + text="/stop", + message_id="stop_msg", + reply_to_message_id="voice", + ) + + await handler.handle_message(incoming) + await asyncio.sleep(0) + + mock_platform.queue_send_message.assert_not_awaited() + assert mock_platform.queue_edit_message.call_args.args[1] == "voice_status" + assert "Stopped" in mock_platform.queue_edit_message.call_args.args[2] + + +@pytest.mark.asyncio +async def test_reply_stop_without_voice_status_sends_fallback_confirmation( + handler, + mock_platform, + incoming_message_factory, +) -> None: + mock_platform.cancel_pending_voice.return_value = VoiceCancellationResult( + scope=_SCOPE, + voice_message_id="voice", + status_message_id=None, + delete_message_ids=frozenset({"voice"}), + ) + incoming = incoming_message_factory( + text="/stop", + message_id="stop_msg", + reply_to_message_id="voice", + ) + + await handler.handle_message(incoming) + + assert "Cancelled 1 request" in mock_platform.queue_send_message.call_args.args[1] + mock_platform.queue_edit_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_reply_stop_resolves_tree_after_joining_voice_handoff( + handler, + mock_platform, + incoming_message_factory, +) -> None: + events: list[str] = [] + + async def cancel_voice(*_args) -> VoiceCancellationResult: + events.append("voice") + return VoiceCancellationResult( + scope=_SCOPE, + voice_message_id="voice", + status_message_id="voice_status", + delete_message_ids=frozenset({"voice", "voice_status"}), + ) + + async def resolve_tree(*_args) -> str: + events.append("tree") + return "voice" + + mock_platform.cancel_pending_voice.side_effect = cancel_voice + cancellation = CancellationResult( + effects=( + CancellationEffect( + node=NodeUiTarget( + scope=_SCOPE, + node_id="voice", + status_message_id="voice_status", + ), + ui_owner=CancellationUiOwner.WORKFLOW, + ), + ) + ) + incoming = incoming_message_factory( + text="/stop", + message_id="stop_msg", + reply_to_message_id="voice", + ) + + with ( + patch.object( + handler.tree_queue, + "resolve_node_id", + side_effect=resolve_tree, + ), + patch.object( + handler.tree_queue, + "cancel_node", + new_callable=AsyncMock, + return_value=cancellation, + ) as cancel_node, + ): + await handler.handle_message(incoming) + + cancel_node.assert_awaited_once_with( + incoming.scope, + "voice", + reason=CancellationReason.STOP, + ) + assert events == ["voice", "tree"] + mock_platform.queue_send_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_reply_stop_port_failure_prevents_tree_transition( + handler, + mock_platform, +) -> None: + mock_platform.cancel_pending_voice.side_effect = RuntimeError("voice port failed") + with ( + patch.object( + handler.tree_queue, + "resolve_node_id", + new_callable=AsyncMock, + ) as resolve, + pytest.raises(RuntimeError, match="voice port failed"), + ): + await handler.stop_reply(_SCOPE, "voice") + + resolve.assert_not_awaited() + mock_platform.queue_edit_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_reply_stop_renders_voice_before_resolve_failure( + handler, + mock_platform, +) -> None: + mock_platform.cancel_pending_voice.return_value = VoiceCancellationResult( + scope=_SCOPE, + voice_message_id="voice", + status_message_id="voice_status", + delete_message_ids=frozenset({"voice", "voice_status"}), + ) + with ( + patch.object( + handler.tree_queue, + "resolve_node_id", + AsyncMock(side_effect=RuntimeError("resolve failed")), + ), + pytest.raises(RuntimeError, match="resolve failed"), + ): + await handler.stop_reply(_SCOPE, "voice") + await asyncio.sleep(0) + + mock_platform.queue_edit_message.assert_awaited_once() + assert mock_platform.queue_edit_message.call_args.args[1] == "voice_status" + + +@pytest.mark.asyncio +async def test_cancelled_reply_stop_finishes_after_voice_join_and_lock_wait( + handler, + mock_platform, +) -> None: + voice_returned = asyncio.Event() + + async def cancel_voice(*_args) -> VoiceCancellationResult: + voice_returned.set() + return VoiceCancellationResult( + scope=_SCOPE, + voice_message_id="voice", + status_message_id="voice_status", + delete_message_ids=frozenset({"voice", "voice_status"}), + ) + + mock_platform.cancel_pending_voice.side_effect = cancel_voice + resolve = AsyncMock(return_value=None) + await handler._state_lock.acquire() + try: + with patch.object(handler.tree_queue, "resolve_node_id", resolve): + stop_task = asyncio.create_task(handler.stop_reply(_SCOPE, "voice")) + await voice_returned.wait() + await asyncio.sleep(0) + stop_task.cancel() + stop_task.cancel() + await asyncio.sleep(0) + + assert not stop_task.done() + handler._state_lock.release() + with pytest.raises(asyncio.CancelledError): + await stop_task + finally: + if handler._state_lock.locked(): + handler._state_lock.release() + await asyncio.sleep(0) + + assert stop_task.cancelling() == 2 + resolve.assert_awaited_once_with(_SCOPE, "voice") + mock_platform.queue_edit_message.assert_awaited_once() + assert mock_platform.queue_edit_message.call_args.args[1] == "voice_status" + + +@pytest.mark.asyncio +async def test_cancelled_reply_stop_preserves_later_operation_failure( + handler, + mock_platform, +) -> None: + voice_returned = asyncio.Event() + failure = RuntimeError("resolve failed after cancellation") + + async def cancel_voice(*_args) -> None: + voice_returned.set() + + mock_platform.cancel_pending_voice.side_effect = cancel_voice + resolve = AsyncMock(side_effect=failure) + await handler._state_lock.acquire() + try: + with patch.object(handler.tree_queue, "resolve_node_id", resolve): + stop_task = asyncio.create_task(handler.stop_reply(_SCOPE, "voice")) + await voice_returned.wait() + await asyncio.sleep(0) + stop_task.cancel() + await asyncio.sleep(0) + + assert not stop_task.done() + handler._state_lock.release() + with pytest.raises(RuntimeError) as raised: + await stop_task + finally: + if handler._state_lock.locked(): + handler._state_lock.release() + + assert raised.value is failure + assert stop_task.cancelling() == 1 + resolve.assert_awaited_once_with(_SCOPE, "voice") + + +@pytest.mark.asyncio +async def test_stats_command_reports_cli_and_tree_counts( + handler, mock_platform, mock_cli_manager, incoming_message_factory +): + mock_cli_manager.get_stats.return_value = {"active_sessions": 2} + + await handler.handle_message(incoming_message_factory(text="/stats")) + + text = mock_platform.queue_send_message.call_args.args[1] + assert "Active CLI: 2" in text + assert "Message Trees: 0" in text + assert mock_platform.queue_send_message.call_args.kwargs["fire_and_forget"] is False + + +@pytest.mark.asyncio +async def test_status_echo_is_filtered( + handler, mock_platform, incoming_message_factory +): + await handler.handle_message(incoming_message_factory(text="⏳ Thinking...")) + + mock_platform.queue_send_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_new_turn_uses_public_admission_and_persists_exact_snapshot( + handler, + mock_platform, + mock_session_store, + incoming_message_factory, +): + incoming = incoming_message_factory(text="hello", message_id="node_1") + mock_platform.queue_send_message.return_value = "status_123" + + await handler.handle_message(incoming) + await _wait_for_idle(handler) + + assert "Launching" in mock_platform.queue_send_message.call_args.args[1] + assert mock_session_store.save_tree_snapshot.call_count >= 2 + view = await handler.tree_queue.get_node(incoming.scope, "node_1") + assert view is not None + assert view.state is MessageState.COMPLETED + + +@pytest.mark.asyncio +async def test_duplicate_delivery_removes_its_provisional_status( + handler, + mock_platform, + mock_session_store, + incoming_message_factory, +): + incoming = incoming_message_factory(text="hello", message_id="duplicate") + mock_platform.queue_send_message.side_effect = ["status-first", "status-rejected"] + + await handler.handle_message(incoming) + await _wait_for_idle(handler) + await handler.handle_message(incoming) + + mock_platform.queue_delete_messages.assert_awaited_once_with( + incoming.chat_id, + ["status-rejected"], + fire_and_forget=False, + ) + mock_session_store.forget_tracked_message_ids.assert_called_once_with( + incoming.platform, + incoming.chat_id, + {"status-rejected"}, + ) + + +@pytest.mark.asyncio +async def test_pre_sent_status_is_edited_in_place( + handler, mock_platform, incoming_message_factory +): + incoming = incoming_message_factory( + text="hello", + message_id="node_1", + status_message_id="existing_status", + ) + + await handler.handle_message(incoming) + await _wait_for_idle(handler) + + first_edit = mock_platform.queue_edit_message.call_args_list[0] + assert first_edit.args[1] == "existing_status" + assert "Launching" in first_edit.args[2] + assert first_edit.kwargs["fire_and_forget"] is False + mock_platform.queue_send_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_busy_reply_is_rendered_with_atomic_queue_position( + handler, mock_platform, mock_cli_manager, incoming_message_factory +): + started = asyncio.Event() + + async def blocking_start(*args, **kwargs): + started.set() + await asyncio.sleep(60) + if False: + yield {} + + session = MagicMock() + session.start_task = blocking_start + mock_cli_manager.get_or_create_session.return_value = ( + session, + "session_1", + False, + ) + mock_platform.queue_send_message.side_effect = ["status_root", "status_child"] + + root = incoming_message_factory(text="root", message_id="root") + await handler.handle_message(root) + await started.wait() + child = incoming_message_factory( + text="child", + message_id="child", + reply_to_message_id="status_root", + ) + await handler.handle_message(child) + + assert "position 1" in mock_platform.queue_send_message.call_args.args[1] + queued_edit = mock_platform.queue_edit_message.call_args_list[-1] + assert queued_edit.args[1] == "status_child" + assert "position 1" in queued_edit.args[2] + + await handler.stop_all_tasks() + + +@pytest.mark.asyncio +async def test_queue_position_callback_consumes_immutable_entries( + handler, mock_platform +): + queue = ( + QueueEntry( + node=NodeUiTarget( + scope=_SCOPE, + node_id="child_1", + status_message_id="status_1", + ), + position=1, + ), + QueueEntry( + node=NodeUiTarget( + scope=_SCOPE, + node_id="child_2", + status_message_id="status_2", + ), + position=2, + ), + ) + + await handler.turn_intake.update_queue_positions(queue) + await asyncio.sleep(0) + + calls = mock_platform.queue_edit_message.call_args_list + assert [call.args[1] for call in calls] == ["status_1", "status_2"] + assert "position 1" in calls[0].args[2] + assert "position 2" in calls[1].args[2] + + +@pytest.mark.asyncio +async def test_claim_started_callback_renders_processing(handler, mock_platform): + claim = _claim() + + await handler.turn_intake.mark_node_processing(claim) + await asyncio.sleep(0) + + args, kwargs = mock_platform.queue_edit_message.call_args + assert args[0:2] == ("chat_1", "status_1") + assert "Processing" in args[2] + assert kwargs["parse_mode"] == "MarkdownV2" + + +@pytest.mark.asyncio +async def test_stop_all_applies_immutable_ui_ownership_and_snapshots( + handler, mock_cli_manager, mock_platform, mock_session_store +): + workflow_owned = CancellationEffect( + node=NodeUiTarget( + scope=_SCOPE, + node_id="queued", + status_message_id="status_queued", + ), + ui_owner=CancellationUiOwner.WORKFLOW, + ) + runner_owned = CancellationEffect( + node=NodeUiTarget( + scope=_SCOPE, + node_id="active", + status_message_id="status_active", + ), + ui_owner=CancellationUiOwner.RUNNER, + ) + snapshot = _snapshot() + result = CancellationResult( + effects=(workflow_owned, runner_owned), + snapshots=(snapshot,), + ) + with patch.object( + handler.tree_queue, + "cancel_all", + AsyncMock(return_value=result), + ) as cancel_all: + outcome = await handler.stop_all_tasks() + await asyncio.sleep(0) + + assert outcome == _stop_outcome(2) + cancel_all.assert_awaited_once_with(reason=CancellationReason.STOP) + mock_cli_manager.stop_all.assert_awaited_once() + assert mock_platform.fire_and_forget.call_count == 1 + assert mock_platform.queue_edit_message.call_args.args[1] == "status_queued" + mock_session_store.save_tree_snapshot.assert_called_once_with(snapshot) + + +@pytest.mark.asyncio +async def test_stop_all_joins_voices_before_tree_transaction_and_deduplicates( + handler, + mock_cli_manager, + mock_platform, +) -> None: + events: list[str] = [] + voice = VoiceCancellationResult( + scope=_SCOPE, + voice_message_id="voice", + status_message_id=None, + delete_message_ids=frozenset({"voice"}), + ) + tree_result = CancellationResult( + effects=( + CancellationEffect( + node=NodeUiTarget( + scope=_SCOPE, + node_id="voice", + status_message_id="voice_status", + ), + ui_owner=CancellationUiOwner.WORKFLOW, + ), + ) + ) + + async def cancel_voices() -> tuple[VoiceCancellationResult, ...]: + assert not handler._state_lock.locked() + events.append("voices") + return (voice,) + + async def cancel_trees(*, reason: CancellationReason) -> CancellationResult: + assert reason is CancellationReason.STOP + assert handler._state_lock.locked() + events.append("trees") + return tree_result + + mock_platform.cancel_all_pending_voices.side_effect = cancel_voices + with patch.object(handler.tree_queue, "cancel_all", side_effect=cancel_trees): + outcome = await handler.stop_all_tasks() + await asyncio.sleep(0) + + assert outcome == _stop_outcome(1) + assert events == ["voices", "trees"] + mock_cli_manager.stop_all.assert_awaited_once() + mock_platform.queue_edit_message.assert_awaited_once() + assert mock_platform.queue_edit_message.call_args.args[1] == "voice_status" + + +@pytest.mark.asyncio +async def test_stop_all_voice_join_failure_prevents_false_transition( + handler, + mock_cli_manager, + mock_platform, +) -> None: + mock_platform.cancel_all_pending_voices.side_effect = RuntimeError( + "voice handoff cleanup failed" + ) + + with ( + patch.object( + handler.tree_queue, "cancel_all", new_callable=AsyncMock + ) as cancel, + pytest.raises(RuntimeError, match="voice handoff cleanup failed"), + ): + await handler.stop_all_tasks() + + cancel.assert_not_awaited() + mock_cli_manager.stop_all.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_stop_all_persists_committed_transition_before_cli_shutdown( + handler, + mock_cli_manager, + mock_session_store, +): + shutdown_started = asyncio.Event() + release_shutdown = asyncio.Event() + snapshot = _snapshot() + result = CancellationResult(snapshots=(snapshot,)) + + async def block_shutdown() -> None: + shutdown_started.set() + await release_shutdown.wait() + + mock_cli_manager.stop_all.side_effect = block_shutdown + with patch.object( + handler.tree_queue, + "cancel_all", + AsyncMock(return_value=result), + ): + stop_task = asyncio.create_task(handler.stop_all_tasks()) + await shutdown_started.wait() + + mock_session_store.save_tree_snapshot.assert_called_once_with(snapshot) + stop_task.cancel() + stop_task.cancel() + await asyncio.sleep(0) + assert not stop_task.done() + release_shutdown.set() + with pytest.raises(asyncio.CancelledError): + await stop_task + assert stop_task.cancelling() == 2 + + +@pytest.mark.asyncio +async def test_terminal_close_waits_past_interactive_drain_timeout( + monkeypatch, + mock_platform, + mock_cli_manager, + mock_session_store, + incoming_message_factory, +) -> None: + monkeypatch.setattr( + "free_claude_code.messaging.trees.manager.CANCEL_TASK_DRAIN_TIMEOUT_S", + 0.01, + ) + runner_started = asyncio.Event() + cancellation_seen = asyncio.Event() + release_cleanup = asyncio.Event() + cli_stop_seen = asyncio.Event() + + async def cancellation_delayed_runner(_claim: NodeClaim) -> None: + runner_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cancellation_seen.set() + await release_cleanup.wait() + raise + + async def stop_cli() -> None: + cli_stop_seen.set() + + mock_platform.queue_send_message.return_value = "status_1" + mock_cli_manager.stop_all.side_effect = stop_cli + workflow = MessagingWorkflow( + mock_platform, + mock_cli_manager, + mock_session_store, + platform_name="telegram", + ) + workflow._tree_queue = TreeQueueManager(cancellation_delayed_runner) + await workflow.handle_message( + incoming_message_factory(text="work", message_id="work_1") + ) + await runner_started.wait() + + close_task = asyncio.create_task(workflow.close()) + try: + await asyncio.wait_for(cancellation_seen.wait(), timeout=1) + await asyncio.wait_for(cli_stop_seen.wait(), timeout=1) + + mock_cli_manager.stop_all.assert_awaited_once() + assert close_task.done() is False + assert workflow.tree_queue.task_count() == 1 + mock_session_store.flush_pending_save.assert_not_called() + + release_cleanup.set() + await asyncio.wait_for(close_task, timeout=1) + + assert workflow.tree_queue.task_count() == 0 + mock_session_store.flush_pending_save.assert_called_once() + finally: + release_cleanup.set() + if not close_task.done(): + await asyncio.wait_for(close_task, timeout=1) + + +@pytest.mark.asyncio +async def test_node_runner_success_uses_claim_and_semantic_completion( + handler, mock_cli_manager, mock_platform, mock_session_store +): + claim = _claim(prompt="say hello") + session = _session( + [ + { + "type": "assistant", + "message": { + "content": [ + {"type": "thinking", "thinking": "Let me think"}, + {"type": "text", "text": "Hello world"}, + ] + }, + }, + {"type": "exit", "code": 0}, + ] + ) + mock_cli_manager.get_or_create_session.return_value = ( + session, + "session_1", + False, + ) + snapshot = _snapshot() + with patch.object( + handler.tree_queue, + "complete_claim", + AsyncMock(return_value=snapshot), + ) as complete_claim: + await handler.node_runner.process_node(claim) + + complete_claim.assert_awaited_once_with(claim, "session_1") + mock_session_store.save_tree_snapshot.assert_called_once_with(snapshot) + rendered = mock_platform.queue_edit_message.call_args_list[-1].args[2] + assert "✅ *Complete*" in rendered + assert "Hello world" in rendered + mock_cli_manager.get_or_create_session.assert_awaited_once_with(session_id=None) + assert session.start_task.call_args.args == ("say hello",) + assert session.start_task.call_args.kwargs == { + "session_id": None, + "fork_session": False, + } + + +@pytest.mark.asyncio +async def test_node_runner_uses_claim_parent_session_for_fork( + handler, mock_cli_manager +): + claim = _claim(parent_session_id="parent_session") + session = _session([{"type": "exit", "code": 0}]) + mock_cli_manager.get_or_create_session.return_value = ( + session, + "child_session", + False, + ) + + await handler.node_runner.process_node(claim) + + mock_cli_manager.get_or_create_session.assert_awaited_once_with( + session_id="parent_session" + ) + assert session.start_task.call_args.kwargs == { + "session_id": "parent_session", + "fork_session": True, + } + + +@pytest.mark.asyncio +async def test_session_info_records_real_session_through_manager( + handler, mock_cli_manager, mock_session_store +): + claim = _claim() + session = _session( + [ + {"type": "session_info", "session_id": "real_session"}, + {"type": "exit", "code": 0}, + ] + ) + mock_cli_manager.get_or_create_session.return_value = ( + session, + "temporary_session", + True, + ) + record_snapshot = _snapshot("record") + complete_snapshot = _snapshot("complete") + with ( + patch.object( + handler.tree_queue, + "record_session", + AsyncMock(return_value=record_snapshot), + ) as record_session, + patch.object( + handler.tree_queue, + "complete_claim", + AsyncMock(return_value=complete_snapshot), + ) as complete_claim, + ): + await handler.node_runner.process_node(claim) + + mock_cli_manager.register_real_session_id.assert_awaited_once_with( + "temporary_session", "real_session" + ) + record_session.assert_awaited_once_with(claim, "real_session") + complete_claim.assert_awaited_once_with(claim, "real_session") + assert mock_session_store.save_tree_snapshot.call_args_list == [ + ((record_snapshot,), {}), + ((complete_snapshot,), {}), + ] + + +@pytest.mark.asyncio +async def test_session_info_rejects_unregistered_real_session_without_recording_it( + handler, + mock_cli_manager, +) -> None: + from free_claude_code.messaging.node_event_pipeline import ( + handle_session_info_event, + ) + + claim = _claim() + record_session = AsyncMock() + mock_cli_manager.register_real_session_id.return_value = False + + with pytest.raises( + RuntimeError, + match=r"^Managed Claude session registration failed\.$", + ) as raised: + await handle_session_info_event( + {"type": "session_info", "session_id": "real_session"}, + claim, + None, + "temporary_session", + cli_manager=mock_cli_manager, + record_session=record_session, + ) + + assert "real_session" not in str(raised.value) + assert "temporary_session" not in str(raised.value) + record_session.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_session_limit_failure_uses_non_propagating_claim_failure( + handler, mock_cli_manager, mock_platform +): + claim = _claim() + mock_cli_manager.get_or_create_session.side_effect = RuntimeError("session limit") + result = FailureResult( + affected=(claim.node,), + queue_update=None, + snapshot=_snapshot(), + ) + with patch.object( + handler.tree_queue, + "fail_claim", + AsyncMock(return_value=result), + ) as fail_claim: + await handler.node_runner.process_node(claim) + + fail_claim.assert_awaited_once_with(claim, propagate=False) + rendered = mock_platform.queue_edit_message.call_args_list[-1].args[2] + assert "Session limit reached" in rendered + + +@pytest.mark.asyncio +async def test_non_exit_error_defers_child_failure_until_stream_ends( + handler, mock_cli_manager, mock_platform, mock_session_store +): + claim = _claim() + session = _session([{"type": "error", "error": {"message": "CLI crashed"}}]) + mock_cli_manager.get_or_create_session.return_value = ( + session, + "session_1", + False, + ) + child = NodeUiTarget( + scope=_SCOPE, + node_id="child", + status_message_id="status_child", + ) + snapshot = _snapshot() + result = FailureResult( + affected=(claim.node, child), + queue_update=None, + snapshot=snapshot, + ) + with patch.object( + handler.tree_queue, + "fail_claim", + AsyncMock(return_value=result), + ) as fail_claim: + await handler.node_runner.process_node(claim) + await asyncio.sleep(0) + + assert fail_claim.await_args_list == [ + call(claim, propagate=False), + call(claim, propagate=True), + ] + assert mock_session_store.save_tree_snapshot.call_args_list == [ + call(snapshot), + call(snapshot), + ] + rendered = "\n".join( + call.args[2] for call in mock_platform.queue_edit_message.call_args_list + ) + assert "❌ *Error*" in rendered + assert "CLI crashed" in rendered + assert "Parent task failed" in rendered + + +@pytest.mark.asyncio +async def test_provider_error_exit_does_not_mask_or_complete( + handler, mock_cli_manager, mock_platform +): + claim = _claim() + provider_error = "API Error: Request rejected (429)\nProvider rate limit reached." + session = _session( + [ + {"type": "error", "error": {"message": provider_error}}, + {"type": "exit", "code": 1}, + ] + ) + mock_cli_manager.get_or_create_session.return_value = ( + session, + "session_1", + False, + ) + failure = FailureResult( + affected=(claim.node,), + queue_update=None, + snapshot=_snapshot(), + ) + with ( + patch.object( + handler.tree_queue, + "fail_claim", + AsyncMock(return_value=failure), + ) as fail_claim, + patch.object( + handler.tree_queue, + "complete_claim", + AsyncMock(), + ) as complete_claim, + ): + await handler.node_runner.process_node(claim) + + assert fail_claim.await_args_list == [ + call(claim, propagate=False), + call(claim, propagate=True), + ] + complete_claim.assert_not_awaited() + rendered = mock_platform.queue_edit_message.call_args_list[-1].args[2] + assert "API Error: Request rejected" in rendered + assert "Process exited with code" not in rendered + assert "✅ *Complete*" not in rendered + + +@pytest.mark.asyncio +async def test_success_exit_still_renders_complete_after_non_exit_error( + handler, mock_cli_manager, mock_platform +): + claim = _claim() + session = _session( + [ + {"type": "error", "error": {"message": "recoverable warning"}}, + {"type": "exit", "code": 0}, + ] + ) + mock_cli_manager.get_or_create_session.return_value = ( + session, + "session_1", + False, + ) + failure = FailureResult( + affected=(claim.node,), + queue_update=None, + snapshot=_snapshot(), + ) + with ( + patch.object( + handler.tree_queue, + "fail_claim", + AsyncMock(return_value=failure), + ) as fail_claim, + patch.object( + handler.tree_queue, + "complete_claim", + AsyncMock(return_value=_snapshot()), + ) as complete_claim, + ): + await handler.node_runner.process_node(claim) + + fail_claim.assert_awaited_once_with( + claim, + propagate=False, + ) + complete_claim.assert_awaited_once_with(claim, "session_1") + assert ( + "✅ *Complete*" in mock_platform.queue_edit_message.call_args_list[-1].args[2] + ) + + +@pytest.mark.asyncio +async def test_unexpected_runner_exception_uses_detailed_task_failed_ui( + handler, mock_cli_manager, mock_platform +): + claim = _claim() + + async def failing_start(*args, **kwargs): + raise ValueError("runner exploded") + if False: + yield {} + + session = MagicMock() + session.start_task = failing_start + mock_cli_manager.get_or_create_session.return_value = ( + session, + "session_1", + False, + ) + failure = FailureResult( + affected=(claim.node,), + queue_update=None, + snapshot=_snapshot(), + ) + with patch.object( + handler.tree_queue, + "fail_claim", + AsyncMock(return_value=failure), + ) as fail_claim: + await handler.node_runner.process_node(claim) + + fail_claim.assert_awaited_once_with( + claim, + propagate=True, + ) + rendered = mock_platform.queue_edit_message.call_args_list[-1].args[2] + assert "Task Failed" in rendered + assert "runner exploded" in rendered + + +@pytest.mark.asyncio +async def test_stop_cancellation_preserves_partial_transcript( + handler, mock_cli_manager, mock_platform +): + claim = _claim(prompt="work") + started = asyncio.Event() + + async def start_task(*args, **kwargs): + yield { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "partial answer"}]}, + } + started.set() + await asyncio.sleep(60) + + session = MagicMock() + session.start_task = start_task + mock_cli_manager.get_or_create_session.return_value = ( + session, + "session_1", + False, + ) + failure = FailureResult( + affected=(claim.node,), + queue_update=None, + snapshot=_snapshot(), + ) + with patch.object( + handler.tree_queue, + "fail_claim", + AsyncMock(return_value=failure), + ) as fail_claim: + task = asyncio.create_task(handler.node_runner.process_node(claim)) + await started.wait() + task.cancel(CancellationReason.STOP) + await task + + fail_claim.assert_awaited_once_with( + claim, + propagate=False, + ) + rendered = mock_platform.queue_edit_message.call_args_list[-1].args[2] + assert "partial answer" in rendered + assert "⏹ *Stopped\\.*" in rendered + assert rendered.index("partial answer") < rendered.index("⏹ *Stopped\\.*") + + +@pytest.mark.asyncio +async def test_global_clear_command_deletes_returned_ids( + handler, mock_platform, incoming_message_factory +): + handler.clear_chat = AsyncMock(return_value=frozenset({"100", "101"})) + incoming = incoming_message_factory( + text="/clear", + chat_id="chat_1", + message_id="150", + ) + + await handler.handle_message(incoming) + + handler.clear_chat.assert_awaited_once_with("telegram", "chat_1") + mock_platform.queue_delete_messages.assert_awaited_once_with( + "chat_1", + ["150", "101", "100"], + fire_and_forget=False, + ) + mock_platform.queue_send_message.assert_not_awaited() + handler.session_store.record_message_id.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "failure", + (RuntimeError("clear failed"), asyncio.CancelledError()), + ids=("failure", "cancellation"), +) +async def test_interrupted_global_clear_records_its_deferred_command_id( + handler, + mock_platform, + mock_session_store, + incoming_message_factory, + failure: BaseException, +) -> None: + handler.clear_chat = AsyncMock(side_effect=failure) + incoming = incoming_message_factory( + text="/clear", + chat_id="chat_1", + message_id="150", + ) + + with pytest.raises(type(failure)): + await handler.handle_message(incoming) + + mock_session_store.record_message_id.assert_called_once_with( + incoming.platform, + incoming.chat_id, + incoming.message_id, + "in", + "command", + ) + mock_platform.queue_delete_messages.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_startup_notice_is_published_and_recorded_for_clear( + handler, + mock_platform, + mock_session_store, +) -> None: + mock_platform.queue_send_message.return_value = "startup_1" + + await handler.publish_startup_notice(_startup_notice()) + + mock_platform.queue_send_message.assert_awaited_once_with( + _SCOPE.chat_id, + "🚀 *Claude Code Proxy is online\\!* \\(Bot API\\)", + parse_mode="MarkdownV2", + fire_and_forget=False, + ) + mock_session_store.record_message_id.assert_called_once_with( + _SCOPE.platform, + _SCOPE.chat_id, + "startup_1", + "out", + "startup", + ) + mock_platform.queue_delete_messages.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_startup_notice_failure_is_nonfatal_and_records_nothing( + handler, + mock_platform, + mock_session_store, +) -> None: + mock_platform.queue_send_message.side_effect = RuntimeError("unavailable") + + await handler.publish_startup_notice(_startup_notice()) + + mock_session_store.record_message_id.assert_not_called() + mock_platform.queue_delete_messages.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_startup_notice_without_delivery_receipt_records_nothing( + handler, + mock_platform, + mock_session_store, +) -> None: + mock_platform.queue_send_message.return_value = None + + await handler.publish_startup_notice(_startup_notice()) + + mock_session_store.record_message_id.assert_not_called() + mock_platform.queue_delete_messages.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_startup_notice_record_failure_is_compensated_outside_state_lock( + handler, + mock_platform, + mock_session_store, +) -> None: + mock_platform.queue_send_message.return_value = "startup_1" + mock_session_store.record_message_id.side_effect = OSError("store unavailable") + + async def delete_notice(*args, **kwargs) -> None: + assert not handler._state_lock.locked() + + mock_platform.queue_delete_messages.side_effect = delete_notice + + await handler.publish_startup_notice(_startup_notice()) + + mock_session_store.record_message_id.assert_called_once_with( + _SCOPE.platform, + _SCOPE.chat_id, + "startup_1", + "out", + "startup", + ) + mock_platform.queue_delete_messages.assert_awaited_once_with( + _SCOPE.chat_id, + ["startup_1"], + fire_and_forget=False, + ) + mock_session_store.forget_tracked_message_ids.assert_called_once_with( + _SCOPE.platform, + _SCOPE.chat_id, + {"startup_1"}, + ) + + +@pytest.mark.asyncio +async def test_failed_startup_notice_compensation_restores_clear_ownership( + handler, + mock_platform, + mock_session_store, +) -> None: + mock_platform.queue_send_message.return_value = "startup_1" + mock_session_store.record_message_id.side_effect = ( + OSError("store unavailable"), + None, + ) + mock_platform.queue_delete_messages.side_effect = RuntimeError("delete unavailable") + + await handler.publish_startup_notice(_startup_notice()) + + assert mock_session_store.record_message_id.call_count == 2 + assert mock_session_store.record_message_id.call_args_list == [ + call( + _SCOPE.platform, + _SCOPE.chat_id, + "startup_1", + "out", + "startup", + ), + call( + _SCOPE.platform, + _SCOPE.chat_id, + "startup_1", + "out", + "startup", + ), + ] + mock_session_store.forget_tracked_message_ids.assert_not_called() + + +@pytest.mark.asyncio +async def test_startup_notice_publication_propagates_cancellation( + handler, + mock_platform, + mock_session_store, +) -> None: + send_started = asyncio.Event() + send_cancelled = asyncio.Event() + + async def send_notice(*args, **kwargs) -> None: + send_started.set() + try: + await asyncio.Event().wait() + finally: + send_cancelled.set() + + mock_platform.queue_send_message.side_effect = send_notice + task = asyncio.create_task(handler.publish_startup_notice(_startup_notice())) + await send_started.wait() + + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + await send_cancelled.wait() + mock_session_store.record_message_id.assert_not_called() + mock_platform.queue_delete_messages.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_startup_notice_cancellation_after_receipt_finishes_compensation( + handler, + mock_platform, + mock_session_store, +) -> None: + send_started = asyncio.Event() + release_send = asyncio.Event() + finalizer_started = asyncio.Event() + + async def send_notice(*args, **kwargs) -> str: + send_started.set() + await release_send.wait() + return "startup_1" + + original_finalize = handler._finalize_startup_notice + + async def finalize_notice(*args, **kwargs) -> None: + finalizer_started.set() + await original_finalize(*args, **kwargs) + + mock_platform.queue_send_message.side_effect = send_notice + with patch.object( + handler, + "_finalize_startup_notice", + side_effect=finalize_notice, + ): + task = asyncio.create_task(handler.publish_startup_notice(_startup_notice())) + await send_started.wait() + await handler._state_lock.acquire() + try: + release_send.set() + await finalizer_started.wait() + finally: + handler._state_lock.release() + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + + mock_session_store.record_message_id.assert_not_called() + mock_platform.queue_delete_messages.assert_awaited_once_with( + _SCOPE.chat_id, + ["startup_1"], + fire_and_forget=False, + ) + mock_session_store.forget_tracked_message_ids.assert_called_once_with( + _SCOPE.platform, + _SCOPE.chat_id, + {"startup_1"}, + ) + + +@pytest.mark.asyncio +async def test_concurrent_global_clear_does_not_wait_for_startup_delivery( + mock_platform, + mock_cli_manager, + tmp_path, +) -> None: + store = SessionStore(storage_path=str(tmp_path / "sessions.json")) + workflow = MessagingWorkflow( + mock_platform, + mock_cli_manager, + store, + platform_name="telegram", + voice_cancellation=mock_platform, + ) + send_started = asyncio.Event() + release_send = asyncio.Event() + + async def send_notice(*args, **kwargs) -> str: + send_started.set() + await release_send.wait() + return "startup_1" + + mock_platform.queue_send_message.side_effect = send_notice + publish_task = asyncio.create_task( + workflow.publish_startup_notice(_startup_notice()) + ) + await send_started.wait() + clear_task = asyncio.create_task( + workflow.clear_chat(_SCOPE.platform, _SCOPE.chat_id) + ) + try: + done, _pending = await asyncio.wait({clear_task}, timeout=1) + assert clear_task in done + assert clear_task.result() == frozenset() + finally: + release_send.set() + await asyncio.gather(publish_task, clear_task, return_exceptions=True) + + assert store.get_tracked_message_ids_for_chat(_SCOPE.platform, _SCOPE.chat_id) == [] + mock_platform.queue_delete_messages.assert_awaited_once_with( + _SCOPE.chat_id, + ["startup_1"], + fire_and_forget=False, + ) + + +@pytest.mark.asyncio +async def test_global_stop_does_not_wait_for_or_invalidate_startup_delivery( + handler, + mock_platform, + mock_session_store, +) -> None: + send_started = asyncio.Event() + release_send = asyncio.Event() + + async def send_notice(*args, **kwargs) -> str: + send_started.set() + await release_send.wait() + return "startup_1" + + mock_platform.queue_send_message.side_effect = send_notice + publish_task = asyncio.create_task( + handler.publish_startup_notice(_startup_notice()) + ) + await send_started.wait() + stop_task = asyncio.create_task(handler.stop_all_tasks()) + try: + done, _pending = await asyncio.wait({stop_task}, timeout=1) + assert stop_task in done + stop_task.result() + finally: + release_send.set() + await asyncio.gather(publish_task, stop_task, return_exceptions=True) + + mock_session_store.record_message_id.assert_called_once_with( + _SCOPE.platform, + _SCOPE.chat_id, + "startup_1", + "out", + "startup", + ) + mock_platform.queue_delete_messages.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_global_clear_precedes_concurrent_startup_notice_publication( + mock_platform, + mock_cli_manager, + tmp_path, +) -> None: + store = SessionStore(storage_path=str(tmp_path / "sessions.json")) + workflow = MessagingWorkflow( + mock_platform, + mock_cli_manager, + store, + platform_name="telegram", + voice_cancellation=mock_platform, + ) + clear_started = asyncio.Event() + release_clear = asyncio.Event() + + async def clear_trees( + scope: MessageScope, *, reason: CancellationReason + ) -> CancellationResult: + assert reason is CancellationReason.CLEAR + clear_started.set() + await release_clear.wait() + return CancellationResult() + + with patch.object(workflow.tree_queue, "clear_scope", side_effect=clear_trees): + clear_task = asyncio.create_task( + workflow.clear_chat(_SCOPE.platform, _SCOPE.chat_id) + ) + await clear_started.wait() + publish_task = asyncio.create_task( + workflow.publish_startup_notice(_startup_notice()) + ) + await asyncio.sleep(0) + mock_platform.queue_send_message.assert_not_awaited() + + release_clear.set() + + assert await clear_task == frozenset() + await publish_task + + assert store.get_tracked_message_ids_for_chat(_SCOPE.platform, _SCOPE.chat_id) == [ + "msg_123" + ] + mock_platform.queue_delete_messages.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_clear_command_cannot_evict_startup_notice_at_managed_message_cap( + mock_platform, + mock_cli_manager, + incoming_message_factory, + tmp_path, +) -> None: + store = SessionStore( + storage_path=str(tmp_path / "sessions.json"), + managed_message_cap=1, + ) + workflow = MessagingWorkflow( + mock_platform, + mock_cli_manager, + store, + platform_name="telegram", + voice_cancellation=mock_platform, + ) + store.record_message_id( + _SCOPE.platform, + _SCOPE.chat_id, + "100", + "out", + "startup", + ) + incoming = incoming_message_factory( + text="/clear", + chat_id=_SCOPE.chat_id, + message_id="101", + ) + + await workflow.handle_message(incoming) + + mock_platform.queue_delete_messages.assert_awaited_once_with( + _SCOPE.chat_id, + ["101", "100"], + fire_and_forget=False, + ) + + +@pytest.mark.asyncio +async def test_clear_chat_is_fully_scoped_to_the_invoking_chat( + handler, mock_cli_manager, mock_session_store, incoming_message_factory +): + root_1 = incoming_message_factory( + text="one", + chat_id="chat_1", + message_id="100", + ) + root_2 = incoming_message_factory( + text="two", + chat_id="chat_2", + message_id="200", + ) + await handler.tree_queue.admit(root_1, "101") + await handler.tree_queue.admit(root_2, "201") + await _wait_for_idle(handler) + mock_session_store.get_tracked_message_ids_for_chat.return_value = ["42"] + mock_session_store.reset_mock() + mock_session_store.get_tracked_message_ids_for_chat.return_value = ["42"] + + message_ids = await handler.clear_chat("telegram", "chat_1") + + assert message_ids == frozenset({"42", "100", "101"}) + assert "200" not in message_ids + assert handler.get_tree_count() == 1 + assert await handler.tree_queue.get_node(root_2.scope, "200") is not None + mock_cli_manager.stop_all.assert_not_awaited() + mock_session_store.clear_scope.assert_called_once_with(_SCOPE) + + +@pytest.mark.asyncio +async def test_global_clear_cancels_and_deletes_only_current_chat_voices( + handler, + mock_platform, + mock_session_store, +) -> None: + events: list[str] = [] + current = VoiceCancellationResult( + scope=_SCOPE, + voice_message_id="voice", + status_message_id="voice_status", + delete_message_ids=frozenset({"voice", "voice_status"}), + ) + + async def cancel_voices(scope: MessageScope) -> tuple[VoiceCancellationResult, ...]: + assert not handler._state_lock.locked() + assert scope == _SCOPE + events.append("voices") + return (current,) + + async def clear_trees( + scope: MessageScope, *, reason: CancellationReason + ) -> CancellationResult: + assert reason is CancellationReason.CLEAR + assert handler._state_lock.locked() + events.append("trees") + return CancellationResult() + + mock_platform.cancel_pending_voices_in_scope.side_effect = cancel_voices + mock_session_store.get_tracked_message_ids_for_chat.return_value = ["stored"] + with patch.object(handler.tree_queue, "clear_scope", side_effect=clear_trees): + message_ids = await handler.clear_chat("telegram", "chat_1") + await asyncio.sleep(0) + + assert events == ["voices", "trees"] + assert message_ids == frozenset({"stored", "voice", "voice_status"}) + assert "other_voice" not in message_ids + assert "other_status" not in message_ids + mock_platform.queue_edit_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_global_clear_persists_after_tree_detach( + handler, + mock_cli_manager, + mock_session_store, +) -> None: + events: list[str] = [] + + async def clear_trees( + scope: MessageScope, *, reason: CancellationReason + ) -> CancellationResult: + assert scope == _SCOPE + assert reason is CancellationReason.CLEAR + events.append("trees.clear_scope") + return CancellationResult() + + mock_session_store.clear_scope.side_effect = lambda scope: events.append( + f"store.clear_scope:{scope.chat_id}" + ) + + with patch.object(handler.tree_queue, "clear_scope", side_effect=clear_trees): + result = await handler.clear_chat("telegram", "chat_1") + + assert result == frozenset() + assert events == ["trees.clear_scope", "store.clear_scope:chat_1"] + mock_cli_manager.stop_all.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_global_clear_finishes_cleanup_before_final_persistence_error_escapes( + handler, + mock_cli_manager, + mock_session_store, +) -> None: + events: list[str] = [] + + async def clear_trees( + scope: MessageScope, *, reason: CancellationReason + ) -> CancellationResult: + assert scope == _SCOPE + assert reason is CancellationReason.CLEAR + events.append("trees.clear_scope") + return CancellationResult() + + def fail_final_clear(scope: MessageScope) -> None: + assert scope == _SCOPE + events.append("store.clear_scope") + raise OSError("final clear failure") + + mock_session_store.clear_scope.side_effect = fail_final_clear + + with ( + patch.object(handler.tree_queue, "clear_scope", side_effect=clear_trees), + pytest.raises(OSError, match="final clear failure"), + ): + await handler.clear_chat("telegram", "chat_1") + + assert events == ["trees.clear_scope", "store.clear_scope"] + mock_cli_manager.stop_all.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_global_clear_preserves_tree_and_persistence_failures( + handler, + mock_cli_manager, + mock_session_store, +) -> None: + events: list[str] = [] + persistence_error = OSError("final clear failure") + tree_error = RuntimeError("tree clear failure") + + async def fail_tree_clear( + scope: MessageScope, *, reason: CancellationReason + ) -> CancellationResult: + assert scope == _SCOPE + assert reason is CancellationReason.CLEAR + events.append("trees.clear_scope") + raise tree_error + + def fail_final_clear(scope: MessageScope) -> None: + assert scope == _SCOPE + events.append("store.clear_scope") + raise persistence_error + + mock_session_store.clear_scope.side_effect = fail_final_clear + + with ( + patch.object(handler.tree_queue, "clear_scope", side_effect=fail_tree_clear), + pytest.raises(ExceptionGroup) as raised, + ): + await handler.clear_chat("telegram", "chat_1") + + assert raised.value.exceptions == (tree_error, persistence_error) + assert events == ["trees.clear_scope", "store.clear_scope"] + mock_cli_manager.stop_all.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_committed_global_clear_attempts_remaining_steps_after_tree_failure( + handler, + mock_cli_manager, + mock_session_store, +) -> None: + events: list[str] = [] + tree_error = RuntimeError("tree clear failure") + + async def fail_tree_clear( + scope: MessageScope, *, reason: CancellationReason + ) -> CancellationResult: + assert scope == _SCOPE + assert reason is CancellationReason.CLEAR + events.append("trees.clear_scope") + raise tree_error + + mock_session_store.clear_scope.side_effect = lambda scope: events.append( + f"store.clear_scope:{scope.chat_id}" + ) + + with ( + patch.object(handler.tree_queue, "clear_scope", side_effect=fail_tree_clear), + pytest.raises(RuntimeError, match="tree clear failure") as raised, + ): + await handler.clear_chat("telegram", "chat_1") + + assert raised.value is tree_error + assert events == [ + "trees.clear_scope", + "store.clear_scope:chat_1", + ] + mock_cli_manager.stop_all.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_cancelled_global_clear_finishes_owned_transaction_before_propagating( + handler, + mock_cli_manager, + mock_session_store, + incoming_message_factory, +): + root = incoming_message_factory(text="work", message_id="100") + await handler.tree_queue.admit(root, "101") + await _wait_for_idle(handler) + id_read_started = asyncio.Event() + release_id_read = asyncio.Event() + + async def block_id_read(platform: str, chat_id: str) -> set[str]: + id_read_started.set() + await release_id_read.wait() + return {"100", "101"} + + mock_session_store.reset_mock() + with patch.object( + handler.tree_queue, + "get_message_ids_for_chat", + new=block_id_read, + ): + clear_task = asyncio.create_task(handler.clear_chat("telegram", root.chat_id)) + await id_read_started.wait() + clear_task.cancel() + await asyncio.sleep(0) + assert not clear_task.done() + release_id_read.set() + with pytest.raises(asyncio.CancelledError): + await clear_task + + assert handler._clear_generations[_SCOPE] == 1 + assert handler.get_tree_count() == 0 + mock_session_store.clear_scope.assert_called_once_with(_SCOPE) + mock_cli_manager.stop_all.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_clear_with_mention_uses_same_global_command( + handler, mock_platform, incoming_message_factory +): + handler.clear_chat = AsyncMock(return_value=frozenset()) + incoming = incoming_message_factory( + text="/clear@MyBot", + chat_id="chat_1", + message_id="10", + ) + + await handler.handle_message(incoming) + + handler.clear_chat.assert_awaited_once_with("telegram", "chat_1") + mock_platform.queue_delete_messages.assert_awaited_once_with( + "chat_1", + ["10"], + fire_and_forget=False, + ) + + +@pytest.mark.asyncio +async def test_clear_continues_after_platform_delete_failure( + handler, mock_platform, incoming_message_factory +): + handler.clear_chat = AsyncMock(return_value=frozenset({"41", "42"})) + mock_platform.queue_delete_messages.side_effect = RuntimeError( + "platform rejected delete" + ) + + await handler.handle_message( + incoming_message_factory(text="/clear", message_id="150") + ) + + handler.clear_chat.assert_awaited_once() + mock_platform.queue_delete_messages.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_reply_clear_status_preserves_prompt_and_persists_remaining_tree( + handler, + mock_platform, + mock_session_store, + incoming_message_factory, +): + root = incoming_message_factory(text="root", message_id="100") + child = incoming_message_factory( + text="child", + message_id="102", + reply_to_message_id="100", + ) + await handler.tree_queue.admit(root, "101") + await _wait_for_idle(handler) + await handler.tree_queue.admit(child, "103", parent_reference_id="100") + await _wait_for_idle(handler) + mock_session_store.reset_mock() + deleted_ids: list[str] = [] + + async def capture_delete(chat_id, message_ids, fire_and_forget=True): + deleted_ids.extend(message_ids) + + mock_platform.queue_delete_messages.side_effect = capture_delete + await handler.handle_message( + incoming_message_factory( + text="/clear", + message_id="150", + reply_to_message_id="103", + ) + ) + + assert set(deleted_ids) == {"103", "150"} + assert "102" not in deleted_ids + assert "100" not in deleted_ids + assert "101" not in deleted_ids + cleared_prompt = await handler.tree_queue.get_node(root.scope, "102") + assert cleared_prompt is not None + assert cleared_prompt.state is MessageState.ERROR + assert cleared_prompt.session_id is None + assert await handler.tree_queue.get_node(root.scope, "100") is not None + mock_session_store.save_tree_snapshot.assert_called_once() + mock_session_store.record_message_id.assert_called_once_with( + "telegram", + "chat_1", + "150", + "in", + "command", + ) + mock_session_store.forget_tracked_message_ids.assert_called_once_with( + "telegram", + "chat_1", + {"103", "150"}, + ) + + +@pytest.mark.asyncio +async def test_reply_clear_unknown_reports_nothing_to_clear( + handler, mock_platform, mock_session_store, incoming_message_factory +): + incoming = incoming_message_factory( + text="/clear", + message_id="150", + reply_to_message_id="999", + ) + + await handler.handle_message(incoming) + + assert "Nothing to clear" in mock_platform.queue_send_message.call_args.args[1] + mock_session_store.record_message_id.assert_any_call( + incoming.platform, + incoming.chat_id, + incoming.message_id, + "in", + "command", + ) + mock_session_store.clear_scope.assert_not_called() + + +@pytest.mark.asyncio +async def test_reply_clear_root_removes_tree_snapshot( + handler, + mock_platform, + mock_session_store, + incoming_message_factory, +): + root = incoming_message_factory(text="root", message_id="100") + await handler.tree_queue.admit(root, "101") + await _wait_for_idle(handler) + mock_session_store.reset_mock() + deleted_ids: list[str] = [] + + async def capture_delete(chat_id, message_ids, fire_and_forget=True): + deleted_ids.extend(message_ids) + + mock_platform.queue_delete_messages.side_effect = capture_delete + await handler.handle_message( + incoming_message_factory( + text="/clear", + message_id="150", + reply_to_message_id="100", + ) + ) + + assert set(deleted_ids) == {"100", "101", "150"} + mock_session_store.remove_tree_snapshot.assert_called_once_with( + TreeIdentity(scope=root.scope, root_id="100") + ) + assert handler.get_tree_count() == 0 + + +@pytest.mark.asyncio +async def test_late_cancelled_runner_cannot_save_or_render_after_chat_clear( + handler, + mock_platform, + mock_cli_manager, + mock_session_store, + incoming_message_factory, +): + started = asyncio.Event() + + async def blocking_start(*args, **kwargs): + started.set() + await asyncio.sleep(60) + if False: + yield {} + + session = MagicMock() + session.start_task = blocking_start + mock_cli_manager.get_or_create_session.return_value = ( + session, + "pending_1", + True, + ) + await handler.handle_message( + incoming_message_factory(text="work", message_id="100") + ) + await started.wait() + mock_session_store.reset_mock() + mock_platform.queue_edit_message.reset_mock() + + await handler.clear_chat("telegram", "chat_1") + + mock_session_store.save_tree_snapshot.assert_not_called() + mock_session_store.clear_scope.assert_called_once_with(_SCOPE) + mock_platform.queue_edit_message.assert_not_awaited() + assert handler.get_tree_count() == 0 + + +@pytest.mark.asyncio +async def test_global_clear_removes_snapshot_saved_during_detach_window( + tmp_path, + mock_platform, + mock_cli_manager, + incoming_message_factory, +): + runner_started = asyncio.Event() + release_runner = asyncio.Event() + id_read_started = asyncio.Event() + release_id_read = asyncio.Event() + + async def finish_after_release(*args, **kwargs): + runner_started.set() + await release_runner.wait() + yield {"type": "exit", "code": 0} + + session = MagicMock() + session.start_task = finish_after_release + mock_cli_manager.get_or_create_session.return_value = ( + session, + "session_1", + False, + ) + mock_platform.queue_send_message.return_value = "status-new" + store_path = tmp_path / "sessions.json" + store = SessionStore(storage_path=str(store_path)) + workflow = MessagingWorkflow( + mock_platform, + mock_cli_manager, + store, + platform_name="telegram", + ) + incoming = incoming_message_factory(text="work", message_id="new") + await workflow.handle_message(incoming) + await runner_started.wait() + + get_ids = workflow.tree_queue.get_message_ids_for_chat + + async def block_id_read(platform: str, chat_id: str) -> set[str]: + id_read_started.set() + await release_id_read.wait() + return await get_ids(platform, chat_id) + + try: + with patch.object( + workflow.tree_queue, + "get_message_ids_for_chat", + new=block_id_read, + ): + clear_task = asyncio.create_task( + workflow.clear_chat("telegram", incoming.chat_id) + ) + await id_read_started.wait() + release_runner.set() + await _wait_for_idle(workflow) + + assert not store.load_conversation_snapshot().is_empty + store.flush_pending_save() + assert ( + not SessionStore(storage_path=str(store_path)) + .load_conversation_snapshot() + .is_empty + ) + release_id_read.set() + await clear_task + + assert store.load_conversation_snapshot().is_empty + assert ( + SessionStore(storage_path=str(store_path)) + .load_conversation_snapshot() + .is_empty + ) + finally: + release_runner.set() + release_id_read.set() + await workflow.close() + + +@pytest.mark.asyncio +async def test_global_clear_invalidates_inflight_prompt_without_waiting_for_status( + handler, + mock_platform, + incoming_message_factory, +): + status_send_started = asyncio.Event() + release_status_send = asyncio.Event() + + async def block_status_send(*args, **kwargs): + status_send_started.set() + await release_status_send.wait() + return "status-new" + + mock_platform.queue_send_message.side_effect = block_status_send + prompt_task = asyncio.create_task( + handler.handle_message( + incoming_message_factory(text="new prompt", message_id="new") + ) + ) + await status_send_started.wait() + clear_task = asyncio.create_task(handler.clear_chat("telegram", "chat_1")) + + try: + await asyncio.wait_for(clear_task, timeout=1) + release_status_send.set() + await prompt_task + assert handler.get_tree_count() == 0 + mock_platform.queue_delete_messages.assert_awaited_once_with( + "chat_1", + ["status-new"], + fire_and_forget=False, + ) + finally: + release_status_send.set() + if not prompt_task.done(): + prompt_task.cancel() + if not clear_task.done(): + clear_task.cancel() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("status_message_id", "expected_deleted_ids"), + [ + ("101", {"100", "101", "150"}), + (None, {"100", "150"}), + ], +) +async def test_reply_clear_pending_voice_cancels_and_reports( + handler, + mock_platform, + incoming_message_factory, + status_message_id, + expected_deleted_ids, +) -> None: + delete_message_ids = {"100"} + if status_message_id is not None: + delete_message_ids.add(status_message_id) + handler.clear_reply = AsyncMock( + return_value=ReplyClearResult( + delete_message_ids=frozenset(delete_message_ids), + tree_matched=False, + ) + ) + deleted_ids: list[str] = [] + + async def capture_delete(chat_id, message_ids, fire_and_forget=True): + deleted_ids.extend(message_ids) + + mock_platform.queue_delete_messages.side_effect = capture_delete + incoming = incoming_message_factory( + text="/clear", + message_id="150", + reply_to_message_id="100", + ) + + await handler.handle_message(incoming) + + handler.clear_reply.assert_awaited_once_with(incoming.scope, "100") + assert set(deleted_ids) == expected_deleted_ids + mock_platform.queue_send_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_reply_clear_deletes_owned_result_ids( + handler, + mock_platform, + incoming_message_factory, +) -> None: + handler.clear_reply = AsyncMock( + return_value=ReplyClearResult( + delete_message_ids=frozenset({"tree_status"}), + tree_matched=True, + ) + ) + deleted_ids: list[str] = [] + + async def capture_delete(_chat_id, message_ids, fire_and_forget=True): + deleted_ids.extend(message_ids) + + mock_platform.queue_delete_messages.side_effect = capture_delete + incoming = incoming_message_factory( + text="/clear", + message_id="clear", + reply_to_message_id="voice", + ) + + await handler.handle_message(incoming) + + handler.clear_reply.assert_awaited_once_with(incoming.scope, "voice") + assert set(deleted_ids) == { + "tree_status", + "clear", + } + assert "voice" not in deleted_ids + mock_platform.queue_send_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_reply_clear_joins_voice_then_unions_authoritative_branch_ids( + handler, + mock_platform, +) -> None: + events: list[str] = [] + + async def cancel_voice(*_args) -> VoiceCancellationResult: + events.append("voice") + return VoiceCancellationResult( + scope=_SCOPE, + voice_message_id="voice", + status_message_id="voice_status", + delete_message_ids=frozenset({"voice", "voice_status"}), + ) + + branch = MessageSubtreeRemovalResult( + cancellation=CancellationResult(), + removed_tree_identity=None, + delete_message_ids=frozenset({"tree_status"}), + tree_matched=True, + ) + mock_platform.cancel_pending_voice.side_effect = cancel_voice + with patch.object( + handler.tree_queue, + "remove_message_subtree", + new_callable=AsyncMock, + return_value=branch, + ) as remove_message_subtree: + result = await handler.clear_reply(_SCOPE, "voice") + await asyncio.sleep(0) + + assert result == ReplyClearResult( + delete_message_ids=frozenset({"voice", "voice_status", "tree_status"}), + tree_matched=True, + ) + assert events == ["voice"] + remove_message_subtree.assert_awaited_once_with( + _SCOPE, + "voice", + reason=CancellationReason.CLEAR, + ) + mock_platform.queue_edit_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_cancelled_reply_clear_finishes_voice_only_owner_after_lock_wait( + handler, + mock_platform, +) -> None: + voice_returned = asyncio.Event() + + async def cancel_voice(*_args) -> VoiceCancellationResult: + voice_returned.set() + return VoiceCancellationResult( + scope=_SCOPE, + voice_message_id="voice", + status_message_id="voice_status", + delete_message_ids=frozenset({"voice", "voice_status"}), + ) + + mock_platform.cancel_pending_voice.side_effect = cancel_voice + remove_subtree = AsyncMock( + return_value=MessageSubtreeRemovalResult( + cancellation=CancellationResult(), + removed_tree_identity=None, + delete_message_ids=frozenset(), + tree_matched=False, + ) + ) + await handler._state_lock.acquire() + try: + with patch.object( + handler.tree_queue, + "remove_message_subtree", + remove_subtree, + ): + clear_task = asyncio.create_task(handler.clear_reply(_SCOPE, "voice")) + await voice_returned.wait() + await asyncio.sleep(0) + clear_task.cancel() + await asyncio.sleep(0) + + assert not clear_task.done() + handler._state_lock.release() + with pytest.raises(asyncio.CancelledError): + await clear_task + finally: + if handler._state_lock.locked(): + handler._state_lock.release() + await asyncio.sleep(0) + + remove_subtree.assert_awaited_once_with( + _SCOPE, + "voice", + reason=CancellationReason.CLEAR, + ) + mock_platform.queue_edit_message.assert_not_awaited() diff --git a/tests/messaging/test_handler_context_isolation.py b/tests/messaging/test_handler_context_isolation.py new file mode 100644 index 0000000..6470e91 --- /dev/null +++ b/tests/messaging/test_handler_context_isolation.py @@ -0,0 +1,93 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from free_claude_code.messaging.workflow import MessagingWorkflow + + +async def _wait_for_idle(handler: MessagingWorkflow) -> None: + for _ in range(100): + if handler.tree_queue.task_count() == 0: + return + await asyncio.sleep(0) + raise AssertionError("messaging claims did not finish") + + +def _session_factory(calls: list[tuple[str, str | None, bool]]): + async def get_or_create_session(session_id=None): + session = MagicMock() + + async def start_task(prompt, session_id=None, fork_session=False): + calls.append((prompt, session_id, fork_session)) + yield {"type": "session_info", "session_id": f"sess_{prompt}"} + yield {"type": "exit", "code": 0, "stderr": None} + + session.start_task = start_task + return session, f"pending_{len(calls)}", True + + return get_or_create_session + + +@pytest.mark.asyncio +async def test_sibling_replies_fork_from_parent_session_id( + mock_platform, + mock_cli_manager, + mock_session_store, + incoming_message_factory, +) -> None: + calls: list[tuple[str, str | None, bool]] = [] + mock_cli_manager.get_or_create_session = AsyncMock( + side_effect=_session_factory(calls) + ) + mock_platform.queue_send_message = AsyncMock( + side_effect=["status_A", "status_R1", "status_R2"] + ) + handler = MessagingWorkflow(mock_platform, mock_cli_manager, mock_session_store) + + await handler.handle_message(incoming_message_factory(text="A", message_id="A")) + await _wait_for_idle(handler) + await handler.handle_message( + incoming_message_factory(text="R1", message_id="R1", reply_to_message_id="A") + ) + await _wait_for_idle(handler) + await handler.handle_message( + incoming_message_factory(text="R2", message_id="R2", reply_to_message_id="A") + ) + await _wait_for_idle(handler) + + assert calls == [ + ("A", None, False), + ("R1", "sess_A", True), + ("R2", "sess_A", True), + ] + + +@pytest.mark.asyncio +async def test_grandchild_reply_forks_from_branch_session( + mock_platform, + mock_cli_manager, + mock_session_store, + incoming_message_factory, +) -> None: + calls: list[tuple[str, str | None, bool]] = [] + mock_cli_manager.get_or_create_session = AsyncMock( + side_effect=_session_factory(calls) + ) + mock_platform.queue_send_message = AsyncMock( + side_effect=["status_A", "status_R1", "status_C1"] + ) + handler = MessagingWorkflow(mock_platform, mock_cli_manager, mock_session_store) + + await handler.handle_message(incoming_message_factory(text="A", message_id="A")) + await _wait_for_idle(handler) + await handler.handle_message( + incoming_message_factory(text="R1", message_id="R1", reply_to_message_id="A") + ) + await _wait_for_idle(handler) + await handler.handle_message( + incoming_message_factory(text="C1", message_id="C1", reply_to_message_id="R1") + ) + await _wait_for_idle(handler) + + assert calls[-1] == ("C1", "sess_R1", True) diff --git a/tests/messaging/test_handler_format.py b/tests/messaging/test_handler_format.py new file mode 100644 index 0000000..b689ed4 --- /dev/null +++ b/tests/messaging/test_handler_format.py @@ -0,0 +1,131 @@ +from unittest.mock import MagicMock + +import pytest + +from free_claude_code.messaging.rendering.telegram_markdown import ( + escape_md_v2, + escape_md_v2_code, + mdv2_bold, + mdv2_code_inline, + render_markdown_to_mdv2, +) +from free_claude_code.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_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/tests/messaging/test_handler_integration.py b/tests/messaging/test_handler_integration.py new file mode 100644 index 0000000..bb85a18 --- /dev/null +++ b/tests/messaging/test_handler_integration.py @@ -0,0 +1,163 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from free_claude_code.messaging.models import MessageScope +from free_claude_code.messaging.trees import MessageState +from free_claude_code.messaging.workflow import MessagingWorkflow + +_SCOPE = MessageScope(platform="telegram", chat_id="chat_1") + + +@pytest.fixture +def handler_integration(mock_platform, mock_cli_manager, mock_session_store): + return MessagingWorkflow(mock_platform, mock_cli_manager, mock_session_store) + + +async def _events(events): + for event in events: + yield event + + +async def _wait_for_idle(handler: MessagingWorkflow) -> None: + for _ in range(100): + if handler.tree_queue.task_count() == 0: + return + await asyncio.sleep(0) + raise AssertionError("messaging claims did not finish") + + +@pytest.mark.asyncio +async def test_full_conversation_flow_single_user( + handler_integration, + mock_platform, + mock_cli_manager, + incoming_message_factory, +) -> None: + mock_platform.queue_send_message = AsyncMock(side_effect=["s1", "s2"]) + root_session = MagicMock() + root_session.start_task.return_value = _events( + [ + {"type": "session_info", "session_id": "sess1"}, + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "Reply 1"}]}, + }, + {"type": "exit", "code": 0, "stderr": None}, + ] + ) + reply_session = MagicMock() + reply_session.start_task.return_value = _events( + [ + {"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.side_effect = [ + (root_session, "pending_1", True), + (reply_session, "pending_2", True), + ] + + await handler_integration.handle_message( + incoming_message_factory(text="message 1", message_id="m1") + ) + await _wait_for_idle(handler_integration) + root = await handler_integration.tree_queue.get_node(_SCOPE, "m1") + assert root is not None + assert root.state is MessageState.COMPLETED + assert root.session_id == "sess1" + + await handler_integration.handle_message( + incoming_message_factory( + text="message 2", + message_id="m2", + reply_to_message_id="m1", + ) + ) + await _wait_for_idle(handler_integration) + reply = await handler_integration.tree_queue.get_node(_SCOPE, "m2") + assert reply is not None + assert reply.state is MessageState.COMPLETED + assert reply.parent_id == "m1" + mock_cli_manager.get_or_create_session.assert_called_with(session_id="sess1") + reply_session.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, +) -> None: + started = asyncio.Event() + release_error = asyncio.Event() + + async def failing_events(): + started.set() + await release_error.wait() + yield {"type": "error", "error": {"message": "failed"}} + + session = MagicMock() + session.start_task.return_value = failing_events() + mock_cli_manager.get_or_create_session.return_value = (session, "sess1", False) + mock_platform.queue_send_message = AsyncMock(side_effect=["s1", "s2"]) + + await handler_integration.handle_message( + incoming_message_factory(text="m1", message_id="m1") + ) + await started.wait() + await handler_integration.handle_message( + incoming_message_factory(text="m2", message_id="m2", reply_to_message_id="m1") + ) + release_error.set() + await _wait_for_idle(handler_integration) + + root = await handler_integration.tree_queue.get_node(_SCOPE, "m1") + child = await handler_integration.tree_queue.get_node(_SCOPE, "m2") + assert root is not None and root.state is MessageState.ERROR + assert child is not None and child.state is MessageState.ERROR + rendered = "\n".join( + call.args[2] for call in mock_platform.queue_edit_message.call_args_list + ) + assert "Parent task failed" in rendered + + +@pytest.mark.asyncio +async def test_different_trees_process_independently( + handler_integration, + mock_platform, + mock_cli_manager, + incoming_message_factory, +) -> None: + session_one = MagicMock() + session_one.start_task.return_value = _events([{"type": "exit", "code": 0}]) + session_two = MagicMock() + session_two.start_task.return_value = _events([{"type": "exit", "code": 0}]) + mock_cli_manager.get_or_create_session.side_effect = [ + (session_one, "s1", False), + (session_two, "s2", False), + ] + mock_platform.queue_send_message = AsyncMock(side_effect=["status-t1", "status-t2"]) + + await asyncio.gather( + handler_integration.handle_message( + incoming_message_factory(text="t1", message_id="t1") + ), + handler_integration.handle_message( + incoming_message_factory(text="t2", message_id="t2") + ), + ) + await _wait_for_idle(handler_integration) + + node_one = await handler_integration.tree_queue.get_node(_SCOPE, "t1") + node_two = await handler_integration.tree_queue.get_node(_SCOPE, "t2") + assert node_one is not None and node_one.state is MessageState.COMPLETED + assert node_two is not None and node_two.state is MessageState.COMPLETED diff --git a/tests/messaging/test_handler_markdown_and_status_edges.py b/tests/messaging/test_handler_markdown_and_status_edges.py new file mode 100644 index 0000000..48156cb --- /dev/null +++ b/tests/messaging/test_handler_markdown_and_status_edges.py @@ -0,0 +1,544 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.messaging.command_context import StopOutcome +from free_claude_code.messaging.models import IncomingMessage, MessageScope +from free_claude_code.messaging.node_event_pipeline import process_parsed_cli_event +from free_claude_code.messaging.rendering.telegram_markdown import ( + render_markdown_to_mdv2, +) +from free_claude_code.messaging.trees import ( + CancellationReason, + CancellationResult, + CancellationUiOwner, + FailureResult, + MessageReferenceKind, + NodeClaim, + NodeUiTarget, + QueueDecision, + QueueEntry, + ReplyTarget, + TreeIdentity, + TreeSnapshot, +) +from free_claude_code.messaging.trees.transitions import CancellationEffect +from free_claude_code.messaging.workflow import MessagingWorkflow + +_SCOPE = MessageScope(platform="telegram", chat_id="c") + + +def _claim() -> NodeClaim: + return NodeClaim( + identity=TreeIdentity(scope=_SCOPE, root_id="root"), + claim_id="claim-1", + node=NodeUiTarget( + scope=_SCOPE, + node_id="n1", + status_message_id="s1", + ), + prompt="hi", + parent_session_id=None, + ) + + +def _snapshot(marker: str) -> TreeSnapshot: + return TreeSnapshot( + scope=_SCOPE, + root_id="root", + nodes={"root": {"marker": marker}}, + ) + + +def _decision(*, position: int | None = None) -> QueueDecision: + return QueueDecision( + claim=_claim() if position is None else None, + position=position, + snapshot=_snapshot("admitted"), + ) + + +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" + "![alt](http://example.com/img.png)\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 = MessagingWorkflow(platform, cli_manager, session_store) + + s1 = handler.turn_intake._get_initial_status( + ReplyTarget( + node_id="p", + reference_id="p", + reference_kind=MessageReferenceKind.PROMPT, + queue_position=3, + ) + ) + assert "Queued" in s1 + assert "position 3" in s1 or "position 3" in s1.replace("\\", "") + + s2 = handler.turn_intake._get_initial_status( + ReplyTarget( + node_id="p", + reference_id="status-p", + reference_kind=MessageReferenceKind.STATUS, + queue_position=None, + ) + ) + assert "Continuing" in s2 + + s3 = handler.turn_intake._get_initial_status(None) + assert "Launching" in s3 + + +@pytest.mark.asyncio +async def test_update_queue_positions_renders_immutable_queue_entries(): + 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 = MessagingWorkflow(platform, cli_manager, session_store) + + await handler.turn_intake.update_queue_positions(()) + platform.fire_and_forget.assert_not_called() + + await handler.turn_intake.update_queue_positions( + ( + QueueEntry( + node=NodeUiTarget( + scope=_SCOPE, + node_id="n1", + status_message_id="s", + ), + position=2, + ), + ) + ) + assert platform.fire_and_forget.call_count == 1 + assert "position 2" in platform.queue_edit_message.call_args.args[2] + + +@pytest.mark.asyncio +async def test_node_runner_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 = MessagingWorkflow(platform, cli_manager, session_store) + + claim = _claim() + snapshot = _snapshot("error") + fail_claim = AsyncMock( + return_value=FailureResult(affected=(), queue_update=None, snapshot=snapshot) + ) + with patch.object( + handler.tree_queue, + "fail_claim", + fail_claim, + ): + await handler.node_runner.process_node(claim) + assert platform.queue_edit_message.await_count >= 1 + fail_claim.assert_awaited_once_with( + claim, + propagate=False, + ) + session_store.save_tree_snapshot.assert_called_once_with(snapshot) + + +@pytest.mark.asyncio +async def test_node_runner_cancellation_marks_error_and_saves_tree(): + platform = MagicMock() + platform.queue_edit_message = AsyncMock() + platform.fire_and_forget = MagicMock( + side_effect=lambda c: getattr(c, "close", lambda: None)() + ) + + async def _cancelled_start_task(*args, **kwargs): + raise asyncio.CancelledError + yield + + mock_session = MagicMock() + mock_session.start_task = _cancelled_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 = MessagingWorkflow(platform, cli_manager, session_store) + + claim = _claim() + snapshot = _snapshot("cancelled") + fail_claim = AsyncMock( + return_value=FailureResult(affected=(), queue_update=None, snapshot=snapshot) + ) + with patch.object( + handler.tree_queue, + "fail_claim", + fail_claim, + ): + await handler.node_runner.process_node(claim) + + fail_claim.assert_awaited_once_with( + claim, + propagate=False, + ) + session_store.save_tree_snapshot.assert_called_once_with(snapshot) + + +@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 = MessagingWorkflow(platform, cli_manager, session_store) + + snapshot = _snapshot("ok") + result = CancellationResult( + effects=( + CancellationEffect( + node=_claim().node, + ui_owner=CancellationUiOwner.RUNNER, + ), + ), + snapshots=(snapshot,), + ) + cancel_all = AsyncMock(return_value=result) + with patch.object( + handler.tree_queue, + "cancel_all", + cancel_all, + ): + outcome = await handler.stop_all_tasks() + assert outcome == StopOutcome( + cancelled_count=1, + status_feedback_scopes=frozenset({_SCOPE}), + fallback_required=False, + ) + cancel_all.assert_awaited_once_with(reason=CancellationReason.STOP) + cli_manager.stop_all.assert_awaited_once() + session_store.save_tree_snapshot.assert_called_once_with(snapshot) + + +@pytest.mark.asyncio +async def test_handle_message_unresolved_reply_is_admitted_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 = MessagingWorkflow(platform, cli_manager, session_store) + + resolve_reply = AsyncMock(return_value=None) + admit = AsyncMock(return_value=_decision()) + + incoming = IncomingMessage( + text="reply", + chat_id="c", + user_id="u", + message_id="m1", + platform="telegram", + reply_to_message_id="some_reply", + ) + + with ( + patch.object(handler.tree_queue, "resolve_reply", resolve_reply), + patch.object(handler.tree_queue, "admit", admit), + ): + await handler.handle_message(incoming) + + resolve_reply.assert_awaited_once_with(incoming.scope, "some_reply") + admit.assert_awaited_once_with( + incoming, + "status_1", + parent_reference_id=None, + ) + + +@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 = MessagingWorkflow(platform, cli_manager, session_store) + claim = _claim() + snapshot = _snapshot("complete") + + with ( + patch.object( + handler.node_runner, "_create_transcript_and_render_ctx" + ) as mock_create, + patch.object( + handler.tree_queue, + "complete_claim", + AsyncMock(return_value=snapshot), + ), + ): + transcript = MagicMock() + transcript.render = MagicMock(side_effect=ValueError("render failed")) + render_ctx = MagicMock() + mock_create.return_value = (transcript, render_ctx) + + await handler.node_runner.process_node(claim) + + 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 = MessagingWorkflow(platform, cli_manager, session_store) + admit = AsyncMock(return_value=_decision()) + + 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.status_message_id = None + incoming.message_thread_id = None + incoming.is_reply = MagicMock(return_value=False) + + with patch.object(handler.tree_queue, "admit", admit): + await handler.handle_message(incoming) + admit.assert_awaited_once_with( + incoming, + "status_1", + parent_reference_id=None, + ) + + +@pytest.mark.asyncio +async def test_process_parsed_event_malformed_content_continues(): + """Malformed/unknown parsed event does not crash process_parsed_cli_event.""" + platform = MagicMock() + platform.queue_edit_message = AsyncMock() + + cli_manager = MagicMock() + session_store = MagicMock() + handler = MessagingWorkflow(platform, cli_manager, session_store) + + transcript = MagicMock() + update_ui = AsyncMock() + complete_claim = AsyncMock() + fail_claim = AsyncMock() + + last_status, had = await process_parsed_cli_event( + parsed={"type": "unknown_type"}, + transcript=transcript, + update_ui=update_ui, + last_status=None, + had_transcript_events=False, + claim=_claim(), + captured_session_id=None, + format_status=handler.format_status, + complete_claim=complete_claim, + fail_claim=fail_claim, + ) + assert last_status is None + assert had is False + complete_claim.assert_not_awaited() + fail_claim.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_process_parsed_event_failed_complete_does_not_mark_success(): + """Failed terminal events are not rendered as successful completion.""" + platform = MagicMock() + platform.queue_edit_message = AsyncMock() + + cli_manager = MagicMock() + session_store = MagicMock() + handler = MessagingWorkflow(platform, cli_manager, session_store) + + transcript = MagicMock() + update_ui = AsyncMock() + complete_claim = AsyncMock() + fail_claim = AsyncMock() + + last_status, had = await process_parsed_cli_event( + parsed={"type": "complete", "status": "failed"}, + transcript=transcript, + update_ui=update_ui, + last_status="❌ Error", + had_transcript_events=True, + claim=_claim(), + captured_session_id="session_1", + format_status=handler.format_status, + complete_claim=complete_claim, + fail_claim=fail_claim, + ) + + assert last_status == "❌ Error" + assert had is True + update_ui.assert_not_awaited() + complete_claim.assert_not_awaited() + fail_claim.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_handler_update_ui_edit_failure_does_not_crash(): + """When queue_edit_message raises during streaming, node_runner.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 = MessagingWorkflow(platform, cli_manager, session_store) + snapshot = _snapshot("complete") + with patch.object( + handler.tree_queue, + "complete_claim", + AsyncMock(return_value=snapshot), + ): + await handler.node_runner.process_node(_claim()) + + cli_manager.remove_session.assert_awaited_once() diff --git a/tests/messaging/test_limiter.py b/tests/messaging/test_limiter.py new file mode 100644 index 0000000..3ed42f6 --- /dev/null +++ b/tests/messaging/test_limiter.py @@ -0,0 +1,431 @@ +import asyncio +import contextlib +import time +from collections.abc import Callable + +import pytest +import pytest_asyncio + +from free_claude_code.messaging.limiter import MessagingRateLimiter + + +class TestMessagingRateLimiter: + """Tests for MessagingRateLimiter.""" + + @pytest_asyncio.fixture(autouse=True) + async def limiter_factory(self): + """Build started limiters and stop every instance after the test.""" + instances: list[MessagingRateLimiter] = [] + + def create( + *, rate_limit: int = 1, rate_window: float = 1.0 + ) -> MessagingRateLimiter: + limiter = MessagingRateLimiter( + rate_limit=rate_limit, + rate_window=rate_window, + ) + limiter.start() + instances.append(limiter) + return limiter + + self.create_limiter: Callable[..., MessagingRateLimiter] = create + yield + for limiter in reversed(instances): + await limiter.shutdown(timeout=0.1) + + @pytest.mark.asyncio + async def test_instances_are_independent(self): + """Each messaging runtime receives independent limiter state.""" + limiter1 = self.create_limiter(rate_limit=1, rate_window=0.5) + limiter2 = self.create_limiter(rate_limit=99, rate_window=99.0) + + assert limiter1 is not limiter2 + assert limiter1.limiter._rate_limit == 1 + assert limiter1.limiter._rate_window == 0.5 + assert limiter2.limiter._rate_limit == 99 + assert limiter2.limiter._rate_window == 99.0 + + await limiter1.shutdown(timeout=0.1) + + async def succeed() -> str: + return "still running" + + assert await limiter2.enqueue(succeed) == "still running" + + @pytest.mark.asyncio + async def test_start_is_required_and_shutdown_is_idempotent(self): + limiter = MessagingRateLimiter(rate_limit=1, rate_window=1.0) + + async def succeed() -> str: + return "ok" + + with pytest.raises(RuntimeError, match="has not been started"): + await limiter.enqueue(succeed) + + limiter.start() + limiter.start() + assert await limiter.enqueue(succeed) == "ok" + await limiter.shutdown(timeout=0.1) + await limiter.shutdown(timeout=0.1) + + with pytest.raises(RuntimeError, match="is closed"): + await limiter.enqueue(succeed) + + @pytest.mark.asyncio + async def test_compaction(self): + """ + Verify multiple rapid requests with same dedup_key are compacted. + Logic ported from verify_limiter.py + """ + limiter = self.create_limiter(rate_limit=1, rate_window=1.0) + + 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 + """ + limiter = self.create_limiter(rate_limit=1, rate_window=0.5) + + 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.""" + limiter = self.create_limiter(rate_limit=1, rate_window=1.0) + + # 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.""" + limiter = self.create_limiter(rate_limit=1, rate_window=1.0) + + 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.""" + limiter = self.create_limiter(rate_limit=1, rate_window=1.0) + + 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.""" + limiter = self.create_limiter(rate_limit=1, rate_window=1.0) + + 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). + """ + limiter = self.create_limiter(rate_limit=2, rate_window=0.5) + + 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.""" + limiter = self.create_limiter(rate_limit=1, rate_window=1.0) + + 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.""" + limiter = self.create_limiter(rate_limit=1, rate_window=1.0) + + 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) + + joined = " ".join(str(r.message) for r in caplog.records) + assert "ValueError" in joined + assert "fire_and_forget failed" not in joined + + @pytest.mark.asyncio + async def test_shutdown_settles_active_queued_and_background_work(self): + limiter = self.create_limiter(rate_limit=1, rate_window=60.0) + active_started = asyncio.Event() + never_finish = asyncio.Event() + + async def active_operation() -> None: + active_started.set() + await never_finish.wait() + + active = asyncio.create_task( + limiter.enqueue(active_operation, dedup_key="active") + ) + await active_started.wait() + + async def queued_operation() -> None: + await never_finish.wait() + + queued = asyncio.create_task( + limiter.enqueue(queued_operation, dedup_key="queued") + ) + limiter.fire_and_forget(queued_operation, dedup_key="background") + await asyncio.sleep(0) + + await limiter.shutdown(timeout=0.1) + results = await asyncio.gather(active, queued, return_exceptions=True) + + assert all(isinstance(result, asyncio.CancelledError) for result in results) + assert limiter._background_tasks == set() + assert limiter._queue_map == {} + assert not limiter._queue_list + + @pytest.mark.asyncio + async def test_enqueue_cannot_enter_after_shutdown_begins(self): + limiter = self.create_limiter(rate_limit=1, rate_window=1.0) + await limiter._condition.acquire() + + async def succeed() -> str: + return "unexpected" + + enqueue_task = asyncio.create_task( + limiter.enqueue(succeed, dedup_key="shutdown-race") + ) + await asyncio.sleep(0) + shutdown_task = asyncio.create_task(limiter.shutdown(timeout=0.1)) + await asyncio.sleep(0) + assert limiter._closed is True + + limiter._condition.release() + await shutdown_task + + with pytest.raises(RuntimeError, match="is closed"): + await enqueue_task + + @pytest.mark.asyncio + async def test_shutdown_preserves_external_cancellation(self): + limiter = self.create_limiter(rate_limit=1, rate_window=1.0) + release = asyncio.Event() + + async def cancellation_resistant_worker() -> None: + try: + await release.wait() + except asyncio.CancelledError: + await release.wait() + + worker_task = limiter._worker_task + assert worker_task is not None + await asyncio.sleep(0) + worker_task.cancel() + await asyncio.gather(worker_task, return_exceptions=True) + limiter._worker_task = asyncio.create_task(cancellation_resistant_worker()) + shutdown_task = asyncio.create_task(limiter.shutdown(timeout=1.0)) + await asyncio.sleep(0) + + shutdown_task.cancel() + release.set() + + with pytest.raises(asyncio.CancelledError): + await shutdown_task + + @pytest.mark.asyncio + async def test_cancelled_shutdown_retries_queued_future_settlement(self): + limiter = self.create_limiter(rate_limit=1, rate_window=60.0) + active_started = asyncio.Event() + never_finish = asyncio.Event() + + async def active_operation() -> None: + active_started.set() + await never_finish.wait() + + active = asyncio.create_task( + limiter.enqueue(active_operation, dedup_key="active") + ) + await active_started.wait() + + async def queued_operation() -> None: + await never_finish.wait() + + queued = asyncio.create_task( + limiter.enqueue(queued_operation, dedup_key="queued") + ) + while "queued" not in limiter._queue_map: + await asyncio.sleep(0) + + await limiter._condition.acquire() + shutdown_task = asyncio.create_task(limiter.shutdown()) + await asyncio.sleep(0) + assert limiter._closed is True + + shutdown_task.cancel() + with pytest.raises(asyncio.CancelledError): + await shutdown_task + limiter._condition.release() + + await limiter.shutdown(timeout=0.1) + results = await asyncio.gather(active, queued, return_exceptions=True) + + assert all(isinstance(result, asyncio.CancelledError) for result in results) + assert limiter._queue_map == {} + assert not limiter._queue_list + assert limiter._worker_task is None + + @pytest.mark.asyncio + async def test_cancelled_operation_does_not_stop_owned_worker(self): + limiter = self.create_limiter(rate_limit=2, rate_window=1.0) + + async def cancelled_operation() -> None: + raise asyncio.CancelledError + + async def successful_operation() -> str: + return "delivered" + + first = asyncio.create_task( + limiter.enqueue(cancelled_operation, dedup_key="cancelled") + ) + second = asyncio.create_task( + limiter.enqueue(successful_operation, dedup_key="next") + ) + results = await asyncio.gather(first, second, return_exceptions=True) + + assert isinstance(results[0], asyncio.CancelledError) + assert results[1] == "delivered" + assert limiter._worker_task is not None + assert not limiter._worker_task.done() diff --git a/tests/messaging/test_message_tree_transitions.py b/tests/messaging/test_message_tree_transitions.py new file mode 100644 index 0000000..6b0a0b5 --- /dev/null +++ b/tests/messaging/test_message_tree_transitions.py @@ -0,0 +1,258 @@ +import asyncio +from dataclasses import FrozenInstanceError + +import pytest + +from free_claude_code.messaging.models import MessageScope +from free_claude_code.messaging.trees.node import MessageNode, MessageState +from free_claude_code.messaging.trees.runtime import MessageTree + +_SCOPE = MessageScope(platform="telegram", chat_id="chat") + + +def _tree() -> MessageTree: + return MessageTree( + MessageNode( + node_id="root", + scope=_SCOPE, + prompt="prompt root", + status_message_id="status-root", + ) + ) + + +async def _add( + tree: MessageTree, + node_id: str, + status_message_id: str, + parent_id: str = "root", + parent_reference_id: str | None = None, +): + return await tree.add_and_enqueue( + node_id, + _SCOPE, + f"prompt {node_id}", + status_message_id, + parent_id, + parent_reference_id or parent_id, + ) + + +@pytest.mark.asyncio +async def test_enqueue_finish_and_claim_next_are_atomic_and_fifo() -> None: + tree = _tree() + + root = await tree.enqueue_or_claim("root") + child_a = await _add(tree, "child-a", "status-a") + child_b = await _add(tree, "child-b", "status-b") + + assert root.accepted and root.claim is not None and root.position is None + assert child_a.claim is None and child_a.position == 1 + assert child_b.claim is None and child_b.position == 2 + + completion = await tree.finish_and_claim_next(root.claim.claim_id) + + assert completion.next_claim is not None + assert completion.next_claim.node.node_id == "child-a" + assert [(entry.node.node_id, entry.position) for entry in completion.queue] == [ + ("child-b", 1) + ] + + +@pytest.mark.asyncio +async def test_duplicate_unknown_and_terminal_admission_are_rejected_without_wedge() -> ( + None +): + tree = _tree() + + first = await tree.enqueue_or_claim("root") + duplicate = await tree.enqueue_or_claim("root") + unknown = await tree.enqueue_or_claim("status-root") + assert first.claim is not None + assert duplicate.accepted is False + assert unknown.accepted is False + assert duplicate.snapshot is None + assert unknown.snapshot is None + + await tree.complete_claim(first.claim.claim_id, "session") + await tree.finish_and_claim_next(first.claim.claim_id) + terminal = await tree.enqueue_or_claim("root") + assert terminal.accepted is False + assert terminal.snapshot is None + + +@pytest.mark.asyncio +async def test_stale_claim_id_cannot_clear_a_newer_claim() -> None: + tree = _tree() + first = await tree.enqueue_or_claim("root") + assert first.claim is not None + await tree.finish_and_claim_next(first.claim.claim_id) + + second = await _add(tree, "child", "status-child") + assert second.claim is not None + + stale = await tree.finish_and_claim_next(first.claim.claim_id) + assert stale.next_claim is None + assert await tree.record_session(second.claim.claim_id, "session") is not None + await tree.finish_and_claim_next(second.claim.claim_id) + third = await _add(tree, "third", "status-third") + assert third.claim is not None + + +@pytest.mark.asyncio +async def test_cancellation_keeps_active_identity_until_matching_finish() -> None: + tree = _tree() + root = await tree.enqueue_or_claim("root") + queued = await _add(tree, "queued", "status-queued") + assert root.claim is not None and queued.position == 1 + + cancelled = await tree.cancel_node("root") + + assert cancelled.active_claim == root.claim + assert [node.node_id for node in cancelled.nodes] == ["root"] + + completion = await tree.finish_and_claim_next(root.claim.claim_id) + assert completion.next_claim is not None + assert completion.next_claim.node.node_id == "queued" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("propagate", [False, True]) +async def test_cancelled_claim_rejects_late_failure_and_descendant_propagation( + propagate: bool, +) -> None: + tree = _tree() + root = await tree.enqueue_or_claim("root") + queued = await _add(tree, "queued", "status-queued") + assert root.claim is not None and queued.position == 1 + + await tree.cancel_node("root") + late_failure = await tree.fail_claim( + root.claim.claim_id, + propagate=propagate, + ) + + assert late_failure.snapshot is None + assert late_failure.affected == () + assert late_failure.queue_update is None + queued_view = await tree.node_view("queued") + assert queued_view is not None + assert queued_view.state is MessageState.PENDING + completion = await tree.finish_and_claim_next(root.claim.claim_id) + assert completion.next_claim is not None + assert completion.next_claim.node.node_id == "queued" + + +@pytest.mark.asyncio +async def test_cancelled_queue_member_is_never_claimed() -> None: + tree = _tree() + root = await tree.enqueue_or_claim("root") + await _add(tree, "a", "status-a") + await _add(tree, "b", "status-b") + assert root.claim is not None + + cancelled = await tree.cancel_node("a") + assert [node.node_id for node in cancelled.nodes] == ["a"] + + completion = await tree.finish_and_claim_next(root.claim.claim_id) + assert completion.next_claim is not None + assert completion.next_claim.node.node_id == "b" + + +@pytest.mark.asyncio +async def test_prompt_subtree_removal_returns_every_platform_message() -> None: + tree = _tree() + await _add(tree, "child", "status-child") + + removed = await tree.remove_message_subtree("root") + + assert removed.removed_message_ids == frozenset( + {"root", "status-root", "child", "status-child"} + ) + + +@pytest.mark.asyncio +async def test_status_subtree_preserves_prompt_siblings_and_invalidates_resume() -> ( + None +): + tree = _tree() + root = await tree.enqueue_or_claim("root") + assert root.claim is not None + await tree.record_session(root.claim.claim_id, "session-root") + direct_sibling = await _add(tree, "sibling", "status-sibling") + status_descendant = await _add( + tree, + "descendant", + "status-descendant", + parent_reference_id="status-root", + ) + assert direct_sibling.position == 1 + assert status_descendant.position == 2 + + removed = await tree.remove_message_subtree("status-root") + + assert removed.removed_message_ids == frozenset( + {"status-root", "descendant", "status-descendant"} + ) + root_view = await tree.node_view("root") + sibling_view = await tree.node_view("sibling") + assert root_view is not None + assert root_view.state is MessageState.ERROR + assert root_view.session_id is None + assert sibling_view is not None + assert sibling_view.state is MessageState.PENDING + assert await tree.node_view("descendant") is None + assert await tree.resolve_reply("status-root") is None + + completion = await tree.finish_and_claim_next(root.claim.claim_id) + assert completion.next_claim is not None + assert completion.next_claim.node.node_id == "sibling" + assert completion.next_claim.parent_session_id is None + + +@pytest.mark.asyncio +async def test_concurrent_duplicate_enqueue_produces_one_claim_only() -> None: + tree = _tree() + gate = asyncio.Event() + + async def enqueue(): + await gate.wait() + return await tree.enqueue_or_claim("root") + + tasks = [asyncio.create_task(enqueue()) for _ in range(20)] + gate.set() + results = await asyncio.gather(*tasks) + + assert sum(result.claim is not None for result in results) == 1 + assert sum(result.accepted for result in results) == 1 + + +@pytest.mark.asyncio +async def test_results_are_frozen_and_do_not_expose_mutable_nodes() -> None: + tree = _tree() + decision = await tree.enqueue_or_claim("root") + assert decision.claim is not None + + with pytest.raises(FrozenInstanceError): + decision.claim.__setattr__("claim_id", "replacement") + assert not hasattr(decision.claim, "__dict__") + assert not isinstance(decision.claim.node, MessageNode) + + +def test_message_tree_has_no_lock_or_partial_mutation_escape_hatches() -> None: + forbidden = { + "with_lock", + "enqueue", + "dequeue", + "put_queue_unlocked", + "remove_from_queue", + "set_processing_state", + "clear_current_node", + "set_current_task", + "cancel_current_task", + "set_node_error_sync", + "drain_queue_and_mark_cancelled", + "reset_processing_state", + } + + assert forbidden.isdisjoint(vars(MessageTree)) diff --git a/tests/messaging/test_messaging.py b/tests/messaging/test_messaging.py new file mode 100644 index 0000000..b97e804 --- /dev/null +++ b/tests/messaging/test_messaging.py @@ -0,0 +1,241 @@ +"""Tests for messaging/ module.""" + +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +# --- Existing Tests --- + + +class TestMessagingModels: + """Test messaging models.""" + + def test_incoming_message_creation(self): + """Test IncomingMessage dataclass.""" + from free_claude_code.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 free_claude_code.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 TestMessagingPorts: + """Test explicit messaging platform component ports.""" + + def test_components_bundle_runtime_and_outbound(self): + """Verify the factory handoff shape is explicit.""" + from free_claude_code.messaging.platforms.ports import ( + MessagingPlatformComponents, + ) + + runtime = MagicMock() + runtime.name = "telegram" + runtime.start = AsyncMock() + runtime.quiesce = AsyncMock() + runtime.close = AsyncMock() + runtime.on_message = MagicMock() + outbound = MagicMock() + outbound.queue_send_message = AsyncMock() + outbound.queue_edit_message = AsyncMock() + outbound.queue_delete_messages = AsyncMock() + outbound.fire_and_forget = MagicMock() + components = MessagingPlatformComponents( + name="telegram", + runtime=runtime, + outbound=outbound, + voice_cancellation=None, + ) + assert components.runtime is runtime + assert components.outbound is outbound + + +class TestSessionStore: + """Test SessionStore.""" + + def test_session_store_init(self, tmp_path): + """Test SessionStore initialization.""" + from free_claude_code.messaging.session import SessionStore + + store = SessionStore(storage_path=str(tmp_path / "sessions.json")) + assert store.load_conversation_snapshot().is_empty + + # --- Tree Tests --- + + def test_save_and_get_tree(self, tmp_path): + """Test saving and retrieving trees.""" + from free_claude_code.messaging.models import MessageScope + from free_claude_code.messaging.session import SessionStore + from free_claude_code.messaging.trees import TreeIdentity, TreeSnapshot + + store = SessionStore(storage_path=str(tmp_path / "sessions.json")) + scope = MessageScope(platform="telegram", chat_id="chat") + + tree_data = { + "scope": {"platform": scope.platform, "chat_id": scope.chat_id}, + "root_id": "r1", + "nodes": { + "r1": {"node_id": "r1", "status_message_id": "s1"}, + "n1": {"node_id": "n1", "status_message_id": "s2"}, + }, + } + snapshot = TreeSnapshot.from_json(tree_data) + assert snapshot is not None + store.save_tree_snapshot(snapshot) + + identity = TreeIdentity(scope=scope, root_id="r1") + loaded = store.load_conversation_snapshot().get_tree(identity) + assert loaded is not None + assert loaded == snapshot + assert loaded.lookup_ids() == {"r1", "s1", "n1", "s2"} + + # --- Persistence & Edge Cases --- + + def test_load_existing_file_with_trees(self, tmp_path): + """Test loading file with trees (legacy sessions ignored).""" + from free_claude_code.messaging.models import MessageScope + from free_claude_code.messaging.session import SessionStore + from free_claude_code.messaging.trees import TreeIdentity + + data = { + "sessions": {}, + "trees": { + "r1": { + "root_id": "r1", + "nodes": { + "r1": { + "node_id": "r1", + "incoming": { + "platform": "telegram", + "chat_id": "chat", + }, + } + }, + } + }, + "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)) + identity = TreeIdentity( + scope=MessageScope(platform="telegram", chat_id="chat"), + root_id="r1", + ) + assert store.load_conversation_snapshot().get_tree(identity) 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 free_claude_code.messaging.session import SessionStore + + # Should log error and start empty, avoiding crash + store = SessionStore(storage_path=str(p)) + assert store.load_conversation_snapshot().is_empty + + def test_save_error_handling(self, tmp_path): + """Test error during save.""" + from free_claude_code.messaging.models import MessageScope + from free_claude_code.messaging.session import SessionStore + from free_claude_code.messaging.trees import TreeIdentity, TreeSnapshot + + store = SessionStore(storage_path=str(tmp_path / "sessions.json")) + scope = MessageScope(platform="telegram", chat_id="chat") + snapshot = TreeSnapshot(scope=scope, root_id="r1", nodes={"r1": {}}) + store.save_tree_snapshot(snapshot) + + with ( + patch( + "free_claude_code.messaging.session.persistence.os.replace", + side_effect=OSError("Disk full"), + ), + pytest.raises(OSError, match="Disk full"), + ): + store.flush_pending_save() + + assert store.dirty is True + identity = TreeIdentity(scope=scope, root_id="r1") + assert store.load_conversation_snapshot().get_tree(identity) is not None + + +class TestTreeQueueManager: + """Test TreeQueueManager.""" + + def test_tree_queue_manager_init(self): + from free_claude_code.messaging.trees import TreeQueueManager + + async def process(_claim): + return None + + mgr = TreeQueueManager(process) + assert mgr.get_tree_count() == 0 + + @pytest.mark.asyncio + async def test_admit_creates_tree_and_claim(self): + from free_claude_code.messaging.models import IncomingMessage + from free_claude_code.messaging.trees import TreeQueueManager + + processed = asyncio.Event() + + async def processor(claim): + assert claim.node.node_id == "1" + processed.set() + + incoming = IncomingMessage( + text="test", + chat_id="1", + user_id="1", + message_id="1", + platform="test", + ) + + mgr = TreeQueueManager(processor) + decision = await mgr.admit(incoming, "status_1") + + assert decision.accepted is True + assert decision.claim is not None + await processed.wait() + + @pytest.mark.asyncio + async def test_cancel_unknown_node_is_empty(self): + from free_claude_code.messaging.models import MessageScope + from free_claude_code.messaging.trees import TreeQueueManager + + async def process(_claim): + return None + + mgr = TreeQueueManager(process) + scope = MessageScope(platform="test", chat_id="1") + cancelled = await mgr.cancel_node(scope, "nonexistent") + assert cancelled.effects == () diff --git a/tests/messaging/test_messaging_factory.py b/tests/messaging/test_messaging_factory.py new file mode 100644 index 0000000..bc813e3 --- /dev/null +++ b/tests/messaging/test_messaging_factory.py @@ -0,0 +1,191 @@ +"""Tests for messaging platform factory.""" + +from unittest.mock import MagicMock, patch + +from free_claude_code.messaging.platforms.factory import ( + MessagingPlatformOptions, + create_messaging_components, +) +from free_claude_code.messaging.platforms.ports import MessagingStartupNotice + + +class TestCreateMessagingComponents: + """Tests for create_messaging_components factory function.""" + + def test_telegram_with_token(self): + """Create Telegram platform when bot_token is provided.""" + mock_runtime = MagicMock() + mock_runtime.name = "telegram" + mock_runtime.outbound = MagicMock() + limiter = MagicMock() + transcriber = MagicMock() + with ( + patch( + "free_claude_code.messaging.platforms.factory.MessagingRateLimiter", + return_value=limiter, + ) as limiter_cls, + patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ), + patch( + "free_claude_code.messaging.platforms.telegram.TelegramRuntime", + return_value=mock_runtime, + ) as runtime_cls, + ): + result = create_messaging_components( + "telegram", + MessagingPlatformOptions( + telegram_bot_token="test_token", + allowed_telegram_user_id="12345", + telegram_proxy_url="socks5://127.0.0.1:1080", + transcriber=transcriber, + messaging_rate_limit=7, + messaging_rate_window=2.5, + ), + ) + + assert result is not None + assert result.runtime is mock_runtime + assert result.outbound is mock_runtime.outbound + assert result.voice_cancellation is mock_runtime + assert result.startup_notice == MessagingStartupNotice( + chat_id="12345", + transport_label="Bot API", + ) + limiter_cls.assert_called_once_with( + rate_limit=7, + rate_window=2.5, + log_error_details=False, + ) + runtime_cls.assert_called_once_with( + bot_token="test_token", + allowed_user_id="12345", + telegram_proxy_url="socks5://127.0.0.1:1080", + limiter=limiter, + transcriber=transcriber, + log_raw_messaging_content=False, + log_api_error_tracebacks=False, + ) + + def test_telegram_without_token(self): + """Return None when no bot_token for Telegram.""" + result = create_messaging_components("telegram") + assert result is None + + def test_telegram_empty_token(self): + """Return None when bot_token is empty string.""" + result = create_messaging_components( + "telegram", MessagingPlatformOptions(telegram_bot_token="") + ) + assert result is None + + def test_discord_with_token(self): + """Create Discord platform when discord_bot_token is provided.""" + mock_runtime = MagicMock() + mock_runtime.name = "discord" + mock_runtime.outbound = MagicMock() + limiter = MagicMock() + transcriber = MagicMock() + with ( + patch( + "free_claude_code.messaging.platforms.factory.MessagingRateLimiter", + return_value=limiter, + ) as limiter_cls, + patch( + "free_claude_code.messaging.platforms.discord.DISCORD_AVAILABLE", True + ), + patch( + "free_claude_code.messaging.platforms.discord.DiscordRuntime", + return_value=mock_runtime, + ) as runtime_cls, + ): + result = create_messaging_components( + "discord", + MessagingPlatformOptions( + discord_bot_token="test_token", + allowed_discord_channels="123,456", + transcriber=transcriber, + messaging_rate_limit=3, + messaging_rate_window=4.5, + ), + ) + + assert result is not None + assert result.runtime is mock_runtime + assert result.outbound is mock_runtime.outbound + assert result.voice_cancellation is mock_runtime + assert result.startup_notice is None + limiter_cls.assert_called_once_with( + rate_limit=3, + rate_window=4.5, + log_error_details=False, + ) + runtime_cls.assert_called_once_with( + bot_token="test_token", + allowed_channel_ids="123,456", + limiter=limiter, + transcriber=transcriber, + log_raw_messaging_content=False, + log_api_error_tracebacks=False, + ) + + def test_discord_without_token(self): + """Return None when no discord_bot_token for Discord.""" + result = create_messaging_components("discord") + assert result is None + + def test_discord_empty_token(self): + """Return None when discord_bot_token is empty string.""" + result = create_messaging_components( + "discord", + MessagingPlatformOptions( + 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_components("slack") + assert result is None + + def test_unknown_platform_with_kwargs(self): + """Return None for unknown platform even with kwargs.""" + result = create_messaging_components( + "slack", MessagingPlatformOptions(telegram_bot_token="token") + ) + assert result is None + + def test_separate_factory_calls_construct_distinct_limiters(self): + """Each selected platform runtime owns a new limiter instance.""" + runtime = MagicMock(name="runtime") + runtime.name = "telegram" + runtime.outbound = MagicMock() + with ( + patch( + "free_claude_code.messaging.platforms.telegram.TelegramRuntime", + return_value=runtime, + ) as runtime_cls, + patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ), + ): + first = create_messaging_components( + "telegram", + MessagingPlatformOptions(telegram_bot_token="one"), + ) + second = create_messaging_components( + "telegram", + MessagingPlatformOptions(telegram_bot_token="two"), + ) + + assert first is not None + assert second is not None + assert first.startup_notice is None + assert second.startup_notice is None + first_limiter = runtime_cls.call_args_list[0].kwargs["limiter"] + second_limiter = runtime_cls.call_args_list[1].kwargs["limiter"] + assert first_limiter is not second_limiter + assert runtime_cls.call_args_list[0].kwargs["transcriber"] is None + assert runtime_cls.call_args_list[1].kwargs["transcriber"] is None diff --git a/tests/messaging/test_platform_outbox.py b/tests/messaging/test_platform_outbox.py new file mode 100644 index 0000000..c582cdb --- /dev/null +++ b/tests/messaging/test_platform_outbox.py @@ -0,0 +1,186 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.messaging.platforms.outbox import PlatformOutbox + + +def _noop_outbox(*, limiter=None, delete_many=None) -> PlatformOutbox: + async def send( + chat_id: str, + text: str, + reply_to: str | None, + parse_mode: str | None, + message_thread_id: str | None, + ) -> str: + return f"{chat_id}:{text}:{reply_to}:{parse_mode}:{message_thread_id}" + + async def edit( + chat_id: str, + message_id: str, + text: str, + parse_mode: str | None, + ) -> None: + return None + + async def default_delete_many(chat_id: str, message_ids: list[str]) -> None: + return None + + return PlatformOutbox( + limiter=limiter or MagicMock(), + send=send, + edit=edit, + delete_many=delete_many or default_delete_many, + ) + + +@pytest.mark.asyncio +async def test_queue_send_awaits_required_limiter() -> None: + limiter = MagicMock() + + async def enqueue(operation, dedup_key=None): + return await operation() + + limiter.enqueue = AsyncMock(side_effect=enqueue) + outbox = _noop_outbox(limiter=limiter) + + result = await outbox.queue_send_message( + "chat", + "hello", + reply_to="reply", + parse_mode="MarkdownV2", + fire_and_forget=False, + message_thread_id="thread", + ) + + assert result == "chat:hello:reply:MarkdownV2:thread" + limiter.enqueue.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_queue_edit_awaits_limiter_with_dedup_key() -> None: + limiter = MagicMock() + limiter.enqueue = AsyncMock() + outbox = _noop_outbox(limiter=limiter) + + await outbox.queue_edit_message( + "chat", + "message", + "updated", + parse_mode="MarkdownV2", + fire_and_forget=False, + ) + + limiter.enqueue.assert_awaited_once() + operation = limiter.enqueue.call_args.args[0] + assert limiter.enqueue.call_args.kwargs["dedup_key"] == "edit:chat:message" + await operation() + + +@pytest.mark.asyncio +async def test_queue_delete_many_skips_empty_batches() -> None: + limiter = MagicMock() + outbox = _noop_outbox(limiter=limiter) + + await outbox.queue_delete_messages("chat", [], fire_and_forget=True) + + limiter.fire_and_forget.assert_not_called() + + +@pytest.mark.asyncio +async def test_queue_delete_many_dedups_by_batch() -> None: + limiter = MagicMock() + outbox = _noop_outbox(limiter=limiter) + + await outbox.queue_delete_messages("chat", ["1", "2"], fire_and_forget=True) + + limiter.fire_and_forget.assert_called_once() + assert ( + limiter.fire_and_forget.call_args.kwargs["dedup_key"] + == "del_bulk:chat:11f0530a8259fffb" + ) + + +@pytest.mark.asyncio +async def test_queue_delete_many_snapshots_ids_before_queueing() -> None: + limiter = MagicMock() + deleted: list[list[str]] = [] + + async def delete_many(_chat_id: str, message_ids: list[str]) -> None: + deleted.append(message_ids) + + outbox = _noop_outbox(limiter=limiter, delete_many=delete_many) + message_ids = ["1", "2"] + + await outbox.queue_delete_messages("chat", message_ids, fire_and_forget=True) + message_ids.append("3") + operation = limiter.fire_and_forget.call_args.args[0] + await operation() + + assert deleted == [["1", "2"]] + + +@pytest.mark.asyncio +async def test_close_cancels_and_settles_owned_background_work() -> None: + outbox = _noop_outbox() + started = asyncio.Event() + cancelled = asyncio.Event() + + async def pending() -> None: + started.set() + try: + await asyncio.Event().wait() + finally: + cancelled.set() + + outbox.fire_and_forget(pending()) + await started.wait() + + await outbox.close() + + assert cancelled.is_set() + assert outbox._background_tasks == set() + + +@pytest.mark.asyncio +async def test_completed_background_failure_is_observed_and_released() -> None: + outbox = _noop_outbox() + + async def fail() -> None: + raise RuntimeError("background failed") + + with patch("free_claude_code.messaging.platforms.outbox.logger.error") as error_log: + outbox.fire_and_forget(fail()) + await asyncio.sleep(0) + await asyncio.sleep(0) + + assert outbox._background_tasks == set() + error_log.assert_called_once_with( + "Outbound background task failed: exc_type={}", + "RuntimeError", + ) + + +@pytest.mark.asyncio +async def test_close_rejects_all_later_work() -> None: + limiter = MagicMock() + outbox = _noop_outbox(limiter=limiter) + await outbox.close() + + with pytest.raises(RuntimeError, match="outbox is closed"): + await outbox.queue_send_message("chat", "message") + + ran = False + + async def late_task() -> None: + nonlocal ran + ran = True + + with pytest.raises(RuntimeError, match="outbox is closed"): + outbox.fire_and_forget(late_task()) + await asyncio.sleep(0) + + assert ran is False + assert outbox._background_tasks == set() + limiter.fire_and_forget.assert_not_called() diff --git a/tests/messaging/test_platform_voice_flow.py b/tests/messaging/test_platform_voice_flow.py new file mode 100644 index 0000000..40ee494 --- /dev/null +++ b/tests/messaging/test_platform_voice_flow.py @@ -0,0 +1,927 @@ +import asyncio +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest + +from free_claude_code.messaging.models import MessageScope +from free_claude_code.messaging.platforms.voice_flow import ( + VOICE_DISABLED_MESSAGE, + VOICE_TRANSCRIPTION_ERROR_MESSAGE, + VoiceNoteFlow, + VoiceNoteRequest, + audio_suffix_from_metadata, + is_audio_metadata, +) +from free_claude_code.messaging.trees.runtime import MessageTree +from free_claude_code.messaging.voice import Transcriber +from free_claude_code.messaging.workflow import MessagingWorkflow + +VOICE_SCOPE = MessageScope(platform="telegram", chat_id="chat") + + +class FatalVoiceError(BaseException): + """Fatal sentinel used to verify ownership finalization.""" + + +class MockTranscriber: + def __init__(self, result: str = "hello from voice") -> None: + self.run = AsyncMock(return_value=result) + self.close_run = AsyncMock() + self.paths: list[Path] = [] + + async def transcribe(self, file_path: Path) -> str: + self.paths.append(file_path) + return await self.run(file_path) + + async def close(self) -> None: + await self.close_run() + + +def _flow(*, enabled: bool = True) -> tuple[VoiceNoteFlow, MockTranscriber]: + transcriber = MockTranscriber() + configured: Transcriber | None = transcriber if enabled else None + return ( + VoiceNoteFlow( + transcriber=configured, + log_raw_messaging_content=False, + log_api_error_tracebacks=False, + ), + transcriber, + ) + + +def _request( + *, + download_to=None, + reply_text=None, + message_id: str = "voice", +) -> VoiceNoteRequest: + async def default_download_to(path: Path) -> None: + path.write_bytes(b"voice") + + return VoiceNoteRequest( + platform="telegram", + chat_id="chat", + user_id="user", + message_id=message_id, + raw_event={"raw": True}, + content_type="audio/ogg", + temp_suffix=".ogg", + status_text="transcribing", + status_parse_mode="MarkdownV2", + message_thread_id="thread", + reply_to_message_id="reply", + download_to=download_to or default_download_to, + reply_text=reply_text or AsyncMock(), + ) + + +@pytest.mark.asyncio +async def test_voice_flow_success_builds_incoming_message() -> None: + flow, transcriber = _flow() + handler = AsyncMock() + queue_send = AsyncMock(return_value="status") + queue_delete = AsyncMock() + downloaded_paths: list[Path] = [] + + async def download_to(path: Path) -> None: + downloaded_paths.append(path) + path.write_bytes(b"voice") + + handled = await flow.handle( + _request(download_to=download_to), + message_handler=handler, + queue_send_message=queue_send, + queue_delete_messages=queue_delete, + ) + + assert handled is True + queue_send.assert_awaited_once_with( + "chat", + "transcribing", + reply_to="voice", + parse_mode="MarkdownV2", + fire_and_forget=False, + message_thread_id="thread", + ) + queue_delete.assert_not_awaited() + handler.assert_awaited_once() + incoming = handler.call_args.args[0] + assert incoming.text == "hello from voice" + assert incoming.chat_id == "chat" + assert incoming.message_id == "voice" + assert incoming.reply_to_message_id == "reply" + assert incoming.message_thread_id == "thread" + assert incoming.status_message_id == "status" + transcriber.run.assert_awaited_once() + assert transcriber.paths == downloaded_paths + assert downloaded_paths and not downloaded_paths[0].exists() + + +@pytest.mark.asyncio +async def test_voice_flow_disabled_replies_without_transcribing() -> None: + flow, transcriber = _flow(enabled=False) + reply_text = AsyncMock() + + handled = await flow.handle( + _request(reply_text=reply_text), + message_handler=AsyncMock(), + queue_send_message=AsyncMock(), + queue_delete_messages=AsyncMock(), + ) + + assert handled is True + reply_text.assert_awaited_once_with(VOICE_DISABLED_MESSAGE) + transcriber.run.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_voice_flow_missing_status_id_stops_before_transcription() -> None: + flow, transcriber = _flow() + reply_text = AsyncMock() + handler = AsyncMock() + queue_delete = AsyncMock() + + handled = await flow.handle( + _request(reply_text=reply_text), + message_handler=handler, + queue_send_message=AsyncMock(return_value=None), + queue_delete_messages=queue_delete, + ) + + assert handled is True + transcriber.run.assert_not_awaited() + handler.assert_not_awaited() + queue_delete.assert_not_awaited() + reply_text.assert_awaited_once_with(VOICE_TRANSCRIPTION_ERROR_MESSAGE) + assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None + + +@pytest.mark.asyncio +async def test_voice_flow_cancelled_transcription_preserves_owned_status() -> None: + flow, transcriber = _flow() + + async def canceling_transcribe(_path: Path) -> str: + await flow.cancel_pending_voice(VOICE_SCOPE, "voice") + return "ignored" + + transcriber.run.side_effect = canceling_transcribe + handler = AsyncMock() + queue_send = AsyncMock(return_value="status") + queue_delete = AsyncMock() + + handled = await flow.handle( + _request(), + message_handler=handler, + queue_send_message=queue_send, + queue_delete_messages=queue_delete, + ) + + assert handled is True + handler.assert_not_awaited() + queue_delete.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_voice_flow_cancel_during_status_delivery_prevents_transcription() -> ( + None +): + flow, transcriber = _flow() + status_send_started = asyncio.Event() + release_status_send = asyncio.Event() + handler = AsyncMock() + queue_delete = AsyncMock() + + async def send_status(*_args, **_kwargs) -> str: + status_send_started.set() + await release_status_send.wait() + return "status" + + handle_task = asyncio.create_task( + flow.handle( + _request(), + message_handler=handler, + queue_send_message=send_status, + queue_delete_messages=queue_delete, + ) + ) + + try: + await asyncio.wait_for(status_send_started.wait(), timeout=1) + cancelled = await flow.cancel_pending_voice(VOICE_SCOPE, "voice") + + assert cancelled is not None + assert cancelled.voice_message_id == "voice" + assert cancelled.status_message_id is None + + release_status_send.set() + assert await asyncio.wait_for(handle_task, timeout=1) is True + finally: + release_status_send.set() + if not handle_task.done(): + handle_task.cancel() + with pytest.raises(asyncio.CancelledError): + await handle_task + + transcriber.run.assert_not_awaited() + handler.assert_not_awaited() + queue_delete.assert_awaited_once_with("chat", ["status"]) + + +@pytest.mark.asyncio +async def test_voice_flow_cancel_during_status_delivery_suppresses_late_failure() -> ( + None +): + flow, transcriber = _flow() + status_send_started = asyncio.Event() + release_status_send = asyncio.Event() + reply_text = AsyncMock() + handler = AsyncMock() + + async def fail_status_send(*_args, **_kwargs) -> str: + status_send_started.set() + await release_status_send.wait() + raise RuntimeError("late status failure") + + handle_task = asyncio.create_task( + flow.handle( + _request(reply_text=reply_text), + message_handler=handler, + queue_send_message=fail_status_send, + queue_delete_messages=AsyncMock(), + ) + ) + + try: + await asyncio.wait_for(status_send_started.wait(), timeout=1) + assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is not None + + release_status_send.set() + assert await asyncio.wait_for(handle_task, timeout=1) is True + finally: + release_status_send.set() + if not handle_task.done(): + handle_task.cancel() + with pytest.raises(asyncio.CancelledError): + await handle_task + + transcriber.run.assert_not_awaited() + handler.assert_not_awaited() + reply_text.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_voice_flow_cancel_during_transcription_suppresses_late_failure() -> None: + flow, transcriber = _flow() + transcription_started = asyncio.Event() + release_transcription = asyncio.Event() + reply_text = AsyncMock() + handler = AsyncMock() + queue_delete = AsyncMock() + + async def fail_transcription(_path: Path) -> str: + transcription_started.set() + await release_transcription.wait() + raise RuntimeError("late transcription failure") + + transcriber.run.side_effect = fail_transcription + handle_task = asyncio.create_task( + flow.handle( + _request(reply_text=reply_text), + message_handler=handler, + queue_send_message=AsyncMock(return_value="status"), + queue_delete_messages=queue_delete, + ) + ) + + try: + await asyncio.wait_for(transcription_started.wait(), timeout=1) + cancelled = await flow.cancel_pending_voice(VOICE_SCOPE, "voice") + assert cancelled is not None + assert cancelled.status_message_id == "status" + + release_transcription.set() + assert await asyncio.wait_for(handle_task, timeout=1) is True + finally: + release_transcription.set() + if not handle_task.done(): + handle_task.cancel() + with pytest.raises(asyncio.CancelledError): + await handle_task + + handler.assert_not_awaited() + reply_text.assert_not_awaited() + queue_delete.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_reply_stop_status_survives_late_transcription_success( + mock_platform, + mock_cli_manager, + mock_session_store, + incoming_message_factory, +) -> None: + flow, transcriber = _flow() + workflow = MessagingWorkflow( + mock_platform, + mock_cli_manager, + mock_session_store, + platform_name="telegram", + voice_cancellation=flow, + ) + transcription_started = asyncio.Event() + release_transcription = asyncio.Event() + + async def delayed_transcription(_path: Path) -> str: + transcription_started.set() + await release_transcription.wait() + return "late voice prompt" + + transcriber.run.side_effect = delayed_transcription + mock_platform.queue_send_message.return_value = "voice_status" + voice_task = asyncio.create_task( + flow.handle( + _request(), + message_handler=workflow.handle_message, + queue_send_message=mock_platform.queue_send_message, + queue_delete_messages=mock_platform.queue_delete_messages, + ) + ) + await asyncio.wait_for(transcription_started.wait(), timeout=1) + + try: + await workflow.handle_message( + incoming_message_factory( + text="/stop", + chat_id="chat", + message_id="stop_command", + reply_to_message_id="voice", + ) + ) + finally: + release_transcription.set() + assert await asyncio.wait_for(voice_task, timeout=1) is True + await asyncio.sleep(0) + + stopped = workflow.format_status("⏹", "Stopped.") + assert any( + call.args[:3] == ("chat", "voice_status", stopped) + for call in mock_platform.queue_edit_message.await_args_list + ) + mock_platform.queue_delete_messages.assert_not_awaited() + assert await workflow.tree_queue.resolve_node_id(VOICE_SCOPE, "voice") is None + + +@pytest.mark.asyncio +async def test_voice_flow_cancel_during_handoff_stops_and_drains_handler() -> None: + flow, _transcriber = _flow() + handler_started = asyncio.Event() + handler_cancelled = asyncio.Event() + release_handler = asyncio.Event() + queue_delete = AsyncMock() + + async def handler(_incoming) -> None: + handler_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + handler_cancelled.set() + await release_handler.wait() + raise + + handle_task = asyncio.create_task( + flow.handle( + _request(), + message_handler=handler, + queue_send_message=AsyncMock(return_value="status"), + queue_delete_messages=queue_delete, + ) + ) + + try: + await asyncio.wait_for(handler_started.wait(), timeout=1) + cancel_task = asyncio.create_task( + flow.cancel_pending_voice(VOICE_SCOPE, "voice") + ) + await asyncio.wait_for(handler_cancelled.wait(), timeout=1) + + assert not cancel_task.done() + release_handler.set() + cancelled = await asyncio.wait_for(cancel_task, timeout=1) + assert cancelled is not None + assert cancelled.status_message_id == "status" + assert await asyncio.wait_for(handle_task, timeout=1) is True + finally: + release_handler.set() + if not handle_task.done(): + handle_task.cancel() + with pytest.raises(asyncio.CancelledError): + await handle_task + + queue_delete.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_voice_flow_cancel_during_handoff_suppresses_late_handler_error() -> None: + flow, _transcriber = _flow() + handler_started = asyncio.Event() + reply_text = AsyncMock() + + async def handler(_incoming) -> None: + handler_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + raise RuntimeError("late handler failure") from None + + handle_task = asyncio.create_task( + flow.handle( + _request(reply_text=reply_text), + message_handler=handler, + queue_send_message=AsyncMock(return_value="status"), + queue_delete_messages=AsyncMock(), + ) + ) + + await asyncio.wait_for(handler_started.wait(), timeout=1) + assert await flow.cancel_pending_voice(VOICE_SCOPE, "status") is not None + assert await asyncio.wait_for(handle_task, timeout=1) is True + reply_text.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("command", ["/stop", "/clear"]) +async def test_reply_command_cancels_voice_at_tree_admission_commit( + command: str, + monkeypatch: pytest.MonkeyPatch, + mock_platform, + mock_cli_manager, + mock_session_store, + incoming_message_factory, +) -> None: + flow, _transcriber = _flow() + workflow = MessagingWorkflow( + mock_platform, + mock_cli_manager, + mock_session_store, + platform_name="telegram", + voice_cancellation=flow, + ) + admission_mutated = asyncio.Event() + release_admission = asyncio.Event() + original_enqueue_or_claim = MessageTree.enqueue_or_claim + + async def mutate_then_block(tree: MessageTree, node_id: str): + decision = await original_enqueue_or_claim(tree, node_id) + if node_id == "voice": + admission_mutated.set() + await release_admission.wait() + return decision + + monkeypatch.setattr(MessageTree, "enqueue_or_claim", mutate_then_block) + mock_platform.queue_send_message.return_value = "voice_status" + voice_task = asyncio.create_task( + flow.handle( + _request(), + message_handler=workflow.handle_message, + queue_send_message=mock_platform.queue_send_message, + queue_delete_messages=mock_platform.queue_delete_messages, + ) + ) + await asyncio.wait_for(admission_mutated.wait(), timeout=1) + + command_task = asyncio.create_task( + workflow.handle_message( + incoming_message_factory( + text=command, + chat_id="chat", + message_id="command", + reply_to_message_id="voice", + ) + ) + ) + try: + await asyncio.sleep(0) + assert not command_task.done() + finally: + release_admission.set() + await asyncio.wait_for(command_task, timeout=1) + voice_result = await asyncio.wait_for(voice_task, timeout=1) + assert voice_result is True + await asyncio.sleep(0) + + mock_session_store.save_tree_snapshot.assert_called() + if command == "/clear": + assert workflow.get_tree_count() == 0 + assert await workflow.tree_queue.resolve_node_id(VOICE_SCOPE, "voice") is None + else: + assert workflow.get_tree_count() == 1 + assert ( + await workflow.tree_queue.resolve_node_id(VOICE_SCOPE, "voice") == "voice" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("command", ["/stop", "/clear"]) +async def test_global_command_rejects_transcription_that_finishes_late( + command: str, + mock_platform, + mock_cli_manager, + mock_session_store, + incoming_message_factory, +) -> None: + flow, transcriber = _flow() + workflow = MessagingWorkflow( + mock_platform, + mock_cli_manager, + mock_session_store, + platform_name="telegram", + voice_cancellation=flow, + ) + transcription_started = asyncio.Event() + release_transcription = asyncio.Event() + + async def delayed_transcription(_path: Path) -> str: + transcription_started.set() + await release_transcription.wait() + return "late voice prompt" + + transcriber.run.side_effect = delayed_transcription + mock_platform.queue_send_message.return_value = "voice_status" + voice_task = asyncio.create_task( + flow.handle( + _request(), + message_handler=workflow.handle_message, + queue_send_message=mock_platform.queue_send_message, + queue_delete_messages=mock_platform.queue_delete_messages, + ) + ) + await asyncio.wait_for(transcription_started.wait(), timeout=1) + + await asyncio.wait_for( + workflow.handle_message( + incoming_message_factory( + text=command, + message_id="command", + chat_id=VOICE_SCOPE.chat_id, + ) + ), + timeout=1, + ) + release_transcription.set() + assert await asyncio.wait_for(voice_task, timeout=1) is True + + assert workflow.get_tree_count() == 0 + assert await workflow.tree_queue.resolve_node_id(VOICE_SCOPE, "voice") is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("command", ["/stop", "/clear"]) +async def test_transcribed_global_command_does_not_cancel_its_own_handoff( + command: str, + mock_platform, + mock_cli_manager, + mock_session_store, +) -> None: + flow, transcriber = _flow() + transcriber.run.return_value = command + workflow = MessagingWorkflow( + mock_platform, + mock_cli_manager, + mock_session_store, + platform_name="telegram", + voice_cancellation=flow, + ) + mock_platform.queue_send_message.return_value = "voice_status" + + assert ( + await asyncio.wait_for( + flow.handle( + _request(), + message_handler=workflow.handle_message, + queue_send_message=mock_platform.queue_send_message, + queue_delete_messages=mock_platform.queue_delete_messages, + ), + timeout=1, + ) + is True + ) + + assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None + + +@pytest.mark.asyncio +async def test_voice_flow_caller_cancellation_drains_handoff_child() -> None: + flow, _transcriber = _flow() + handler_started = asyncio.Event() + handler_cancelled = asyncio.Event() + release_handler = asyncio.Event() + queue_delete = AsyncMock() + + async def handler(_incoming) -> None: + handler_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + handler_cancelled.set() + await release_handler.wait() + raise + + handle_task = asyncio.create_task( + flow.handle( + _request(), + message_handler=handler, + queue_send_message=AsyncMock(return_value="status"), + queue_delete_messages=queue_delete, + ) + ) + await asyncio.wait_for(handler_started.wait(), timeout=1) + handle_task.cancel() + await asyncio.wait_for(handler_cancelled.wait(), timeout=1) + + assert not handle_task.done() + release_handler.set() + with pytest.raises(asyncio.CancelledError): + await handle_task + assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None + queue_delete.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_voice_flow_task_cancellation_waits_then_cleans_pending_state() -> None: + flow, transcriber = _flow() + started = asyncio.Event() + cancellation_received = asyncio.Event() + release = asyncio.Event() + stopped = asyncio.Event() + + async def cancellation_safe_transcribe(_path: Path) -> str: + started.set() + try: + await asyncio.Event().wait() + return "unreachable" + except asyncio.CancelledError: + cancellation_received.set() + await release.wait() + stopped.set() + raise + + transcriber.run.side_effect = cancellation_safe_transcribe + handler = AsyncMock() + queue_delete = AsyncMock() + handle_task = asyncio.create_task( + flow.handle( + _request(), + message_handler=handler, + queue_send_message=AsyncMock(return_value="status"), + queue_delete_messages=queue_delete, + ) + ) + + await started.wait() + handle_task.cancel() + await cancellation_received.wait() + + assert not handle_task.done() + queue_delete.assert_not_awaited() + + release.set() + with pytest.raises(asyncio.CancelledError): + await handle_task + + assert stopped.is_set() + handler.assert_not_awaited() + queue_delete.assert_awaited_once_with("chat", ["status"]) + assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None + + +@pytest.mark.asyncio +async def test_voice_flow_repeated_cancellation_cannot_interrupt_cleanup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + flow, transcriber = _flow() + transcription_started = asyncio.Event() + cleanup_started = asyncio.Event() + release_cleanup = asyncio.Event() + + async def blocked_transcription(_path: Path) -> str: + transcription_started.set() + await asyncio.Event().wait() + return "unreachable" + + original_discard = flow._pending_voice.discard + + async def delayed_discard(claim) -> bool: + cleanup_started.set() + await release_cleanup.wait() + return await original_discard(claim) + + transcriber.run.side_effect = blocked_transcription + monkeypatch.setattr(flow._pending_voice, "discard", delayed_discard) + queue_delete = AsyncMock() + handle_task = asyncio.create_task( + flow.handle( + _request(), + message_handler=AsyncMock(), + queue_send_message=AsyncMock(return_value="status"), + queue_delete_messages=queue_delete, + ) + ) + + await asyncio.wait_for(transcription_started.wait(), timeout=1) + handle_task.cancel("first cancellation") + await asyncio.wait_for(cleanup_started.wait(), timeout=1) + handle_task.cancel("second cancellation") + await asyncio.sleep(0) + + assert not handle_task.done() + release_cleanup.set() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(handle_task, timeout=1) + + queue_delete.assert_awaited_once_with("chat", ["status"]) + assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None + assert await flow.cancel_pending_voice(VOICE_SCOPE, "status") is None + + +@pytest.mark.asyncio +async def test_voice_flow_fatal_status_failure_releases_claim() -> None: + flow, transcriber = _flow() + queue_delete = AsyncMock() + + async def fatal_status(*_args, **_kwargs) -> str: + raise FatalVoiceError + + with pytest.raises(FatalVoiceError): + await flow.handle( + _request(), + message_handler=AsyncMock(), + queue_send_message=fatal_status, + queue_delete_messages=queue_delete, + ) + + transcriber.run.assert_not_awaited() + queue_delete.assert_not_awaited() + assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None + + +@pytest.mark.asyncio +async def test_voice_flow_fatal_download_failure_releases_status_ownership() -> None: + flow, transcriber = _flow() + queue_delete = AsyncMock() + + async def fatal_download(_path: Path) -> None: + raise FatalVoiceError + + with pytest.raises(FatalVoiceError): + await flow.handle( + _request(download_to=fatal_download), + message_handler=AsyncMock(), + queue_send_message=AsyncMock(return_value="status"), + queue_delete_messages=queue_delete, + ) + + transcriber.run.assert_not_awaited() + queue_delete.assert_awaited_once_with("chat", ["status"]) + assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None + assert await flow.cancel_pending_voice(VOICE_SCOPE, "status") is None + + +@pytest.mark.asyncio +async def test_voice_flow_fatal_transcription_releases_status_ownership() -> None: + flow, transcriber = _flow() + transcriber.run.side_effect = FatalVoiceError() + handler = AsyncMock() + queue_delete = AsyncMock() + + with pytest.raises(FatalVoiceError): + await flow.handle( + _request(), + message_handler=handler, + queue_send_message=AsyncMock(return_value="status"), + queue_delete_messages=queue_delete, + ) + + handler.assert_not_awaited() + queue_delete.assert_awaited_once_with("chat", ["status"]) + assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None + assert await flow.cancel_pending_voice(VOICE_SCOPE, "status") is None + + +@pytest.mark.asyncio +async def test_voice_flow_download_failure_cleans_pending_state() -> None: + flow, transcriber = _flow() + reply_text = AsyncMock() + queue_delete = AsyncMock() + + async def failing_download(_path: Path) -> None: + raise RuntimeError("download failed") + + handled = await flow.handle( + _request(download_to=failing_download, reply_text=reply_text), + message_handler=AsyncMock(), + queue_send_message=AsyncMock(return_value="status"), + queue_delete_messages=queue_delete, + ) + + assert handled is True + transcriber.run.assert_not_awaited() + queue_delete.assert_awaited_once_with("chat", ["status"]) + reply_text.assert_awaited_once_with(VOICE_TRANSCRIPTION_ERROR_MESSAGE) + assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None + + +@pytest.mark.asyncio +async def test_voice_flow_transcription_failure_cleans_pending_state() -> None: + flow, transcriber = _flow() + transcriber.run.side_effect = RuntimeError("transcription failed") + reply_text = AsyncMock() + queue_delete = AsyncMock() + + handled = await flow.handle( + _request(reply_text=reply_text), + message_handler=AsyncMock(), + queue_send_message=AsyncMock(return_value="status"), + queue_delete_messages=queue_delete, + ) + + assert handled is True + queue_delete.assert_awaited_once_with("chat", ["status"]) + reply_text.assert_awaited_once_with(VOICE_TRANSCRIPTION_ERROR_MESSAGE) + assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None + + +@pytest.mark.asyncio +async def test_voice_flow_handler_failure_cleans_pending_without_deleting_status() -> ( + None +): + flow, _transcriber = _flow() + reply_text = AsyncMock() + queue_delete = AsyncMock() + + async def failing_handler(_incoming) -> None: + raise RuntimeError("handler failed") + + handled = await flow.handle( + _request(reply_text=reply_text), + message_handler=failing_handler, + queue_send_message=AsyncMock(return_value="status"), + queue_delete_messages=queue_delete, + ) + + assert handled is True + queue_delete.assert_not_awaited() + reply_text.assert_awaited_once_with(VOICE_TRANSCRIPTION_ERROR_MESSAGE) + assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None + + +@pytest.mark.asyncio +async def test_voice_flow_rejects_oversized_audio_before_transcription( + monkeypatch, +) -> None: + monkeypatch.setattr( + "free_claude_code.messaging.platforms.voice_flow.MAX_AUDIO_SIZE_BYTES", + 3, + ) + flow, transcriber = _flow() + reply_text = AsyncMock() + queue_delete = AsyncMock() + + async def download(path: Path) -> None: + path.write_bytes(b"four") + + handled = await flow.handle( + _request(download_to=download, reply_text=reply_text), + message_handler=AsyncMock(), + queue_send_message=AsyncMock(return_value="status"), + queue_delete_messages=queue_delete, + ) + + assert handled is True + transcriber.run.assert_not_awaited() + queue_delete.assert_awaited_once_with("chat", ["status"]) + assert reply_text.await_args is not None + assert "too large" in reply_text.await_args.args[0] + + +def test_audio_metadata_helpers() -> None: + assert is_audio_metadata("voice.ogg", "application/octet-stream") is True + assert is_audio_metadata("file.txt", "audio/ogg") is True + assert is_audio_metadata("file.txt", "text/plain") is False + assert ( + audio_suffix_from_metadata(filename="voice.ogg", content_type="audio/mp4") + == ".mp4" + ) + assert ( + audio_suffix_from_metadata(filename="clip.m4a", content_type="audio/mp4") + == ".m4a" + ) + assert ( + audio_suffix_from_metadata(filename="clip.m4a", content_type="audio/mpeg") + == ".mp3" + ) + assert audio_suffix_from_metadata(content_type="audio/mpeg") == ".mp3" + assert audio_suffix_from_metadata(filename="clip.m4a") == ".m4a" + assert audio_suffix_from_metadata(content_type="audio/mp4") == ".mp4" + assert audio_suffix_from_metadata(content_type="audio/wav") == ".wav" diff --git a/tests/messaging/test_reliability.py b/tests/messaging/test_reliability.py new file mode 100644 index 0000000..8cbc544 --- /dev/null +++ b/tests/messaging/test_reliability.py @@ -0,0 +1,146 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from telegram.error import NetworkError, RetryAfter, TelegramError + +from free_claude_code.messaging.limiter import MessagingRateLimiter +from free_claude_code.messaging.platforms.telegram import TelegramRuntime + + +@pytest.fixture +def telegram_platform(): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = TelegramRuntime( + bot_token="test_token", + allowed_user_id="12345", + limiter=MessagingRateLimiter(rate_limit=1, rate_window=1.0), + transcriber=None, + ) + 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.outbound.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.outbound.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.outbound.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 free_claude_code.messaging.rendering.telegram_markdown import ( + escape_md_v2, + escape_md_v2_code, + mdv2_bold, + mdv2_code_inline, + render_markdown_to_mdv2, + ) + from free_claude_code.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 free_claude_code.messaging.rendering.telegram_markdown import ( + escape_md_v2, + escape_md_v2_code, + mdv2_bold, + mdv2_code_inline, + render_markdown_to_mdv2, + ) + from free_claude_code.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/tests/messaging/test_rendering_profiles.py b/tests/messaging/test_rendering_profiles.py new file mode 100644 index 0000000..e3c4d5f --- /dev/null +++ b/tests/messaging/test_rendering_profiles.py @@ -0,0 +1,17 @@ +from free_claude_code.messaging.rendering.profiles import build_rendering_profile + + +def test_discord_rendering_profile_has_plain_parse_mode(): + profile = build_rendering_profile("discord") + + assert profile.parse_mode is None + assert profile.limit_chars == 1900 + assert profile.format_status("x", "Working", None).startswith("x") + + +def test_telegram_rendering_profile_uses_markdown_v2(): + profile = build_rendering_profile("telegram") + + assert profile.parse_mode == "MarkdownV2" + assert profile.limit_chars == 3900 + assert profile.format_status("x", "Working", None).startswith("x") diff --git a/tests/messaging/test_restart_reply_restore.py b/tests/messaging/test_restart_reply_restore.py new file mode 100644 index 0000000..d42d016 --- /dev/null +++ b/tests/messaging/test_restart_reply_restore.py @@ -0,0 +1,312 @@ +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from free_claude_code.messaging.models import IncomingMessage, MessageScope +from free_claude_code.messaging.session import SessionStore +from free_claude_code.messaging.trees import TreeIdentity +from free_claude_code.messaging.trees.node import MessageNode, MessageState +from free_claude_code.messaging.trees.runtime import MessageTree +from free_claude_code.messaging.workflow import MessagingWorkflow + +TELEGRAM_CHAT_1 = MessageScope(platform="telegram", chat_id="chat_1") + + +async def _wait_for_idle(workflow: MessagingWorkflow) -> None: + for _ in range(100): + if workflow.tree_queue.task_count() == 0: + return + await asyncio.sleep(0) + raise AssertionError("messaging claims did not finish") + + +async def _write_completed_root(store: SessionStore) -> None: + tree = MessageTree( + MessageNode( + node_id="A", + scope=TELEGRAM_CHAT_1, + prompt="A", + status_message_id="status_A", + state=MessageState.COMPLETED, + session_id="sess_A", + ) + ) + store.save_tree_snapshot(await tree.snapshot()) + store.flush_pending_save() + + +async def _write_interrupted_root(store: SessionStore) -> None: + tree = MessageTree( + MessageNode( + node_id="A", + scope=TELEGRAM_CHAT_1, + prompt="A", + status_message_id="status_A", + state=MessageState.IN_PROGRESS, + ) + ) + store.save_tree_snapshot(await tree.snapshot()) + store.flush_pending_save() + + +def _successful_session(session_id: str): + session = MagicMock() + + async def events(*_args, **_kwargs): + yield {"type": "session_info", "session_id": session_id} + yield {"type": "exit", "code": 0, "stderr": None} + + session.start_task = events + return session + + +@pytest.mark.asyncio +async def test_reply_to_old_status_message_after_restore_routes_to_parent( + tmp_path, + mock_platform, + mock_cli_manager, +) -> None: + store_path = tmp_path / "sessions.json" + await _write_completed_root(SessionStore(storage_path=str(store_path))) + + restored_store = SessionStore(storage_path=str(store_path)) + workflow = MessagingWorkflow(mock_platform, mock_cli_manager, restored_store) + workflow.restore() + mock_platform.queue_send_message = AsyncMock(return_value="status_reply") + mock_cli_manager.get_or_create_session.return_value = ( + _successful_session("sess_R1"), + "pending_R1", + True, + ) + + await workflow.handle_message( + IncomingMessage( + text="R1", + chat_id="chat_1", + user_id="user_1", + message_id="R1", + platform="telegram", + reply_to_message_id="status_A", + ) + ) + await _wait_for_idle(workflow) + + reply = await workflow.tree_queue.get_node(TELEGRAM_CHAT_1, "R1") + assert reply is not None + assert reply.parent_id == "A" + mock_cli_manager.get_or_create_session.assert_called_with(session_id="sess_A") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("wrapped", [False, True]) +async def test_legacy_session_json_restores_through_workflow_and_routes_reply( + wrapped: bool, + tmp_path, + mock_platform, + mock_cli_manager, +) -> None: + legacy_tree = { + "root_id": "A", + "nodes": { + "A": { + "node_id": "A", + "incoming": { + "text": "legacy prompt", + "chat_id": "chat_1", + "user_id": "legacy-user", + "message_id": "A", + "platform": "telegram", + }, + "status_message_id": "status_A", + "state": "completed", + "parent_id": None, + "session_id": "sess_A", + "children_ids": [], + "created_at": "2025-01-01T00:00:00+00:00", + "completed_at": "2025-01-01T00:00:01+00:00", + "error_message": None, + } + }, + } + conversation = {"trees": {"A": legacy_tree}} + payload = ( + {"conversation": conversation, "message_log": {}} + if wrapped + else { + **conversation, + "node_to_tree": {"A": "A"}, + "message_log": {}, + } + ) + store_path = tmp_path / "sessions.json" + store_path.write_text(json.dumps(payload), encoding="utf-8") + workflow = MessagingWorkflow( + mock_platform, + mock_cli_manager, + SessionStore(storage_path=str(store_path)), + ) + workflow.restore() + mock_platform.queue_send_message = AsyncMock(return_value="status_reply") + mock_cli_manager.get_or_create_session.return_value = ( + _successful_session("sess_R1"), + "pending_R1", + True, + ) + + await workflow.handle_message( + IncomingMessage( + text="continue legacy tree", + chat_id="chat_1", + user_id="user_1", + message_id="R1", + platform="telegram", + reply_to_message_id="status_A", + ) + ) + await _wait_for_idle(workflow) + + reply = await workflow.tree_queue.get_node(TELEGRAM_CHAT_1, "R1") + assert reply is not None and reply.parent_id == "A" + mock_cli_manager.get_or_create_session.assert_called_with(session_id="sess_A") + + +@pytest.mark.asyncio +async def test_save_tree_snapshot_restores_status_lookup_without_manual_index( + tmp_path, + mock_platform, + mock_cli_manager, +) -> None: + store_path = tmp_path / "sessions.json" + await _write_completed_root(SessionStore(storage_path=str(store_path))) + workflow = MessagingWorkflow( + mock_platform, + mock_cli_manager, + SessionStore(storage_path=str(store_path)), + ) + workflow.restore() + + assert await workflow.tree_queue.resolve_node_id(TELEGRAM_CHAT_1, "status_A") == "A" + + +@pytest.mark.asyncio +async def test_reply_clear_purges_removed_status_mapping_from_persisted_store( + tmp_path, + mock_platform, + mock_cli_manager, +) -> None: + store_path = tmp_path / "sessions.json" + store = SessionStore(storage_path=str(store_path)) + workflow = MessagingWorkflow(mock_platform, mock_cli_manager, store) + mock_platform.queue_send_message = AsyncMock( + side_effect=["root_status", "child_status"] + ) + mock_cli_manager.get_or_create_session.side_effect = [ + (_successful_session("sess_root"), "pending_root", True), + (_successful_session("sess_child"), "pending_child", True), + ] + + await workflow.handle_message( + IncomingMessage( + text="root", + chat_id="chat_1", + user_id="user_1", + message_id="root", + platform="telegram", + ) + ) + await _wait_for_idle(workflow) + await workflow.handle_message( + IncomingMessage( + text="child", + chat_id="chat_1", + user_id="user_1", + message_id="child", + platform="telegram", + reply_to_message_id="root", + ) + ) + await _wait_for_idle(workflow) + await workflow.handle_message( + IncomingMessage( + text="/clear", + chat_id="chat_1", + user_id="user_1", + message_id="clear_command", + platform="telegram", + reply_to_message_id="child", + ) + ) + store.flush_pending_save() + + identity = TreeIdentity(scope=TELEGRAM_CHAT_1, root_id="root") + persisted = SessionStore(storage_path=str(store_path)).load_conversation_snapshot() + tree = persisted.get_tree(identity) + assert tree is not None + assert tree.lookup_ids() == {"root", "root_status"} + + +@pytest.mark.asyncio +async def test_restore_repairs_interrupted_status_after_delivery_starts( + tmp_path, + mock_platform, + mock_cli_manager, +) -> None: + store_path = tmp_path / "sessions.json" + await _write_interrupted_root(SessionStore(storage_path=str(store_path))) + workflow = MessagingWorkflow( + mock_platform, + mock_cli_manager, + SessionStore(storage_path=str(store_path)), + platform_name="telegram", + ) + workflow.restore() + + await workflow.repair_restored_statuses() + await workflow.repair_restored_statuses() + + mock_platform.queue_edit_message.assert_awaited_once_with( + TELEGRAM_CHAT_1.chat_id, + "status_A", + workflow.format_status("❌", "Interrupted by server restart"), + parse_mode="MarkdownV2", + fire_and_forget=False, + ) + + +@pytest.mark.asyncio +async def test_workflow_close_waits_for_claim_cleanup_before_flushing( + mock_platform, + mock_cli_manager, + mock_session_store, +) -> None: + workflow = MessagingWorkflow(mock_platform, mock_cli_manager, mock_session_store) + cleanup_release = asyncio.Event() + wait_started = asyncio.Event() + events: list[str] = [] + + async def stop_all() -> int: + events.append("stop") + return 1 + + async def wait_idle() -> None: + events.append("wait") + wait_started.set() + await cleanup_release.wait() + + workflow.stop_all_tasks = AsyncMock(side_effect=stop_all) + workflow.tree_queue.wait_idle = AsyncMock(side_effect=wait_idle) + mock_session_store.flush_pending_save.side_effect = lambda: events.append("flush") + + close_task = asyncio.create_task(workflow.close()) + await wait_started.wait() + + assert events == ["stop", "wait"] + mock_session_store.flush_pending_save.assert_not_called() + + cleanup_release.set() + await close_task + + assert events == ["stop", "wait", "flush"] + mock_session_store.flush_pending_save.assert_called_once() diff --git a/tests/messaging/test_robust_formatting.py b/tests/messaging/test_robust_formatting.py new file mode 100644 index 0000000..a2470fb --- /dev/null +++ b/tests/messaging/test_robust_formatting.py @@ -0,0 +1,96 @@ +from unittest.mock import MagicMock + +import pytest + +from free_claude_code.messaging.rendering.telegram_markdown import ( + escape_md_v2, + escape_md_v2_code, + mdv2_bold, + mdv2_code_inline, + render_markdown_to_mdv2, +) +from free_claude_code.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 free_claude_code.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 free_claude_code.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/tests/messaging/test_session_store_edge_cases.py b/tests/messaging/test_session_store_edge_cases.py new file mode 100644 index 0000000..077060d --- /dev/null +++ b/tests/messaging/test_session_store_edge_cases.py @@ -0,0 +1,680 @@ +"""Edge case tests for the messaging session store.""" + +import json +import threading +from collections.abc import Callable +from typing import Any, ClassVar +from unittest.mock import patch + +import pytest + +import free_claude_code.messaging.session.persistence as persistence_module +from free_claude_code.messaging.models import MessageScope +from free_claude_code.messaging.session import SessionStore +from free_claude_code.messaging.session.persistence import DebouncedJsonPersistence +from free_claude_code.messaging.trees import TreeIdentity, TreeSnapshot + +TELEGRAM_C1 = MessageScope(platform="telegram", chat_id="c1") +TELEGRAM_C2 = MessageScope(platform="telegram", chat_id="c2") + + +def _identity(root_id: str, scope: MessageScope = TELEGRAM_C1) -> TreeIdentity: + return TreeIdentity(scope=scope, root_id=root_id) + + +@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) + + +def _tree_node(node_id: str, status_message_id: str) -> dict: + return { + "node_id": node_id, + "status_message_id": status_message_id, + } + + +class FakeTimer: + instances: ClassVar[list[FakeTimer]] = [] + + def __init__( + self, + interval: float, + function: Callable[..., None], + args: tuple[Any, ...] | None = None, + kwargs: dict[str, Any] | None = None, + ) -> None: + self.interval = interval + self.function = function + self.args = args or () + self.kwargs = kwargs or {} + self.daemon = False + self.canceled = False + self.started = False + self.instances.append(self) + + def cancel(self) -> None: + self.canceled = True + + def start(self) -> None: + self.started = True + + def fire(self, *, force: bool = False) -> None: + if self.canceled and not force: + return + self.function(*self.args, **self.kwargs) + + +class RecordingPersistence(DebouncedJsonPersistence): + def __init__( + self, + storage_path: str, + *, + snapshot: Callable[[], dict[str, Any]], + on_dirty: Callable[[bool], None], + ) -> None: + self.writes: list[dict[str, Any]] = [] + super().__init__(storage_path, snapshot=snapshot, on_dirty=on_dirty) + + def _write_file(self, data: dict[str, Any]) -> None: + self.writes.append(data) + + +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 store.load_conversation_snapshot().is_empty + + 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 store.load_conversation_snapshot().is_empty + + 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 store.load_conversation_snapshot().is_empty + + 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 store.load_conversation_snapshot().is_empty + + 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_id": "r1", + "incoming": { + "platform": TELEGRAM_C1.platform, + "chat_id": TELEGRAM_C1.chat_id, + }, + } + }, + } + }, + "node_to_tree": {"r1": "r1"}, + "message_log": {}, + } + with open(path, "w") as f: + json.dump(data, f) + + store = SessionStore(storage_path=path) + assert store.load_conversation_snapshot().get_tree(_identity("r1")) is not None + + +class TestSessionStoreSaveEdgeCases: + """Tests for save failure handling.""" + + def test_explicit_flush_reports_io_error_and_keeps_store_dirty(self, tmp_store): + """A durability request must not hide that the snapshot was not saved.""" + tmp_store.save_tree_snapshot( + TreeSnapshot(scope=TELEGRAM_C1, root_id="r1", nodes={"r1": {}}) + ) + with ( + patch( + "free_claude_code.messaging.session.persistence.os.replace", + side_effect=OSError("disk full"), + ), + pytest.raises(OSError, match="disk full"), + ): + tmp_store.flush_pending_save() + assert tmp_store.dirty is True + + def test_explicit_flush_snapshot_failure_is_dirty_and_retryable( + self, + tmp_path, + ) -> None: + dirty_states: list[bool] = [] + should_fail = True + + def snapshot() -> dict[str, Any]: + if should_fail: + raise RuntimeError("snapshot failed") + return {"saved": True} + + persistence = DebouncedJsonPersistence( + str(tmp_path / "sessions.json"), + snapshot=snapshot, + on_dirty=dirty_states.append, + ) + + with pytest.raises(RuntimeError, match="snapshot failed"): + persistence.flush() + + assert dirty_states[-1] is True + + should_fail = False + persistence.flush() + + assert dirty_states[-1] is False + + def test_timer_save_io_error_is_best_effort_and_keeps_store_dirty( + self, + tmp_path, + monkeypatch, + ) -> None: + """A background timer may report failure without crashing its thread.""" + FakeTimer.instances = [] + monkeypatch.setattr(persistence_module.threading, "Timer", FakeTimer) + store = SessionStore(storage_path=str(tmp_path / "sessions.json")) + store.save_tree_snapshot( + TreeSnapshot(scope=TELEGRAM_C1, root_id="r1", nodes={"r1": {}}) + ) + + with patch( + "free_claude_code.messaging.session.persistence.os.replace", + side_effect=OSError("timer disk full"), + ): + FakeTimer.instances[-1].fire() + + assert store.dirty is True + + store.flush_pending_save() + + assert store.dirty is False + assert ( + SessionStore(storage_path=store.storage_path) + .load_conversation_snapshot() + .get_tree(_identity("r1")) + is not None + ) + + def test_timer_snapshot_failure_is_best_effort_type_only_and_stays_dirty( + self, + tmp_path, + monkeypatch, + ) -> None: + FakeTimer.instances = [] + monkeypatch.setattr(persistence_module.threading, "Timer", FakeTimer) + dirty_states: list[bool] = [] + + def fail_snapshot() -> dict[str, Any]: + raise RuntimeError("SECRET_SNAPSHOT_DETAIL") + + persistence = DebouncedJsonPersistence( + str(tmp_path / "sessions.json"), + snapshot=fail_snapshot, + on_dirty=dirty_states.append, + ) + persistence.schedule_save() + + with patch.object(persistence_module.logger, "error") as error: + FakeTimer.instances[-1].fire() + + assert dirty_states[-1] is True + error.assert_called_once_with( + "Failed to save sessions: exc_type={}", + "RuntimeError", + ) + assert "SECRET_SNAPSHOT_DETAIL" not in str(error.call_args) + + def test_stale_timer_callback_cannot_clear_newer_timer(self, tmp_path, monkeypatch): + """An already-running old timer cannot consume the newest save.""" + FakeTimer.instances = [] + monkeypatch.setattr(persistence_module.threading, "Timer", FakeTimer) + + dirty_states: list[bool] = [] + snapshot_count = 0 + + def snapshot() -> dict[str, Any]: + nonlocal snapshot_count + snapshot_count += 1 + return {"snapshot": snapshot_count} + + persistence = RecordingPersistence( + str(tmp_path / "sessions.json"), + snapshot=snapshot, + on_dirty=dirty_states.append, + ) + + persistence.schedule_save() + first_timer = FakeTimer.instances[0] + persistence.schedule_save() + second_timer = FakeTimer.instances[1] + + first_timer.fire(force=True) + assert persistence.writes == [] + assert dirty_states[-1] is True + assert second_timer.canceled is False + + second_timer.fire() + assert persistence.writes == [{"snapshot": 1}] + assert dirty_states[-1] is False + + def test_running_old_write_finishes_before_newer_flush(self, tmp_path, monkeypatch): + """A claimed old snapshot cannot land after a newer flushed snapshot.""" + FakeTimer.instances = [] + monkeypatch.setattr(persistence_module.threading, "Timer", FakeTimer) + + state = {"version": "old"} + dirty_states: list[bool] = [] + old_write_started = threading.Event() + release_old_write = threading.Event() + + class BlockingPersistence(RecordingPersistence): + def _write_file(self, data: dict[str, Any]) -> None: + if data == {"version": "old"}: + old_write_started.set() + release_old_write.wait(timeout=2) + super()._write_file(data) + + persistence = BlockingPersistence( + str(tmp_path / "sessions.json"), + snapshot=lambda: dict(state), + on_dirty=dirty_states.append, + ) + persistence.schedule_save() + old_writer = threading.Thread(target=FakeTimer.instances[0].fire) + old_writer.start() + assert old_write_started.wait(timeout=2) + + state["version"] = "new" + persistence.schedule_save() + new_writer = threading.Thread(target=persistence.flush) + new_writer.start() + assert new_writer.is_alive() + + release_old_write.set() + old_writer.join(timeout=2) + new_writer.join(timeout=2) + + assert not old_writer.is_alive() + assert not new_writer.is_alive() + assert persistence.writes == [{"version": "old"}, {"version": "new"}] + assert dirty_states[-1] is False + + +class TestSessionStoreTreeSnapshots: + def test_unscoped_tree_without_legacy_ingress_is_reported_and_skipped( + self, tmp_path + ): + path = tmp_path / "sessions.json" + path.write_text( + json.dumps( + { + "conversation": { + "trees": [ + { + "root_id": "root", + "nodes": { + "root": { + "node_id": "root", + "status_message_id": "status", + "state": "completed", + } + }, + } + ] + } + } + ), + encoding="utf-8", + ) + + with patch( + "free_claude_code.messaging.trees.snapshot.logger.warning" + ) as warning: + store = SessionStore(storage_path=str(path)) + + assert store.load_conversation_snapshot().is_empty + warning.assert_called_once_with( + "Skipping messaging tree snapshot without recoverable scope: root_id={}", + "root", + ) + + def test_snapshot_ingress_and_egress_are_deeply_detached(self, tmp_path): + store = SessionStore(storage_path=str(tmp_path / "sessions.json")) + snapshot = TreeSnapshot( + scope=TELEGRAM_C1, + root_id="root", + nodes={"root": {"node_id": "root", "state": "completed"}}, + ) + store.save_tree_snapshot(snapshot) + snapshot.nodes["root"]["state"] = "mutated-after-save" + + loaded = store.load_conversation_snapshot() + loaded_tree = loaded.get_tree(_identity("root")) + assert loaded_tree is not None + assert loaded_tree.nodes["root"]["state"] == "completed" + loaded_tree.nodes["root"]["state"] = "mutated-after-load" + + reloaded = store.load_conversation_snapshot().get_tree(_identity("root")) + assert reloaded is not None + assert reloaded.nodes["root"]["state"] == "completed" + + def test_save_tree_replaces_snapshot_for_scoped_identity(self, tmp_path): + path = str(tmp_path / "sessions.json") + store = SessionStore(storage_path=path) + + store.save_tree_snapshot( + TreeSnapshot( + scope=TELEGRAM_C1, + root_id="root", + nodes={ + "root": _tree_node("root", "root_status"), + "child": _tree_node("child", "child_status"), + }, + ) + ) + + saved = store.load_conversation_snapshot().get_tree(_identity("root")) + assert saved is not None + assert saved.lookup_ids() == { + "root", + "root_status", + "child", + "child_status", + } + + store.save_tree_snapshot( + TreeSnapshot( + scope=TELEGRAM_C1, + root_id="root", + nodes={ + "root": _tree_node("root", "root_status"), + }, + ) + ) + + replaced = store.load_conversation_snapshot().get_tree(_identity("root")) + assert replaced is not None + assert replaced.lookup_ids() == {"root", "root_status"} + + def test_remove_tree_removes_only_scoped_identity(self, tmp_path): + path = str(tmp_path / "sessions.json") + store = SessionStore(storage_path=path) + store.save_tree_snapshot( + TreeSnapshot( + scope=TELEGRAM_C1, + root_id="root", + nodes={ + "root": _tree_node("root", "root_status"), + "child": _tree_node("child", "child_status"), + }, + ) + ) + + store.remove_tree_snapshot(_identity("root")) + + assert store.load_conversation_snapshot().get_tree(_identity("root")) is None + + +class TestSessionStoreAtomicWrites: + """Atomic persistence: failed replace must not truncate the prior file.""" + + def test_failed_replace_keeps_prior_bytes_and_marks_dirty(self, tmp_path): + path = str(tmp_path / "sessions.json") + store = SessionStore(storage_path=path) + store.save_tree_snapshot( + TreeSnapshot(scope=TELEGRAM_C1, root_id="r1", nodes={"r1": {}}) + ) + store.flush_pending_save() + with open(path, encoding="utf-8") as f: + disk_after_first = f.read() + + store.save_tree_snapshot( + TreeSnapshot(scope=TELEGRAM_C1, root_id="r2", nodes={"r2": {}}) + ) + + with ( + patch( + "free_claude_code.messaging.session.persistence.os.replace", + side_effect=OSError("replace failed"), + ), + pytest.raises(OSError, match="replace failed"), + ): + store.flush_pending_save() + + with open(path, encoding="utf-8") as f: + disk_after_failed = f.read() + assert disk_after_failed == disk_after_first + assert store.dirty is True + assert store.load_conversation_snapshot().get_tree(_identity("r2")) is not None + + def test_failed_authoritative_clear_is_visible_and_retryable(self, tmp_path): + path = str(tmp_path / "sessions.json") + store = SessionStore(storage_path=path) + store.save_tree_snapshot( + TreeSnapshot(scope=TELEGRAM_C1, root_id="r1", nodes={"r1": {}}) + ) + store.flush_pending_save() + + with ( + patch( + "free_claude_code.messaging.session.persistence.os.replace", + side_effect=OSError("clear replace failed"), + ), + pytest.raises(OSError, match="clear replace failed"), + ): + store.clear_scope(TELEGRAM_C1) + + assert store.load_conversation_snapshot().is_empty + assert store.dirty is True + assert not SessionStore(storage_path=path).load_conversation_snapshot().is_empty + + store.flush_pending_save() + + assert store.dirty is False + assert SessionStore(storage_path=path).load_conversation_snapshot().is_empty + + def test_authoritative_snapshot_failure_marks_store_dirty_before_retry( + self, + tmp_path, + ) -> None: + store = SessionStore(storage_path=str(tmp_path / "sessions.json")) + + with ( + patch.object( + store, + "_snapshot_for_persistence", + side_effect=RuntimeError("authoritative snapshot failed"), + ), + pytest.raises(RuntimeError, match="authoritative snapshot failed"), + ): + store.clear_scope(TELEGRAM_C1) + + assert store.dirty is True + + store.clear_scope(TELEGRAM_C1) + + assert store.dirty is False + + +class TestSessionStoreClearScope: + def test_clear_scope_wipes_matching_state_and_persists(self, tmp_path): + path = str(tmp_path / "sessions.json") + store = SessionStore(storage_path=path) + + store.save_tree_snapshot( + TreeSnapshot( + scope=TELEGRAM_C1, + 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, + }, + "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_scope(TELEGRAM_C1) + + assert store.load_conversation_snapshot().is_empty + + with open(path, encoding="utf-8") as f: + data = json.load(f) + assert data["conversation"]["trees"] == [] + assert data["managed_messages"] == {} + + store2 = SessionStore(storage_path=path) + assert store2.load_conversation_snapshot().is_empty + + def test_clear_scope_preserves_other_chat_trees_and_messages(self, tmp_path): + path = str(tmp_path / "sessions.json") + store = SessionStore(storage_path=path) + other_tree = TreeSnapshot( + scope=TELEGRAM_C2, + root_id="root2", + nodes={ + "root2": { + "node_id": "root2", + "status_message_id": "status2", + "state": "completed", + "parent_id": None, + "parent_reference_id": None, + "session_id": "session2", + } + }, + ) + store.save_tree_snapshot(other_tree) + store.record_message_id("telegram", "c1", "message1", "in", "prompt") + store.record_message_id("telegram", "c2", "message2", "in", "prompt") + + store.clear_scope(TELEGRAM_C1) + + assert ( + store.load_conversation_snapshot().get_tree(other_tree.identity) is not None + ) + assert store.get_tracked_message_ids_for_chat("telegram", "c1") == [] + assert store.get_tracked_message_ids_for_chat("telegram", "c2") == ["message2"] + restored = SessionStore(storage_path=path) + assert ( + restored.load_conversation_snapshot().get_tree(other_tree.identity) + is not None + ) + assert restored.get_tracked_message_ids_for_chat("telegram", "c2") == [ + "message2" + ] + + def test_managed_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", "2", "out", "command") + store.record_message_id("telegram", "c1", "2", "out", "command") + store.record_message_id("telegram", "c1", "3", "in", "command") + + ids = store.get_tracked_message_ids_for_chat("telegram", "c1") + assert ids == ["2", "3"] + + store.flush_pending_save() + store2 = SessionStore(storage_path=path) + assert store2.get_tracked_message_ids_for_chat("telegram", "c1") == [ + "2", + "3", + ] + + def test_load_preserves_legacy_managed_messages(self, tmp_path): + path = tmp_path / "sessions.json" + path.write_text( + json.dumps( + { + "conversation": {"trees": []}, + "message_log": { + "telegram:c1": [ + { + "message_id": "prompt", + "direction": "in", + "kind": "content", + }, + { + "message_id": "old-command", + "direction": "in", + "kind": "command", + }, + { + "message_id": "status", + "direction": "out", + "kind": "status", + }, + { + "message_id": "clear-command", + "direction": "in", + "kind": "clear_command", + }, + ] + }, + } + ), + encoding="utf-8", + ) + + store = SessionStore(storage_path=str(path)) + + assert store.get_tracked_message_ids_for_chat("telegram", "c1") == [ + "prompt", + "old-command", + "status", + "clear-command", + ] diff --git a/tests/messaging/test_stream_transcript_contract.py b/tests/messaging/test_stream_transcript_contract.py new file mode 100644 index 0000000..ab5b8ac --- /dev/null +++ b/tests/messaging/test_stream_transcript_contract.py @@ -0,0 +1,60 @@ +"""Messaging-specific assertions built on neutral Anthropic stream contracts.""" + +from free_claude_code.core.anthropic import AnthropicStreamLedger +from free_claude_code.core.anthropic.stream_contracts import ( + assert_anthropic_stream_contract, + has_tool_use, + parse_sse_text, +) +from free_claude_code.messaging.event_parser import parse_cli_event +from free_claude_code.messaging.transcript import RenderCtx, TranscriptBuffer + + +def test_thinking_tool_text_and_transcript_order_contract() -> None: + builder = AnthropicStreamLedger("msg_contract", "contract-model") + chunks = [builder.message_start()] + chunks.extend(builder.ensure_thinking_block()) + chunks.append(builder.emit_thinking_delta("inspect first")) + chunks.extend(builder.close_content_blocks()) + tool_block_index = builder.blocks.allocate_index() + chunks.append( + builder.content_block_start( + tool_block_index, "tool_use", id="toolu_1", name="Read" + ) + ) + chunks.append( + builder.content_block_delta( + tool_block_index, "input_json_delta", '{"file":"README.md"}' + ) + ) + chunks.append(builder.content_block_stop(tool_block_index)) + chunks.extend(builder.ensure_text_block()) + chunks.append(builder.emit_text_delta("done")) + chunks.extend(builder.close_all_blocks()) + chunks.append(builder.message_delta("end_turn", 20)) + chunks.append(builder.message_stop()) + + events = parse_sse_text("".join(chunks)) + assert_anthropic_stream_contract(events) + assert has_tool_use(events) + + transcript = TranscriptBuffer() + for event in events: + for parsed in parse_cli_event(event.data): + transcript.apply(parsed) + rendered = transcript.render(_render_ctx(), limit_chars=3900, status=None) + assert ( + rendered.find("inspect first") + < rendered.find("Tool call:") + < rendered.find("done") + ) + + +def _render_ctx() -> RenderCtx: + return RenderCtx( + bold=lambda s: f"*{s}*", + code_inline=lambda s: f"`{s}`", + escape_code=lambda s: s, + escape_text=lambda s: s, + render_markdown=lambda s: s, + ) diff --git a/tests/messaging/test_telegram.py b/tests/messaging/test_telegram.py new file mode 100644 index 0000000..c4baac6 --- /dev/null +++ b/tests/messaging/test_telegram.py @@ -0,0 +1,312 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from telegram.error import TelegramError + +from free_claude_code.messaging.platforms.telegram import TelegramRuntime + + +def _limiter_mock() -> MagicMock: + limiter = MagicMock() + limiter.start = MagicMock() + limiter.shutdown = AsyncMock() + return limiter + + +def _telegram_runtime( + *args, limiter=None, transcriber=None, **kwargs +) -> TelegramRuntime: + return TelegramRuntime( + *args, + limiter=limiter or _limiter_mock(), + transcriber=transcriber, + **kwargs, + ) + + +@pytest.fixture +def telegram_platform(): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(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 = _telegram_runtime(bot_token=None) + assert platform.bot_token is None + + +@pytest.mark.asyncio +async def test_telegram_platform_start_success(telegram_platform): + telegram_platform.outbound.send_message = AsyncMock() + 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 + + await telegram_platform.start() + + assert telegram_platform._connected is True + mock_app.initialize.assert_called_once() + mock_app.start.assert_called_once() + telegram_platform._limiter.start.assert_called_once_with() + telegram_platform.outbound.send_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_telegram_platform_start_with_proxy(): + limiter = _limiter_mock() + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime( + bot_token="test_token", + allowed_user_id="12345", + telegram_proxy_url="socks5://127.0.0.1:1080", + limiter=limiter, + ) + + with ( + patch("telegram.ext.Application.builder") as mock_builder, + patch( + "free_claude_code.messaging.platforms.telegram.HTTPXRequest" + ) as request_cls, + ): + mock_app = MagicMock() + mock_app.initialize = AsyncMock() + mock_app.start = AsyncMock() + mock_app.updater.start_polling = AsyncMock() + + builder = mock_builder.return_value + builder.token.return_value = builder + builder.request.return_value = builder + builder.get_updates_request.return_value = builder + builder.build.return_value = mock_app + request = MagicMock() + update_request = MagicMock() + request_cls.side_effect = [request, update_request] + + await platform.start() + + assert request_cls.call_count == 2 + request_cls.assert_any_call( + connection_pool_size=8, + connect_timeout=30.0, + read_timeout=30.0, + proxy="socks5://127.0.0.1:1080", + ) + builder.request.assert_called_once_with(request) + builder.get_updates_request.assert_called_once_with(update_request) + assert platform._connected is True + limiter.start.assert_called_once_with() + + +@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.outbound.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.outbound.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_delete_messages_uses_batch_api(telegram_platform): + mock_bot = AsyncMock() + telegram_platform._application = MagicMock() + telegram_platform._application.bot = mock_bot + + await telegram_platform.outbound.delete_messages("chat_1", ["1", "2", "bad"]) + + mock_bot.delete_messages.assert_awaited_once_with( + chat_id="chat_1", + message_ids=[1, 2], + ) + mock_bot.delete_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_telegram_platform_delete_messages_chunks_batch_api(telegram_platform): + mock_bot = AsyncMock() + telegram_platform._application = MagicMock() + telegram_platform._application.bot = mock_bot + + await telegram_platform.outbound.delete_messages( + "chat_1", + [str(i) for i in range(105)], + ) + + assert mock_bot.delete_messages.await_count == 2 + assert mock_bot.delete_messages.await_args_list[0].kwargs["message_ids"] == list( + range(100) + ) + assert mock_bot.delete_messages.await_args_list[1].kwargs["message_ids"] == list( + range(100, 105) + ) + + +@pytest.mark.asyncio +async def test_telegram_platform_delete_messages_falls_back_without_batch( + telegram_platform, +): + class BotWithoutBatch: + def __init__(self) -> None: + self.delete_message = AsyncMock() + + bot = BotWithoutBatch() + telegram_platform._application = MagicMock() + telegram_platform._application.bot = bot + + await telegram_platform.outbound.delete_messages("chat_1", ["1", "2"]) + + assert bot.delete_message.await_args_list[0].kwargs == { + "chat_id": "chat_1", + "message_id": 1, + } + assert bot.delete_message.await_args_list[1].kwargs == { + "chat_id": "chat_1", + "message_id": 2, + } + + +@pytest.mark.asyncio +async def test_telegram_platform_delete_messages_falls_back_after_batch_failure( + telegram_platform, +): + mock_bot = AsyncMock() + mock_bot.delete_messages.side_effect = RuntimeError("bulk failed") + telegram_platform._application = MagicMock() + telegram_platform._application.bot = mock_bot + + await telegram_platform.outbound.delete_messages("chat_1", ["1", "2"]) + + mock_bot.delete_messages.assert_awaited_once_with( + chat_id="chat_1", + message_ids=[1, 2], + ) + assert mock_bot.delete_message.await_args_list[0].kwargs == { + "chat_id": "chat_1", + "message_id": 1, + } + assert mock_bot.delete_message.await_args_list[1].kwargs == { + "chat_id": "chat_1", + "message_id": 2, + } + + +@pytest.mark.asyncio +async def test_telegram_platform_delete_messages_falls_back_after_swallowed_error( + telegram_platform, +): + mock_bot = AsyncMock() + mock_bot.delete_messages.side_effect = TelegramError("message can't be deleted") + telegram_platform._application = MagicMock() + telegram_platform._application.bot = mock_bot + + await telegram_platform.outbound.delete_messages("chat_1", ["1", "2"]) + + mock_bot.delete_messages.assert_awaited_once_with( + chat_id="chat_1", + message_ids=[1, 2], + ) + assert mock_bot.delete_message.await_args_list[0].kwargs == { + "chat_id": "chat_1", + "message_id": 1, + } + assert mock_bot.delete_message.await_args_list[1].kwargs == { + "chat_id": "chat_1", + "message_id": 2, + } + + +@pytest.mark.asyncio +async def test_telegram_platform_single_delete_still_swallows_known_error( + telegram_platform, +): + mock_bot = AsyncMock() + mock_bot.delete_message.side_effect = TelegramError("message can't be deleted") + telegram_platform._application = MagicMock() + telegram_platform._application.bot = mock_bot + + await telegram_platform.outbound.delete_message("chat_1", "1") + + mock_bot.delete_message.assert_awaited_once_with( + chat_id="chat_1", + message_id=1, + ) + + +@pytest.mark.asyncio +async def test_telegram_platform_queue_send_message(telegram_platform): + mock_limiter = telegram_platform._limiter + mock_limiter.enqueue = AsyncMock() + + await telegram_platform.outbound.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" + + +@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/tests/messaging/test_telegram_edge_cases.py b/tests/messaging/test_telegram_edge_cases.py new file mode 100644 index 0000000..2da9fe8 --- /dev/null +++ b/tests/messaging/test_telegram_edge_cases.py @@ -0,0 +1,539 @@ +import asyncio +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from telegram.error import NetworkError, RetryAfter, TelegramError + + +def _limiter_mock() -> MagicMock: + limiter = MagicMock() + limiter.start = MagicMock() + limiter.shutdown = AsyncMock() + return limiter + + +def _telegram_runtime(*args, limiter=None, transcriber=None, **kwargs): + from free_claude_code.messaging.platforms.telegram import TelegramRuntime + + return TelegramRuntime( + *args, + limiter=limiter or _limiter_mock(), + transcriber=transcriber, + **kwargs, + ) + + +def test_telegram_platform_init_raises_when_dependency_missing(): + with ( + patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", False + ), + pytest.raises(ImportError), + ): + _telegram_runtime(bot_token="x") + + +@pytest.mark.asyncio +async def test_telegram_platform_start_requires_token(): + with ( + patch.dict("os.environ", {}, clear=True), + patch("free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True), + ): + platform = _telegram_runtime(bot_token=None) + with pytest.raises(ValueError): + await platform.start() + + +@pytest.mark.asyncio +async def test_telegram_platform_quiesce_and_close_without_application(): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + platform._application = None + platform._connected = True + await platform.quiesce() + await platform.close() + assert platform.is_connected is False + platform._limiter.shutdown.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_telegram_close_cleans_up_partially_initialized_application(): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + platform._application = MagicMock() + platform._application.running = False + platform._application.updater.running = False + platform._application.updater.stop = AsyncMock() + platform._application.stop = AsyncMock() + platform._application.shutdown = AsyncMock() + platform.outbound.close = AsyncMock() + + await platform.quiesce() + await platform.close() + + platform._application.updater.stop.assert_not_awaited() + platform._application.stop.assert_not_awaited() + platform.outbound.close.assert_awaited_once_with() + platform._limiter.shutdown.assert_awaited_once_with() + platform._application.shutdown.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_telegram_two_phase_lifecycle_drains_before_delivery_close(): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + order: list[str] = [] + platform._application = MagicMock() + platform._application.running = True + platform._application.updater.running = True + platform._application.updater.stop = AsyncMock( + side_effect=lambda: order.append("updater.stop") + ) + platform._application.stop = AsyncMock( + side_effect=lambda: order.append("application.stop") + ) + platform.outbound.close = AsyncMock( + side_effect=lambda: order.append("outbound.close") + ) + platform._limiter.shutdown = AsyncMock( + side_effect=lambda: order.append("limiter.shutdown") + ) + platform._application.shutdown = AsyncMock( + side_effect=lambda: order.append("application.shutdown") + ) + + await platform.quiesce() + assert order == ["updater.stop", "application.stop"] + + await platform.close() + + assert order == [ + "updater.stop", + "application.stop", + "outbound.close", + "limiter.shutdown", + "application.shutdown", + ] + assert platform.is_connected is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "failing_step", + ["updater.stop", "application.stop"], +) +async def test_telegram_quiesce_attempts_all_steps_after_failure(failing_step): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + order: list[str] = [] + + async def record(step: str) -> None: + order.append(step) + if step == failing_step: + raise RuntimeError(step) + + def action(step: str): + async def run() -> None: + await record(step) + + return run + + platform._application = MagicMock() + platform._application.running = True + platform._application.updater.running = True + platform._application.updater.stop = AsyncMock( + side_effect=action("updater.stop") + ) + platform._application.stop = AsyncMock(side_effect=action("application.stop")) + platform.outbound.close = AsyncMock(side_effect=action("outbound.close")) + platform._limiter.shutdown = AsyncMock(side_effect=action("limiter.shutdown")) + platform._application.shutdown = AsyncMock( + side_effect=action("application.shutdown") + ) + + with pytest.raises(RuntimeError, match=failing_step): + await platform.quiesce() + + assert order == ["updater.stop", "application.stop"] + assert platform.is_connected is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "failing_step", + ["outbound.close", "limiter.shutdown", "application.shutdown"], +) +async def test_telegram_close_attempts_all_steps_after_failure(failing_step): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + order: list[str] = [] + + async def record(step: str) -> None: + order.append(step) + if step == failing_step: + raise RuntimeError(step) + + def action(step: str): + async def run() -> None: + await record(step) + + return run + + platform._application = MagicMock() + platform._application.shutdown = AsyncMock( + side_effect=action("application.shutdown") + ) + platform.outbound.close = AsyncMock(side_effect=action("outbound.close")) + platform._limiter.shutdown = AsyncMock(side_effect=action("limiter.shutdown")) + + with pytest.raises(RuntimeError, match=failing_step): + await platform.close() + + assert order == [ + "outbound.close", + "limiter.shutdown", + "application.shutdown", + ] + + +@pytest.mark.asyncio +async def test_with_retry_returns_none_when_message_not_modified_network_error(): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + + async def _f(): + raise NetworkError("Message is not modified") + + assert await platform.outbound._with_retry(_f) is None + + +@pytest.mark.asyncio +async def test_with_retry_retries_network_error_then_succeeds(monkeypatch): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(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.outbound._with_retry(_f) == "ok" + assert calls["n"] == 2 + + +@pytest.mark.asyncio +async def test_with_retry_honors_retry_after_timedelta(monkeypatch): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(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.outbound._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( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(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.outbound._with_retry(_f, parse_mode="MarkdownV2") == "ok" + assert calls == ["MarkdownV2", None] + + +@pytest.mark.asyncio +async def test_with_retry_can_raise_known_message_errors_for_bulk_fallback(): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + + async def _f(): + raise TelegramError("message can't be deleted") + + with pytest.raises(TelegramError): + await platform.outbound._with_retry( + _f, + suppress_known_message_errors=False, + ) + + +@pytest.mark.asyncio +async def test_queue_send_message_uses_required_limiter(): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(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) + + async def enqueue(operation, dedup_key=None): + return await operation() + + platform._limiter.enqueue = AsyncMock(side_effect=enqueue) + assert ( + await platform.outbound.queue_send_message("c", "t", fire_and_forget=False) + == "1" + ) + platform._limiter.enqueue.assert_awaited_once() + platform._application.bot.send_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_queue_edit_message_uses_required_limiter(): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + platform._application = MagicMock() + platform._application.bot = AsyncMock() + platform._application.bot.edit_message_text = AsyncMock() + + async def enqueue(operation, dedup_key=None): + return await operation() + + platform._limiter.enqueue = AsyncMock(side_effect=enqueue) + await platform.outbound.queue_edit_message("c", "1", "t", fire_and_forget=False) + platform._limiter.enqueue.assert_awaited_once() + platform._application.bot.edit_message_text.assert_awaited_once() + + +def test_fire_and_forget_non_coroutine_uses_ensure_future(monkeypatch): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + + ef = MagicMock() + monkeypatch.setattr(asyncio, "ensure_future", ef) + + platform.outbound.fire_and_forget(MagicMock()) + ef.assert_called_once() + + +@pytest.mark.asyncio +async def test_on_start_command_replies_and_forwards(): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(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( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t", allowed_user_id="123") + with patch.object( + platform.outbound, "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( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="token", allowed_user_id=None) + + monkeypatch.setattr(asyncio, "sleep", AsyncMock()) + + with patch("telegram.ext.Application.builder") as mock_builder: + 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 + assert mock_app.initialize.await_count == 2 + mock_app.start.assert_awaited_once_with() + platform._limiter.start.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_telegram_polling_retry_does_not_restart_running_application( + monkeypatch, +): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="token", allowed_user_id=None) + + monkeypatch.setattr(asyncio, "sleep", AsyncMock()) + + 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( + side_effect=[NetworkError("temporary polling failure"), 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 + mock_app.initialize.assert_awaited_once_with() + mock_app.start.assert_awaited_once_with() + assert mock_app.updater.start_polling.await_count == 2 + platform._limiter.start.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_edit_message_with_text_exceeding_4096_raises(): + """edit_message with text > 4096 raises TelegramError (BadRequest).""" + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(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.outbound.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( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + platform._application = MagicMock() + platform._application.bot = AsyncMock() + platform._application.bot.edit_message_text = AsyncMock() + + await platform.outbound.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( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(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.outbound.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( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(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( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + + async def _f(): + raise TelegramError("message to edit not found") + + result = await platform.outbound._with_retry(_f) + assert result is None diff --git a/tests/messaging/test_transcript.py b/tests/messaging/test_transcript.py new file mode 100644 index 0000000..632ef29 --- /dev/null +++ b/tests/messaging/test_transcript.py @@ -0,0 +1,361 @@ +from free_claude_code.messaging.rendering.telegram_markdown import ( + escape_md_v2, + escape_md_v2_code, + mdv2_bold, + mdv2_code_inline, + render_markdown_to_mdv2, +) +from free_claude_code.messaging.transcript import RenderCtx, TranscriptBuffer +from free_claude_code.messaging.transcript.renderer import render_segments +from free_claude_code.messaging.transcript.segments import Segment, SubagentSegment +from free_claude_code.messaging.transcript.subagents import SubagentState + + +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_can_hide_tool_results(): + t = TranscriptBuffer(show_tool_results=False) + t.apply({"type": "tool_use", "id": "tool_1", "name": "ls", "input": {"path": "."}}) + t.apply({"type": "tool_result", "tool_use_id": "tool_1", "content": "secret"}) + + out = t.render(_ctx(), limit_chars=3900, status=None) + assert "Tool call:" in out + assert "Tool result:" not in out + assert "secret" not in out + + +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(): + state = SubagentState() + state.push("task_1", SubagentSegment("Outer")) + + assert not state.close_for_tool_result("totally_unrelated", tool_name=None) + assert state.open_ids == ("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"}, + } + ) + t.apply({"type": "text_chunk", "text": "still hidden inside task three"}) + + 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 "still hidden inside task three" not in out + + +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"}) + t.apply({"type": "text_chunk", "text": "hidden while synthetic task is open"}) + + out = t.render(_ctx(), limit_chars=3900, status=None) + assert "hidden while synthetic task is open" not in out + + +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": "alpha visible"}) + # 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": {}} + ) + t.apply({"type": "text_delta", "index": 0, "text": "omega visible"}) + + out = t.render(_ctx(), limit_chars=3900, status=None) + assert out.find("alpha") < out.find("Tool call:") < out.find("omega") + + +def test_transcript_render_segment_exception_skipped(): + """When a segment's render() raises, that segment is skipped and rest is rendered.""" + + class StaticSegment(Segment): + def __init__(self, text: str) -> None: + super().__init__(kind="static") + self._text = text + + def render(self, ctx: RenderCtx) -> str: + return self._text + + class BrokenSegment(Segment): + def __init__(self) -> None: + super().__init__(kind="broken") + + def render(self, ctx: RenderCtx) -> str: + raise ValueError("render failed") + + out = render_segments( + [StaticSegment("before"), BrokenSegment(), StaticSegment("after")], + _ctx(), + limit_chars=3900, + status=None, + ) + assert "before" in out + assert "after" 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/tests/messaging/test_transcription.py b/tests/messaging/test_transcription.py new file mode 100644 index 0000000..2b8ebf3 --- /dev/null +++ b/tests/messaging/test_transcription.py @@ -0,0 +1,262 @@ +"""Tests for the instance-owned local Whisper transcriber.""" + +import asyncio +import threading +import time +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from free_claude_code.messaging.transcription import TranscriptionService + + +def _service(*, api_key: str = "") -> TranscriptionService: + return TranscriptionService( + model="base", + device="cpu", + huggingface_api_key=api_key, + ) + + +def _fake_optional_modules( + pipeline: MagicMock, +) -> tuple[SimpleNamespace, SimpleNamespace, MagicMock, MagicMock]: + torch = SimpleNamespace( + cuda=SimpleNamespace(is_available=lambda: False), + float16=object(), + float32=object(), + ) + + model = MagicMock() + model.to.return_value = model + model_loader = MagicMock() + model_loader.from_pretrained.return_value = model + processor = SimpleNamespace(tokenizer=object(), feature_extractor=object()) + processor_loader = MagicMock() + processor_loader.from_pretrained.return_value = processor + + transformers = SimpleNamespace( + AutoModelForSpeechSeq2Seq=model_loader, + AutoProcessor=processor_loader, + pipeline=MagicMock(return_value=pipeline), + ) + return torch, transformers, model_loader, processor_loader + + +@pytest.mark.asyncio +async def test_transcription_service_transcribes_and_reuses_its_pipeline( + tmp_path: Path, +) -> None: + audio_path = tmp_path / "voice.ogg" + audio_path.write_bytes(b"voice") + pipeline = MagicMock(return_value={"text": " Hello world "}) + torch, transformers, model_loader, processor_loader = _fake_optional_modules( + pipeline + ) + fake_audio = {"array": [0.0], "sampling_rate": 16000} + service = _service(api_key="hf-provider-key") + + with ( + patch.dict( + "sys.modules", + {"torch": torch, "transformers": transformers}, + ), + patch( + "free_claude_code.messaging.transcription._load_audio", + return_value=fake_audio, + ), + ): + first = await service.transcribe(audio_path) + second = await service.transcribe(audio_path) + + assert first == "Hello world" + assert second == "Hello world" + assert transformers.pipeline.call_count == 1 + assert pipeline.call_count == 2 + model_loader.from_pretrained.assert_called_once_with( + "openai/whisper-base", + dtype=torch.float32, + low_cpu_mem_usage=True, + attn_implementation="sdpa", + token="hf-provider-key", + ) + processor_loader.from_pretrained.assert_called_once_with( + "openai/whisper-base", + token="hf-provider-key", + ) + + +@pytest.mark.asyncio +async def test_separate_services_do_not_share_pipeline_instances( + tmp_path: Path, +) -> None: + audio_path = tmp_path / "voice.wav" + audio_path.write_bytes(b"voice") + pipeline = MagicMock(return_value={"text": "ok"}) + torch, transformers, _model_loader, _processor_loader = _fake_optional_modules( + pipeline + ) + first = _service() + second = _service() + + with ( + patch.dict( + "sys.modules", + {"torch": torch, "transformers": transformers}, + ), + patch( + "free_claude_code.messaging.transcription._load_audio", + return_value={"array": [0.0], "sampling_rate": 16000}, + ), + ): + await first.transcribe(audio_path) + await second.transcribe(audio_path) + + assert transformers.pipeline.call_count == 2 + + +@pytest.mark.asyncio +async def test_transcription_service_serializes_concurrent_inference( + tmp_path: Path, +) -> None: + service = _service() + audio_path = tmp_path / "voice.wav" + audio_path.write_bytes(b"voice") + state_lock = threading.Lock() + active = 0 + max_active = 0 + + def transcribe_sync(_path: Path) -> str: + nonlocal active, max_active + with state_lock: + active += 1 + max_active = max(max_active, active) + time.sleep(0.02) + with state_lock: + active -= 1 + return "ok" + + with patch.object(service, "_transcribe_sync", side_effect=transcribe_sync): + results = await asyncio.gather( + service.transcribe(audio_path), + service.transcribe(audio_path), + service.transcribe(audio_path), + ) + + assert results == ["ok", "ok", "ok"] + assert max_active == 1 + + +@pytest.mark.asyncio +async def test_transcription_service_close_releases_pipeline_and_is_terminal( + tmp_path: Path, +) -> None: + service = _service() + audio_path = tmp_path / "voice.wav" + audio_path.write_bytes(b"voice") + pipeline = MagicMock(return_value={"text": "ok"}) + + with ( + patch.object(service, "_get_pipeline", return_value=pipeline), + patch( + "free_claude_code.messaging.transcription._load_audio", + return_value={"array": [0.0], "sampling_rate": 16000}, + ), + ): + await service.transcribe(audio_path) + + service._pipeline = pipeline + await service.close() + await service.close() + + assert service._pipeline is None + with pytest.raises(RuntimeError, match="closed"): + await service.transcribe(audio_path) + + +@pytest.mark.asyncio +async def test_cancelled_transcription_keeps_ownership_until_thread_exits( + tmp_path: Path, +) -> None: + service = _service(api_key="hf-secret") + audio_path = tmp_path / "voice.wav" + audio_path.write_bytes(b"voice") + started = threading.Event() + release = threading.Event() + pipeline = object() + + def blocking_transcribe(_path: Path) -> str: + started.set() + if not release.wait(timeout=5): + raise TimeoutError("test did not release active transcription") + service._pipeline = pipeline + return "finished" + + close_task: asyncio.Task[None] | None = None + with patch.object(service, "_transcribe_sync", side_effect=blocking_transcribe): + transcribe_task = asyncio.create_task(service.transcribe(audio_path)) + try: + assert await asyncio.to_thread(started.wait, 2) + transcribe_task.cancel() + await asyncio.sleep(0) + close_task = asyncio.create_task(service.close()) + await asyncio.sleep(0) + + assert not transcribe_task.done() + assert not close_task.done() + assert service._huggingface_api_key == "hf-secret" + finally: + release.set() + + with pytest.raises(asyncio.CancelledError): + await transcribe_task + assert close_task is not None + await close_task + + assert service._pipeline is None + assert service._huggingface_api_key == "" + with pytest.raises(RuntimeError, match="closed"): + await service.transcribe(audio_path) + + +@pytest.mark.asyncio +async def test_transcription_service_returns_no_speech_placeholder( + tmp_path: Path, +) -> None: + service = _service() + audio_path = tmp_path / "voice.ogg" + audio_path.write_bytes(b"voice") + pipeline = MagicMock(return_value={"text": []}) + + with ( + patch.object(service, "_get_pipeline", return_value=pipeline), + patch( + "free_claude_code.messaging.transcription._load_audio", + return_value={"array": [0.0], "sampling_rate": 16000}, + ), + ): + result = await service.transcribe(audio_path) + + assert result == "(no speech detected)" + + +def test_transcription_service_rejects_non_local_device() -> None: + with pytest.raises(ValueError, match="must be 'cpu' or 'cuda'"): + TranscriptionService(model="base", device="nvidia_nim") + + +@pytest.mark.asyncio +async def test_transcription_service_reports_missing_local_extra( + tmp_path: Path, +) -> None: + service = _service() + audio_path = tmp_path / "voice.ogg" + audio_path.write_bytes(b"voice") + + with ( + patch.dict("sys.modules", {"torch": None}), + pytest.raises(ImportError, match="voice_local extra"), + ): + await service.transcribe(audio_path) diff --git a/tests/messaging/test_transcription_nim.py b/tests/messaging/test_transcription_nim.py new file mode 100644 index 0000000..39e77ea --- /dev/null +++ b/tests/messaging/test_transcription_nim.py @@ -0,0 +1,244 @@ +"""Tests for the NVIDIA NIM voice transcription adapter.""" + +import asyncio +from pathlib import Path +from threading import Event +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from free_claude_code.providers.nvidia_nim.voice import ( + _NIM_ASR_MODEL_MAP, + NvidiaNimTranscriber, +) + + +def _fake_riva_client( + transcript: str, +) -> tuple[SimpleNamespace, SimpleNamespace, MagicMock, MagicMock]: + response = SimpleNamespace( + results=[ + SimpleNamespace( + alternatives=[SimpleNamespace(transcript=transcript)], + ) + ] + ) + asr_service = MagicMock() + asr_service.offline_recognize.return_value = response + auth = MagicMock() + + client = SimpleNamespace( + Auth=MagicMock(return_value=auth), + ASRService=MagicMock(return_value=asr_service), + RecognitionConfig=MagicMock(return_value=object()), + ) + riva = SimpleNamespace(__path__=[], client=client) + return riva, client, asr_service, auth + + +@pytest.mark.asyncio +async def test_nvidia_nim_transcriber_calls_riva_with_owned_configuration( + tmp_path: Path, +) -> None: + wav = tmp_path / "stub.wav" + wav.write_bytes(b"audio bytes") + transcriber = NvidiaNimTranscriber( + model="openai/whisper-large-v3", + api_key=" test-nim-key ", + ) + riva, client, asr_service, auth = _fake_riva_client(" hello from NIM ") + + with patch.dict( + "sys.modules", + {"riva": riva, "riva.client": client}, + ): + result = await transcriber.transcribe(wav) + + assert result == " hello from NIM " + client.Auth.assert_called_once_with( + use_ssl=True, + uri="grpc.nvcf.nvidia.com:443", + metadata_args=[ + ["function-id", "b702f636-f60c-4a3d-a6f4-f3568c13bd7d"], + ["authorization", "Bearer test-nim-key"], + ], + ) + client.RecognitionConfig.assert_called_once_with( + language_code="multi", + max_alternatives=1, + verbatim_transcripts=True, + ) + asr_service.offline_recognize.assert_called_once_with( + b"audio bytes", + client.RecognitionConfig.return_value, + ) + auth.channel.close.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_nvidia_nim_transcriber_closes_channel_when_recognition_fails( + tmp_path: Path, +) -> None: + wav = tmp_path / "stub.wav" + wav.write_bytes(b"audio bytes") + transcriber = NvidiaNimTranscriber( + model="openai/whisper-large-v3", + api_key="test-nim-key", + ) + riva, client, asr_service, auth = _fake_riva_client("") + asr_service.offline_recognize.side_effect = RuntimeError("recognition failed") + + with ( + patch.dict( + "sys.modules", + {"riva": riva, "riva.client": client}, + ), + pytest.raises(RuntimeError, match="recognition failed"), + ): + await transcriber.transcribe(wav) + + auth.channel.close.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_nvidia_nim_transcriber_validates_key_and_model_before_import( + tmp_path: Path, +) -> None: + wav = tmp_path / "stub.wav" + wav.write_bytes(b"audio") + + with pytest.raises(ValueError, match="non-empty"): + await NvidiaNimTranscriber( + model="openai/whisper-large-v3", + api_key="", + ).transcribe(wav) + + with pytest.raises(ValueError, match="No NVIDIA NIM config"): + await NvidiaNimTranscriber( + model="unknown/model", + api_key="key", + ).transcribe(wav) + + +@pytest.mark.asyncio +async def test_nvidia_nim_transcriber_close_is_terminal(tmp_path: Path) -> None: + wav = tmp_path / "stub.wav" + wav.write_bytes(b"audio") + transcriber = NvidiaNimTranscriber( + model="openai/whisper-large-v3", + api_key="key", + ) + + await transcriber.close() + await transcriber.close() + + with pytest.raises(RuntimeError, match="closed"): + await transcriber.transcribe(wav) + + +@pytest.mark.asyncio +async def test_nvidia_nim_transcriber_close_waits_for_active_work( + tmp_path: Path, +) -> None: + wav = tmp_path / "stub.wav" + wav.write_bytes(b"audio") + transcriber = NvidiaNimTranscriber( + model="openai/whisper-large-v3", + api_key="secret-key", + ) + started = Event() + release = Event() + + def blocking_transcribe(_file_path: Path) -> str: + started.set() + if not release.wait(timeout=5): + raise TimeoutError("test did not release active transcription") + return "finished" + + close_task: asyncio.Task[None] | None = None + with patch.object( + transcriber, + "_transcribe_sync", + side_effect=blocking_transcribe, + ): + transcribe_task = asyncio.create_task(transcriber.transcribe(wav)) + try: + assert await asyncio.to_thread(started.wait, 2) + close_task = asyncio.create_task(transcriber.close()) + await asyncio.sleep(0) + + assert not close_task.done() + finally: + release.set() + transcript = await transcribe_task + if close_task is not None: + await close_task + + assert transcript == "finished" + assert transcriber._key == "" + with pytest.raises(RuntimeError, match="closed"): + await transcriber.transcribe(wav) + + +@pytest.mark.asyncio +async def test_cancelled_nim_transcription_keeps_key_until_thread_exits( + tmp_path: Path, +) -> None: + wav = tmp_path / "stub.wav" + wav.write_bytes(b"audio") + transcriber = NvidiaNimTranscriber( + model="openai/whisper-large-v3", + api_key="secret-key", + ) + started = Event() + release = Event() + observed_keys: list[str] = [] + + def blocking_transcribe(_file_path: Path) -> str: + observed_keys.append(transcriber._key) + started.set() + if not release.wait(timeout=5): + raise TimeoutError("test did not release active transcription") + observed_keys.append(transcriber._key) + return "finished" + + close_task: asyncio.Task[None] | None = None + with patch.object( + transcriber, + "_transcribe_sync", + side_effect=blocking_transcribe, + ): + transcribe_task = asyncio.create_task(transcriber.transcribe(wav)) + try: + assert await asyncio.to_thread(started.wait, 2) + transcribe_task.cancel() + await asyncio.sleep(0) + close_task = asyncio.create_task(transcriber.close()) + await asyncio.sleep(0) + + assert not transcribe_task.done() + assert not close_task.done() + assert transcriber._key == "secret-key" + finally: + release.set() + + with pytest.raises(asyncio.CancelledError): + await transcribe_task + assert close_task is not None + await close_task + + assert observed_keys == ["secret-key", "secret-key"] + assert transcriber._key == "" + with pytest.raises(RuntimeError, match="closed"): + await transcriber.transcribe(wav) + + +def test_nim_asr_model_map_entries_are_real_function_ids() -> None: + for function_id, language_code in _NIM_ASR_MODEL_MAP.values(): + assert function_id + assert function_id.strip().lower() != "none" + parts = function_id.split("-") + assert len(parts) == 5 + assert all(parts) + assert language_code is not None diff --git a/tests/messaging/test_tree_concurrency.py b/tests/messaging/test_tree_concurrency.py new file mode 100644 index 0000000..bd0e951 --- /dev/null +++ b/tests/messaging/test_tree_concurrency.py @@ -0,0 +1,308 @@ +"""Deterministic manager concurrency contracts.""" + +import asyncio + +import pytest + +from free_claude_code.messaging.models import IncomingMessage, MessageScope +from free_claude_code.messaging.trees import ( + CancellationReason, + CancellationUiOwner, + NodeClaim, + QueueEntry, + TreeQueueManager, +) + +_SCOPE = MessageScope(platform="telegram", chat_id="chat") + + +def _incoming(node_id: str, *, reply_to: str | None = None) -> IncomingMessage: + return IncomingMessage( + text=f"prompt {node_id}", + chat_id=_SCOPE.chat_id, + user_id="user", + message_id=node_id, + platform=_SCOPE.platform, + reply_to_message_id=reply_to, + ) + + +async def _wait_for_no_tasks(manager: TreeQueueManager) -> None: + loop = asyncio.get_running_loop() + for _ in range(30): + if manager.task_count() == 0: + return + checkpoint = asyncio.Event() + loop.call_soon(checkpoint.set) + await checkpoint.wait() + assert manager.task_count() == 0 + + +@pytest.mark.asyncio +async def test_one_tree_processes_fifo_with_transition_owned_queue_updates() -> None: + node_ids = ("root", "a", "b", "c") + releases = {node_id: asyncio.Event() for node_id in node_ids} + completions = {node_id: asyncio.Event() for node_id in node_ids} + started: asyncio.Queue[str] = asyncio.Queue() + started_callbacks: list[str] = [] + queue_updates: list[tuple[tuple[str, int], ...]] = [] + manager: TreeQueueManager + + async def process(claim: NodeClaim) -> None: + node_id = claim.node.node_id + started.put_nowait(node_id) + await releases[node_id].wait() + await manager.complete_claim(claim, f"session-{node_id}") + completions[node_id].set() + + async def capture_started(claim: NodeClaim) -> None: + started_callbacks.append(claim.node.node_id) + + async def capture_queue(queue: tuple[QueueEntry, ...]) -> None: + queue_updates.append( + tuple((entry.node.node_id, entry.position) for entry in queue) + ) + + manager = TreeQueueManager( + process, + queue_update_callback=capture_queue, + node_started_callback=capture_started, + ) + root = await manager.admit(_incoming("root"), "status-root") + assert root.claim is not None + identity = root.claim.identity + assert await started.get() == "root" + + decisions = [ + await manager.admit( + _incoming(node_id, reply_to="root"), + f"status-{node_id}", + parent_reference_id="root", + ) + for node_id in node_ids[1:] + ] + assert [decision.position for decision in decisions] == [1, 2, 3] + + observed = ["root"] + for node_id in node_ids: + releases[node_id].set() + await completions[node_id].wait() + if node_id != node_ids[-1]: + observed.append(await started.get()) + + await _wait_for_no_tasks(manager) + + assert observed == list(node_ids) + assert started_callbacks == ["a", "b", "c"] + assert queue_updates == [ + (("b", 1), ("c", 2)), + (("c", 1),), + (), + ] + snapshot = await manager.snapshot() + assert { + node_id: snapshot.trees[identity].nodes[node_id]["state"] + for node_id in node_ids + } == dict.fromkeys(node_ids, "completed") + + +@pytest.mark.asyncio +async def test_separate_trees_process_in_parallel() -> None: + started = {node_id: asyncio.Event() for node_id in ("one", "two")} + releases = {node_id: asyncio.Event() for node_id in ("one", "two")} + completed = {node_id: asyncio.Event() for node_id in ("one", "two")} + all_started = asyncio.Event() + active = 0 + maximum_active = 0 + manager: TreeQueueManager + + async def process(claim: NodeClaim) -> None: + nonlocal active, maximum_active + node_id = claim.node.node_id + active += 1 + maximum_active = max(maximum_active, active) + started[node_id].set() + if all(event.is_set() for event in started.values()): + all_started.set() + try: + await releases[node_id].wait() + await manager.complete_claim(claim, f"session-{node_id}") + finally: + active -= 1 + completed[node_id].set() + + manager = TreeQueueManager(process) + await asyncio.gather( + manager.admit(_incoming("one"), "status-one"), + manager.admit(_incoming("two"), "status-two"), + ) + await all_started.wait() + + assert maximum_active == 2 + assert active == 2 + assert manager.get_tree_count() == 2 + + releases["one"].set() + releases["two"].set() + await asyncio.gather(*(event.wait() for event in completed.values())) + await _wait_for_no_tasks(manager) + + +@pytest.mark.asyncio +async def test_cancel_all_cancels_active_and_queued_work_across_trees() -> None: + active_started = {node_id: asyncio.Event() for node_id in ("one", "two")} + processed: list[str] = [] + + async def process(claim: NodeClaim) -> None: + node_id = claim.node.node_id + processed.append(node_id) + if node_id in active_started: + active_started[node_id].set() + await asyncio.Event().wait() + + manager = TreeQueueManager(process) + await manager.admit(_incoming("one"), "status-one") + await manager.admit(_incoming("two"), "status-two") + await asyncio.gather(*(event.wait() for event in active_started.values())) + await manager.admit( + _incoming("one-child", reply_to="one"), + "status-one-child", + parent_reference_id="one", + ) + await manager.admit( + _incoming("two-child", reply_to="two"), + "status-two-child", + parent_reference_id="two", + ) + + result = await manager.cancel_all(reason=CancellationReason.STOP) + + owners = {effect.node.node_id: effect.ui_owner for effect in result.effects} + assert owners == { + "one": CancellationUiOwner.RUNNER, + "one-child": CancellationUiOwner.WORKFLOW, + "two": CancellationUiOwner.RUNNER, + "two-child": CancellationUiOwner.WORKFLOW, + } + assert len(result.snapshots) == 2 + assert { + node["state"] + for snapshot in result.snapshots + for node in snapshot.nodes.values() + } == {"error"} + assert set(processed) == {"one", "two"} + assert manager.task_count() == 0 + + +@pytest.mark.asyncio +async def test_branch_removal_atomically_unindexes_subtree_and_preserves_sibling() -> ( + None +): + root_started = asyncio.Event() + release_root = asyncio.Event() + sibling_started = asyncio.Event() + release_sibling = asyncio.Event() + unexpected: list[str] = [] + + async def process(claim: NodeClaim) -> None: + node_id = claim.node.node_id + if node_id == "root": + root_started.set() + await release_root.wait() + elif node_id == "sibling": + sibling_started.set() + await release_sibling.wait() + else: + unexpected.append(node_id) + + manager = TreeQueueManager(process) + await manager.admit(_incoming("root"), "status-root") + await root_started.wait() + await manager.admit( + _incoming("branch", reply_to="root"), + "status-branch", + parent_reference_id="root", + ) + await manager.admit( + _incoming("leaf", reply_to="branch"), + "status-leaf", + parent_reference_id="branch", + ) + await manager.admit( + _incoming("sibling", reply_to="root"), + "status-sibling", + parent_reference_id="root", + ) + + result = await manager.remove_message_subtree( + _SCOPE, + "branch", + reason=CancellationReason.STOP, + ) + + assert result.removed_tree_identity is None + assert result.delete_message_ids == frozenset( + {"branch", "status-branch", "leaf", "status-leaf"} + ) + assert { + effect.node.node_id: effect.ui_owner for effect in result.cancellation.effects + } == { + "branch": CancellationUiOwner.WORKFLOW, + "leaf": CancellationUiOwner.WORKFLOW, + } + assert len(result.cancellation.snapshots) == 1 + assert set(result.cancellation.snapshots[0].nodes) == {"root", "sibling"} + assert await manager.resolve_node_id(_SCOPE, "branch") is None + assert await manager.resolve_node_id(_SCOPE, "status-leaf") is None + assert await manager.resolve_node_id(_SCOPE, "sibling") == "sibling" + + release_root.set() + await asyncio.wait_for(sibling_started.wait(), timeout=1) + assert unexpected == [] + release_sibling.set() + await _wait_for_no_tasks(manager) + + +@pytest.mark.asyncio +async def test_root_removal_atomically_cancels_and_unindexes_entire_tree() -> None: + root_started = asyncio.Event() + processed: list[str] = [] + + async def process(claim: NodeClaim) -> None: + processed.append(claim.node.node_id) + root_started.set() + await asyncio.Event().wait() + + manager = TreeQueueManager(process) + await manager.admit(_incoming("root"), "status-root") + await root_started.wait() + await manager.admit( + _incoming("child", reply_to="root"), + "status-child", + parent_reference_id="root", + ) + + result = await manager.remove_message_subtree( + _SCOPE, + "root", + reason=CancellationReason.STOP, + ) + + assert result.removed_tree_identity is not None + assert result.removed_tree_identity.scope == _SCOPE + assert result.removed_tree_identity.root_id == "root" + assert result.delete_message_ids == frozenset( + {"root", "status-root", "child", "status-child"} + ) + assert { + effect.node.node_id: effect.ui_owner for effect in result.cancellation.effects + } == { + "root": CancellationUiOwner.RUNNER, + "child": CancellationUiOwner.WORKFLOW, + } + assert result.cancellation.snapshots == () + assert manager.get_tree_count() == 0 + assert manager.task_count() == 0 + assert await manager.resolve_node_id(_SCOPE, "root") is None + assert await manager.resolve_node_id(_SCOPE, "status-child") is None + assert processed == ["root"] diff --git a/tests/messaging/test_tree_customer_regressions.py b/tests/messaging/test_tree_customer_regressions.py new file mode 100644 index 0000000..45d7e66 --- /dev/null +++ b/tests/messaging/test_tree_customer_regressions.py @@ -0,0 +1,113 @@ +"""Customer-visible regressions at the tree manager ownership boundary.""" + +import asyncio + +import pytest + +from free_claude_code.messaging.models import IncomingMessage, MessageScope +from free_claude_code.messaging.trees import ( + ConversationSnapshot, + MessageState, + NodeClaim, + TreeIdentity, + TreeQueueManager, +) + + +def _incoming(chat_id: str) -> IncomingMessage: + return IncomingMessage( + text=f"prompt from {chat_id}", + chat_id=chat_id, + user_id=f"user-{chat_id}", + message_id="42", + platform="telegram", + ) + + +async def _wait_for_no_tasks(manager: TreeQueueManager) -> None: + for _ in range(100): + if manager.task_count() == 0: + return + await asyncio.sleep(0) + raise AssertionError("messaging claims did not finish") + + +def _snapshot_chat_ids(snapshot: ConversationSnapshot) -> set[str]: + return {tree.scope.chat_id for tree in snapshot.trees.values()} + + +@pytest.mark.asyncio +async def test_same_telegram_ids_in_different_chats_remain_independent_after_restore() -> ( + None +): + async def process(_claim: NodeClaim) -> None: + return + + manager = TreeQueueManager(process) + + first = await manager.admit(_incoming("chat-a"), "99") + second = await manager.admit(_incoming("chat-b"), "99") + await _wait_for_no_tasks(manager) + + assert first.accepted is True + assert second.accepted is True + assert manager.get_tree_count() == 2 + + snapshot = await manager.snapshot() + assert len(snapshot.trees) == 2 + assert _snapshot_chat_ids(snapshot) == {"chat-a", "chat-b"} + chat_a = MessageScope(platform="telegram", chat_id="chat-a") + chat_b = MessageScope(platform="telegram", chat_id="chat-b") + assert snapshot.get_tree(TreeIdentity(scope=chat_a, root_id="42")) is not None + assert snapshot.get_tree(TreeIdentity(scope=chat_b, root_id="42")) is not None + + restored = TreeQueueManager.from_snapshot(snapshot, process) + + assert restored.get_tree_count() == 2 + assert _snapshot_chat_ids(await restored.snapshot()) == {"chat-a", "chat-b"} + assert await restored.get_message_ids_for_chat("telegram", "chat-a") == {"42", "99"} + assert await restored.get_message_ids_for_chat("telegram", "chat-b") == {"42", "99"} + assert await restored.get_node(chat_a, "42") is not None + assert await restored.get_node(chat_b, "42") is not None + + +@pytest.mark.asyncio +async def test_successful_completion_overrides_recoverable_failure_and_records_session() -> ( + None +): + started = asyncio.Event() + release = asyncio.Event() + + async def process(_claim: NodeClaim) -> None: + started.set() + await release.wait() + + manager = TreeQueueManager(process) + decision = await manager.admit(_incoming("chat"), "99") + assert decision.claim is not None + await started.wait() + + try: + failure = await manager.fail_claim( + decision.claim, + propagate=True, + ) + assert failure.snapshot is not None + failed_node = next(iter(failure.snapshot.nodes.values())) + assert failed_node["state"] == MessageState.ERROR.value + + completed = await manager.complete_claim(decision.claim, "session-42") + + assert completed is not None + completed_node = next(iter(completed.nodes.values())) + assert completed_node["state"] == MessageState.COMPLETED.value + assert completed_node["session_id"] == "session-42" + + persisted = await manager.snapshot() + persisted_node = next(iter(persisted.trees.values())).nodes.values() + persisted_node = next(iter(persisted_node)) + assert persisted_node["state"] == MessageState.COMPLETED.value + assert persisted_node["session_id"] == "session-42" + finally: + release.set() + await _wait_for_no_tasks(manager) diff --git a/tests/messaging/test_tree_ownership_concurrency.py b/tests/messaging/test_tree_ownership_concurrency.py new file mode 100644 index 0000000..e324c9a --- /dev/null +++ b/tests/messaging/test_tree_ownership_concurrency.py @@ -0,0 +1,610 @@ +import asyncio +import contextlib + +import pytest + +from free_claude_code.messaging.models import IncomingMessage, MessageScope +from free_claude_code.messaging.trees import manager as manager_module +from free_claude_code.messaging.trees.manager import TreeQueueManager +from free_claude_code.messaging.trees.transitions import ( + AdmissionRejection, + CancellationReason, + CancellationUiOwner, + NodeClaim, +) + +_SCOPE = MessageScope(platform="telegram", chat_id="chat") + + +def _incoming(node_id: str, *, reply_to: str | None = None) -> IncomingMessage: + return IncomingMessage( + text=node_id, + chat_id=_SCOPE.chat_id, + user_id="user", + message_id=node_id, + platform=_SCOPE.platform, + reply_to_message_id=reply_to, + ) + + +async def _wait_for_no_tasks(manager: TreeQueueManager) -> None: + loop = asyncio.get_running_loop() + for _ in range(20): + if manager.task_count() == 0: + return + checkpoint = asyncio.Event() + loop.call_soon(checkpoint.set) + await checkpoint.wait() + assert manager.task_count() == 0 + + +@pytest.mark.asyncio +async def test_cancelled_finisher_cannot_erase_or_overlap_a_new_claim() -> None: + root_started = asyncio.Event() + cancellation_seen = asyncio.Event() + release_cleanup = asyncio.Event() + child_started = asyncio.Event() + release_child = asyncio.Event() + active = 0 + maximum_active = 0 + + async def process(claim: NodeClaim) -> None: + nonlocal active, maximum_active + active += 1 + maximum_active = max(maximum_active, active) + try: + if claim.node.node_id == "root": + root_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cancellation_seen.set() + await release_cleanup.wait() + raise + child_started.set() + await release_child.wait() + finally: + active -= 1 + + manager = TreeQueueManager(process) + await manager.admit(_incoming("root"), "status-root") + await root_started.wait() + + cancellation = asyncio.create_task( + manager.cancel_node(_SCOPE, "root", reason=CancellationReason.STOP) + ) + await cancellation_seen.wait() + child = await manager.admit( + _incoming("child", reply_to="root"), + "status-child", + parent_reference_id="root", + ) + + assert child.position == 1 + assert not child_started.is_set() + assert maximum_active == 1 + + release_cleanup.set() + await cancellation + await asyncio.wait_for(child_started.wait(), timeout=1) + assert maximum_active == 1 + + release_child.set() + + +@pytest.mark.asyncio +async def test_cancelled_runner_exception_cannot_fail_or_skip_queued_claim() -> None: + root_started = asyncio.Event() + child_started = asyncio.Event() + release_child = asyncio.Event() + unexpected_failures = [] + + async def process(claim: NodeClaim) -> None: + if claim.node.node_id == "root": + root_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + raise RuntimeError("runner escaped after cancellation") from None + child_started.set() + await release_child.wait() + + manager = TreeQueueManager( + process, + unexpected_failure_callback=unexpected_failures.append, + ) + await manager.admit(_incoming("root"), "status-root") + await root_started.wait() + await manager.admit( + _incoming("child", reply_to="root"), + "status-child", + parent_reference_id="root", + ) + + try: + await manager.cancel_node(_SCOPE, "root", reason=CancellationReason.STOP) + await asyncio.wait_for(child_started.wait(), timeout=1) + + assert len(unexpected_failures) == 1 + assert unexpected_failures[0].affected == () + assert unexpected_failures[0].queue_update is None + assert unexpected_failures[0].snapshot is None + finally: + release_child.set() + await _wait_for_no_tasks(manager) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "operation", + ["global_stop", "global_clear", "branch_clear"], +) +async def test_terminal_operation_serializes_with_successor_task_publication( + monkeypatch: pytest.MonkeyPatch, + operation: str, +) -> None: + root_started = asyncio.Event() + release_root = asyncio.Event() + successor_selected = asyncio.Event() + release_successor_publication = asyncio.Event() + child_started = asyncio.Event() + child_exited = asyncio.Event() + release_child = asyncio.Event() + + async def process(claim: NodeClaim) -> None: + if claim.node.node_id == "root": + root_started.set() + await release_root.wait() + return + child_started.set() + try: + await release_child.wait() + finally: + child_exited.set() + + manager = TreeQueueManager(process) + root = await manager.admit(_incoming("root"), "status-root") + assert root.claim is not None + await root_started.wait() + await manager.admit( + _incoming("child", reply_to="root"), + "status-child", + parent_reference_id="root", + ) + tree = manager._repository.get_tree(root.claim.identity) + assert tree is not None + original_finish = tree.finish_and_claim_next + + async def pause_after_successor_selection(claim_id: str): + completion = await original_finish(claim_id) + if completion.next_claim is not None: + successor_selected.set() + await release_successor_publication.wait() + return completion + + monkeypatch.setattr( + tree, + "finish_and_claim_next", + pause_after_successor_selection, + ) + operation_task: asyncio.Task | None = None + try: + release_root.set() + await successor_selected.wait() + if operation == "global_stop": + operation_task = asyncio.create_task( + manager.cancel_all(reason=CancellationReason.STOP) + ) + elif operation == "global_clear": + operation_task = asyncio.create_task( + manager.clear_scope(_SCOPE, reason=CancellationReason.STOP) + ) + else: + operation_task = asyncio.create_task( + manager.remove_message_subtree( + _SCOPE, + "root", + reason=CancellationReason.STOP, + ) + ) + for _ in range(5): + await asyncio.sleep(0) + if operation_task.done(): + break + + assert not operation_task.done() + finally: + release_successor_publication.set() + try: + if operation_task is not None: + await operation_task + finally: + release_child.set() + await _wait_for_no_tasks(manager) + + assert manager.get_tree_count() == (1 if operation == "global_stop" else 0) + assert manager.task_count() == 0 + if child_started.is_set(): + assert child_exited.is_set() + + +@pytest.mark.asyncio +async def test_duplicate_admission_is_rejected_and_processed_once() -> None: + started = asyncio.Event() + release = asyncio.Event() + calls: list[str] = [] + + async def process(claim: NodeClaim) -> None: + calls.append(claim.node.node_id) + started.set() + await release.wait() + + manager = TreeQueueManager(process) + first = await manager.admit(_incoming("root"), "status-root") + duplicate = await manager.admit(_incoming("root"), "status-duplicate") + await started.wait() + + assert first.accepted is True + assert duplicate.accepted is False + assert calls == ["root"] + assert manager.task_count() == 1 + + release.set() + + +@pytest.mark.asyncio +async def test_node_and_status_references_are_published_together() -> None: + release = asyncio.Event() + + async def process(_claim: NodeClaim) -> None: + await release.wait() + + manager = TreeQueueManager(process) + await manager.admit(_incoming("root"), "status-root") + + assert await manager.resolve_node_id(_SCOPE, "root") == "root" + assert await manager.resolve_node_id(_SCOPE, "status-root") == "root" + + release.set() + + +@pytest.mark.asyncio +async def test_callback_failure_does_not_block_the_next_claim() -> None: + release_root = asyncio.Event() + child_started = asyncio.Event() + release_child = asyncio.Event() + + async def process(claim: NodeClaim) -> None: + if claim.node.node_id == "root": + await release_root.wait() + else: + child_started.set() + await release_child.wait() + + async def broken_queue_callback(_queue) -> None: + raise RuntimeError("UI unavailable") + + async def broken_started_callback(_claim) -> None: + raise RuntimeError("UI unavailable") + + manager = TreeQueueManager( + process, + queue_update_callback=broken_queue_callback, + node_started_callback=broken_started_callback, + ) + await manager.admit(_incoming("root"), "status-root") + await manager.admit( + _incoming("child", reply_to="root"), + "status-child", + parent_reference_id="root", + ) + + release_root.set() + await asyncio.wait_for(child_started.wait(), timeout=1) + release_child.set() + + +@pytest.mark.asyncio +async def test_branch_removal_cannot_leave_a_detached_running_descendant() -> None: + root_started = asyncio.Event() + release_root = asyncio.Event() + + async def process(claim: NodeClaim) -> None: + if claim.node.node_id == "root": + root_started.set() + await release_root.wait() + + manager = TreeQueueManager(process) + await manager.admit(_incoming("root"), "status-root") + await root_started.wait() + await manager.admit( + _incoming("branch", reply_to="root"), + "status-branch", + parent_reference_id="root", + ) + + removed = await manager.remove_message_subtree(_SCOPE, "branch") + late = await manager.admit( + _incoming("late", reply_to="branch"), + "status-late", + parent_reference_id="branch", + ) + + assert removed.delete_message_ids == frozenset({"branch", "status-branch"}) + assert await manager.resolve_node_id(_SCOPE, "branch") is None + assert late.accepted is False + assert late.rejection is AdmissionRejection.PARENT_REMOVED + assert await manager.get_node(_SCOPE, "late") is None + + release_root.set() + + +@pytest.mark.asyncio +async def test_clear_all_drains_terminal_claim_task_before_returning() -> None: + terminal = asyncio.Event() + release_cleanup = asyncio.Event() + cleanup_finished = asyncio.Event() + manager: TreeQueueManager + + async def process(claim: NodeClaim) -> None: + await manager.complete_claim(claim, "session-root") + terminal.set() + try: + await release_cleanup.wait() + finally: + cleanup_finished.set() + + manager = TreeQueueManager(process) + await manager.admit(_incoming("root"), "status-root") + await terminal.wait() + + try: + await manager.clear_scope(_SCOPE, reason=CancellationReason.STOP) + + assert cleanup_finished.is_set() + assert manager.task_count() == 0 + assert manager.get_tree_count() == 0 + finally: + release_cleanup.set() + await _wait_for_no_tasks(manager) + + +@pytest.mark.asyncio +async def test_clear_all_returns_committed_result_after_caller_cancellation() -> None: + runner_cancelled = asyncio.Event() + release_cleanup = asyncio.Event() + cleanup_finished = asyncio.Event() + + async def process(_claim: NodeClaim) -> None: + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + runner_cancelled.set() + try: + await release_cleanup.wait() + finally: + cleanup_finished.set() + raise + + manager = TreeQueueManager(process) + await manager.admit(_incoming("root"), "status-root") + checkpoint = asyncio.Event() + asyncio.get_running_loop().call_soon(checkpoint.set) + await checkpoint.wait() + + clear_task = asyncio.create_task( + manager.clear_scope(_SCOPE, reason=CancellationReason.STOP) + ) + await runner_cancelled.wait() + + try: + clear_task.cancel() + cancellation_checkpoint = asyncio.Event() + asyncio.get_running_loop().call_soon(cancellation_checkpoint.set) + await cancellation_checkpoint.wait() + + assert not clear_task.done() + release_cleanup.set() + result = await clear_task + assert {effect.node.node_id for effect in result.effects} == {"root"} + assert cleanup_finished.is_set() + assert manager.task_count() == 0 + assert manager.get_tree_count() == 0 + finally: + release_cleanup.set() + if not clear_task.done(): + clear_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await clear_task + await _wait_for_no_tasks(manager) + + +@pytest.mark.asyncio +async def test_eager_task_factory_cannot_run_claim_before_admission_returns() -> None: + started = asyncio.Event() + release = asyncio.Event() + + async def process(_claim: NodeClaim) -> None: + started.set() + await release.wait() + + manager = TreeQueueManager(process) + loop = asyncio.get_running_loop() + original_factory = loop.get_task_factory() + try: + loop.set_task_factory(asyncio.eager_task_factory) + decision = await manager.admit(_incoming("root"), "status-root") + finally: + loop.set_task_factory(original_factory) + + try: + assert decision.accepted is True + assert decision.claim is not None + assert decision.claim.node.scope == _SCOPE + assert manager.task_count() == 1 + assert not started.is_set() + await asyncio.sleep(0) + assert started.is_set() + finally: + release.set() + await _wait_for_no_tasks(manager) + + +@pytest.mark.asyncio +async def test_absorbed_pre_run_cancellation_cannot_start_node_processor() -> None: + root_started = asyncio.Event() + release_root = asyncio.Event() + callback_entered = asyncio.Event() + callback_absorbed_cancellation = asyncio.Event() + child_processor_started = asyncio.Event() + + async def process(claim: NodeClaim) -> None: + if claim.node.node_id == "root": + root_started.set() + await release_root.wait() + return + child_processor_started.set() + + async def announce_started(claim: NodeClaim) -> None: + if claim.node.node_id != "child": + return + callback_entered.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + callback_absorbed_cancellation.set() + + manager = TreeQueueManager(process, node_started_callback=announce_started) + await manager.admit(_incoming("root"), "status-root") + await root_started.wait() + child = await manager.admit( + _incoming("child", reply_to="root"), + "status-child", + parent_reference_id="root", + ) + assert child.position == 1 + + release_root.set() + await callback_entered.wait() + result = await manager.cancel_node( + _SCOPE, + "child", + reason=CancellationReason.STOP, + ) + + assert callback_absorbed_cancellation.is_set() + assert not child_processor_started.is_set() + assert [(effect.node.node_id, effect.ui_owner) for effect in result.effects] == [ + ("child", CancellationUiOwner.WORKFLOW) + ] + node = await manager.get_node(_SCOPE, "child") + assert node is not None and node.state.value == "error" + await _wait_for_no_tasks(manager) + + +@pytest.mark.asyncio +async def test_same_scoped_root_can_be_readmitted_while_detached_claim_finishes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(manager_module, "CANCEL_TASK_DRAIN_TIMEOUT_S", 0.01) + old_started = asyncio.Event() + old_cancelled = asyncio.Event() + release_old = asyncio.Event() + new_started = asyncio.Event() + release_new = asyncio.Event() + claims: list[NodeClaim] = [] + + async def process(claim: NodeClaim) -> None: + claims.append(claim) + if len(claims) == 1: + old_started.set() + while True: + try: + await release_old.wait() + return + except asyncio.CancelledError: + old_cancelled.set() + new_started.set() + await release_new.wait() + + manager = TreeQueueManager(process) + old = await manager.admit(_incoming("root"), "status-root") + await old_started.wait() + + try: + cleared = await manager.clear_scope(_SCOPE, reason=CancellationReason.STOP) + await old_cancelled.wait() + assert {effect.node.node_id for effect in cleared.effects} == {"root"} + assert manager.get_tree_count() == 0 + assert manager.task_count() == 1 + + new = await manager.admit(_incoming("root"), "status-root") + await new_started.wait() + + assert old.claim is not None + assert new.claim is not None + assert new.accepted is True + assert new.claim.identity == old.claim.identity + assert new.claim.claim_id != old.claim.claim_id + assert manager.task_count() == 2 + finally: + release_old.set() + release_new.set() + await _wait_for_no_tasks(manager) + + +@pytest.mark.asyncio +async def test_active_error_claim_can_be_cancelled_again( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(manager_module, "CANCEL_TASK_DRAIN_TIMEOUT_S", 0.01) + started = asyncio.Event() + first_cancellation = asyncio.Event() + second_cancellation = asyncio.Event() + release = asyncio.Event() + cancellation_count = 0 + + async def process(_claim: NodeClaim) -> None: + nonlocal cancellation_count + started.set() + while True: + try: + await release.wait() + return + except asyncio.CancelledError: + cancellation_count += 1 + if cancellation_count == 1: + first_cancellation.set() + continue + second_cancellation.set() + raise + + manager = TreeQueueManager(process) + await manager.admit(_incoming("root"), "status-root") + await started.wait() + + try: + first = await manager.cancel_node( + _SCOPE, + "root", + reason=CancellationReason.STOP, + ) + await first_cancellation.wait() + assert first.effects[0].ui_owner is CancellationUiOwner.RUNNER + assert manager.task_count() == 1 + + second = await manager.cancel_node( + _SCOPE, + "root", + reason=CancellationReason.STOP, + ) + await second_cancellation.wait() + + assert [ + (effect.node.node_id, effect.ui_owner) for effect in second.effects + ] == [("root", CancellationUiOwner.RUNNER)] + assert cancellation_count == 2 + await _wait_for_no_tasks(manager) + finally: + release.set() + await _wait_for_no_tasks(manager) diff --git a/tests/messaging/test_tree_processor.py b/tests/messaging/test_tree_processor.py new file mode 100644 index 0000000..277688a --- /dev/null +++ b/tests/messaging/test_tree_processor.py @@ -0,0 +1,438 @@ +"""Manager-level task and cancellation ownership tests.""" + +import asyncio +import logging + +import pytest + +from free_claude_code.messaging.models import IncomingMessage, MessageScope +from free_claude_code.messaging.trees import ( + CancellationReason, + CancellationUiOwner, + FailureResult, + MessageState, + NodeClaim, + QueueEntry, + TreeQueueManager, +) +from free_claude_code.messaging.trees import manager as manager_module +from free_claude_code.messaging.trees import processor as processor_module +from free_claude_code.messaging.trees.node import MessageNode +from free_claude_code.messaging.trees.processor import TreeQueueProcessor +from free_claude_code.messaging.trees.runtime import MessageTree + +_SCOPE = MessageScope(platform="telegram", chat_id="chat") + + +def _incoming(node_id: str, *, reply_to: str | None = None) -> IncomingMessage: + return IncomingMessage( + text=f"prompt {node_id}", + chat_id=_SCOPE.chat_id, + user_id="user", + message_id=node_id, + platform=_SCOPE.platform, + reply_to_message_id=reply_to, + ) + + +async def _wait_for_no_tasks(manager: TreeQueueManager) -> None: + """Yield deterministic ready-queue checkpoints until task cleanup completes.""" + loop = asyncio.get_running_loop() + for _ in range(20): + if manager.task_count() == 0: + return + checkpoint = asyncio.Event() + loop.call_soon(checkpoint.set) + await checkpoint.wait() + assert manager.task_count() == 0 + + +@pytest.mark.asyncio +async def test_active_cancel_returns_runner_owned_effect_and_terminal_snapshot() -> ( + None +): + started = asyncio.Event() + + async def process(_claim: NodeClaim) -> None: + started.set() + await asyncio.Event().wait() + + manager = TreeQueueManager(process) + await manager.admit(_incoming("root"), "status-root") + await started.wait() + + result = await manager.cancel_node( + _SCOPE, + "root", + reason=CancellationReason.STOP, + ) + + assert [(effect.node.node_id, effect.ui_owner) for effect in result.effects] == [ + ("root", CancellationUiOwner.RUNNER) + ] + assert len(result.snapshots) == 1 + assert result.snapshots[0].nodes["root"]["state"] == "error" + view = await manager.get_node(_SCOPE, "root") + assert view is not None and view.state is MessageState.ERROR + assert manager.task_count() == 0 + + +@pytest.mark.asyncio +async def test_queued_cancel_returns_workflow_effect_and_exact_queue_update() -> None: + release_root = asyncio.Event() + root_started = asyncio.Event() + child_started = asyncio.Event() + queue_updates: list[tuple[tuple[str, int], ...]] = [] + + async def process(claim: NodeClaim) -> None: + if claim.node.node_id == "root": + root_started.set() + await release_root.wait() + else: + child_started.set() + + async def capture_queue(queue: tuple[QueueEntry, ...]) -> None: + queue_updates.append( + tuple((entry.node.node_id, entry.position) for entry in queue) + ) + + manager = TreeQueueManager(process, queue_update_callback=capture_queue) + await manager.admit(_incoming("root"), "status-root") + await root_started.wait() + decision = await manager.admit( + _incoming("child", reply_to="root"), + "status-child", + parent_reference_id="root", + ) + assert decision.position == 1 + + result = await manager.cancel_node( + _SCOPE, + "child", + reason=CancellationReason.STOP, + ) + + assert [(effect.node.node_id, effect.ui_owner) for effect in result.effects] == [ + ("child", CancellationUiOwner.WORKFLOW) + ] + assert queue_updates == [()] + assert result.snapshots[0].nodes["child"]["state"] == "error" + assert child_started.is_set() is False + + release_root.set() + await _wait_for_no_tasks(manager) + assert child_started.is_set() is False + + +@pytest.mark.asyncio +async def test_cancel_cleanup_timeout_is_bounded_and_task_remains_owned( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(manager_module, "CANCEL_TASK_DRAIN_TIMEOUT_S", 0.01) + started = asyncio.Event() + cancellation_seen = asyncio.Event() + release_cleanup = asyncio.Event() + + async def process(_claim: NodeClaim) -> None: + started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cancellation_seen.set() + await release_cleanup.wait() + raise + + manager = TreeQueueManager(process) + await manager.admit(_incoming("root"), "status-root") + await started.wait() + + try: + result = await asyncio.wait_for( + manager.cancel_node( + _SCOPE, + "root", + reason=CancellationReason.STOP, + ), + timeout=0.5, + ) + await cancellation_seen.wait() + assert result.effects[0].node.node_id == "root" + assert manager.task_count() == 1 + finally: + release_cleanup.set() + + await _wait_for_no_tasks(manager) + + +@pytest.mark.asyncio +async def test_escaped_processor_failure_persists_effects_through_manager_owner() -> ( + None +): + started = asyncio.Event() + release = asyncio.Event() + failures: list[FailureResult] = [] + queue_updates: list[tuple[QueueEntry, ...]] = [] + + async def process(claim: NodeClaim) -> None: + if claim.node.node_id == "root": + started.set() + await release.wait() + raise RuntimeError("processor boundary failed") + + async def capture_queue(queue: tuple[QueueEntry, ...]) -> None: + queue_updates.append(queue) + + manager = TreeQueueManager( + process, + queue_update_callback=capture_queue, + unexpected_failure_callback=failures.append, + ) + await manager.admit(_incoming("root"), "status-root") + await started.wait() + await manager.admit( + _incoming("child", reply_to="root"), + "status-child", + parent_reference_id="root", + ) + + release.set() + await _wait_for_no_tasks(manager) + + assert len(failures) == 1 + failure = failures[0] + assert failure.snapshot is not None + assert {target.node_id for target in failure.affected} == {"root", "child"} + assert failure.snapshot.nodes["root"]["state"] == "error" + assert failure.snapshot.nodes["child"]["state"] == "error" + assert queue_updates == [()] + + +@pytest.mark.asyncio +async def test_wait_idle_spans_successor_publication_and_completion() -> None: + root_started = asyncio.Event() + release_root = asyncio.Event() + child_started = asyncio.Event() + release_child = asyncio.Event() + + async def process(claim: NodeClaim) -> None: + if claim.node.node_id == "root": + root_started.set() + await release_root.wait() + return + child_started.set() + await release_child.wait() + + manager = TreeQueueManager(process) + await asyncio.wait_for(manager.wait_idle(), timeout=0.1) + await manager.admit(_incoming("root"), "status-root") + await asyncio.wait_for(root_started.wait(), timeout=1) + await manager.admit( + _incoming("child", reply_to="root"), + "status-child", + parent_reference_id="root", + ) + idle_task = asyncio.create_task(manager.wait_idle()) + + try: + await asyncio.sleep(0) + assert not idle_task.done() + + release_root.set() + await asyncio.wait_for(child_started.wait(), timeout=1) + assert not idle_task.done() + + release_child.set() + await asyncio.wait_for(idle_task, timeout=1) + assert manager.task_count() == 0 + finally: + release_root.set() + release_child.set() + if not idle_task.done(): + idle_task.cancel() + with pytest.raises(asyncio.CancelledError): + await idle_task + + +@pytest.mark.asyncio +async def test_wait_idle_spans_pre_run_cancellation_recovery() -> None: + finish_started = asyncio.Event() + release_finish = asyncio.Event() + + async def process(_claim: NodeClaim) -> None: + raise AssertionError("pre-run cancellation must not enter the processor") + + async def fail_claim(_claim: NodeClaim) -> None: + raise AssertionError("cancellation must not fail the claim") + + async def finish_claim(_tree: MessageTree, _claim: NodeClaim) -> None: + finish_started.set() + await release_finish.wait() + + tree = MessageTree( + MessageNode( + node_id="root", + scope=_SCOPE, + prompt="prompt root", + status_message_id="status-root", + ) + ) + decision = await tree.enqueue_or_claim("root") + assert decision.claim is not None + processor = TreeQueueProcessor( + process, + claim_failure_callback=fail_claim, + claim_finished_callback=finish_claim, + ) + processor.launch(tree, decision.claim) + cancelled = processor.cancel(decision.claim, CancellationReason.STOP) + assert cancelled is not None + assert cancelled.runner_started is False + idle_task = asyncio.create_task(processor.wait_idle()) + + try: + await asyncio.wait_for(finish_started.wait(), timeout=1) + assert not idle_task.done() + + release_finish.set() + await asyncio.wait_for(cancelled.task, timeout=1) + await asyncio.wait_for(idle_task, timeout=1) + assert processor.task_count() == 0 + finally: + release_finish.set() + if not idle_task.done(): + idle_task.cancel() + with pytest.raises(asyncio.CancelledError): + await idle_task + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("log_messaging_error_details", "secret_is_logged"), + [(False, False), (True, True)], +) +async def test_wait_idle_surfaces_finish_failure_without_leaking_task_owner( + caplog: pytest.LogCaptureFixture, + log_messaging_error_details: bool, + secret_is_logged: bool, +) -> None: + secret = "unique-finish-callback-secret" + finish_error = RuntimeError(secret) + finish_calls = 0 + + async def process(_claim: NodeClaim) -> None: + return + + async def fail_claim(_claim: NodeClaim) -> None: + raise AssertionError("successful processing must not fail the claim") + + async def finish_claim(_tree: MessageTree, _claim: NodeClaim) -> None: + nonlocal finish_calls + finish_calls += 1 + raise finish_error + + tree = MessageTree( + MessageNode( + node_id="root", + scope=_SCOPE, + prompt="prompt root", + status_message_id="status-root", + ) + ) + decision = await tree.enqueue_or_claim("root") + assert decision.claim is not None + processor = TreeQueueProcessor( + process, + claim_failure_callback=fail_claim, + claim_finished_callback=finish_claim, + log_messaging_error_details=log_messaging_error_details, + ) + + with caplog.at_level(logging.ERROR): + processor.launch(tree, decision.claim) + with pytest.raises(RuntimeError) as raised: + await asyncio.wait_for(processor.wait_idle(), timeout=1) + + assert raised.value is finish_error + assert finish_calls == 1 + assert processor.task_count() == 0 + await asyncio.wait_for(processor.wait_idle(), timeout=0.1) + + messages = "\n".join(record.getMessage() for record in caplog.records) + assert "Claim completion callback failed for node root" in messages + assert "RuntimeError" in messages + assert (secret in messages) is secret_is_logged + + +@pytest.mark.asyncio +async def test_failed_launch_rolls_idle_state_back( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def process(_claim: NodeClaim) -> None: + return + + async def fail_claim(_claim: NodeClaim) -> None: + return + + async def finish_claim(_tree: MessageTree, _claim: NodeClaim) -> None: + return + + tree = MessageTree( + MessageNode( + node_id="root", + scope=_SCOPE, + prompt="prompt root", + status_message_id="status-root", + ) + ) + decision = await tree.enqueue_or_claim("root") + assert decision.claim is not None + processor = TreeQueueProcessor( + process, + claim_failure_callback=fail_claim, + claim_finished_callback=finish_claim, + ) + + def fail_create_task(*_args: object, **_kwargs: object) -> None: + raise RuntimeError("launch failed") + + monkeypatch.setattr( + processor_module.asyncio, + "create_task", + fail_create_task, + ) + + with pytest.raises(RuntimeError, match="launch failed"): + processor.launch(tree, decision.claim) + + assert processor.task_count() == 0 + await asyncio.wait_for(processor.wait_idle(), timeout=0.1) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("log_messaging_error_details", "secret_is_logged"), + [(False, False), (True, True)], +) +async def test_processor_failure_logging_respects_diagnostic_policy( + caplog: pytest.LogCaptureFixture, + log_messaging_error_details: bool, + secret_is_logged: bool, +) -> None: + secret = "unique-processor-exception-secret" + + async def process(_claim: NodeClaim) -> None: + raise RuntimeError(secret) + + manager = TreeQueueManager( + process, + log_messaging_error_details=log_messaging_error_details, + ) + with caplog.at_level(logging.ERROR): + await manager.admit(_incoming("root"), "status-root") + await asyncio.wait_for(manager.wait_idle(), timeout=1) + + messages = "\n".join(record.getMessage() for record in caplog.records) + + assert "Error processing node root" in messages + assert "RuntimeError" in messages + assert (secret in messages) is secret_is_logged diff --git a/tests/messaging/test_tree_queue.py b/tests/messaging/test_tree_queue.py new file mode 100644 index 0000000..1169298 --- /dev/null +++ b/tests/messaging/test_tree_queue.py @@ -0,0 +1,187 @@ +"""Focused tests for the tree package's private graph and queue values.""" + +import pytest + +from free_claude_code.messaging.models import MessageScope +from free_claude_code.messaging.trees.graph import MessageTreeGraph +from free_claude_code.messaging.trees.node import MessageNode, MessageState +from free_claude_code.messaging.trees.queue import MessageNodeQueue +from free_claude_code.messaging.trees.snapshot import ( + TreeSnapshot, + node_from_snapshot, + node_to_snapshot, +) + +_SCOPE = MessageScope(platform="telegram", chat_id="chat") + + +def _root() -> MessageNode: + return MessageNode( + node_id="root", + scope=_SCOPE, + prompt="prompt root", + status_message_id="status-root", + ) + + +def _add( + graph: MessageTreeGraph, + node_id: str, + status_message_id: str, + parent_id: str, +) -> MessageNode: + return graph.add_node( + node_id=node_id, + scope=_SCOPE, + prompt=f"prompt {node_id}", + status_message_id=status_message_id, + parent_id=parent_id, + parent_reference_id=parent_id, + ) + + +def test_node_snapshot_round_trip_preserves_execution_state_only() -> None: + node = _root() + node.children_ids.extend(["child-a", "child-b"]) + node.update_state(MessageState.ERROR) + node.update_state(MessageState.COMPLETED, session_id="session-root") + + snapshot = node_to_snapshot(node) + restored = node_from_snapshot(snapshot, _SCOPE) + + assert restored.node_id == "root" + assert restored.scope == _SCOPE + assert restored.prompt == "" + assert restored.status_message_id == "status-root" + assert restored.state is MessageState.COMPLETED + assert restored.session_id == "session-root" + assert restored.parent_reference_id is None + assert restored.children_ids == [] + assert "children_ids" not in snapshot + assert "created_at" not in snapshot + assert "completed_at" not in snapshot + assert "error_message" not in snapshot + + +def test_graph_snapshot_round_trip_preserves_links_and_status_lookup() -> None: + graph = MessageTreeGraph(_root()) + child = _add(graph, "child", "status-child", "root") + _add(graph, "grandchild", "status-grandchild", "child") + child.update_state(MessageState.COMPLETED, session_id="session-child") + + restored = MessageTreeGraph.from_snapshot(graph.snapshot()) + parent = restored.get_parent("grandchild") + status_child = restored.find_node_by_status_message("status-child") + + assert restored.root_id == "root" + assert parent is not None + assert parent.node_id == "child" + assert restored.get_parent_session_id("grandchild") == "session-child" + assert status_child is not None + assert status_child.node_id == "child" + assert restored.get_descendants("root") == ["root", "child", "grandchild"] + + +def test_graph_restore_normalizes_numeric_ids_to_string_references() -> None: + graph = MessageTreeGraph(_root()) + _add(graph, "child", "status-child", "root") + snapshot = graph.snapshot() + snapshot.nodes["child"]["node_id"] = 2 + snapshot.nodes["child"]["status_message_id"] = 123 + snapshot.nodes["child"]["parent_id"] = "root" + snapshot.nodes["2"] = snapshot.nodes.pop("child") + + restored = MessageTreeGraph.from_snapshot(snapshot) + + child = restored.find_node_by_status_message("123") + assert child is not None and child.node_id == "2" + + +def test_graph_restore_normalizes_legacy_parent_to_prompt_reference() -> None: + graph = MessageTreeGraph(_root()) + _add(graph, "child", "status-child", "root") + snapshot = graph.snapshot() + snapshot.nodes["child"].pop("parent_reference_id") + + restored = MessageTreeGraph.from_snapshot(snapshot) + + child = restored.get_node("child") + assert child is not None + assert child.parent_reference_id == "root" + assert restored.snapshot().nodes["child"]["parent_reference_id"] == "root" + + +def test_cleared_status_round_trip_preserves_prompt_anchor() -> None: + graph = MessageTreeGraph(_root()) + graph.clear_status("root") + + restored = MessageTreeGraph.from_snapshot(graph.snapshot()) + + root = restored.get_root() + assert root.status_message_id is None + assert root.state is MessageState.ERROR + assert restored.resolve_reference("root") is not None + assert restored.resolve_reference("status-root") is None + + +def test_snapshot_rejects_runnable_node_without_status() -> None: + snapshot = MessageTreeGraph(_root()).snapshot() + snapshot.nodes["root"]["status_message_id"] = None + + with pytest.raises(ValueError, match="requires a status"): + MessageTreeGraph.from_snapshot(snapshot) + + +def test_graph_rejects_duplicate_node_and_status_identity() -> None: + graph = MessageTreeGraph(_root()) + _add(graph, "child", "status-child", "root") + + with pytest.raises(ValueError, match="already exists"): + _add(graph, "child", "status-other", "root") + with pytest.raises(ValueError, match="already exists"): + _add(graph, "other", "status-child", "root") + with pytest.raises(ValueError, match="must be distinct"): + _add(graph, "same", "same", "root") + + +def test_graph_reference_subtree_can_remove_exact_nodes_and_status_lookups() -> None: + graph = MessageTreeGraph(_root()) + _add(graph, "branch", "status-branch", "root") + _add(graph, "leaf", "status-leaf", "branch") + _add(graph, "sibling", "status-sibling", "root") + + references = graph.get_reference_descendants("branch") + graph.remove_nodes( + {reference for reference in references if not reference.startswith("status-")} + ) + + assert graph.get_node("branch") is None + assert graph.get_node("leaf") is None + assert graph.find_node_by_status_message("status-branch") is None + assert graph.find_node_by_status_message("status-leaf") is None + assert graph.get_descendants("root") == ["root", "sibling"] + + +def test_tree_snapshot_rejects_invalid_wire_shapes() -> None: + assert TreeSnapshot.from_json(None) is None + assert TreeSnapshot.from_json({"root_id": "root", "nodes": []}) is None + assert TreeSnapshot.from_json({"nodes": {}}) is None + + +def test_node_queue_is_unique_fifo_and_supports_atomic_removal() -> None: + queue = MessageNodeQueue() + + assert queue.put("a") is True + assert queue.put("b") is True + assert queue.put("a") is False + assert queue.items() == ("a", "b") + assert queue.remove("a") is True + assert queue.remove("a") is False + assert queue.items() == ("b",) + assert queue.pop() == "b" + assert queue.pop() is None + + assert queue.put("c") is True + assert queue.put("d") is True + assert queue.drain() == ("c", "d") + assert queue.items() == () diff --git a/tests/messaging/test_tree_repository.py b/tests/messaging/test_tree_repository.py new file mode 100644 index 0000000..18e817c --- /dev/null +++ b/tests/messaging/test_tree_repository.py @@ -0,0 +1,194 @@ +"""Persistence and restored-manager contracts for messaging trees.""" + +import asyncio + +import pytest + +from free_claude_code.messaging.models import IncomingMessage, MessageScope +from free_claude_code.messaging.trees import ( + ConversationSnapshot, + MessageState, + NodeClaim, + TreeIdentity, + TreeQueueManager, + TreeSnapshot, +) +from free_claude_code.messaging.trees.node import MessageNode +from free_claude_code.messaging.trees.snapshot import node_to_snapshot + +TELEGRAM_CHAT = MessageScope(platform="telegram", chat_id="chat") +ROOT_IDENTITY = TreeIdentity(scope=TELEGRAM_CHAT, root_id="root") + + +def _incoming(node_id: str, *, reply_to: str | None = None) -> IncomingMessage: + return IncomingMessage( + text=f"prompt {node_id}", + chat_id="chat", + user_id="user", + message_id=node_id, + platform="telegram", + reply_to_message_id=reply_to, + ) + + +async def _wait_for_no_tasks(manager: TreeQueueManager) -> None: + loop = asyncio.get_running_loop() + for _ in range(20): + if manager.task_count() == 0: + return + checkpoint = asyncio.Event() + loop.call_soon(checkpoint.set) + await checkpoint.wait() + assert manager.task_count() == 0 + + +def _interrupted_conversation() -> ConversationSnapshot: + root = MessageNode( + node_id="root", + scope=TELEGRAM_CHAT, + prompt="prompt root", + status_message_id="status-root", + state=MessageState.COMPLETED, + session_id="session-root", + children_ids=["pending", "failed"], + ) + pending = MessageNode( + node_id="pending", + scope=TELEGRAM_CHAT, + prompt="prompt pending", + status_message_id="status-pending", + parent_id="root", + state=MessageState.PENDING, + children_ids=["running"], + ) + running = MessageNode( + node_id="running", + scope=TELEGRAM_CHAT, + prompt="prompt running", + status_message_id="status-running", + parent_id="pending", + state=MessageState.IN_PROGRESS, + ) + failed = MessageNode( + node_id="failed", + scope=TELEGRAM_CHAT, + prompt="prompt failed", + status_message_id="status-failed", + parent_id="root", + state=MessageState.ERROR, + ) + snapshot = TreeSnapshot( + scope=TELEGRAM_CHAT, + root_id="root", + nodes={ + node.node_id: node_to_snapshot(node) + for node in (root, pending, running, failed) + }, + ) + return ConversationSnapshot(trees={snapshot.identity: snapshot}) + + +@pytest.mark.asyncio +async def test_restore_reconciles_interrupted_nodes_before_manager_exposure() -> None: + processed: list[str] = [] + + async def process(claim: NodeClaim) -> None: + processed.append(claim.node.node_id) + + manager = TreeQueueManager.from_snapshot(_interrupted_conversation(), process) + + assert len(manager.restored_stale_targets) == 2 + assert manager.restored_snapshot is not None + assert manager.restored_snapshot == await manager.snapshot() + assert manager.task_count() == 0 + assert processed == [] + + root = await manager.get_node(TELEGRAM_CHAT, "root") + pending = await manager.get_node(TELEGRAM_CHAT, "pending") + running = await manager.get_node(TELEGRAM_CHAT, "status-running") + failed = await manager.get_node(TELEGRAM_CHAT, "failed") + assert root is not None and root.state is MessageState.COMPLETED + assert root.session_id == "session-root" + assert pending is not None and pending.state is MessageState.ERROR + assert running is not None and running.state is MessageState.ERROR + assert failed is not None and failed.state is MessageState.ERROR + assert await manager.resolve_node_id(TELEGRAM_CHAT, "status-pending") == "pending" + assert { + (target.scope, target.node_id) for target in manager.restored_stale_targets + } == { + (TELEGRAM_CHAT, "pending"), + (TELEGRAM_CHAT, "running"), + } + + normalized = await manager.snapshot() + normalized_tree = normalized.get_tree(ROOT_IDENTITY) + assert normalized_tree is not None + assert normalized_tree.nodes["pending"]["state"] == "error" + assert normalized_tree.nodes["running"]["state"] == "error" + assert normalized_tree.nodes["failed"]["state"] == "error" + assert all("error_message" not in node for node in normalized_tree.nodes.values()) + + +@pytest.mark.parametrize("corruption", ["cross_scope", "duplicate_reference"]) +def test_restore_rejects_tree_that_violates_scoped_reference_invariants( + corruption: str, +) -> None: + snapshot = _interrupted_conversation() + tree = snapshot.get_tree(ROOT_IDENTITY) + assert tree is not None + if corruption == "cross_scope": + tree.nodes["pending"]["incoming"] = { + "platform": "telegram", + "chat_id": "other-chat", + } + else: + tree.nodes["failed"]["status_message_id"] = "status-pending" + + manager = TreeQueueManager.from_snapshot(snapshot, lambda _claim: asyncio.sleep(0)) + + assert manager.get_tree_count() == 0 + assert manager.restored_snapshot == ConversationSnapshot() + + +@pytest.mark.asyncio +async def test_claim_state_and_session_updates_produce_detached_snapshots() -> None: + release = asyncio.Event() + started = asyncio.Event() + + async def process(_claim: NodeClaim) -> None: + started.set() + await release.wait() + + manager = TreeQueueManager(process) + decision = await manager.admit(_incoming("root"), "status-root") + assert decision.claim is not None + await started.wait() + + session_snapshot = await manager.record_session(decision.claim, "session-root") + completed_snapshot = await manager.complete_claim( + decision.claim, + "session-root", + ) + + assert session_snapshot is not None + assert session_snapshot.nodes["root"]["state"] == "in_progress" + assert session_snapshot.nodes["root"]["session_id"] == "session-root" + assert completed_snapshot is not None + assert completed_snapshot.nodes["root"]["state"] == "completed" + assert completed_snapshot.nodes["root"]["session_id"] == "session-root" + + assert decision.snapshot is not None + decision.snapshot.nodes["root"]["state"] = "mutated-copy" + view = await manager.get_node(TELEGRAM_CHAT, "status-root") + assert view is not None and view.state is MessageState.COMPLETED + assert view.session_id == "session-root" + assert await manager.get_message_ids_for_chat("telegram", "chat") == { + "root", + "status-root", + } + + release.set() + await _wait_for_no_tasks(manager) + assert await manager.snapshot() == ConversationSnapshot( + trees={ROOT_IDENTITY: completed_snapshot} + ) diff --git a/tests/messaging/test_voice_handlers.py b/tests/messaging/test_voice_handlers.py new file mode 100644 index 0000000..5275a7d --- /dev/null +++ b/tests/messaging/test_voice_handlers.py @@ -0,0 +1,205 @@ +"""Tests for voice note handling in Telegram and Discord platforms.""" + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.messaging.platforms.discord import ( + DISCORD_AVAILABLE, + DiscordRuntime, +) +from free_claude_code.messaging.platforms.discord_inbound import get_audio_attachment +from free_claude_code.messaging.platforms.telegram import TelegramRuntime + + +@pytest.fixture +def telegram_platform(): + transcriber = MagicMock() + transcriber.transcribe = AsyncMock(return_value="Hello from voice") + transcriber.close = AsyncMock() + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = TelegramRuntime( + bot_token="test_token", + allowed_user_id="12345", + limiter=MagicMock(), + transcriber=transcriber, + ) + return platform, transcriber + + +@pytest.mark.asyncio +async def test_telegram_voice_disabled_sends_reply(): + """When voice_note_enabled is False, reply with disabled message.""" + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + telegram_platform = TelegramRuntime( + bot_token="test_token", + allowed_user_id="12345", + limiter=MagicMock(), + transcriber=None, + ) + 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() + + 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).""" + platform, transcriber = telegram_platform + 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() + + await platform._on_telegram_voice(mock_update, MagicMock()) + + mock_update.message.reply_text.assert_not_called() + transcriber.transcribe.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_telegram_voice_success_invokes_handler(telegram_platform): + """Successful transcription invokes message handler with transcribed text.""" + platform, transcriber = telegram_platform + handler = AsyncMock() + 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) + + 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_queue_send = AsyncMock(return_value="999") + with patch.object( + platform.outbound, + "queue_send_message", + mock_queue_send, + ): + await 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 + transcriber.transcribe.assert_awaited_once() + + handler.assert_called_once() + incoming = handler.call_args[0][0] + assert incoming.text == "Hello from voice" + assert incoming.chat_id == "6789" + assert incoming.platform == "telegram" + assert incoming.status_message_id == "999" + + +@pytest.mark.asyncio +async def test_telegram_bulk_voice_cancellation_delegates(telegram_platform) -> None: + platform, _transcriber = telegram_platform + platform._voice_flow.cancel_all_pending_voices = AsyncMock(return_value=()) + + assert await platform.cancel_all_pending_voices() == () + + platform._voice_flow.cancel_all_pending_voices.assert_awaited_once() + + +@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): + msg = MagicMock() + msg.attachments = [] + assert get_audio_attachment(msg) is None + + def test_returns_none_when_no_audio_attachments(self): + msg = MagicMock() + att = MagicMock() + att.content_type = "image/png" + att.filename = "pic.png" + msg.attachments = [att] + assert get_audio_attachment(msg) is None + + def test_returns_attachment_by_content_type(self): + msg = MagicMock() + att = MagicMock() + att.content_type = "audio/ogg" + att.filename = "voice.ogg" + msg.attachments = [att] + assert get_audio_attachment(msg) is att + + def test_returns_attachment_by_extension(self): + msg = MagicMock() + att = MagicMock() + att.content_type = "application/octet-stream" + att.filename = "voice.ogg" + msg.attachments = [att] + assert 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 = DiscordRuntime( + bot_token="token", + allowed_channel_ids="123", + limiter=MagicMock(), + transcriber=None, + ) + 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] + + await platform._on_discord_message(mock_message) + + mock_message.reply.assert_called_once_with("Voice notes are disabled.") + + +@pytest.mark.skipif(not DISCORD_AVAILABLE, reason="discord.py not installed") +@pytest.mark.asyncio +async def test_discord_bulk_voice_cancellation_delegates() -> None: + platform = DiscordRuntime( + bot_token="token", + allowed_channel_ids="123", + limiter=MagicMock(), + transcriber=None, + ) + platform._voice_flow.cancel_all_pending_voices = AsyncMock(return_value=()) + + assert await platform.cancel_all_pending_voices() == () + + platform._voice_flow.cancel_all_pending_voices.assert_awaited_once() diff --git a/tests/messaging/test_voice_services.py b/tests/messaging/test_voice_services.py new file mode 100644 index 0000000..f836358 --- /dev/null +++ b/tests/messaging/test_voice_services.py @@ -0,0 +1,653 @@ +import asyncio + +import pytest + +from free_claude_code.messaging.models import MessageScope +from free_claude_code.messaging.voice import ( + PendingVoiceClaim, + PendingVoiceRegistry, + VoiceCancellationResult, + VoiceHandoffOutcome, +) + +TELEGRAM_CHAT = MessageScope(platform="telegram", chat_id="chat") +DISCORD_CHAT = MessageScope(platform="discord", chat_id="chat") + + +class AsyncNoop: + async def __call__(self) -> None: + return None + + +class FatalVoiceFailure(BaseException): + pass + + +async def _reserve_bound( + registry: PendingVoiceRegistry, + *, + scope: MessageScope = TELEGRAM_CHAT, + voice_id: str = "voice-1", + status_id: str = "status-1", +) -> PendingVoiceClaim: + claim = await registry.reserve(scope, voice_id) + assert claim is not None + assert await registry.bind_status(claim, status_id) is True + return claim + + +@pytest.mark.asyncio +async def test_cancel_before_status_binding_rejects_late_flow() -> None: + registry = PendingVoiceRegistry() + claim = await registry.reserve(TELEGRAM_CHAT, "voice-1") + callback_called = False + + async def callback() -> None: + nonlocal callback_called + callback_called = True + + assert claim is not None + cancellation = await registry.cancel(TELEGRAM_CHAT, "voice-1") + assert cancellation is not None + assert cancellation == VoiceCancellationResult( + scope=TELEGRAM_CHAT, + voice_message_id="voice-1", + status_message_id=None, + delete_message_ids=frozenset({"voice-1"}), + ) + assert cancellation.delete_message_ids == frozenset({"voice-1"}) + assert await registry.bind_status(claim, "status-1") is False + assert await registry.handoff(claim, callback) is VoiceHandoffOutcome.REJECTED + assert callback_called is False + + +@pytest.mark.asyncio +async def test_handoff_remains_addressable_until_callback_completes() -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + started = asyncio.Event() + release = asyncio.Event() + + async def callback() -> None: + started.set() + await release.wait() + + handoff_task = asyncio.create_task(registry.handoff(claim, callback)) + await asyncio.wait_for(started.wait(), timeout=1) + + assert await registry.reserve(TELEGRAM_CHAT, "voice-1") is None + release.set() + assert await asyncio.wait_for(handoff_task, timeout=1) is ( + VoiceHandoffOutcome.COMPLETED + ) + assert await registry.cancel(TELEGRAM_CHAT, "status-1") is None + + +@pytest.mark.asyncio +async def test_cancel_removes_then_drains_handoff_without_holding_lock() -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + started = asyncio.Event() + cancellation_received = asyncio.Event() + release = asyncio.Event() + + async def callback() -> None: + started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cancellation_received.set() + await release.wait() + raise + + handoff_task = asyncio.create_task(registry.handoff(claim, callback)) + await asyncio.wait_for(started.wait(), timeout=1) + cancel_task = asyncio.create_task(registry.cancel(TELEGRAM_CHAT, "status-1")) + await asyncio.wait_for(cancellation_received.wait(), timeout=1) + + assert not cancel_task.done() + replacement = await asyncio.wait_for( + registry.reserve(TELEGRAM_CHAT, "voice-1"), timeout=1 + ) + assert replacement is not None + assert await registry.bind_status(replacement, "status-new") is True + + release.set() + cancellation = await asyncio.wait_for(cancel_task, timeout=1) + assert cancellation is not None + assert cancellation == VoiceCancellationResult( + scope=TELEGRAM_CHAT, + voice_message_id="voice-1", + status_message_id="status-1", + delete_message_ids=frozenset({"status-1"}), + ) + assert cancellation.delete_message_ids == frozenset({"status-1"}) + assert await asyncio.wait_for(handoff_task, timeout=1) is ( + VoiceHandoffOutcome.CANCELLED + ) + assert await registry.cancel(TELEGRAM_CHAT, "status-new") is not None + + +@pytest.mark.asyncio +async def test_voice_reference_authorizes_voice_and_status_deletion() -> None: + registry = PendingVoiceRegistry() + await _reserve_bound(registry) + + cancellation = await registry.cancel(TELEGRAM_CHAT, "voice-1") + + assert cancellation is not None + assert cancellation.delete_message_ids == frozenset({"voice-1", "status-1"}) + + +@pytest.mark.asyncio +async def test_cancel_scope_preserves_other_platform_chats() -> None: + registry = PendingVoiceRegistry() + await _reserve_bound(registry) + await _reserve_bound( + registry, + scope=DISCORD_CHAT, + voice_id="voice-2", + status_id="status-2", + ) + + cancellations = await registry.cancel_scope(TELEGRAM_CHAT) + + assert len(cancellations) == 1 + assert cancellations[0].delete_message_ids == frozenset({"voice-1", "status-1"}) + assert await registry.cancel(DISCORD_CHAT, "status-2") is not None + + +@pytest.mark.asyncio +async def test_handoff_propagates_callback_error_when_completion_wins() -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + + async def callback() -> None: + raise RuntimeError("handler failed") + + with pytest.raises(RuntimeError, match="handler failed"): + await registry.handoff(claim, callback) + + assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is None + + +@pytest.mark.asyncio +async def test_handoff_suppresses_late_callback_error_when_cancellation_wins() -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + started = asyncio.Event() + + async def callback() -> None: + started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + raise RuntimeError("late handler failure") from None + + handoff_task = asyncio.create_task(registry.handoff(claim, callback)) + await asyncio.wait_for(started.wait(), timeout=1) + + assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is not None + assert await asyncio.wait_for(handoff_task, timeout=1) is ( + VoiceHandoffOutcome.CANCELLED + ) + + +@pytest.mark.asyncio +async def test_handoff_caller_cancellation_drains_child_and_propagates() -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + started = asyncio.Event() + cancellation_received = asyncio.Event() + release = asyncio.Event() + + async def callback() -> None: + started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cancellation_received.set() + await release.wait() + raise + + handoff_task = asyncio.create_task(registry.handoff(claim, callback)) + await asyncio.wait_for(started.wait(), timeout=1) + handoff_task.cancel() + await asyncio.wait_for(cancellation_received.wait(), timeout=1) + assert handoff_task.cancelling() == 1 + assert await registry.reserve(TELEGRAM_CHAT, "voice-1") is None + assert await registry.reserve(TELEGRAM_CHAT, "status-1") is None + + handoff_task.cancel() + await asyncio.sleep(0) + assert handoff_task.cancelling() == 2 + handoff_task.cancel() + await asyncio.sleep(0) + assert handoff_task.cancelling() == 3 + assert not handoff_task.done() + release.set() + with pytest.raises(asyncio.CancelledError): + await handoff_task + assert handoff_task.cancelling() == 3 + assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is None + + +@pytest.mark.asyncio +async def test_handoff_drains_child_when_cancellation_is_already_pending() -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + started = asyncio.Event() + finished = asyncio.Event() + release = asyncio.Event() + + async def callback() -> None: + started.set() + try: + await release.wait() + finally: + finished.set() + + async def cancelled_caller() -> VoiceHandoffOutcome: + current = asyncio.current_task() + assert current is not None + current.cancel() + return await registry.handoff(claim, callback) + + handoff_task = asyncio.create_task(cancelled_caller()) + try: + with pytest.raises(asyncio.CancelledError): + await handoff_task + assert started.is_set() + assert finished.is_set() + assert handoff_task.cancelling() == 1 + assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is None + finally: + release.set() + await asyncio.sleep(0) + + +@pytest.mark.asyncio +async def test_external_cancel_owns_aliases_during_caller_cancelled_drain() -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + started = asyncio.Event() + caller_cancellation_received = asyncio.Event() + external_cancellation_received = asyncio.Event() + release = asyncio.Event() + + async def callback() -> None: + started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + caller_cancellation_received.set() + try: + await release.wait() + except asyncio.CancelledError: + external_cancellation_received.set() + await release.wait() + raise + + handoff_task = asyncio.create_task(registry.handoff(claim, callback)) + await asyncio.wait_for(started.wait(), timeout=1) + + handoff_task.cancel() + await asyncio.wait_for(caller_cancellation_received.wait(), timeout=1) + cancel_task = asyncio.create_task(registry.cancel(TELEGRAM_CHAT, "status-1")) + await asyncio.wait_for(external_cancellation_received.wait(), timeout=1) + + replacement = await registry.reserve(TELEGRAM_CHAT, "voice-1") + assert replacement is not None + assert await registry.bind_status(replacement, "status-new") is True + release.set() + + cancellation = await asyncio.wait_for(cancel_task, timeout=1) + assert cancellation == VoiceCancellationResult( + scope=TELEGRAM_CHAT, + voice_message_id="voice-1", + status_message_id="status-1", + delete_message_ids=frozenset({"status-1"}), + ) + with pytest.raises(asyncio.CancelledError): + await handoff_task + assert await registry.cancel(TELEGRAM_CHAT, "status-new") is not None + + +@pytest.mark.asyncio +async def test_handoff_finalization_survives_repeated_caller_cancellation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + finalization_started = asyncio.Event() + release_finalization = asyncio.Event() + complete_if_owned = registry._complete_if_owned + + async def gated_completion(entry) -> bool: + finalization_started.set() + await release_finalization.wait() + return await complete_if_owned(entry) + + monkeypatch.setattr(registry, "_complete_if_owned", gated_completion) + handoff_task = asyncio.create_task(registry.handoff(claim, AsyncNoop())) + await asyncio.wait_for(finalization_started.wait(), timeout=1) + + handoff_task.cancel() + await asyncio.sleep(0) + handoff_task.cancel() + await asyncio.sleep(0) + assert not handoff_task.done() + + release_finalization.set() + with pytest.raises(asyncio.CancelledError): + await handoff_task + assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is None + + +@pytest.mark.asyncio +async def test_cancel_drain_survives_repeated_caller_cancellation() -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + started = asyncio.Event() + child_cancelled = asyncio.Event() + release = asyncio.Event() + + async def callback() -> None: + started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + child_cancelled.set() + await release.wait() + raise + + handoff_task = asyncio.create_task(registry.handoff(claim, callback)) + await asyncio.wait_for(started.wait(), timeout=1) + cancel_task = asyncio.create_task(registry.cancel(TELEGRAM_CHAT, "voice-1")) + await asyncio.wait_for(child_cancelled.wait(), timeout=1) + + cancel_task.cancel() + await asyncio.sleep(0) + cancel_task.cancel() + await asyncio.sleep(0) + assert not cancel_task.done() + + release.set() + with pytest.raises(asyncio.CancelledError): + await cancel_task + assert await asyncio.wait_for(handoff_task, timeout=1) is ( + VoiceHandoffOutcome.CANCELLED + ) + assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is None + + +@pytest.mark.asyncio +async def test_timeout_and_independent_cancellation_remain_distinct() -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + started = asyncio.Event() + child_cancelled = asyncio.Event() + release = asyncio.Event() + + async def callback() -> None: + started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + child_cancelled.set() + await release.wait() + raise + + handoff_task = asyncio.create_task(registry.handoff(claim, callback)) + await asyncio.wait_for(started.wait(), timeout=1) + + async def timed_cancel() -> VoiceCancellationResult | None: + async with asyncio.timeout(0.01): + return await registry.cancel(TELEGRAM_CHAT, "voice-1") + + cancel_task = asyncio.create_task(timed_cancel()) + await asyncio.wait_for(child_cancelled.wait(), timeout=1) + await asyncio.sleep(0.05) + assert cancel_task.cancelling() == 1 + cancel_task.cancel() + await asyncio.sleep(0) + assert cancel_task.cancelling() == 2 + release.set() + + with pytest.raises(asyncio.CancelledError): + await cancel_task + assert await asyncio.wait_for(handoff_task, timeout=1) is ( + VoiceHandoffOutcome.CANCELLED + ) + + +@pytest.mark.asyncio +async def test_reentrant_callback_cancellation_does_not_join_a_cycle() -> None: + registry = PendingVoiceRegistry() + first = await _reserve_bound( + registry, + voice_id="voice-1", + status_id="status-1", + ) + second = await _reserve_bound( + registry, + voice_id="voice-2", + status_id="status-2", + ) + second_started = asyncio.Event() + first_cancellation: VoiceCancellationResult | None = None + second_cancellation: VoiceCancellationResult | None = None + + async def first_callback() -> None: + nonlocal second_cancellation + await second_started.wait() + second_cancellation = await registry.cancel(TELEGRAM_CHAT, "voice-2") + + async def second_callback() -> None: + nonlocal first_cancellation + second_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + first_cancellation = await registry.cancel(TELEGRAM_CHAT, "voice-1") + raise + + first_handoff = asyncio.create_task(registry.handoff(first, first_callback)) + second_handoff = asyncio.create_task(registry.handoff(second, second_callback)) + + assert await asyncio.wait_for(first_handoff, timeout=1) is ( + VoiceHandoffOutcome.COMPLETED + ) + assert await asyncio.wait_for(second_handoff, timeout=1) is ( + VoiceHandoffOutcome.CANCELLED + ) + assert first_cancellation is None + assert second_cancellation is not None + assert second_cancellation.voice_message_id == "voice-2" + + +@pytest.mark.asyncio +async def test_nested_callback_task_cannot_cancel_its_own_claim() -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + cancellation: VoiceCancellationResult | None = None + + async def nested_cancel() -> None: + nonlocal cancellation + cancellation = await registry.cancel(TELEGRAM_CHAT, "status-1") + + async def callback() -> None: + await asyncio.create_task(nested_cancel()) + + assert await asyncio.wait_for(registry.handoff(claim, callback), timeout=1) is ( + VoiceHandoffOutcome.COMPLETED + ) + assert cancellation is None + assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is None + + +@pytest.mark.asyncio +async def test_nested_callback_task_is_excluded_from_bulk_cancellation() -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + cancellations: tuple[VoiceCancellationResult, ...] | None = None + + async def nested_cancel_all() -> None: + nonlocal cancellations + cancellations = await registry.cancel_all() + + async def callback() -> None: + await asyncio.create_task(nested_cancel_all()) + + assert await asyncio.wait_for(registry.handoff(claim, callback), timeout=1) is ( + VoiceHandoffOutcome.COMPLETED + ) + assert cancellations == () + assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is None + + +@pytest.mark.asyncio +async def test_fatal_callback_failure_releases_aliases_and_propagates() -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + + async def callback() -> None: + raise FatalVoiceFailure + + with pytest.raises(FatalVoiceFailure): + await registry.handoff(claim, callback) + + assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is None + assert await registry.cancel(TELEGRAM_CHAT, "status-1") is None + + +@pytest.mark.asyncio +async def test_fatal_failure_after_caller_cancellation_releases_aliases() -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + started = asyncio.Event() + cancellation_received = asyncio.Event() + release = asyncio.Event() + + async def callback() -> None: + started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cancellation_received.set() + await release.wait() + raise FatalVoiceFailure from None + + handoff_task = asyncio.create_task(registry.handoff(claim, callback)) + await asyncio.wait_for(started.wait(), timeout=1) + handoff_task.cancel() + await asyncio.wait_for(cancellation_received.wait(), timeout=1) + + assert await registry.reserve(TELEGRAM_CHAT, "voice-1") is None + release.set() + with pytest.raises(FatalVoiceFailure): + await handoff_task + + assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is None + assert await registry.cancel(TELEGRAM_CHAT, "status-1") is None + + +@pytest.mark.asyncio +async def test_fatal_loser_failure_is_retrieved_and_propagated() -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + started = asyncio.Event() + + async def callback() -> None: + started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + raise FatalVoiceFailure from None + + handoff_task = asyncio.create_task(registry.handoff(claim, callback)) + await asyncio.wait_for(started.wait(), timeout=1) + + with pytest.raises(FatalVoiceFailure): + await registry.cancel(TELEGRAM_CHAT, "voice-1") + with pytest.raises(FatalVoiceFailure): + await handoff_task + + assert await registry.cancel(TELEGRAM_CHAT, "status-1") is None + + +@pytest.mark.asyncio +async def test_cancel_all_deduplicates_aliases_and_excludes_current_child() -> None: + registry = PendingVoiceRegistry() + first = await _reserve_bound(registry, voice_id="voice-1", status_id="status-1") + second = await _reserve_bound( + registry, + scope=DISCORD_CHAT, + voice_id="voice-2", + status_id="status-2", + ) + first_started = asyncio.Event() + first_cancelled = asyncio.Event() + cancellations: tuple[VoiceCancellationResult, ...] | None = None + + async def first_callback() -> None: + first_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + first_cancelled.set() + raise + + async def second_callback() -> None: + nonlocal cancellations + await first_started.wait() + cancellations = await registry.cancel_all() + + first_handoff = asyncio.create_task(registry.handoff(first, first_callback)) + second_handoff = asyncio.create_task(registry.handoff(second, second_callback)) + + assert await asyncio.wait_for(first_handoff, timeout=1) is ( + VoiceHandoffOutcome.CANCELLED + ) + assert await asyncio.wait_for(second_handoff, timeout=1) is ( + VoiceHandoffOutcome.COMPLETED + ) + assert first_cancelled.is_set() + assert cancellations is not None + assert len(cancellations) == 1 + assert {result.scope for result in cancellations} == {TELEGRAM_CHAT} + assert {result.delete_message_ids for result in cancellations} == { + frozenset({"voice-1", "status-1"}), + } + + +@pytest.mark.asyncio +async def test_stale_claim_cannot_mutate_reused_voice_id() -> None: + registry = PendingVoiceRegistry() + stale_claim = await registry.reserve(TELEGRAM_CHAT, "voice-1") + + assert stale_claim is not None + assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is not None + + current_claim = await _reserve_bound( + registry, voice_id="voice-1", status_id="status-current" + ) + + assert current_claim != stale_claim + assert await registry.bind_status(stale_claim, "status-stale") is False + assert ( + await registry.handoff(stale_claim, AsyncNoop()) is VoiceHandoffOutcome.REJECTED + ) + assert await registry.discard(stale_claim) is False + assert await registry.cancel(TELEGRAM_CHAT, "status-current") is not None + + +@pytest.mark.asyncio +async def test_pending_voice_registry_rejects_duplicate_and_unbound_handoff() -> None: + registry = PendingVoiceRegistry() + claim = await registry.reserve(TELEGRAM_CHAT, "voice-1") + + assert claim is not None + assert await registry.reserve(TELEGRAM_CHAT, "voice-1") is None + assert await registry.handoff(claim, AsyncNoop()) is VoiceHandoffOutcome.REJECTED + assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is not None diff --git a/tests/provider_request_mocks.py b/tests/provider_request_mocks.py new file mode 100644 index 0000000..a8ae036 --- /dev/null +++ b/tests/provider_request_mocks.py @@ -0,0 +1,25 @@ +"""Shared MagicMock request objects for OpenAI-compatible provider tests.""" + +from unittest.mock import MagicMock + + +def make_openai_compat_stream_request( + *, model: str = "test-model", stream: bool = True +) -> MagicMock: + """Minimal request stub matching :meth:`OpenAIChatProvider._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 diff --git a/tests/providers/request_factory.py b/tests/providers/request_factory.py new file mode 100644 index 0000000..bce4cff --- /dev/null +++ b/tests/providers/request_factory.py @@ -0,0 +1,25 @@ +"""Concrete Anthropic request factory for provider tests.""" + +from typing import Any + +from free_claude_code.core.anthropic.models import MessagesRequest + + +def make_messages_request( + model: str = "test-model", **overrides: Any +) -> MessagesRequest: + """Build a real Messages request with provider-test defaults.""" + data: dict[str, Any] = { + "model": model, + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 100, + "temperature": 0.5, + "top_p": 0.9, + "system": "System prompt", + "stop_sequences": None, + "tools": [], + "extra_body": {}, + "thinking": {"enabled": True}, + } + data.update(overrides) + return MessagesRequest.model_validate(data) diff --git a/tests/providers/support.py b/tests/providers/support.py new file mode 100644 index 0000000..1f3266c --- /dev/null +++ b/tests/providers/support.py @@ -0,0 +1,76 @@ +"""Provider test doubles with explicit limiter ownership.""" + +from collections.abc import Callable +from typing import Any + +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.openai_chat import ( + OpenAIChatProvider, + create_openai_chat_provider, +) +from free_claude_code.providers.rate_limit import ProviderRateLimiter + + +class PassthroughProviderRateLimiter(ProviderRateLimiter): + """Skip retry timing while retaining the real concurrency context manager.""" + + def __init__(self) -> None: + super().__init__( + rate_limit=1_000_000, + rate_window=1.0, + max_concurrency=1_000, + ) + + async def execute_with_retry( + self, + fn: Callable[..., Any], + *args: Any, + **kwargs: Any, + ) -> Any: + kwargs.pop("provider_failure_override", None) + return await fn(*args, **kwargs) + + +class ImmediateRetryProviderRateLimiter(ProviderRateLimiter): + """Run the real retry policy without exercising wall-clock backoff.""" + + async def execute_with_retry( + self, + fn: Callable[..., Any], + *args: Any, + **kwargs: Any, + ) -> Any: + kwargs.update(base_delay=0.0, max_delay=0.0, jitter=0.0) + return await super().execute_with_retry(fn, *args, **kwargs) + + def extend_reactive_block(self, seconds: float) -> None: + """Leave reactive timing to the limiter's dedicated unit tests.""" + del seconds + + +def passthrough_rate_limiter() -> ProviderRateLimiter: + """Return a fresh limiter test double for one provider instance.""" + return PassthroughProviderRateLimiter() + + +def profiled_provider( + provider_id: str, + config: ProviderConfig, + *, + rate_limiter: ProviderRateLimiter | None = None, +) -> OpenAIChatProvider: + """Construct one declarative provider for a focused behavior test.""" + return create_openai_chat_provider( + provider_id, + config, + rate_limiter or passthrough_rate_limiter(), + ) + + +def retrying_rate_limiter() -> ProviderRateLimiter: + """Return a limiter that exercises retry policy without elapsed time.""" + return ImmediateRetryProviderRateLimiter( + rate_limit=1_000_000, + rate_window=1.0, + max_concurrency=1_000, + ) diff --git a/tests/providers/test_cerebras.py b/tests/providers/test_cerebras.py new file mode 100644 index 0000000..d8306bd --- /dev/null +++ b/tests/providers/test_cerebras.py @@ -0,0 +1,197 @@ +"""Tests for Cerebras Inference (OpenAI-compatible) provider.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.config.provider_catalog import CEREBRAS_DEFAULT_BASE +from free_claude_code.providers.base import ProviderConfig +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +def make_request(**overrides): + return make_messages_request("llama3.1-8b", **overrides) + + +@pytest.fixture +def cerebras_config(): + return ProviderConfig( + api_key="test_cerebras_key", + base_url=CEREBRAS_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ) + + +@pytest.fixture +def cerebras_provider(cerebras_config): + return profiled_provider( + "cerebras", cerebras_config, rate_limiter=passthrough_rate_limiter() + ) + + +def test_init(cerebras_config): + """Test provider initialization.""" + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_openai: + provider = profiled_provider( + "cerebras", cerebras_config, rate_limiter=passthrough_rate_limiter() + ) + assert provider._api_key == "test_cerebras_key" + assert provider._base_url == CEREBRAS_DEFAULT_BASE + mock_openai.assert_called_once() + + +def test_default_base_url_constant(): + assert CEREBRAS_DEFAULT_BASE == "https://api.cerebras.ai/v1" + + +def test_build_request_body_basic(cerebras_provider): + """Basic request body conversion attaches system message from Claude request.""" + req = make_request() + body = cerebras_provider._build_request_body(req) + + assert body["model"] == "llama3.1-8b" + assert body["messages"][0]["role"] == "system" + assert "max_completion_tokens" in body + + +def test_build_request_body_global_disable_blocks_reasoning_mapping(): + provider = profiled_provider( + "cerebras", + ProviderConfig( + api_key="test_cerebras_key", + base_url=CEREBRAS_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=False, + ), + rate_limiter=passthrough_rate_limiter(), + ) + req = make_request() + body = provider._build_request_body(req) + + roles = [m.get("role") for m in body.get("messages", [])] + assert "assistant_reasoning_content" not in roles + + +def test_build_request_body_remaps_max_tokens_preserves_message_name(cerebras_provider): + """Cerebras does not strip message ``name``; ``max_tokens`` maps to completion field.""" + with patch( + "free_claude_code.providers.openai_chat.request_policy.build_base_request_body" + ) as mock_convert: + mock_convert.return_value = { + "model": "llama3.1-8b", + "messages": [{"role": "user", "name": "alice", "content": "hi"}], + "max_tokens": 42, + } + req = make_request() + body = cerebras_provider._build_request_body(req) + + assert body["messages"][0].get("name") == "alice" + assert body.get("max_tokens") is None + assert body["max_completion_tokens"] == 42 + + +def test_build_request_body_prefers_existing_max_completion_tokens(cerebras_provider): + with patch( + "free_claude_code.providers.openai_chat.request_policy.build_base_request_body" + ) as mock_convert: + mock_convert.return_value = { + "model": "llama3.1-8b", + "messages": [{"role": "user", "content": "x"}], + "max_completion_tokens": 77, + "max_tokens": 999, + } + body = cerebras_provider._build_request_body(make_request()) + + assert body["max_completion_tokens"] == 77 + assert "max_tokens" not in body + + +def test_build_request_body_preserves_caller_extra_body(cerebras_provider): + req = make_request(extra_body={"clear_thinking": False}) + + body = cerebras_provider._build_request_body(req) + + eb = body.get("extra_body") + assert isinstance(eb, dict) + assert eb.get("clear_thinking") is False + + +@pytest.mark.asyncio +async def test_stream_response_text(cerebras_provider): + """Text content deltas are emitted as text blocks.""" + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content="Hello back!", + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + cerebras_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in cerebras_provider.stream_response(req)] + + assert any( + '"text_delta"' in event and "Hello back!" in event for event in events + ) + + +@pytest.mark.asyncio +async def test_stream_response_reasoning_content(cerebras_provider): + """reasoning_content deltas are emitted as thinking blocks.""" + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=None, + reasoning_content="Thinking...", + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + cerebras_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in cerebras_provider.stream_response(req)] + + assert any( + '"thinking_delta"' in event and "Thinking..." in event for event in events + ) + + +@pytest.mark.asyncio +async def test_cleanup(cerebras_provider): + cerebras_provider._client = AsyncMock() + + await cerebras_provider.cleanup() + + cerebras_provider._client.close.assert_called_once() diff --git a/tests/providers/test_cloudflare.py b/tests/providers/test_cloudflare.py new file mode 100644 index 0000000..3d6411c --- /dev/null +++ b/tests/providers/test_cloudflare.py @@ -0,0 +1,312 @@ +"""Tests for Cloudflare Workers AI OpenAI-compatible chat provider.""" + +from collections.abc import AsyncIterator +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import httpx +import pytest + +from free_claude_code.application.errors import ApplicationUnavailableError +from free_claude_code.config.provider_catalog import CLOUDFLARE_AI_REST_ROOT +from free_claude_code.core.anthropic.models import Message, MessagesRequest +from free_claude_code.core.anthropic.stream_contracts import parse_sse_text +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.cloudflare import ( + CloudflareProvider, + cloudflare_ai_base_url, +) +from tests.providers.support import passthrough_rate_limiter + +_ACCOUNT_ID = "account-123" +_BASE_URL = f"{CLOUDFLARE_AI_REST_ROOT}/accounts/{_ACCOUNT_ID}/ai/v1" +_MODEL_SEARCH_URL = f"{CLOUDFLARE_AI_REST_ROOT}/accounts/{_ACCOUNT_ID}/ai/models/search" + + +@pytest.fixture +def cloudflare_config() -> ProviderConfig: + return ProviderConfig( + api_key="test-cloudflare-token", + base_url=CLOUDFLARE_AI_REST_ROOT, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ) + + +@pytest.fixture +def cloudflare_provider(cloudflare_config: ProviderConfig) -> CloudflareProvider: + return CloudflareProvider( + cloudflare_config, + account_id=_ACCOUNT_ID, + rate_limiter=passthrough_rate_limiter(), + ) + + +def _request(model: str = "@cf/moonshotai/kimi-k2.6") -> MessagesRequest: + return MessagesRequest( + model=model, + max_tokens=100, + messages=[Message(role="user", content="hi")], + ) + + +def _chunk(delta: SimpleNamespace, *, finish_reason: str = "stop") -> SimpleNamespace: + return SimpleNamespace( + choices=[SimpleNamespace(delta=delta, finish_reason=finish_reason)], + usage=SimpleNamespace(completion_tokens=5, prompt_tokens=8), + ) + + +async def _stream(*chunks: SimpleNamespace) -> AsyncIterator[SimpleNamespace]: + for chunk in chunks: + yield chunk + + +def test_cloudflare_ai_base_url_uses_account_scoped_openai_chat_root() -> None: + assert cloudflare_ai_base_url(CLOUDFLARE_AI_REST_ROOT, "account/with slash") == ( + f"{CLOUDFLARE_AI_REST_ROOT}/accounts/account%2Fwith%20slash/ai/v1" + ) + + +def test_missing_account_id_raises_authentication_error( + cloudflare_config: ProviderConfig, +) -> None: + with pytest.raises(ApplicationUnavailableError, match="CLOUDFLARE_ACCOUNT_ID"): + CloudflareProvider( + cloudflare_config, account_id=" ", rate_limiter=passthrough_rate_limiter() + ) + + +def test_init_composes_account_scoped_openai_chat_base_url( + cloudflare_config: ProviderConfig, +) -> None: + with ( + patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_openai, + patch("httpx.AsyncClient") as mock_httpx_client, + ): + provider = CloudflareProvider( + cloudflare_config, + account_id=_ACCOUNT_ID, + rate_limiter=passthrough_rate_limiter(), + ) + + assert provider._api_key == "test-cloudflare-token" + assert provider._base_url == _BASE_URL + assert provider._model_search_url == _MODEL_SEARCH_URL + assert provider._provider_name == "CLOUDFLARE" + assert mock_openai.call_args.kwargs["base_url"] == _BASE_URL + assert mock_openai.call_args.kwargs["api_key"] == "test-cloudflare-token" + assert mock_httpx_client.called + + +def test_model_list_headers_use_bearer_auth( + cloudflare_provider: CloudflareProvider, +) -> None: + assert cloudflare_provider._model_list_headers() == { + "Authorization": "Bearer test-cloudflare-token" + } + + +def test_build_request_body_preserves_literal_cf_model_id_and_controls_thinking( + cloudflare_provider: CloudflareProvider, +) -> None: + request = MessagesRequest.model_validate( + { + "model": "@cf/moonshotai/kimi-k2.6", + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 100, + "thinking": {"type": "enabled", "budget_tokens": 2048}, + } + ) + + body = cloudflare_provider._build_request_body(request, thinking_enabled=True) + + assert body["model"] == "@cf/moonshotai/kimi-k2.6" + assert body["max_completion_tokens"] == 100 + assert "max_tokens" not in body + assert body["extra_body"]["chat_template_kwargs"]["thinking"] is True + + +def test_build_request_body_disabled_thinking_sets_cloudflare_template_flag( + cloudflare_provider: CloudflareProvider, +) -> None: + request = MessagesRequest.model_validate( + { + "model": "@cf/moonshotai/kimi-k2.6", + "messages": [{"role": "user", "content": "Hello"}], + "thinking": {"type": "disabled"}, + } + ) + + body = cloudflare_provider._build_request_body(request, thinking_enabled=True) + + assert body["extra_body"]["chat_template_kwargs"]["thinking"] is False + + +def test_build_request_body_preserves_user_extra_body_without_overriding_thinking( + cloudflare_provider: CloudflareProvider, +) -> None: + request = MessagesRequest.model_validate( + { + "model": "@cf/moonshotai/kimi-k2.6", + "messages": [{"role": "user", "content": "Hello"}], + "extra_body": {"chat_template_kwargs": {"thinking": False}}, + } + ) + + body = cloudflare_provider._build_request_body(request, thinking_enabled=True) + + assert body["extra_body"]["chat_template_kwargs"]["thinking"] is False + + +@pytest.mark.asyncio +async def test_lists_models_from_cloudflare_model_search_endpoint( + cloudflare_provider: CloudflareProvider, +) -> None: + response = httpx.Response( + 200, + json={ + "object": "list", + "data": [ + {"id": "@cf/moonshotai/kimi-k2.6", "object": "model"}, + {"id": "@cf/meta/llama-4-scout-17b-16e-instruct", "object": "model"}, + ], + }, + request=httpx.Request("GET", _MODEL_SEARCH_URL), + ) + with patch.object( + cloudflare_provider._model_list_client, + "get", + new_callable=AsyncMock, + return_value=response, + ) as mock_get: + assert await cloudflare_provider.list_model_ids() == frozenset( + { + "@cf/moonshotai/kimi-k2.6", + "@cf/meta/llama-4-scout-17b-16e-instruct", + } + ) + + mock_get.assert_awaited_once_with( + _MODEL_SEARCH_URL, + params={"format": "openrouter"}, + headers={"Authorization": "Bearer test-cloudflare-token"}, + ) + + +@pytest.mark.asyncio +async def test_stream_uses_openai_chat_completions( + cloudflare_provider: CloudflareProvider, +) -> None: + delta = SimpleNamespace( + content="Hello from Cloudflare", + reasoning_content=None, + reasoning=None, + tool_calls=None, + ) + + with patch.object( + cloudflare_provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=_stream(_chunk(delta)), + ) as mock_create: + events = [ + event async for event in cloudflare_provider.stream_response(_request()) + ] + + parsed = parse_sse_text("".join(events)) + assert any( + event.event == "content_block_delta" + and event.data.get("delta", {}).get("text") == "Hello from Cloudflare" + for event in parsed + ) + assert mock_create.call_args.kwargs["model"] == "@cf/moonshotai/kimi-k2.6" + assert mock_create.call_args.kwargs["stream"] is True + + +@pytest.mark.asyncio +async def test_stream_maps_cloudflare_reasoning_delta_to_thinking( + cloudflare_provider: CloudflareProvider, +) -> None: + delta = SimpleNamespace( + content=None, + reasoning_content=None, + reasoning="Cloudflare reasoning", + tool_calls=None, + ) + + with patch.object( + cloudflare_provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=_stream(_chunk(delta)), + ): + events = [ + event async for event in cloudflare_provider.stream_response(_request()) + ] + + parsed = parse_sse_text("".join(events)) + assert any( + event.event == "content_block_delta" + and event.data.get("delta", {}).get("thinking") == "Cloudflare reasoning" + for event in parsed + ) + + +@pytest.mark.asyncio +async def test_stream_maps_openai_tool_calls_to_tool_use( + cloudflare_provider: CloudflareProvider, +) -> None: + tool_call = SimpleNamespace( + index=0, + id="call_1", + function=SimpleNamespace(name="echo", arguments='{"value":"x"}'), + ) + delta = SimpleNamespace( + content=None, + reasoning_content=None, + reasoning=None, + tool_calls=[tool_call], + ) + request = MessagesRequest.model_validate( + { + "model": "@cf/moonshotai/kimi-k2.6", + "messages": [{"role": "user", "content": "Use the tool"}], + "tools": [ + { + "name": "echo", + "description": "Echo a value", + "input_schema": { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + }, + } + ], + } + ) + + with patch.object( + cloudflare_provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=_stream(_chunk(delta, finish_reason="tool_calls")), + ): + events = [event async for event in cloudflare_provider.stream_response(request)] + + parsed = parse_sse_text("".join(events)) + assert any( + event.event == "content_block_start" + and event.data.get("content_block", {}).get("type") == "tool_use" + and event.data.get("content_block", {}).get("name") == "echo" + for event in parsed + ) + assert any( + event.event == "content_block_delta" + and event.data.get("delta", {}).get("partial_json") == '{"value":"x"}' + for event in parsed + ) diff --git a/tests/providers/test_codestral.py b/tests/providers/test_codestral.py new file mode 100644 index 0000000..72094e1 --- /dev/null +++ b/tests/providers/test_codestral.py @@ -0,0 +1,156 @@ +"""Tests for Mistral Codestral provider.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.config.provider_catalog import CODESTRAL_DEFAULT_BASE +from free_claude_code.providers.base import ProviderConfig +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +def make_request(**overrides): + return make_messages_request("devstral-small-latest", **overrides) + + +@pytest.fixture +def codestral_config(): + return ProviderConfig( + api_key="test_codestral_key", + base_url=CODESTRAL_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ) + + +@pytest.fixture +def codestral_provider(codestral_config): + return profiled_provider( + "mistral_codestral", codestral_config, rate_limiter=passthrough_rate_limiter() + ) + + +def test_init(codestral_config): + """Test provider initialization.""" + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_openai: + provider = profiled_provider( + "mistral_codestral", + codestral_config, + rate_limiter=passthrough_rate_limiter(), + ) + assert provider._api_key == "test_codestral_key" + assert provider._base_url == CODESTRAL_DEFAULT_BASE + mock_openai.assert_called_once() + + +def test_default_base_url(): + assert CODESTRAL_DEFAULT_BASE == "https://codestral.mistral.ai/v1" + + +def test_build_request_body_basic(codestral_provider): + """Basic request body conversion works for Codestral.""" + req = make_request() + body = codestral_provider._build_request_body(req) + + assert body["model"] == "devstral-small-latest" + assert body["messages"][0]["role"] == "system" + + +def test_build_request_body_global_disable_blocks_reasoning_mapping(): + """Global disable disables reasoning replay in the converter.""" + provider = profiled_provider( + "mistral_codestral", + ProviderConfig( + api_key="test_codestral_key", + base_url=CODESTRAL_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=False, + ), + rate_limiter=passthrough_rate_limiter(), + ) + req = make_request() + body = provider._build_request_body(req) + + roles = [m.get("role") for m in body.get("messages", [])] + assert "assistant_reasoning_content" not in roles + + +@pytest.mark.asyncio +async def test_stream_response_text(codestral_provider): + """Text content deltas are emitted as text blocks.""" + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content="Hello back!", + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + codestral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in codestral_provider.stream_response(req)] + + assert any( + '"text_delta"' in event and "Hello back!" in event for event in events + ) + + +@pytest.mark.asyncio +async def test_stream_response_reasoning_content(codestral_provider): + """reasoning_content deltas are emitted as thinking blocks.""" + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=None, + reasoning_content="Thinking...", + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + codestral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in codestral_provider.stream_response(req)] + + assert any( + '"thinking_delta"' in event and "Thinking..." in event for event in events + ) + + +@pytest.mark.asyncio +async def test_cleanup(codestral_provider): + """cleanup closes the OpenAI client.""" + codestral_provider._client = AsyncMock() + + await codestral_provider.cleanup() + + codestral_provider._client.close.assert_called_once() diff --git a/tests/providers/test_cohere.py b/tests/providers/test_cohere.py new file mode 100644 index 0000000..e151b83 --- /dev/null +++ b/tests/providers/test_cohere.py @@ -0,0 +1,291 @@ +"""Tests for Cohere Compatibility API provider.""" + +from dataclasses import replace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.application.errors import InvalidRequestError +from free_claude_code.config.provider_catalog import COHERE_DEFAULT_BASE +from free_claude_code.providers.base import ProviderConfig +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +def make_request(**overrides): + return make_messages_request("command-a-plus-05-2026", **overrides) + + +@pytest.fixture +def cohere_config(): + return ProviderConfig( + api_key="test_cohere_key", + base_url=COHERE_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ) + + +@pytest.fixture +def cohere_provider(cohere_config): + return profiled_provider( + "cohere", cohere_config, rate_limiter=passthrough_rate_limiter() + ) + + +def test_default_base_url_constant(): + assert COHERE_DEFAULT_BASE == "https://api.cohere.ai/compatibility/v1" + + +def test_init_uses_default_base_url_and_api_key(cohere_config): + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_openai: + provider = profiled_provider( + "cohere", cohere_config, rate_limiter=passthrough_rate_limiter() + ) + + assert provider._api_key == "test_cohere_key" + assert provider._base_url == COHERE_DEFAULT_BASE + mock_openai.assert_called_once() + + +def test_init_strips_trailing_slash(cohere_config): + config = replace(cohere_config, base_url=f"{COHERE_DEFAULT_BASE}/") + + with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"): + provider = profiled_provider( + "cohere", config, rate_limiter=passthrough_rate_limiter() + ) + + assert provider._base_url == COHERE_DEFAULT_BASE + + +def test_build_request_body_sanitizes_documented_unsupported_fields(cohere_provider): + with patch( + "free_claude_code.providers.openai_chat.request_policy.build_base_request_body" + ) as mock_convert: + mock_convert.return_value = { + "model": "command-a-plus-05-2026", + "messages": [{"role": "user", "name": "alice", "content": "hi"}], + "max_tokens": 42, + "store": True, + "metadata": {"trace": "abc"}, + "logit_bias": {"1": -100}, + "top_logprobs": 2, + "n": 4, + "modalities": ["text"], + "prediction": {"type": "content", "content": "x"}, + "audio": {"voice": "alloy"}, + "service_tier": "auto", + "parallel_tool_calls": True, + } + + body = cohere_provider._build_request_body(make_request()) + + assert body["messages"][0].get("name") is None + assert body["max_tokens"] == 42 + assert "max_completion_tokens" not in body + for key in ( + "audio", + "logit_bias", + "metadata", + "modalities", + "n", + "parallel_tool_calls", + "prediction", + "service_tier", + "store", + "top_logprobs", + ): + assert key not in body + + +def test_build_request_body_maps_thinking_enabled_to_reasoning_high(cohere_provider): + body = cohere_provider._build_request_body(make_request()) + + assert body["reasoning_effort"] == "high" + + +def test_build_request_body_preserves_replayed_reasoning_content(cohere_provider): + with patch( + "free_claude_code.providers.openai_chat.request_policy.build_base_request_body" + ) as mock_convert: + mock_convert.return_value = { + "model": "command-a-plus-05-2026", + "messages": [ + { + "role": "assistant", + "content": "answer", + "reasoning_content": "hidden chain", + } + ], + } + + body = cohere_provider._build_request_body(make_request()) + + assert body["messages"] == [ + { + "role": "assistant", + "content": "answer", + "reasoning_content": "hidden chain", + } + ] + assert body["reasoning_effort"] == "high" + + +def test_build_request_body_maps_thinking_disabled_to_reasoning_none(): + provider = profiled_provider( + "cohere", + ProviderConfig( + api_key="test_cohere_key", + base_url=COHERE_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=False, + ), + rate_limiter=passthrough_rate_limiter(), + ) + + body = provider._build_request_body(make_request()) + + assert body["reasoning_effort"] == "none" + + +def test_build_request_body_promotes_allowed_extra_body(cohere_provider): + req = make_request( + extra_body={ + "frequency_penalty": 0.1, + "presence_penalty": 0.2, + "response_format": {"type": "json_object"}, + "seed": 123, + } + ) + + body = cohere_provider._build_request_body(req) + + assert body["frequency_penalty"] == 0.1 + assert body["presence_penalty"] == 0.2 + assert body["response_format"] == {"type": "json_object"} + assert body["seed"] == 123 + assert "extra_body" not in body + + +def test_build_request_body_rejects_unsupported_extra_body(cohere_provider): + req = make_request(extra_body={"documents": [{"text": "x"}]}) + + with pytest.raises(InvalidRequestError, match="Unsupported"): + cohere_provider._build_request_body(req) + + +@pytest.mark.asyncio +async def test_stream_response_text(cohere_provider): + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content="Hello from Cohere", + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + cohere_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [ + event async for event in cohere_provider.stream_response(make_request()) + ] + + assert any( + '"text_delta"' in event and "Hello from Cohere" in event for event in events + ) + + +@pytest.mark.asyncio +async def test_stream_response_tool_call(cohere_provider): + mock_tc = MagicMock() + mock_tc.index = 0 + mock_tc.id = "call_1" + mock_tc.function = MagicMock() + mock_tc.function.name = "Read" + mock_tc.function.arguments = '{"file_path":"a.py"}' + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content=None, reasoning_content=None, tool_calls=[mock_tc]), + finish_reason="tool_calls", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + cohere_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [ + event async for event in cohere_provider.stream_response(make_request()) + ] + + assert any( + '"content_block_start"' in event and '"tool_use"' in event for event in events + ) + assert any( + '"input_json_delta"' in event and "file_path" in event for event in events + ) + + +@pytest.mark.asyncio +async def test_stream_response_reasoning_content(cohere_provider): + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=None, + reasoning_content="Thinking via Cohere", + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + cohere_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [ + event async for event in cohere_provider.stream_response(make_request()) + ] + + assert any( + '"thinking_delta"' in event and "Thinking via Cohere" in event + for event in events + ) + + +@pytest.mark.asyncio +async def test_cleanup(cohere_provider): + cohere_provider._client = AsyncMock() + + await cohere_provider.cleanup() + + cohere_provider._client.close.assert_called_once() diff --git a/tests/providers/test_converter.py b/tests/providers/test_converter.py new file mode 100644 index 0000000..ed656ca --- /dev/null +++ b/tests/providers/test_converter.py @@ -0,0 +1,1046 @@ +import json + +import pytest + +from free_claude_code.core.anthropic import ( + AnthropicToOpenAIConverter, + OpenAIConversionError, + ReasoningReplayMode, + build_base_request_body, +) +from free_claude_code.core.anthropic.models import MessagesRequest + +# --- Mock Classes --- + + +class MockMessage: + def __init__(self, role, content, reasoning_content=None): + self.role = role + self.content = content + self.reasoning_content = reasoning_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=None): + 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 + + +def test_convert_tool_without_input_schema_uses_empty_object_schema(): + tools = [MockTool("web_search", None)] + + result = AnthropicToOpenAIConverter.convert_tools(tools) + + assert result == [ + { + "type": "function", + "function": { + "name": "web_search", + "description": "", + "parameters": {"type": "object", "properties": {}}, + }, + } + ] + + +@pytest.mark.parametrize( + "tool_choice,expected", + [ + ( + {"type": "tool", "name": "echo_smoke"}, + {"type": "function", "function": {"name": "echo_smoke"}}, + ), + ({"type": "any"}, "required"), + ({"type": "auto"}, "auto"), + ({"type": "none"}, "none"), + ( + {"type": "function", "function": {"name": "already_openai"}}, + {"type": "function", "function": {"name": "already_openai"}}, + ), + ], +) +def test_convert_tool_choice(tool_choice, expected): + result = AnthropicToOpenAIConverter.convert_tool_choice(tool_choice) + assert result == expected + + +# --- 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_replays_reasoning_content(): + """Top-level reasoning replay avoids duplicating thinking into content.""" + 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, reasoning_replay=ReasoningReplayMode.REASONING_CONTENT + ) + + assert len(result) == 1 + assert result[0]["reasoning_content"] == "I need to calculate this." + assert result[0]["content"] == "The answer is 4." + assert "" not in result[0]["content"] + + +def test_convert_assistant_top_level_reasoning_content_is_preserved(): + messages = [ + MockMessage( + "assistant", + "The answer is 4.", + reasoning_content="I need to calculate this.", + ) + ] + result = AnthropicToOpenAIConverter.convert_messages( + messages, reasoning_replay=ReasoningReplayMode.REASONING_CONTENT + ) + + assert result == [ + { + "role": "assistant", + "content": "The answer is 4.", + "reasoning_content": "I need to calculate this.", + } + ] + + +def test_convert_assistant_empty_top_level_reasoning_content_is_preserved(): + messages = [MockMessage("assistant", "The answer is 4.", reasoning_content="")] + + result = AnthropicToOpenAIConverter.convert_messages( + messages, reasoning_replay=ReasoningReplayMode.REASONING_CONTENT + ) + + assert result == [ + { + "role": "assistant", + "content": "The answer is 4.", + "reasoning_content": "", + } + ] + + +def test_convert_assistant_thinking_tool_use_replays_top_level_reasoning(): + content = [ + MockBlock(type="thinking", thinking="I should call the tool."), + MockBlock( + type="tool_use", + id="call_reasoning", + name="search", + input={"query": "python"}, + ), + ] + messages = [ + MockMessage("assistant", content), + MockMessage( + "user", + [ + MockBlock( + type="tool_result", + tool_use_id="call_reasoning", + content="result", + ) + ], + ), + ] + result = AnthropicToOpenAIConverter.convert_messages( + messages, reasoning_replay=ReasoningReplayMode.REASONING_CONTENT + ) + + assert len(result) == 2 + assert result[0]["content"] == "" + assert result[0]["reasoning_content"] == "I should call the tool." + assert "" not in result[0]["content"] + assert result[0]["tool_calls"][0]["id"] == "call_reasoning" + + +def test_convert_assistant_empty_thinking_replays_empty_reasoning_content(): + content = [ + MockBlock(type="thinking", thinking=""), + MockBlock(type="text", text="The answer is 4."), + ] + messages = [MockMessage("assistant", content)] + + result = AnthropicToOpenAIConverter.convert_messages( + messages, reasoning_replay=ReasoningReplayMode.REASONING_CONTENT + ) + + assert result == [ + { + "role": "assistant", + "content": "The answer is 4.", + "reasoning_content": "", + } + ] + + +def test_convert_assistant_tool_use_replays_empty_reasoning_content(): + content = [ + MockBlock(type="thinking", thinking=""), + MockBlock(type="tool_use", id="call_empty", name="Read", input={}), + ] + messages = [ + MockMessage("assistant", content), + MockMessage( + "user", + [ + MockBlock( + type="tool_result", + tool_use_id="call_empty", + content="result", + ) + ], + ), + ] + + result = AnthropicToOpenAIConverter.convert_messages( + messages, reasoning_replay=ReasoningReplayMode.REASONING_CONTENT + ) + + assert result[0]["content"] == "" + assert result[0]["reasoning_content"] == "" + assert result[0]["tool_calls"][0]["id"] == "call_empty" + + +def test_convert_assistant_message_thinking_removed_when_disabled(): + 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, + reasoning_replay=ReasoningReplayMode.DISABLED, + ) + + assert len(result) == 1 + assert "reasoning_content" not in result[0] + assert "" not in result[0]["content"] + assert result[0]["content"] == "The answer is 4." + + +def test_convert_assistant_top_level_reasoning_stripped_when_disabled(): + messages = [ + MockMessage( + "assistant", + "The answer is 4.", + reasoning_content="I need to calculate this.", + ) + ] + result = AnthropicToOpenAIConverter.convert_messages( + messages, reasoning_replay=ReasoningReplayMode.DISABLED + ) + + assert result == [{"role": "assistant", "content": "The answer is 4."}] + + +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), + MockMessage( + "user", + [MockBlock(type="tool_result", tool_use_id="call_1", content="result")], + ), + ] + result = AnthropicToOpenAIConverter.convert_messages(messages) + + assert len(result) == 2 + 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_tool_use_preserves_extra_content(): + content = [ + MockBlock( + type="tool_use", + id="call_1", + name="search", + input={"query": "python"}, + extra_content={"google": {"thought_signature": "sig"}}, + ), + ] + messages = [ + MockMessage("assistant", content), + MockMessage( + "user", + [MockBlock(type="tool_result", tool_use_id="call_1", content="result")], + ), + ] + result = AnthropicToOpenAIConverter.convert_messages(messages) + + assert result[0]["tool_calls"][0]["extra_content"] == { + "google": {"thought_signature": "sig"} + } + + +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), + MockMessage( + "user", + [MockBlock(type="tool_result", tool_use_id="call_2", content="result")], + ), + ] + 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={})] + ), + MockMessage( + "user", + [MockBlock(type="tool_result", tool_use_id="t1", content="result")], + ), + ] + result = AnthropicToOpenAIConverter.convert_messages(messages) + + assert len(result) == 4 + 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 free_claude_code.core.anthropic 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), + MockMessage( + "user", + [MockBlock(type="tool_result", tool_use_id="call_x", content="result")], + ), + ] + 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), + MockMessage( + "user", + [MockBlock(type="tool_result", tool_use_id="call_n", content="result")], + ), + ] + result = AnthropicToOpenAIConverter.convert_messages(messages) + assert len(result) == 2 + 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), + MockMessage( + "user", + [MockBlock(type="tool_result", tool_use_id="call_1", content="result")], + ), + ] + result = AnthropicToOpenAIConverter.convert_messages(messages) + + assert len(result) == 2 + 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" + + +def test_convert_user_message_tool_result_dict_as_json(): + content = [ + MockBlock( + type="tool_result", + tool_use_id="t_dict", + content={"ok": True, "count": 2}, + ), + ] + messages = [MockMessage("user", content)] + result = AnthropicToOpenAIConverter.convert_messages(messages) + assert result[0]["role"] == "tool" + assert result[0]["content"] == '{"ok": true, "count": 2}' + + +def test_assistant_redacted_thinking_omitted_from_openai_chat(): + """Opaque redacted_thinking is not materialized as content or reasoning_content.""" + content = [ + MockBlock(type="redacted_thinking", data="secret-opaque"), + MockBlock(type="text", text="Visible."), + ] + messages = [MockMessage("assistant", content)] + result = AnthropicToOpenAIConverter.convert_messages( + messages, reasoning_replay=ReasoningReplayMode.REASONING_CONTENT + ) + assert result[0]["content"] == "Visible." + assert "secret-opaque" not in result[0]["content"] + assert "reasoning_content" not in result[0] + + +def test_convert_user_message_image_raises(): + content = [ + MockBlock(type="image", source={"type": "url", "url": "https://example.com/x"}) + ] + messages = [MockMessage("user", content)] + with pytest.raises(OpenAIConversionError): + AnthropicToOpenAIConverter.convert_messages(messages) + + +def test_convert_assistant_text_after_tool_use_requires_matching_tool_result(): + """Dangling post-tool assistant text cannot be replayed as valid OpenAI chat.""" + content = [ + MockBlock(type="tool_use", id="call_z", name="Read", input={}), + MockBlock(type="text", text="After tool"), + ] + messages = [MockMessage("assistant", content)] + with pytest.raises(OpenAIConversionError, match="missing tool_result"): + AnthropicToOpenAIConverter.convert_messages(messages) + + +def test_convert_assistant_text_after_tool_use_inserts_after_tool_results(): + messages = [ + MockMessage( + "assistant", + [ + MockBlock(type="tool_use", id="call_z", name="Read", input={}), + MockBlock(type="text", text="Post-tool commentary"), + ], + ), + MockMessage( + "user", + [ + MockBlock( + type="tool_result", + tool_use_id="call_z", + content="file contents", + ) + ], + ), + ] + result = AnthropicToOpenAIConverter.convert_messages(messages) + assert result[0]["role"] == "assistant" and "tool_calls" in result[0] + assert result[1]["role"] == "tool" and result[1]["tool_call_id"] == "call_z" + assert result[2] == {"role": "assistant", "content": "Post-tool commentary"} + + +def test_unrelated_user_text_before_tool_result_is_buffered_until_after_tool_result(): + messages = [ + MockMessage( + "assistant", + [MockBlock(type="tool_use", id="call_z", name="Read", input={})], + ), + MockMessage("user", "Please also summarize it."), + MockMessage( + "user", + [ + MockBlock( + type="tool_result", + tool_use_id="call_z", + content="file contents", + ) + ], + ), + ] + + result = AnthropicToOpenAIConverter.convert_messages(messages) + + assert [message["role"] for message in result] == ["assistant", "tool", "user"] + assert result[0]["tool_calls"][0]["id"] == "call_z" + assert result[1]["tool_call_id"] == "call_z" + assert result[2]["content"] == "Please also summarize it." + + +def test_unrelated_assistant_text_before_tool_result_is_buffered_until_after_tool_result(): + messages = [ + MockMessage( + "assistant", + [MockBlock(type="tool_use", id="call_z", name="Read", input={})], + ), + MockMessage("assistant", "Waiting for the result."), + MockMessage( + "user", + [ + MockBlock( + type="tool_result", + tool_use_id="call_z", + content="file contents", + ) + ], + ), + ] + + result = AnthropicToOpenAIConverter.convert_messages(messages) + + assert [message["role"] for message in result] == [ + "assistant", + "tool", + "assistant", + ] + assert result[0]["tool_calls"][0]["id"] == "call_z" + assert result[1]["tool_call_id"] == "call_z" + assert result[2]["content"] == "Waiting for the result." + + +def test_user_text_in_tool_result_message_is_replayed_after_tool_sequence(): + messages = [ + MockMessage( + "assistant", + [ + MockBlock(type="tool_use", id="call_z", name="Read", input={}), + MockBlock(type="text", text="Post-tool commentary"), + ], + ), + MockMessage( + "user", + [ + MockBlock(type="text", text="Use this result too."), + MockBlock( + type="tool_result", + tool_use_id="call_z", + content="file contents", + ), + ], + ), + ] + + result = AnthropicToOpenAIConverter.convert_messages(messages) + + assert [message["role"] for message in result] == [ + "assistant", + "tool", + "assistant", + "user", + ] + assert result[1]["tool_call_id"] == "call_z" + assert result[2]["content"] == "Post-tool commentary" + assert result[3]["content"] == "Use this result too." + + +def test_nested_pending_tool_use_waits_for_its_own_tool_result_before_deferred_text(): + messages = [ + MockMessage( + "assistant", + [MockBlock(type="tool_use", id="call_a", name="ReadA", input={})], + ), + MockMessage( + "assistant", + [ + MockBlock(type="tool_use", id="call_b", name="ReadB", input={}), + MockBlock(type="text", text="Post-call-b commentary"), + ], + ), + MockMessage( + "user", + [MockBlock(type="tool_result", tool_use_id="call_a", content="result a")], + ), + MockMessage( + "user", + [MockBlock(type="tool_result", tool_use_id="call_b", content="result b")], + ), + ] + + result = AnthropicToOpenAIConverter.convert_messages(messages) + + assert [message["role"] for message in result] == [ + "assistant", + "tool", + "assistant", + "tool", + "assistant", + ] + assert result[0]["tool_calls"][0]["id"] == "call_a" + assert result[1]["tool_call_id"] == "call_a" + assert result[2]["tool_calls"][0]["id"] == "call_b" + assert result[3]["tool_call_id"] == "call_b" + assert result[4]["content"] == "Post-call-b commentary" + + +def test_nested_pending_uses_early_nested_tool_result_after_outer_result(): + messages = [ + MockMessage( + "assistant", + [MockBlock(type="tool_use", id="call_a", name="ReadA", input={})], + ), + MockMessage( + "assistant", + [ + MockBlock(type="tool_use", id="call_b", name="ReadB", input={}), + MockBlock(type="text", text="Post-call-b commentary"), + ], + ), + MockMessage( + "user", + [ + MockBlock(type="tool_result", tool_use_id="call_b", content="result b"), + MockBlock(type="tool_result", tool_use_id="call_a", content="result a"), + ], + ), + ] + + result = AnthropicToOpenAIConverter.convert_messages(messages) + + assert [message["role"] for message in result] == [ + "assistant", + "tool", + "assistant", + "tool", + "assistant", + ] + assert result[0]["tool_calls"][0]["id"] == "call_a" + assert result[1]["tool_call_id"] == "call_a" + assert result[2]["tool_calls"][0]["id"] == "call_b" + assert result[3]["tool_call_id"] == "call_b" + assert result[4]["content"] == "Post-call-b commentary" + + +def test_multi_tool_turn_waits_for_all_results_before_deferred_text(): + messages = [ + MockMessage( + "assistant", + [ + MockBlock(type="tool_use", id="call_a", name="ReadA", input={}), + MockBlock(type="tool_use", id="call_b", name="ReadB", input={}), + MockBlock(type="text", text="Both tools are done."), + ], + ), + MockMessage( + "user", + [ + MockBlock(type="tool_result", tool_use_id="call_b", content="result b"), + MockBlock(type="tool_result", tool_use_id="call_a", content="result a"), + ], + ), + ] + + result = AnthropicToOpenAIConverter.convert_messages(messages) + + assert [message["role"] for message in result] == [ + "assistant", + "tool", + "tool", + "assistant", + ] + assert [message["tool_call_id"] for message in result[1:3]] == [ + "call_a", + "call_b", + ] + assert result[3]["content"] == "Both tools are done." + + +def test_nested_pending_buffers_user_text_until_all_prior_tool_sequences_complete(): + messages = [ + MockMessage( + "assistant", + [MockBlock(type="tool_use", id="call_a", name="ReadA", input={})], + ), + MockMessage( + "assistant", + [ + MockBlock(type="tool_use", id="call_b", name="ReadB", input={}), + MockBlock(type="text", text="Post-call-b commentary"), + ], + ), + MockMessage( + "user", + [ + MockBlock(type="text", text="Use both results."), + MockBlock( + type="tool_result", + tool_use_id="call_a", + content="result a", + ), + MockBlock( + type="tool_result", + tool_use_id="call_b", + content="result b", + ), + ], + ), + ] + + result = AnthropicToOpenAIConverter.convert_messages(messages) + + assert [message["role"] for message in result] == [ + "assistant", + "tool", + "assistant", + "tool", + "assistant", + "user", + ] + assert result[1]["tool_call_id"] == "call_a" + assert result[3]["tool_call_id"] == "call_b" + assert result[4]["content"] == "Post-call-b commentary" + assert result[5]["content"] == "Use both results." + + +def test_openai_build_accepts_declared_native_top_level_hints() -> None: + """OpenAI conversion ignores known non-OpenAI hints (e.g. context_management) without 400.""" + req = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "h"}], + "context_management": {"edits": []}, + "output_config": {"foo": 1}, + "mcp_servers": [{"type": "url", "url": "https://x.com"}], + } + ) + body = build_base_request_body(req, default_max_tokens=100) + assert "context_management" not in body + assert "output_config" not in body + assert "mcp_servers" not in body + assert body["model"] == "m" + + +def test_openai_build_rejects_unknown_top_level_extras() -> None: + """Truly unknown keys must still be rejected (not dropped silently).""" + req = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "h"}], + "experimental_client_only_passthrough": True, + } + ) + with pytest.raises(OpenAIConversionError, match="top-level request fields"): + build_base_request_body(req) + + +@pytest.mark.parametrize( + "content", + [ + [MockBlock(type="server_tool_use", id="1", name="web_search", input={})], + [MockBlock(type="web_search_tool_result", tool_use_id="1", content=[])], + [ + MockBlock( + type="web_fetch_tool_result", + tool_use_id="1", + content={"type": "web_fetch_result", "url": "https://a.com/x"}, + ) + ], + ], +) +def test_convert_assistant_server_tool_blocks_raise(content) -> None: + messages = [MockMessage("assistant", content)] + with pytest.raises(OpenAIConversionError, match="server tool"): + AnthropicToOpenAIConverter.convert_messages(messages) diff --git a/tests/providers/test_deepseek.py b/tests/providers/test_deepseek.py new file mode 100644 index 0000000..d074857 --- /dev/null +++ b/tests/providers/test_deepseek.py @@ -0,0 +1,1159 @@ +"""Tests for DeepSeek OpenAI-compatible Chat Completions provider.""" + +import logging +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from free_claude_code.application.errors import InvalidRequestError +from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS +from free_claude_code.config.provider_catalog import DEEPSEEK_DEFAULT_BASE +from free_claude_code.core.anthropic.models import ( + ContentBlockImage, + Message, + MessagesRequest, + Tool, +) +from free_claude_code.core.anthropic.stream_contracts import parse_sse_text +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.deepseek import DeepSeekProvider +from tests.providers.support import passthrough_rate_limiter + + +@pytest.fixture +def deepseek_config(): + return ProviderConfig( + api_key="test_deepseek_key", + base_url=DEEPSEEK_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ) + + +@pytest.fixture +def deepseek_provider(deepseek_config): + return DeepSeekProvider(deepseek_config, rate_limiter=passthrough_rate_limiter()) + + +def test_default_base_url_alias(): + assert DEEPSEEK_DEFAULT_BASE == "https://api.deepseek.com" + + +def test_init(deepseek_config): + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_client: + provider = DeepSeekProvider( + deepseek_config, rate_limiter=passthrough_rate_limiter() + ) + assert provider._api_key == "test_deepseek_key" + assert provider._base_url == "https://api.deepseek.com" + assert mock_client.called + + +def test_build_request_body_openai_chat_shape(deepseek_provider): + request = MessagesRequest( + model="deepseek-v4-pro", + max_tokens=100, + messages=[Message(role="user", content="Hello")], + system="S", + ) + body = deepseek_provider._build_request_body(request) + assert body["model"] == "deepseek-v4-pro" + assert "stream" not in body + assert body["messages"][0] == {"role": "system", "content": "S"} + assert body["messages"][1]["role"] == "user" + assert body["messages"][1] == {"role": "user", "content": "Hello"} + assert body["max_tokens"] == 100 + assert "stream_options" not in body + + +def test_build_request_body_default_max_tokens(deepseek_provider): + request = MessagesRequest( + model="m", + messages=[Message(role="user", content="x")], + ) + body = deepseek_provider._build_request_body(request) + assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS + + +def test_build_request_body_thinking_enabled(deepseek_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "x"}], + "thinking": {"type": "enabled", "budget_tokens": 2000}, + } + ) + body = deepseek_provider._build_request_body(request) + assert body["extra_body"]["thinking"] == {"type": "enabled"} + + +def test_build_request_body_tool_list_keeps_thinking(deepseek_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "x"}], + "tools": [ + { + "name": "Read", + "description": "Read a file", + "input_schema": {"type": "object", "properties": {}}, + } + ], + "thinking": {"type": "enabled", "budget_tokens": 2000}, + } + ) + + body = deepseek_provider._build_request_body(request) + + assert body["extra_body"]["thinking"] == {"type": "enabled"} + assert body["tools"][0]["function"]["name"] == "Read" + + +def test_build_request_body_tool_choice_keeps_thinking(deepseek_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "x"}], + "tool_choice": {"type": "auto"}, + "thinking": {"type": "enabled", "budget_tokens": 2000}, + } + ) + + body = deepseek_provider._build_request_body(request) + + assert body["extra_body"]["thinking"] == {"type": "enabled"} + assert body["tool_choice"] == "auto" + + +def test_build_request_body_forced_tool_choice_downgrades_to_auto( + deepseek_provider, +): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "x"}], + "tool_choice": {"type": "tool", "name": "Read"}, + "tools": [ + { + "name": "Read", + "description": "Read a file", + "input_schema": {"type": "object", "properties": {}}, + } + ], + "thinking": {"type": "enabled", "budget_tokens": 2000}, + } + ) + + body = deepseek_provider._build_request_body(request) + + assert body["extra_body"]["thinking"] == {"type": "enabled"} + assert body["tool_choice"] == "auto" + + +def test_build_request_body_respects_global_thinking_disable(): + provider = DeepSeekProvider( + ProviderConfig( + api_key="k", + base_url=DEEPSEEK_DEFAULT_BASE, + rate_limit=1, + rate_window=1, + enable_thinking=False, + ), + rate_limiter=passthrough_rate_limiter(), + ) + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "x"}], + "thinking": {"type": "enabled", "budget_tokens": 1}, + } + ) + body = provider._build_request_body(request) + assert "extra_body" not in body + assert "stream_options" not in body + + +def test_preserve_unsigned_thinking_when_thinking_on(deepseek_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "plain", + "signature": None, + }, + {"type": "text", "text": "out"}, + ], + } + ], + } + ) + body = deepseek_provider._build_request_body(request) + assert body["messages"][0]["content"] == "out" + assert body["messages"][0]["reasoning_content"] == "plain" + + +def test_strip_redacted_thinking_when_thinking_on(deepseek_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "redacted_thinking", "data": "opaque"}, + {"type": "text", "text": "out"}, + ], + } + ], + } + ) + body = deepseek_provider._build_request_body(request) + assert body["messages"][0] == {"role": "assistant", "content": "out"} + + +def test_tool_history_with_replayable_thinking_preserves_thinking(deepseek_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "hidden", + "signature": "sig_123", + }, + {"type": "redacted_thinking", "data": "opaque"}, + { + "type": "tool_use", + "id": "t1", + "name": "Read", + "input": {"file_path": "x"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": "ok", + } + ], + }, + ], + "thinking": {"type": "enabled", "budget_tokens": 2000}, + "context_management": { + "edits": [{"type": "clear_thinking_20251015", "keep": "all"}] + }, + "output_config": {"effort": "high"}, + } + ) + + body = deepseek_provider._build_request_body(request) + + assert body["extra_body"]["thinking"] == {"type": "enabled"} + assert "context_management" not in body + assert "output_config" not in body + assistant = body["messages"][0] + assert assistant["content"] == "" + assert assistant["reasoning_content"] == "hidden" + assert assistant["tool_calls"][0]["function"]["name"] == "Read" + assert assistant["tool_calls"][0]["function"]["arguments"] == '{"file_path": "x"}' + assert body["messages"][1] == { + "role": "tool", + "tool_call_id": "t1", + "content": "ok", + } + + +def test_tool_history_with_unsigned_thinking_preserves_thinking(deepseek_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "plain"}, + { + "type": "tool_use", + "id": "t1", + "name": "Read", + "input": {"file_path": "x"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": "ok", + } + ], + }, + ], + "thinking": {"type": "enabled"}, + } + ) + + body = deepseek_provider._build_request_body(request) + + assert body["extra_body"]["thinking"] == {"type": "enabled"} + assert body["messages"][0]["reasoning_content"] == "plain" + + +def test_tool_history_without_thinking_disables_thinking_and_hints(deepseek_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "t1", + "name": "Read", + "input": {"file_path": "x"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": "ok", + } + ], + }, + ], + "tools": [ + { + "name": "Read", + "description": "Read a file", + "input_schema": {"type": "object", "properties": {}}, + } + ], + "tool_choice": {"type": "auto"}, + "thinking": {"type": "enabled", "budget_tokens": 2000}, + "context_management": { + "edits": [ + {"type": "clear_thinking_20251015", "keep": "all"}, + {"type": "other_edit", "keep": "all"}, + ], + "other": True, + }, + "output_config": {"effort": "high", "format": "text"}, + } + ) + + body = deepseek_provider._build_request_body(request) + + assert "extra_body" not in body + assert "context_management" not in body + assert "output_config" not in body + assert body["tools"][0]["function"]["name"] == "Read" + assert body["tool_choice"] == "auto" + assert body["messages"][0]["tool_calls"][0]["function"]["name"] == "Read" + assert body["messages"][1]["role"] == "tool" + + +def test_tool_history_with_empty_thinking_preserves_reasoning_state(deepseek_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": ""}, + { + "type": "tool_use", + "id": "t1", + "name": "Read", + "input": {"file_path": "x"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": "ok", + } + ], + }, + ], + "thinking": {"type": "enabled"}, + } + ) + + body = deepseek_provider._build_request_body(request) + + assert body["extra_body"]["thinking"] == {"type": "enabled"} + assert body["messages"][0]["reasoning_content"] == "" + assert body["messages"][0]["tool_calls"][0]["function"]["name"] == "Read" + + +def test_tool_history_with_empty_top_level_reasoning_preserves_reasoning_state( + deepseek_provider, +): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "t1", + "name": "Read", + "input": {"file_path": "x"}, + }, + ], + "reasoning_content": "", + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": "ok", + } + ], + }, + ], + "thinking": {"type": "enabled"}, + } + ) + + body = deepseek_provider._build_request_body(request) + + assert body["extra_body"]["thinking"] == {"type": "enabled"} + assert body["messages"][0]["reasoning_content"] == "" + assert body["messages"][0]["tool_calls"][0]["function"]["name"] == "Read" + + +def test_thinking_off_strips_thinking_history(): + provider = DeepSeekProvider( + ProviderConfig( + api_key="k", + base_url=DEEPSEEK_DEFAULT_BASE, + rate_limit=1, + rate_window=1, + enable_thinking=False, + ), + rate_limiter=passthrough_rate_limiter(), + ) + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "sec"}, + {"type": "text", "text": "hi"}, + ], + } + ], + } + ) + body = provider._build_request_body(request) + assert "reasoning_content" not in body["messages"][0] + assert "sec" not in str(body["messages"]) + + +def test_passthrough_tool_use_and_result(deepseek_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "t1", + "name": "n", + "input": {"a": 1}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": "ok", + } + ], + }, + ], + } + ) + body = deepseek_provider._build_request_body(request) + assert body["messages"][0]["tool_calls"][0]["function"]["name"] == "n" + assert body["messages"][1]["role"] == "tool" + + +def test_preflight_strips_user_image(): + """Image blocks are silently stripped (DeepSeek lacks vision); request must not fail.""" + request = MessagesRequest( + model="m", + messages=[ + Message( + role="user", + content=[ + ContentBlockImage( + type="image", + source={ + "type": "base64", + "media_type": "image/png", + "data": "YQ==", + }, + ) + ], + ) + ], + ) + provider = DeepSeekProvider( + ProviderConfig( + api_key="k", + base_url=DEEPSEEK_DEFAULT_BASE, + rate_limit=1, + rate_window=1, + ), + rate_limiter=passthrough_rate_limiter(), + ) + # Should not raise; image is stripped. + provider.preflight_stream(request, thinking_enabled=True) + body = provider._build_request_body(request) + content = body["messages"][0]["content"] + assert "attachment omitted" in content.lower() + assert "image or document inputs" in content.lower() + + +def test_preflight_rejects_mcp_servers(): + request = MessagesRequest( + model="m", + messages=[Message(role="user", content="x")], + mcp_servers=[{"type": "url", "url": "https://x"}], + ) + provider = DeepSeekProvider( + ProviderConfig( + api_key="k", + base_url=DEEPSEEK_DEFAULT_BASE, + rate_limit=1, + rate_window=1, + ), + rate_limiter=passthrough_rate_limiter(), + ) + with pytest.raises(InvalidRequestError, match="mcp_servers"): + provider.preflight_stream(request) + + +def test_preflight_rejects_listed_server_tools_in_tools_list(): + request = MessagesRequest( + model="m", + messages=[Message(role="user", content="x")], + tools=[Tool(name="web_search", type="web_search_20250305", input_schema={})], + ) + provider = DeepSeekProvider( + ProviderConfig( + api_key="k", + base_url=DEEPSEEK_DEFAULT_BASE, + rate_limit=1, + rate_window=1, + ), + rate_limiter=passthrough_rate_limiter(), + ) + with pytest.raises(InvalidRequestError, match="web_search"): + provider.preflight_stream(request) + + +def test_preflight_rejects_server_tool_result_blocks(): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "server_tool_use", + "id": "s1", + "name": "web_search", + "input": {"q": "a"}, + }, + { + "type": "web_search_tool_result", + "tool_use_id": "s1", + "content": [], + }, + ], + } + ], + } + ) + provider = DeepSeekProvider( + ProviderConfig( + api_key="k", + base_url=DEEPSEEK_DEFAULT_BASE, + rate_limit=1, + rate_window=1, + ), + rate_limiter=passthrough_rate_limiter(), + ) + with pytest.raises(InvalidRequestError, match=r"web_search_tool_result|server"): + provider.preflight_stream(request) + + +def test_reasoning_content_replayed_to_openai_chat(deepseek_provider): + request = MessagesRequest( + model="m", + messages=[ + Message( + role="assistant", + content="hi", + reasoning_content="r", + ) + ], + ) + body = deepseek_provider._build_request_body(request) + assert body["messages"][0]["reasoning_content"] == "r" + + +@pytest.mark.asyncio +async def test_stream_uses_chat_completions_and_maps_cache_usage(deepseek_provider): + request = MessagesRequest( + model="m", + messages=[Message(role="user", content="hi")], + ) + + async def fake_stream(): + yield SimpleNamespace( + choices=[ + SimpleNamespace( + delta=SimpleNamespace( + content="hello", reasoning_content=None, tool_calls=None + ), + finish_reason=None, + ) + ], + usage=None, + ) + yield SimpleNamespace( + choices=[ + SimpleNamespace( + delta=SimpleNamespace( + content=None, reasoning_content=None, tool_calls=None + ), + finish_reason="stop", + ) + ], + usage=None, + ) + yield SimpleNamespace( + choices=[], + usage=SimpleNamespace( + completion_tokens=3, + prompt_tokens=30, + prompt_cache_hit_tokens=10, + prompt_cache_miss_tokens=20, + ), + ) + + create = AsyncMock(return_value=fake_stream()) + with patch.object(deepseek_provider._client.chat.completions, "create", create): + chunks = [ + chunk + async for chunk in deepseek_provider.stream_response( + request, input_tokens=7, request_id="r1" + ) + ] + + create.assert_awaited_once() + await_args = create.await_args + assert await_args is not None + assert await_args.kwargs["model"] == "m" + assert await_args.kwargs["stream"] is True + assert await_args.kwargs["stream_options"] == {"include_usage": True} + parsed = parse_sse_text("".join(chunks)) + usage = next( + event.data["usage"] for event in parsed if event.event == "message_delta" + ) + assert usage == { + "input_tokens": 30, + "output_tokens": 3, + "cache_read_input_tokens": 10, + "cache_creation_input_tokens": 20, + } + + +def test_preserves_extra_body_for_openai_chat_request(deepseek_provider): + raw = { + "model": "m", + "max_tokens": 3, + "messages": [{"role": "user", "content": "x"}], + "extra_body": {"note": 1}, + } + r = MessagesRequest.model_validate(raw) + body = deepseek_provider._build_request_body(r) + assert body["extra_body"] == {"note": 1, "thinking": {"type": "enabled"}} + + +def test_normalizes_tool_result_content_array_to_string(deepseek_provider): + """Test that tool_result content arrays are normalized to strings for DeepSeek API.""" + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "t1", + "name": "list_dir", + "input": {"path": "/"}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": [ + {"type": "text", "text": "file1.txt"}, + {"type": "text", "text": "file2.txt"}, + ], + } + ], + }, + ], + } + ) + + body = deepseek_provider._build_request_body(request) + + tool_result = body["messages"][1] + assert tool_result["role"] == "tool" + assert isinstance(tool_result["content"], str) + assert "file1.txt" in tool_result["content"] + assert "file2.txt" in tool_result["content"] + + +def test_strips_document_blocks_for_deepseek(deepseek_provider): + """Document blocks (e.g. PDFs from Claude Code) are stripped since DeepSeek can't process them.""" + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": "PDF text extracted", + }, + { + "type": "document", + "source": {"type": "file", "file_id": "file_abc"}, + "cache_control": {"type": "ephemeral"}, + }, + ], + }, + ], + } + ) + + body = deepseek_provider._build_request_body(request) + + assert body["messages"][0] == { + "role": "tool", + "tool_call_id": "t1", + "content": "PDF text extracted", + } + + +def test_strips_image_blocks_for_deepseek(deepseek_provider): + """Image blocks are stripped for DeepSeek since it doesn't support vision.""" + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "describe this"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "abc", + }, + }, + ], + }, + ], + } + ) + + body = deepseek_provider._build_request_body(request) + + assert body["messages"][0] == {"role": "user", "content": "describe this"} + + +def test_normalizes_tool_result_content_dict_to_string(deepseek_provider): + """Test that tool_result content dicts are normalized to JSON strings.""" + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "t1", + "name": "get_data", + "input": {}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": {"status": "success", "data": [1, 2, 3]}, + } + ], + }, + ], + } + ) + + body = deepseek_provider._build_request_body(request) + + tool_result = body["messages"][1] + assert tool_result["role"] == "tool" + assert isinstance(tool_result["content"], str) + assert "status" in tool_result["content"] + assert "success" in tool_result["content"] + + +def test_strips_image_block_inside_tool_result(deepseek_provider): + """Image blocks nested inside tool_result.content are stripped, not rejected.""" + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "t1", + "name": "Read", + "input": {"path": "shot.png"}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": [ + {"type": "text", "text": "screenshot saved"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "abc", + }, + }, + ], + } + ], + }, + ], + } + ) + + body = deepseek_provider._build_request_body(request) + + tool_result = body["messages"][1] + assert tool_result["role"] == "tool" + # After stripping + string-normalization, no base64/image marker survives. + assert isinstance(tool_result["content"], str) + assert "screenshot saved" in tool_result["content"] + assert "base64" not in tool_result["content"] + assert "abc" not in tool_result["content"] + + +def test_image_only_tool_result_replaced_with_placeholder(deepseek_provider): + """A tool_result whose only inner block is an image becomes a placeholder string.""" + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "t1", + "name": "Screenshot", + "input": {}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "abc", + }, + }, + ], + } + ], + }, + ], + } + ) + + body = deepseek_provider._build_request_body(request) + + tool_result = body["messages"][1] + assert tool_result["role"] == "tool" + assert isinstance(tool_result["content"], str) + assert tool_result["content"] != "" + assert "attachment omitted" in tool_result["content"].lower() + assert "image or document inputs" in tool_result["content"].lower() + + +def test_document_only_tool_result_replaced_with_generic_placeholder( + deepseek_provider, +): + """A document-only tool_result uses the generic attachment placeholder.""" + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "t1", + "name": "Read", + "input": {"file_path": "paper.pdf"}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": [ + { + "type": "document", + "source": { + "type": "file", + "file_id": "file_pdf", + }, + }, + ], + } + ], + }, + ], + } + ) + + body = deepseek_provider._build_request_body(request) + + tool_result = body["messages"][1] + assert tool_result["role"] == "tool" + assert isinstance(tool_result["content"], str) + assert "attachment omitted" in tool_result["content"].lower() + assert "document inputs" in tool_result["content"].lower() + assert "image omitted" not in tool_result["content"].lower() + + +def test_image_only_message_replaced_with_placeholder(deepseek_provider): + """A top-level image-only message remains non-empty after stripping.""" + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "abc", + }, + }, + ], + }, + ], + } + ) + + body = deepseek_provider._build_request_body(request) + + content = body["messages"][0]["content"] + assert "attachment omitted" in content.lower() + assert "image or document inputs" in content.lower() + + +def test_document_only_message_replaced_with_placeholder(deepseek_provider): + """A top-level document-only message remains non-empty after stripping.""" + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "document", + "source": {"type": "file", "file_id": "file_pdf"}, + }, + ], + }, + ], + } + ) + + body = deepseek_provider._build_request_body(request) + + content = body["messages"][0]["content"] + assert "attachment omitted" in content.lower() + assert "document inputs" in content.lower() + + +def test_warns_when_stripping_attachment_blocks(deepseek_provider, caplog): + """A warning is emitted when image/document blocks are dropped so users notice.""" + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "look"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "abc", + }, + }, + ], + }, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "t1", + "name": "Screenshot", + "input": {}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "abc", + }, + }, + ], + } + ], + }, + ], + } + ) + + with caplog.at_level(logging.WARNING): + deepseek_provider._build_request_body(request) + + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert any("stripped unsupported attachment blocks" in r.message for r in warnings) + + +def test_no_warning_when_no_attachments(deepseek_provider, caplog): + """No warning is emitted on plain text-only requests.""" + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "hello"}], + } + ) + + with caplog.at_level(logging.WARNING): + deepseek_provider._build_request_body(request) + + assert not any( + "stripped unsupported attachment blocks" in r.message + for r in caplog.records + if r.levelno == logging.WARNING + ) diff --git a/tests/providers/test_execution_failure_boundary.py b/tests/providers/test_execution_failure_boundary.py new file mode 100644 index 0000000..dcce33f --- /dev/null +++ b/tests/providers/test_execution_failure_boundary.py @@ -0,0 +1,264 @@ +"""Provider streams raise canonical failures after closing committed blocks.""" + +from collections import deque +from collections.abc import AsyncIterator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.config.nim import NimSettings +from free_claude_code.core.anthropic.stream_contracts import parse_sse_text +from free_claude_code.core.async_iterators import AsyncCloseable +from free_claude_code.core.failures import ExecutionFailure, FailureKind +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.http import close_provider_stream +from free_claude_code.providers.nvidia_nim import NvidiaNimProvider +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter + + +class _FailingStream: + def __init__( + self, + chunks: list[object], + error: Exception | None, + *, + close_error: Exception | None = None, + ) -> None: + self._chunks = chunks + self._error = error + self._close_error = close_error + self.close_calls = 0 + + def __aiter__(self) -> AsyncIterator[object]: + return self._iterate() + + async def _iterate(self) -> AsyncIterator[object]: + for chunk in self._chunks: + yield chunk + if self._error is not None: + raise self._error + + async def aclose(self) -> None: + self.close_calls += 1 + if self._close_error is not None: + raise self._close_error + + +def _chunk(*, content: str | None = None, finish_reason: str | None = None) -> object: + delta = MagicMock(content=content, tool_calls=None, reasoning_content=None) + choice = MagicMock(delta=delta, finish_reason=finish_reason) + return MagicMock(choices=[choice], usage=None) + + +def _provider() -> NvidiaNimProvider: + return NvidiaNimProvider( + ProviderConfig( + api_key="test_key", + base_url="https://test.api.nvidia.com/v1", + rate_limit=10, + rate_window=60, + ), + nim_settings=NimSettings(), + rate_limiter=passthrough_rate_limiter(), + ) + + +@pytest.mark.asyncio +async def test_committed_provider_failure_closes_block_then_raises_canonical_value() -> ( + None +): + provider = _provider() + request = make_messages_request( + "test-model", + messages=[], + max_tokens=32, + ) + # Crossing the recovery buffer's byte threshold makes the content block + # downstream-visible before the failure, so its close prelude must escape. + stream = _FailingStream( + [_chunk(content="x" * 65_536)], + RuntimeError("connection lost after commit"), + ) + emitted: deque[str] = deque() + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream, + ), + pytest.raises(ExecutionFailure) as exc_info, + ): + async for event in provider.stream_response( + request, + request_id="req_committed_failure", + ): + emitted.append(event) + + events = parse_sse_text("".join(emitted)) + assert [event.event for event in events][-1] == "content_block_stop" + assert not any(event.event in {"error", "message_stop"} for event in events) + assert exc_info.value.kind is FailureKind.UPSTREAM + assert exc_info.value.status_code == 502 + assert exc_info.value.retryable is False + assert "connection lost after commit" in exc_info.value.message + assert "Request ID: req_committed_failure" in exc_info.value.message + + +@pytest.mark.asyncio +async def test_openai_stream_close_failure_cannot_mask_execution_failure() -> None: + provider = _provider() + request = make_messages_request( + "test-model", + messages=[], + max_tokens=32, + ) + stream = _FailingStream( + [], + RuntimeError("original provider failure"), + close_error=RuntimeError("cleanup api_key=SECRET"), + ) + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream, + ), + patch("free_claude_code.providers.http.trace_event") as trace_event, + pytest.raises(ExecutionFailure) as exc_info, + ): + [ + event + async for event in provider.stream_response( + request, + request_id="req_close_failure", + ) + ] + + assert stream.close_calls == 1 + assert exc_info.value.status_code == 502 + assert "original provider failure" in exc_info.value.message + assert "cleanup" not in exc_info.value.message + assert "SECRET" not in exc_info.value.message + trace_event.assert_called_once_with( + stage="provider", + event="provider.stream.close_failed", + source="provider", + provider="NIM", + request_id="req_close_failure", + close_exc_type="RuntimeError", + preserved_exc_type="ExecutionFailure", + ) + assert "SECRET" not in repr(trace_event.call_args) + + +@pytest.mark.asyncio +async def test_stream_close_failure_without_active_error_is_observability_only() -> ( + None +): + stream = _FailingStream( + [], + RuntimeError("unused"), + close_error=RuntimeError("normal close failed"), + ) + + with patch("free_claude_code.providers.http.trace_event") as trace_event: + await close_provider_stream( + stream, + active_error=None, + provider_name="TEST", + request_id="req_normal_close", + ) + + assert stream.close_calls == 1 + trace_event.assert_called_once_with( + stage="provider", + event="provider.stream.close_failed", + source="provider", + provider="TEST", + request_id="req_normal_close", + close_exc_type="RuntimeError", + preserved_exc_type=None, + ) + + +@pytest.mark.asyncio +async def test_completed_stream_close_failure_preserves_success_lifecycle() -> None: + provider = _provider() + request = make_messages_request( + "test-model", + messages=[], + max_tokens=32, + ) + stream = _FailingStream( + [_chunk(content="complete", finish_reason="stop")], + None, + close_error=RuntimeError("cleanup api_key=SECRET"), + ) + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream, + ), + patch("free_claude_code.providers.http.trace_event") as trace_event, + ): + emitted = [ + event + async for event in provider.stream_response( + request, + request_id="req_successful_close_failure", + ) + ] + + events = parse_sse_text("".join(emitted)) + assert events[-1].event == "message_stop" + assert sum(event.event == "message_stop" for event in events) == 1 + assert not any(event.event == "error" for event in events) + assert stream.close_calls == 1 + trace_event.assert_called_once_with( + stage="provider", + event="provider.stream.close_failed", + source="provider", + provider="NIM", + request_id="req_successful_close_failure", + close_exc_type="RuntimeError", + preserved_exc_type=None, + ) + assert "SECRET" not in repr(trace_event.call_args) + + +@pytest.mark.asyncio +async def test_closing_public_openai_stream_closes_raw_stream_once() -> None: + provider = _provider() + request = make_messages_request( + "test-model", + messages=[], + max_tokens=32, + ) + raw_stream = _FailingStream( + [ + _chunk(content="x" * 65_536), + _chunk(content="done", finish_reason="stop"), + ], + None, + ) + + with patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=raw_stream, + ): + stream = provider.stream_response(request, request_id="req_early_close") + await anext(stream) + assert isinstance(stream, AsyncCloseable) + await stream.aclose() + + assert raw_stream.close_calls == 1 diff --git a/tests/providers/test_failure_policy.py b/tests/providers/test_failure_policy.py new file mode 100644 index 0000000..10e8d78 --- /dev/null +++ b/tests/providers/test_failure_policy.py @@ -0,0 +1,375 @@ +"""Raw provider failure classification into the canonical neutral model.""" + +from collections.abc import Callable +from dataclasses import dataclass +from unittest.mock import Mock + +import httpx +import openai +import pytest + +from free_claude_code.core.diagnostics import ( + ERROR_DETAIL_DISPLAY_CAP_BYTES, + attach_upstream_error_body, +) +from free_claude_code.core.failures import ExecutionFailure, FailureKind +from free_claude_code.providers.failure_policy import classify_provider_failure + + +def _openai_status_error( + error_type: type[openai.APIStatusError], + *, + status_code: int, + message: str, + body: object | None = None, +) -> openai.APIStatusError: + request = httpx.Request("POST", "https://provider.test/v1/chat/completions") + response = httpx.Response(status_code, request=request) + return error_type( + message, + response=response, + body=body or {"error": {"message": message}}, + ) + + +def _statusless_openai_error(message: str, body: object | None) -> openai.APIError: + return openai.APIError( + message, + request=httpx.Request("POST", "https://provider.test/v1/chat/completions"), + body=body, + ) + + +def _http_status_error(status_code: int, message: str) -> httpx.HTTPStatusError: + request = httpx.Request("POST", "https://provider.test/v1/messages") + response = httpx.Response( + status_code, + request=request, + json={"error": {"message": message, "api_key": "SECRET"}}, + ) + return httpx.HTTPStatusError(message, request=request, response=response) + + +@dataclass(frozen=True, slots=True) +class _ClassificationCase: + name: str + error: Callable[[], Exception] + kind: FailureKind + status_code: int + retryable: bool + rate_limit_block_seconds: int | None = None + + +_CASES = ( + _ClassificationCase( + "openai_authentication", + lambda: _openai_status_error( + openai.AuthenticationError, + status_code=401, + message="Unauthorized", + ), + FailureKind.AUTHENTICATION, + 401, + False, + ), + _ClassificationCase( + "openai_rate_limit", + lambda: _openai_status_error( + openai.RateLimitError, + status_code=429, + message="Too many requests", + ), + FailureKind.RATE_LIMIT, + 429, + True, + 60, + ), + _ClassificationCase( + "openai_bad_request", + lambda: _openai_status_error( + openai.BadRequestError, + status_code=400, + message="bad tool shape", + ), + FailureKind.INVALID_REQUEST, + 400, + False, + ), + _ClassificationCase( + "openai_overload_marker", + lambda: _openai_status_error( + openai.InternalServerError, + status_code=500, + message="No capacity available", + ), + FailureKind.OVERLOADED, + 529, + True, + ), + _ClassificationCase( + "openai_generic_503_preserved", + lambda: _openai_status_error( + openai.InternalServerError, + status_code=503, + message="generic server failure", + ), + FailureKind.UPSTREAM, + 503, + True, + ), + _ClassificationCase( + "statusless_openai_rate_limit_body", + lambda: _statusless_openai_error( + "stream embedded error", + {"error": {"message": "too many requests", "code": 429}}, + ), + FailureKind.RATE_LIMIT, + 429, + True, + 60, + ), + _ClassificationCase( + "statusless_openai_overload_body", + lambda: _statusless_openai_error( + "ResourceExhausted: limit reached", + {"error": {"message": "ResourceExhausted: limit reached"}}, + ), + FailureKind.OVERLOADED, + 529, + True, + ), + _ClassificationCase( + "statusless_openai_unknown_is_not_retryable", + lambda: _statusless_openai_error( + "stream embedded error", + {"error": {"message": "unknown provider failure"}}, + ), + FailureKind.UPSTREAM, + 500, + False, + ), + _ClassificationCase( + "http_403_keeps_authentication_quirk", + lambda: _http_status_error(403, "Forbidden"), + FailureKind.AUTHENTICATION, + 401, + False, + ), + _ClassificationCase( + "http_502_keeps_overload_quirk", + lambda: _http_status_error(502, "Bad gateway"), + FailureKind.OVERLOADED, + 529, + True, + ), + _ClassificationCase( + "http_599_preserves_status", + lambda: _http_status_error(599, "Upstream failure"), + FailureKind.UPSTREAM, + 599, + True, + ), + _ClassificationCase( + "http_405_is_not_retryable", + lambda: _http_status_error(405, "Wrong endpoint"), + FailureKind.UPSTREAM, + 405, + False, + ), + _ClassificationCase( + "read_timeout_keeps_pre_start_status", + lambda: httpx.ReadTimeout( + "", + request=httpx.Request("POST", "https://provider.test/v1/messages"), + ), + FailureKind.TIMEOUT, + 502, + True, + ), + _ClassificationCase( + "openai_connection_error_keeps_status", + lambda: openai.APIConnectionError( + request=httpx.Request("POST", "https://provider.test/v1/chat/completions") + ), + FailureKind.UNAVAILABLE, + 500, + True, + ), + _ClassificationCase( + "unknown_exception_keeps_gateway_status", + lambda: RuntimeError("unexpected provider failure"), + FailureKind.UPSTREAM, + 502, + False, + ), +) + + +@pytest.mark.parametrize("case", _CASES, ids=lambda case: case.name) +def test_raw_provider_failure_maps_to_canonical_failure( + case: _ClassificationCase, +) -> None: + mark_rate_limited = Mock() + + failure = classify_provider_failure( + case.error(), + provider_name="TEST_PROVIDER", + read_timeout_s=30.0, + request_id="req_classification", + mark_rate_limited=mark_rate_limited, + ) + + assert isinstance(failure, ExecutionFailure) + assert failure.kind is case.kind + assert failure.status_code == case.status_code + assert failure.retryable is case.retryable + assert failure.message.strip() + assert "Request ID: req_classification" in failure.message + assert "SECRET" not in failure.message + if case.rate_limit_block_seconds is None: + mark_rate_limited.assert_not_called() + else: + mark_rate_limited.assert_called_once_with(case.rate_limit_block_seconds) + + +def test_classification_preserves_useful_body_while_redacting_credentials() -> None: + error = _http_status_error( + 400, + "unsupported model format authorization: Bearer AUTH_SECRET", + ) + + failure = classify_provider_failure( + error, + provider_name="LOCAL", + read_timeout_s=60.0, + request_id="req_body", + mark_rate_limited=Mock(), + ) + + assert failure.kind is FailureKind.INVALID_REQUEST + assert failure.status_code == 400 + assert "Upstream provider LOCAL returned HTTP 400." in failure.message + assert "unsupported model format" in failure.message + assert "Request ID: req_body" in failure.message + assert "AUTH_SECRET" not in failure.message + assert "SECRET" not in failure.message + + +def test_auth_failure_preserves_model_error_body_instead_of_masking_it() -> None: + error = _openai_status_error( + openai.AuthenticationError, + status_code=401, + message="Unauthorized", + body={ + "type": "error", + "error": { + "type": "ModelError", + "message": ("Model qwen3.7-max is not supported for format oa-compat"), + }, + }, + ) + + failure = classify_provider_failure( + error, + provider_name="OPENCODE_GO", + read_timeout_s=60.0, + request_id="req_model", + mark_rate_limited=Mock(), + ) + + assert failure.kind is FailureKind.AUTHENTICATION + assert failure.status_code == 401 + assert "Category: ModelError" in failure.message + assert "Provider authentication failed. Check API key." in failure.message + assert "Model qwen3.7-max is not supported for format oa-compat" in failure.message + assert "Request ID: req_model" in failure.message + + +def test_empty_http_error_body_is_reported_explicitly() -> None: + request = httpx.Request("POST", "https://provider.test/v1/messages") + response = httpx.Response(500, request=request, content=b"") + error = httpx.HTTPStatusError( + "Server Error", + request=request, + response=response, + ) + + failure = classify_provider_failure( + error, + provider_name="EMPTY", + read_timeout_s=30.0, + request_id="req_empty", + mark_rate_limited=Mock(), + ) + + assert failure.kind is FailureKind.UPSTREAM + assert failure.status_code == 500 + assert "Upstream provider EMPTY returned HTTP 500." in failure.message + assert "(empty upstream error body)" in failure.message + + +def test_http_405_diagnostic_names_rejected_upstream_endpoint() -> None: + failure = classify_provider_failure( + _http_status_error(405, "Method Not Allowed"), + provider_name="LOCAL", + read_timeout_s=30.0, + request_id="req_405", + mark_rate_limited=Mock(), + ) + + assert failure.kind is FailureKind.UPSTREAM + assert failure.status_code == 405 + assert ( + "Upstream provider LOCAL rejected the request method or endpoint (HTTP 405)." + in failure.message + ) + assert "Request ID: req_405" in failure.message + + +def test_connection_cause_chain_is_redacted_and_capped() -> None: + request = httpx.Request("POST", "https://provider.test/v1/chat/completions") + error = openai.APIConnectionError(request=request) + error.__cause__ = httpx.ConnectError( + "connect failed authorization: Bearer CAUSE_SECRET " + + "x" * (ERROR_DETAIL_DISPLAY_CAP_BYTES + 10), + request=request, + ) + + failure = classify_provider_failure( + error, + provider_name="NIM", + read_timeout_s=30.0, + request_id="req_cause", + mark_rate_limited=Mock(), + ) + + assert "Caused by:" in failure.message + assert "ConnectError: connect failed authorization: " in failure.message + assert "CAUSE_SECRET" not in failure.message + assert f"truncated after {ERROR_DETAIL_DISPLAY_CAP_BYTES} bytes" in failure.message + assert "Request ID: req_cause" in failure.message + + +def test_attached_streamed_error_body_remains_bounded() -> None: + request = httpx.Request("POST", "https://provider.test/v1/messages") + response = httpx.Response(500, request=request, content=b"") + error = httpx.HTTPStatusError( + "Server Error", + request=request, + response=response, + ) + attach_upstream_error_body( + error, + "x" * (ERROR_DETAIL_DISPLAY_CAP_BYTES + 10), + ) + + failure = classify_provider_failure( + error, + provider_name="LONG", + read_timeout_s=30.0, + request_id="req_long", + mark_rate_limited=Mock(), + ) + + assert f"truncated after {ERROR_DETAIL_DISPLAY_CAP_BYTES} bytes" in failure.message + assert "x" * 100 in failure.message diff --git a/tests/providers/test_fireworks.py b/tests/providers/test_fireworks.py new file mode 100644 index 0000000..fb1deef --- /dev/null +++ b/tests/providers/test_fireworks.py @@ -0,0 +1,133 @@ +"""Tests for the Fireworks AI OpenAI-chat provider.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from free_claude_code.application.errors import InvalidRequestError +from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS +from free_claude_code.config.provider_catalog import FIREWORKS_DEFAULT_BASE +from free_claude_code.core.anthropic.models import Message, MessagesRequest +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.openai_chat import OpenAIChatProvider +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +@pytest.fixture +def fireworks_provider(): + return profiled_provider( + "fireworks", + ProviderConfig( + api_key="test_fireworks_key", + base_url=FIREWORKS_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ), + rate_limiter=passthrough_rate_limiter(), + ) + + +def test_init_uses_openai_chat_provider(fireworks_provider): + assert isinstance(fireworks_provider, OpenAIChatProvider) + assert fireworks_provider._api_key == "test_fireworks_key" + assert fireworks_provider._base_url == FIREWORKS_DEFAULT_BASE + + +def test_base_url_constant(): + assert FIREWORKS_DEFAULT_BASE == "https://api.fireworks.ai/inference/v1" + + +def test_build_request_body_openai_chat_shape(fireworks_provider): + request = MessagesRequest( + model="accounts/fireworks/models/glm-5p1", + max_tokens=100, + messages=[Message(role="user", content="Hello")], + system="System prompt", + ) + + body = fireworks_provider._build_request_body(request) + + assert body["model"] == "accounts/fireworks/models/glm-5p1" + assert body["max_tokens"] == 100 + assert body["messages"] == [ + {"role": "system", "content": "System prompt"}, + {"role": "user", "content": "Hello"}, + ] + + +def test_build_request_body_default_max_tokens(fireworks_provider): + request = MessagesRequest( + model="m", + messages=[Message(role="user", content="x")], + ) + + body = fireworks_provider._build_request_body(request) + + assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS + + +def test_build_request_body_global_disable_blocks_thinking(): + provider = profiled_provider( + "fireworks", + ProviderConfig( + api_key="k", + base_url=FIREWORKS_DEFAULT_BASE, + rate_limit=1, + rate_window=1, + enable_thinking=False, + ), + rate_limiter=passthrough_rate_limiter(), + ) + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [{"type": "thinking", "thinking": "hidden"}], + } + ], + } + ) + + body = provider._build_request_body(request) + + assert "reasoning_content" not in body["messages"][0] + + +def test_build_request_body_preserves_validated_extra_body(fireworks_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "x"}], + "extra_body": {"custom_param": "value"}, + } + ) + + body = fireworks_provider._build_request_body(request) + + assert body["extra_body"] == {"custom_param": "value"} + + +def test_build_request_body_rejects_reserved_extra_body_keys(fireworks_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "x"}], + "extra_body": {"temperature": 0.1}, + } + ) + + with pytest.raises(InvalidRequestError, match="extra_body must not override"): + fireworks_provider._build_request_body(request) + + +@pytest.mark.asyncio +async def test_cleanup_closes_openai_client(fireworks_provider): + fireworks_provider._client = MagicMock() + fireworks_provider._client.close = AsyncMock() + + await fireworks_provider.cleanup() + + fireworks_provider._client.close.assert_awaited_once() diff --git a/tests/providers/test_gemini.py b/tests/providers/test_gemini.py new file mode 100644 index 0000000..5315f18 --- /dev/null +++ b/tests/providers/test_gemini.py @@ -0,0 +1,430 @@ +"""Tests for Google AI Studio Gemini (OpenAI-compatible) provider.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.config.provider_catalog import GEMINI_DEFAULT_BASE +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.gemini import GeminiProvider +from free_claude_code.providers.gemini.quirks import ( + GEMINI_SKIP_THOUGHT_SIGNATURE_VALIDATOR, +) +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter + + +def make_request(**overrides): + return make_messages_request("models/gemini-3.1-flash-lite", **overrides) + + +def _simulate_openai_sdk_wire_json(body: dict) -> dict: + wire = {key: value for key, value in body.items() if key != "extra_body"} + sdk_extra = body.get("extra_body") + if isinstance(sdk_extra, dict): + wire.update(sdk_extra) + return wire + + +@pytest.fixture +def gemini_config(): + return ProviderConfig( + api_key="test_gemini_key", + base_url=GEMINI_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ) + + +@pytest.fixture +def gemini_provider(gemini_config): + return GeminiProvider(gemini_config, rate_limiter=passthrough_rate_limiter()) + + +def test_init(gemini_config): + """Test provider initialization.""" + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_openai: + provider = GeminiProvider( + gemini_config, rate_limiter=passthrough_rate_limiter() + ) + assert provider._api_key == "test_gemini_key" + assert ( + provider._base_url + == "https://generativelanguage.googleapis.com/v1beta/openai" + ) + mock_openai.assert_called_once() + + +def test_default_base_url_constant(): + assert GEMINI_DEFAULT_BASE == ( + "https://generativelanguage.googleapis.com/v1beta/openai/" + ) + + +def test_build_request_body_basic(gemini_provider): + """Basic body conversion attaches Gemini thinking fields when thinking is on.""" + req = make_request() + body = gemini_provider._build_request_body(req) + + assert body["model"] == "models/gemini-3.1-flash-lite" + assert body["messages"][0]["role"] == "system" + assert "reasoning_effort" not in body + eb = body.get("extra_body") + assert isinstance(eb, dict) + literal_extra_body = eb.get("extra_body") + assert isinstance(literal_extra_body, dict) + gc = literal_extra_body.get("google") + assert isinstance(gc, dict) + tc = gc.get("thinking_config") + assert isinstance(tc, dict) + assert tc.get("include_thoughts") is True + assert "google" not in eb + + +def test_build_request_body_sdk_wire_json_has_literal_extra_body(gemini_provider): + """Regression for issue #542: SDK merge must not send top-level google.""" + req = make_request() + + body = gemini_provider._build_request_body(req) + wire_json = _simulate_openai_sdk_wire_json(body) + + assert "reasoning_effort" not in wire_json + assert "google" not in wire_json + literal_extra_body = wire_json.get("extra_body") + assert isinstance(literal_extra_body, dict) + google = literal_extra_body.get("google") + assert isinstance(google, dict) + thinking_config = google.get("thinking_config") + assert isinstance(thinking_config, dict) + assert thinking_config.get("include_thoughts") is True + + +def test_build_request_body_global_disable_sets_reasoning_none(): + """When thinking is off, Gemini uses reasoning_effort none (Gemini 2.5 convention).""" + provider = GeminiProvider( + ProviderConfig( + api_key="test_gemini_key", + base_url=GEMINI_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=False, + ), + rate_limiter=passthrough_rate_limiter(), + ) + req = make_request() + body = provider._build_request_body(req) + + assert body["reasoning_effort"] == "none" + roles = [m.get("role") for m in body.get("messages", [])] + assert "assistant_reasoning_content" not in roles + + +def test_build_request_body_preserves_caller_extra_body(gemini_provider): + req = make_request(extra_body={"metadata": {"user": "u1"}}) + + body = gemini_provider._build_request_body(req) + + assert "reasoning_effort" not in body + eb = body.get("extra_body") + assert isinstance(eb, dict) + assert eb.get("metadata") == {"user": "u1"} + literal_extra_body = eb.get("extra_body") + assert isinstance(literal_extra_body, dict) + google = literal_extra_body.get("google") + assert isinstance(google, dict) + + +def test_build_request_body_merges_caller_nested_google(gemini_provider): + req = make_request( + extra_body={ + "metadata": {"user": "u1"}, + "extra_body": { + "google": { + "thinking_config": {"budget_tokens": 128}, + "cached_content": "cachedContents/example", + } + }, + } + ) + + body = gemini_provider._build_request_body(req) + + assert "reasoning_effort" not in body + eb = body.get("extra_body") + assert isinstance(eb, dict) + assert eb.get("metadata") == {"user": "u1"} + literal_extra_body = eb.get("extra_body") + assert isinstance(literal_extra_body, dict) + google = literal_extra_body.get("google") + assert isinstance(google, dict) + assert google.get("cached_content") == "cachedContents/example" + thinking_config = google.get("thinking_config") + assert isinstance(thinking_config, dict) + assert thinking_config.get("budget_tokens") == 128 + assert thinking_config.get("include_thoughts") is True + + +def test_build_request_body_preserves_tool_call_extra_content(gemini_provider): + req = make_request( + system=None, + messages=[ + {"role": "user", "content": "Find files"}, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "function-call-1", + "name": "Glob", + "input": {"pattern": "*.py"}, + "extra_content": { + "google": {"thought_signature": "sig-from-client"} + }, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "function-call-1", + "content": "[]", + }, + ], + }, + ], + ) + + body = gemini_provider._build_request_body(req) + + tool_call = body["messages"][1]["tool_calls"][0] + assert tool_call["extra_content"] == { + "google": {"thought_signature": "sig-from-client"} + } + + +def test_build_request_body_uses_cached_tool_call_signature(gemini_provider): + gemini_provider._record_tool_call_extra_content( + "function-call-1", {"google": {"thought_signature": "sig-from-cache"}} + ) + req = make_request( + system=None, + messages=[ + {"role": "user", "content": "Find files"}, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "function-call-1", + "name": "Glob", + "input": {"pattern": "*.py"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "function-call-1", + "content": "[]", + }, + ], + }, + ], + ) + + body = gemini_provider._build_request_body(req) + + tool_call = body["messages"][1]["tool_calls"][0] + assert tool_call["extra_content"] == { + "google": {"thought_signature": "sig-from-cache"} + } + + +def test_build_request_body_adds_gemini3_current_turn_fallback_signature( + gemini_provider, +): + req = make_request( + system=None, + messages=[ + {"role": "user", "content": "Find files"}, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "function-call-1", + "name": "Glob", + "input": {"pattern": "*.py"}, + }, + { + "type": "tool_use", + "id": "function-call-2", + "name": "Read", + "input": {"file_path": "a.py"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "function-call-1", + "content": "[]", + }, + { + "type": "tool_result", + "tool_use_id": "function-call-2", + "content": "contents", + }, + ], + }, + ], + ) + + body = gemini_provider._build_request_body(req) + + tool_calls = body["messages"][1]["tool_calls"] + assert tool_calls[0]["extra_content"] == { + "google": {"thought_signature": GEMINI_SKIP_THOUGHT_SIGNATURE_VALIDATOR} + } + assert "extra_content" not in tool_calls[1] + + +@pytest.mark.asyncio +async def test_stream_response_text(gemini_provider): + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content="Hello back!", + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + gemini_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in gemini_provider.stream_response(req)] + + assert any( + '"text_delta"' in event and "Hello back!" in event for event in events + ) + kwargs = mock_create.call_args.kwargs + assert "reasoning_effort" not in kwargs + extra_body = kwargs.get("extra_body") + assert isinstance(extra_body, dict) + literal_extra_body = extra_body.get("extra_body") + assert isinstance(literal_extra_body, dict) + google = literal_extra_body.get("google") + assert isinstance(google, dict) + thinking_config = google.get("thinking_config") + assert isinstance(thinking_config, dict) + assert thinking_config.get("include_thoughts") is True + + +@pytest.mark.asyncio +async def test_stream_response_preserves_tool_call_extra_content(gemini_provider): + req = make_request() + + mock_tc = MagicMock() + mock_tc.index = 0 + mock_tc.id = "function-call-1" + mock_tc.extra_content = {"google": {"thought_signature": "sig-stream"}} + mock_tc.function = MagicMock() + mock_tc.function.name = "Glob" + mock_tc.function.arguments = '{"pattern":"*.py"}' + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=None, + reasoning_content=None, + tool_calls=[mock_tc], + ), + finish_reason="tool_calls", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + gemini_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in gemini_provider.stream_response(req)] + + tool_starts = [ + event + for event in events + if '"content_block_start"' in event and '"tool_use"' in event + ] + assert any( + '"extra_content"' in event and "sig-stream" in event for event in tool_starts + ) + assert gemini_provider._tool_call_extra_content_by_id["function-call-1"] == { + "google": {"thought_signature": "sig-stream"} + } + + +@pytest.mark.asyncio +async def test_stream_response_reasoning_content(gemini_provider): + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=None, + reasoning_content="Thinking...", + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + gemini_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in gemini_provider.stream_response(req)] + + assert any( + '"thinking_delta"' in event and "Thinking..." in event for event in events + ) + + +@pytest.mark.asyncio +async def test_cleanup(gemini_provider): + gemini_provider._client = AsyncMock() + + await gemini_provider.cleanup() + + gemini_provider._client.close.assert_called_once() diff --git a/tests/providers/test_github_models.py b/tests/providers/test_github_models.py new file mode 100644 index 0000000..ec40751 --- /dev/null +++ b/tests/providers/test_github_models.py @@ -0,0 +1,321 @@ +"""Tests for GitHub Models OpenAI-compatible provider.""" + +from collections.abc import AsyncIterator +from dataclasses import replace +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import httpx +import pytest + +from free_claude_code.config.provider_catalog import GITHUB_MODELS_DEFAULT_BASE +from free_claude_code.core.anthropic.models import Message, MessagesRequest +from free_claude_code.core.anthropic.stream_contracts import parse_sse_text +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.github_models import GitHubModelsProvider +from free_claude_code.providers.github_models.client import GITHUB_MODELS_CATALOG_URL +from free_claude_code.providers.model_listing import ModelListResponseError +from tests.providers.support import passthrough_rate_limiter + + +@pytest.fixture +def github_models_config() -> ProviderConfig: + return ProviderConfig( + api_key="test-github-models-token", + base_url=GITHUB_MODELS_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ) + + +@pytest.fixture +def github_models_provider( + github_models_config: ProviderConfig, +) -> GitHubModelsProvider: + return GitHubModelsProvider( + github_models_config, rate_limiter=passthrough_rate_limiter() + ) + + +def _request(model: str = "openai/gpt-4.1") -> MessagesRequest: + return MessagesRequest( + model=model, + max_tokens=100, + messages=[Message(role="user", content="hi")], + ) + + +def _chunk(delta: SimpleNamespace, *, finish_reason: str = "stop") -> SimpleNamespace: + return SimpleNamespace( + choices=[SimpleNamespace(delta=delta, finish_reason=finish_reason)], + usage=SimpleNamespace(completion_tokens=5, prompt_tokens=8), + ) + + +async def _stream(*chunks: SimpleNamespace) -> AsyncIterator[SimpleNamespace]: + for chunk in chunks: + yield chunk + + +def _catalog_response(payload: object) -> httpx.Response: + return httpx.Response( + 200, + json=payload, + request=httpx.Request("GET", GITHUB_MODELS_CATALOG_URL), + ) + + +def test_default_base_url_constant() -> None: + assert GITHUB_MODELS_DEFAULT_BASE == "https://models.github.ai/inference" + + +def test_init_uses_default_base_url_api_key_and_github_headers( + github_models_config: ProviderConfig, +) -> None: + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_openai: + provider = GitHubModelsProvider( + github_models_config, rate_limiter=passthrough_rate_limiter() + ) + + assert provider._api_key == "test-github-models-token" + assert provider._base_url == GITHUB_MODELS_DEFAULT_BASE + assert provider._catalog_url == GITHUB_MODELS_CATALOG_URL + assert mock_openai.call_args.kwargs["base_url"] == GITHUB_MODELS_DEFAULT_BASE + assert mock_openai.call_args.kwargs["api_key"] == "test-github-models-token" + assert mock_openai.call_args.kwargs["default_headers"] == { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2026-03-10", + } + + +def test_init_strips_trailing_slash(github_models_config: ProviderConfig) -> None: + config = replace( + github_models_config, + base_url=f"{GITHUB_MODELS_DEFAULT_BASE}/", + ) + + with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"): + provider = GitHubModelsProvider(config, rate_limiter=passthrough_rate_limiter()) + + assert provider._base_url == GITHUB_MODELS_DEFAULT_BASE + + +def test_model_list_headers_use_bearer_auth( + github_models_provider: GitHubModelsProvider, +) -> None: + assert github_models_provider._model_list_headers() == { + "Accept": "application/vnd.github+json", + "Authorization": "Bearer test-github-models-token", + "X-GitHub-Api-Version": "2026-03-10", + } + + +def test_build_request_body_uses_shared_openai_chat_policy( + github_models_provider: GitHubModelsProvider, +) -> None: + request = _request() + + body = github_models_provider._build_request_body(request, thinking_enabled=True) + + assert body["model"] == "openai/gpt-4.1" + assert body["max_tokens"] == 100 + assert "extra_body" not in body + + +@pytest.mark.asyncio +async def test_lists_stream_tool_capable_models_only( + github_models_provider: GitHubModelsProvider, +) -> None: + with patch.object( + github_models_provider._model_list_client, + "get", + new_callable=AsyncMock, + return_value=_catalog_response( + [ + { + "id": "openai/gpt-4.1", + "capabilities": ["streaming", "tool-calling"], + }, + { + "id": "openai/text-only", + "capabilities": ["streaming"], + }, + { + "id": "openai/no-stream-tools", + "capabilities": ["tool-calling"], + }, + ] + ), + ) as mock_get: + assert await github_models_provider.list_model_ids() == frozenset( + {"openai/gpt-4.1"} + ) + + mock_get.assert_awaited_once_with( + GITHUB_MODELS_CATALOG_URL, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": "Bearer test-github-models-token", + "X-GitHub-Api-Version": "2026-03-10", + }, + ) + + +@pytest.mark.asyncio +async def test_model_list_rejects_malformed_payload( + github_models_provider: GitHubModelsProvider, +) -> None: + with ( + patch.object( + github_models_provider._model_list_client, + "get", + new_callable=AsyncMock, + return_value=_catalog_response({"data": []}), + ), + pytest.raises(ModelListResponseError, match="top-level array"), + ): + await github_models_provider.list_model_ids() + + +@pytest.mark.asyncio +async def test_model_list_returns_empty_set_when_no_models_support_streaming_tools( + github_models_provider: GitHubModelsProvider, +) -> None: + with patch.object( + github_models_provider._model_list_client, + "get", + new_callable=AsyncMock, + return_value=_catalog_response( + [ + {"id": "openai/text-only", "capabilities": ["streaming"]}, + {"id": "openai/non-stream-tool", "capabilities": ["tool-calling"]}, + ] + ), + ): + assert await github_models_provider.list_model_ids() == frozenset() + + +@pytest.mark.asyncio +async def test_stream_response_text( + github_models_provider: GitHubModelsProvider, +) -> None: + delta = SimpleNamespace( + content="Hello from GitHub Models", + reasoning_content=None, + tool_calls=None, + ) + + with patch.object( + github_models_provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=_stream(_chunk(delta)), + ) as mock_create: + events = [ + event async for event in github_models_provider.stream_response(_request()) + ] + + parsed = parse_sse_text("".join(events)) + assert any( + event.event == "content_block_delta" + and event.data.get("delta", {}).get("text") == "Hello from GitHub Models" + for event in parsed + ) + assert mock_create.call_args.kwargs["model"] == "openai/gpt-4.1" + assert mock_create.call_args.kwargs["stream"] is True + + +@pytest.mark.asyncio +async def test_stream_response_tool_call( + github_models_provider: GitHubModelsProvider, +) -> None: + tool_call = SimpleNamespace( + index=0, + id="call_1", + function=SimpleNamespace(name="echo", arguments='{"value":"x"}'), + ) + delta = SimpleNamespace( + content=None, reasoning_content=None, tool_calls=[tool_call] + ) + request = MessagesRequest.model_validate( + { + "model": "openai/gpt-4.1", + "messages": [{"role": "user", "content": "Use the tool"}], + "tools": [ + { + "name": "echo", + "description": "Echo a value", + "input_schema": { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + }, + } + ], + } + ) + + with patch.object( + github_models_provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=_stream(_chunk(delta, finish_reason="tool_calls")), + ): + events = [ + event async for event in github_models_provider.stream_response(request) + ] + + parsed = parse_sse_text("".join(events)) + assert any( + event.event == "content_block_start" + and event.data.get("content_block", {}).get("type") == "tool_use" + and event.data.get("content_block", {}).get("name") == "echo" + for event in parsed + ) + assert any( + event.event == "content_block_delta" + and event.data.get("delta", {}).get("partial_json") == '{"value":"x"}' + for event in parsed + ) + + +@pytest.mark.asyncio +async def test_stream_response_reasoning_content( + github_models_provider: GitHubModelsProvider, +) -> None: + delta = SimpleNamespace( + content=None, + reasoning_content="Thinking via GitHub Models", + tool_calls=None, + ) + + with patch.object( + github_models_provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=_stream(_chunk(delta)), + ): + events = [ + event async for event in github_models_provider.stream_response(_request()) + ] + + parsed = parse_sse_text("".join(events)) + assert any( + event.event == "content_block_delta" + and event.data.get("delta", {}).get("thinking") == "Thinking via GitHub Models" + for event in parsed + ) + + +@pytest.mark.asyncio +async def test_cleanup(github_models_provider: GitHubModelsProvider) -> None: + github_models_provider._client = AsyncMock() + github_models_provider._model_list_client = AsyncMock() + + await github_models_provider.cleanup() + + github_models_provider._client.close.assert_called_once() + github_models_provider._model_list_client.aclose.assert_called_once() diff --git a/tests/providers/test_groq.py b/tests/providers/test_groq.py new file mode 100644 index 0000000..5960ae3 --- /dev/null +++ b/tests/providers/test_groq.py @@ -0,0 +1,212 @@ +"""Tests for Groq (OpenAI-compatible) provider.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.config.provider_catalog import GROQ_DEFAULT_BASE +from free_claude_code.providers.base import ProviderConfig +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +def make_request(**overrides): + return make_messages_request("llama-3.3-70b-versatile", **overrides) + + +@pytest.fixture +def groq_config(): + return ProviderConfig( + api_key="test_groq_key", + base_url=GROQ_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ) + + +@pytest.fixture +def groq_provider(groq_config): + return profiled_provider( + "groq", groq_config, rate_limiter=passthrough_rate_limiter() + ) + + +def test_init(groq_config): + """Test provider initialization.""" + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_openai: + provider = profiled_provider( + "groq", groq_config, rate_limiter=passthrough_rate_limiter() + ) + assert provider._api_key == "test_groq_key" + assert provider._base_url == GROQ_DEFAULT_BASE + mock_openai.assert_called_once() + + +def test_default_base_url_constant(): + assert GROQ_DEFAULT_BASE == "https://api.groq.com/openai/v1" + + +def test_build_request_body_basic(groq_provider): + """Basic request body conversion attaches system message from Claude request.""" + req = make_request() + body = groq_provider._build_request_body(req) + + assert body["model"] == "llama-3.3-70b-versatile" + assert body["messages"][0]["role"] == "system" + assert "max_completion_tokens" in body + + +def test_build_request_body_global_disable_blocks_reasoning_mapping(): + provider = profiled_provider( + "groq", + ProviderConfig( + api_key="test_groq_key", + base_url=GROQ_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=False, + ), + rate_limiter=passthrough_rate_limiter(), + ) + req = make_request() + body = provider._build_request_body(req) + + roles = [m.get("role") for m in body.get("messages", [])] + assert "assistant_reasoning_content" not in roles + + +def test_build_request_body_sanitizes_and_remaps_via_mock_converter(groq_provider): + with patch( + "free_claude_code.providers.openai_chat.request_policy.build_base_request_body" + ) as mock_convert: + mock_convert.return_value = { + "model": "llama-3.3-70b-versatile", + "messages": [ + {"role": "user", "name": "bad", "content": "hello"}, + { + "role": "assistant", + "tool_calls": [], + "name": "nope", + "content": "ok", + }, + ], + "logprobs": True, + "logit_bias": {"1": -100}, + "top_logprobs": 2, + "max_tokens": 42, + "n": 4, + } + req = make_request() + body = groq_provider._build_request_body(req) + + msgs = body["messages"] + assert msgs[0].get("name") is None and msgs[1].get("name") is None + for key in ("logprobs", "logit_bias", "top_logprobs"): + assert key not in body + assert body.get("max_tokens") is None + assert body["max_completion_tokens"] == 42 + assert body["n"] == 1 + + +def test_build_request_body_prefers_existing_max_completion_tokens(groq_provider): + with patch( + "free_claude_code.providers.openai_chat.request_policy.build_base_request_body" + ) as mock_convert: + mock_convert.return_value = { + "model": "llama-3.3-70b-versatile", + "messages": [{"role": "user", "content": "x"}], + "max_completion_tokens": 77, + "max_tokens": 999, + } + body = groq_provider._build_request_body(make_request()) + + assert body["max_completion_tokens"] == 77 + assert "max_tokens" not in body + + +def test_build_request_body_preserves_caller_extra_body(groq_provider): + req = make_request(extra_body={"metadata": {"user": "u1"}}) + + body = groq_provider._build_request_body(req) + + eb = body.get("extra_body") + assert isinstance(eb, dict) + assert eb.get("metadata") == {"user": "u1"} + + +@pytest.mark.asyncio +async def test_stream_response_text(groq_provider): + """Text content deltas are emitted as text blocks.""" + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content="Hello back!", + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + groq_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in groq_provider.stream_response(req)] + + assert any( + '"text_delta"' in event and "Hello back!" in event for event in events + ) + + +@pytest.mark.asyncio +async def test_stream_response_reasoning_content(groq_provider): + """reasoning_content deltas are emitted as thinking blocks.""" + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=None, + reasoning_content="Thinking...", + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + groq_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in groq_provider.stream_response(req)] + + assert any( + '"thinking_delta"' in event and "Thinking..." in event for event in events + ) + + +@pytest.mark.asyncio +async def test_cleanup(groq_provider): + groq_provider._client = AsyncMock() + + await groq_provider.cleanup() + + groq_provider._client.close.assert_called_once() diff --git a/tests/providers/test_huggingface.py b/tests/providers/test_huggingface.py new file mode 100644 index 0000000..60fa69a --- /dev/null +++ b/tests/providers/test_huggingface.py @@ -0,0 +1,215 @@ +"""Tests for Hugging Face Inference Providers.""" + +from dataclasses import replace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.config.provider_catalog import HUGGINGFACE_DEFAULT_BASE +from free_claude_code.core.anthropic import ReasoningReplayMode +from free_claude_code.providers.base import ProviderConfig +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +def make_request(**overrides): + return make_messages_request("openai/gpt-oss-120b:fastest", **overrides) + + +@pytest.fixture +def huggingface_config(): + return ProviderConfig( + api_key="test_hf_key", + base_url=HUGGINGFACE_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ) + + +@pytest.fixture +def huggingface_provider(huggingface_config): + return profiled_provider( + "huggingface", huggingface_config, rate_limiter=passthrough_rate_limiter() + ) + + +def test_default_base_url_constant(): + assert HUGGINGFACE_DEFAULT_BASE == "https://router.huggingface.co/v1" + + +def test_init_uses_default_base_url_and_api_key(huggingface_config): + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_openai: + provider = profiled_provider( + "huggingface", huggingface_config, rate_limiter=passthrough_rate_limiter() + ) + + assert provider._api_key == "test_hf_key" + assert provider._base_url == HUGGINGFACE_DEFAULT_BASE + mock_openai.assert_called_once() + + +def test_init_strips_trailing_slash(huggingface_config): + config = replace(huggingface_config, base_url=f"{HUGGINGFACE_DEFAULT_BASE}/") + + with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"): + provider = profiled_provider( + "huggingface", config, rate_limiter=passthrough_rate_limiter() + ) + + assert provider._base_url == HUGGINGFACE_DEFAULT_BASE + + +def test_build_request_body_keeps_max_tokens(huggingface_provider): + with patch( + "free_claude_code.providers.openai_chat.request_policy.build_base_request_body" + ) as mock_convert: + mock_convert.return_value = { + "model": "openai/gpt-oss-120b:fastest", + "messages": [{"role": "user", "name": "alice", "content": "hi"}], + "max_tokens": 42, + } + + body = huggingface_provider._build_request_body(make_request()) + + mock_convert.assert_called_once() + assert ( + mock_convert.call_args.kwargs["reasoning_replay"] + is ReasoningReplayMode.DISABLED + ) + assert body["messages"][0].get("name") == "alice" + assert body["max_tokens"] == 42 + assert "max_completion_tokens" not in body + + +def test_build_request_body_preserves_caller_extra_body(huggingface_provider): + extra_body = {"provider": "auto", "routing": {"bill_to": "my-org"}} + req = make_request(extra_body=extra_body) + + body = huggingface_provider._build_request_body(req) + + assert body["extra_body"] == extra_body + assert body["extra_body"] is not extra_body + assert body["extra_body"]["routing"] is not extra_body["routing"] + + +def test_build_request_body_does_not_replay_prior_thinking_blocks( + huggingface_provider, +): + req = make_request( + system=None, + messages=[ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "hidden prior thought"}, + {"type": "text", "text": "visible answer"}, + ], + } + ], + ) + + body = huggingface_provider._build_request_body(req) + + assert body["messages"] == [{"role": "assistant", "content": "visible answer"}] + assert "reasoning_content" not in body["messages"][0] + assert "hidden prior thought" not in str(body) + + +def test_build_request_body_does_not_replay_top_level_reasoning_content( + huggingface_provider, +): + req = make_request( + system=None, + messages=[ + { + "role": "assistant", + "content": "visible answer", + "reasoning_content": "hidden prior reasoning", + } + ], + ) + + body = huggingface_provider._build_request_body(req) + + assert body["messages"] == [{"role": "assistant", "content": "visible answer"}] + assert "hidden prior reasoning" not in str(body) + + +@pytest.mark.asyncio +async def test_stream_response_text(huggingface_provider): + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content="Hello from Hugging Face", + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + huggingface_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [ + event + async for event in huggingface_provider.stream_response(make_request()) + ] + + assert any( + '"text_delta"' in event and "Hello from Hugging Face" in event + for event in events + ) + + +@pytest.mark.asyncio +async def test_stream_response_reasoning_content(huggingface_provider): + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=None, + reasoning_content="Thinking via router", + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + huggingface_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [ + event + async for event in huggingface_provider.stream_response(make_request()) + ] + + assert any( + '"thinking_delta"' in event and "Thinking via router" in event + for event in events + ) + + +@pytest.mark.asyncio +async def test_cleanup(huggingface_provider): + huggingface_provider._client = AsyncMock() + + await huggingface_provider.cleanup() + + huggingface_provider._client.close.assert_called_once() diff --git a/tests/providers/test_kimi.py b/tests/providers/test_kimi.py new file mode 100644 index 0000000..e8e6c40 --- /dev/null +++ b/tests/providers/test_kimi.py @@ -0,0 +1,109 @@ +"""Tests for the Kimi OpenAI-chat provider.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from free_claude_code.application.errors import InvalidRequestError +from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS +from free_claude_code.config.provider_catalog import KIMI_DEFAULT_BASE +from free_claude_code.core.anthropic.models import Message, MessagesRequest +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.openai_chat import OpenAIChatProvider +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +@pytest.fixture +def kimi_provider(): + return profiled_provider( + "kimi", + ProviderConfig( + api_key="test_kimi_key", + base_url=KIMI_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ), + rate_limiter=passthrough_rate_limiter(), + ) + + +def test_init_uses_openai_chat_provider(kimi_provider): + assert isinstance(kimi_provider, OpenAIChatProvider) + assert kimi_provider._api_key == "test_kimi_key" + assert kimi_provider._base_url == "https://api.moonshot.ai/v1" + + +def test_build_request_body_openai_chat(kimi_provider): + request = MessagesRequest( + model="kimi-k2.5", + max_tokens=50, + messages=[Message(role="user", content="hi")], + ) + + body = kimi_provider._build_request_body(request) + + assert body["model"] == "kimi-k2.5" + assert body["max_tokens"] == 50 + assert body["messages"] == [{"role": "user", "content": "hi"}] + assert "extra_body" not in body + + +def test_build_request_body_default_max_tokens(kimi_provider): + request = MessagesRequest( + model="m", + messages=[Message(role="user", content="x")], + ) + + body = kimi_provider._build_request_body(request) + + assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS + + +def test_build_request_body_rejects_caller_extra_body(kimi_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "x"}], + "extra_body": {"x": 1}, + } + ) + + with pytest.raises(InvalidRequestError, match="Kimi Chat Completions"): + kimi_provider._build_request_body(request) + + +def test_build_request_body_disables_kimi_thinking(kimi_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "x"}], + "thinking": {"type": "disabled"}, + } + ) + + body = kimi_provider._build_request_body(request) + + assert body["extra_body"]["thinking"] == {"type": "disabled"} + + +@pytest.mark.asyncio +async def test_model_list_uses_openai_client_models_endpoint(kimi_provider): + kimi_provider._client.models.list = AsyncMock( + return_value=SimpleNamespace(data=[SimpleNamespace(id="kimi-k2.5")]) + ) + + assert await kimi_provider.list_model_ids() == frozenset({"kimi-k2.5"}) + + kimi_provider._client.models.list.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_cleanup_closes_openai_client(kimi_provider): + kimi_provider._client = MagicMock() + kimi_provider._client.close = AsyncMock() + + await kimi_provider.cleanup() + + kimi_provider._client.close.assert_awaited_once() diff --git a/tests/providers/test_llamacpp.py b/tests/providers/test_llamacpp.py new file mode 100644 index 0000000..462a01d --- /dev/null +++ b/tests/providers/test_llamacpp.py @@ -0,0 +1,146 @@ +"""Tests for the llama.cpp OpenAI-compatible provider.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS +from free_claude_code.core.anthropic.stream_contracts import parse_sse_text +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.openai_chat import OpenAIChatProvider +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter, profiled_provider + +LLAMACPP_MODEL = "llamacpp-community/qwen2.5-7b-instruct" + + +@pytest.fixture +def provider() -> OpenAIChatProvider: + return profiled_provider( + "llamacpp", + ProviderConfig(api_key="llamacpp", base_url="http://localhost:8080/v1"), + rate_limiter=passthrough_rate_limiter(), + ) + + +@pytest.mark.parametrize( + ("configured", "expected"), + [ + ("http://localhost:8080", "http://localhost:8080/v1"), + ("http://localhost:8080/", "http://localhost:8080/v1"), + ("http://localhost:8080/v1", "http://localhost:8080/v1"), + ("http://localhost:8080/v1/", "http://localhost:8080/v1"), + ], +) +def test_init_normalizes_openai_base_url(configured: str, expected: str) -> None: + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as openai_client: + provider = profiled_provider( + "llamacpp", + ProviderConfig(api_key="llamacpp", base_url=configured), + rate_limiter=passthrough_rate_limiter(), + ) + + assert provider._base_url == expected + assert openai_client.call_args.kwargs["base_url"] == expected + + +def test_init_uses_openai_chat_client() -> None: + 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( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as openai_client: + provider = profiled_provider( + "llamacpp", config, rate_limiter=passthrough_rate_limiter() + ) + + assert provider._provider_name == "LLAMACPP" + assert provider._base_url == "http://localhost:8080/v1" + assert provider._api_key == "llamacpp" + timeout = openai_client.call_args.kwargs["timeout"] + assert (timeout.read, timeout.write, timeout.connect) == (600.0, 15.0, 5.0) + + +def test_build_request_body_uses_openai_chat_shape( + provider: OpenAIChatProvider, +) -> None: + request = make_messages_request(LLAMACPP_MODEL, max_tokens=None) + + body = provider._build_request_body(request) + + assert body["model"] == LLAMACPP_MODEL + assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS + assert body["messages"][0]["role"] == "system" + assert "thinking" not in body + + +def test_disabled_thinking_does_not_replay_assistant_reasoning( + provider: OpenAIChatProvider, +) -> None: + request = make_messages_request( + LLAMACPP_MODEL, + system=None, + messages=[ + {"role": "user", "content": "Hi"}, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "private", "signature": "s"}, + {"type": "text", "text": "visible"}, + ], + }, + ], + ) + + body = provider._build_request_body(request, thinking_enabled=False) + + assert "private" not in str(body) + assert "visible" in str(body) + + +@pytest.mark.asyncio +async def test_stream_response_uses_shared_openai_chat_provider( + provider: OpenAIChatProvider, +) -> None: + chunk = MagicMock() + chunk.choices = [ + MagicMock( + delta=MagicMock( + content="Hello from llama.cpp", + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + chunk.usage = MagicMock(prompt_tokens=8, completion_tokens=4) + + async def stream(): + yield chunk + + with patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream(), + ) as create: + output = "".join( + [ + event + async for event in provider.stream_response( + make_messages_request(LLAMACPP_MODEL) + ) + ] + ) + + assert create.call_args.kwargs["stream"] is True + assert create.call_args.kwargs["model"] == LLAMACPP_MODEL + assert "Hello from llama.cpp" in output + assert parse_sse_text(output)[-1].event == "message_stop" diff --git a/tests/providers/test_lmstudio.py b/tests/providers/test_lmstudio.py new file mode 100644 index 0000000..25c447b --- /dev/null +++ b/tests/providers/test_lmstudio.py @@ -0,0 +1,242 @@ +"""Tests for LM Studio (OpenAI-compatible chat completions) provider.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from free_claude_code.application.errors import InvalidRequestError +from free_claude_code.config.provider_catalog import LMSTUDIO_DEFAULT_BASE +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.lmstudio import LMStudioProvider +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter + + +def make_request(**overrides): + return make_messages_request("lmstudio-community/qwen2.5-7b-instruct", **overrides) + + +@pytest.fixture +def lmstudio_config(): + return ProviderConfig( + api_key="lm-studio", + base_url=LMSTUDIO_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + ) + + +@pytest.fixture +def lmstudio_provider(lmstudio_config): + return LMStudioProvider(lmstudio_config, rate_limiter=passthrough_rate_limiter()) + + +def test_init(lmstudio_config): + """Test provider initialization.""" + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_openai: + provider = LMStudioProvider( + lmstudio_config, rate_limiter=passthrough_rate_limiter() + ) + assert provider._api_key == "lm-studio" + assert provider._base_url == LMSTUDIO_DEFAULT_BASE + assert provider._provider_name == "LMSTUDIO" + mock_openai.assert_called_once() + + +def test_default_base_url_constant(): + assert LMSTUDIO_DEFAULT_BASE == "http://localhost:1234/v1" + + +def test_build_request_body_basic(lmstudio_provider): + req = make_request() + body = lmstudio_provider._build_request_body(req) + + assert body["model"] == "lmstudio-community/qwen2.5-7b-instruct" + assert body["messages"][0]["role"] == "system" + + +def test_build_request_body_never_replays_prior_thinking(lmstudio_provider): + """Mistral-family templates have no assistant reasoning field; prior-turn + thinking must never be replayed regardless of the enable_thinking setting.""" + req = make_request( + messages=[ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "prior reasoning", + "signature": "s", + } + ], + }, + ] + ) + body = lmstudio_provider._build_request_body(req) + + roles = [m.get("role") for m in body.get("messages", [])] + assert "assistant_reasoning_content" not in roles + assert "prior reasoning" not in str(body) + + +def test_preflight_builds_before_context_budget_and_preserves_false( + lmstudio_provider, +): + request = make_request() + calls: list[tuple[str, object]] = [] + + def build(request_arg, thinking_enabled=None): + assert request_arg is request + calls.append(("build", thinking_enabled)) + return {} + + def check_context(request_arg): + assert request_arg is request + calls.append(("context", request_arg)) + + with ( + patch.object(lmstudio_provider, "_build_request_body", side_effect=build), + patch.object( + lmstudio_provider, + "_preflight_context_budget", + side_effect=check_context, + ), + ): + lmstudio_provider.preflight_stream(request, thinking_enabled=False) + + assert calls == [("build", False), ("context", request)] + + +def test_preflight_conversion_failure_skips_context_budget(lmstudio_provider): + request = make_request() + conversion_error = InvalidRequestError("invalid request conversion") + + with ( + patch.object( + lmstudio_provider, + "_build_request_body", + side_effect=conversion_error, + ), + patch.object(lmstudio_provider, "_preflight_context_budget") as context, + pytest.raises(InvalidRequestError, match="invalid request conversion"), + ): + lmstudio_provider.preflight_stream(request, thinking_enabled=True) + + context.assert_not_called() + + +@pytest.mark.asyncio +async def test_stream_response_text(lmstudio_provider): + """Text content deltas are emitted through the shared OpenAI-chat provider.""" + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content="Hello back!", reasoning_content=None, tool_calls=None + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + lmstudio_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in lmstudio_provider.stream_response(req)] + + assert any( + '"text_delta"' in event and "Hello back!" in event for event in events + ) + + +@pytest.mark.asyncio +async def test_cleanup(lmstudio_provider): + lmstudio_provider._client = AsyncMock() + await lmstudio_provider.cleanup() + + +# --- Context-budget preflight (new: guards against LM Studio's silent +# mid-stream truncation when a prompt exceeds the loaded model's context) --- + + +def test_preflight_context_budget_noop_when_context_length_unknown(lmstudio_provider): + """No LM Studio /api/v0/models data available -> preflight is a no-op (fail open).""" + with patch.object(lmstudio_provider, "_loaded_context_length", return_value=None): + lmstudio_provider._preflight_context_budget(make_request()) # must not raise + + +def test_preflight_context_budget_allows_request_under_budget(lmstudio_provider): + with patch.object( + lmstudio_provider, "_loaded_context_length", return_value=100_000 + ): + req = make_request( + messages=[{"role": "user", "content": "hi"}], system=None, tools=[] + ) + lmstudio_provider._preflight_context_budget(req) # must not raise + + +def test_preflight_context_budget_rejects_request_over_90_percent(lmstudio_provider): + with ( + patch.object(lmstudio_provider, "_loaded_context_length", return_value=1000), + patch( + "free_claude_code.providers.lmstudio.client.get_token_count", + return_value=901, + ), + pytest.raises(InvalidRequestError, match="prompt is too long"), + ): + lmstudio_provider._preflight_context_budget(make_request()) + + +def test_loaded_context_length_reads_max_across_loaded_models(lmstudio_provider): + response = MagicMock() + response.raise_for_status = MagicMock() + response.json.return_value = { + "data": [ + {"state": "loaded", "loaded_context_length": 40960}, + {"state": "loaded", "loaded_context_length": 8192}, + {"state": "not-loaded", "loaded_context_length": 999999}, + ] + } + with patch( + "free_claude_code.providers.lmstudio.client.httpx.get", return_value=response + ) as mock_get: + value = lmstudio_provider._loaded_context_length() + + assert value == 40960 + mock_get.assert_called_once() + assert mock_get.call_args[0][0] == "http://localhost:1234/api/v0/models" + + +def test_loaded_context_length_fails_open_on_error(lmstudio_provider): + with patch( + "free_claude_code.providers.lmstudio.client.httpx.get", + side_effect=httpx.ConnectError("refused"), + ): + assert lmstudio_provider._loaded_context_length() is None + + +def test_loaded_context_length_is_cached_within_ttl(lmstudio_provider): + response = MagicMock() + response.raise_for_status = MagicMock() + response.json.return_value = { + "data": [{"state": "loaded", "loaded_context_length": 40960}] + } + with patch( + "free_claude_code.providers.lmstudio.client.httpx.get", return_value=response + ) as mock_get: + first = lmstudio_provider._loaded_context_length() + second = lmstudio_provider._loaded_context_length() + + assert first == second == 40960 + mock_get.assert_called_once() diff --git a/tests/providers/test_minimax.py b/tests/providers/test_minimax.py new file mode 100644 index 0000000..75b6788 --- /dev/null +++ b/tests/providers/test_minimax.py @@ -0,0 +1,168 @@ +"""Tests for the MiniMax OpenAI-chat provider.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS +from free_claude_code.config.provider_catalog import MINIMAX_DEFAULT_BASE +from free_claude_code.core.anthropic.models import Message, MessagesRequest, Tool +from free_claude_code.core.anthropic.stream_contracts import ( + parse_sse_text, + text_content, + thinking_content, +) +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.openai_chat import OpenAIChatProvider +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +class AsyncStream: + def __init__(self, chunks): + self._chunks = chunks + self.closed = False + + def __aiter__(self): + return self._iter() + + async def _iter(self): + for chunk in self._chunks: + yield chunk + + async def aclose(self): + self.closed = True + + +@pytest.fixture +def minimax_provider(): + return profiled_provider( + "minimax", + ProviderConfig( + api_key="test-minimax-key", + base_url=MINIMAX_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ), + rate_limiter=passthrough_rate_limiter(), + ) + + +def _chunk( + *, + content: str | None = None, + reasoning_content: str | None = None, + finish_reason: str | None = None, +): + delta = SimpleNamespace( + content=content, + reasoning_content=reasoning_content, + tool_calls=None, + ) + return SimpleNamespace( + choices=[SimpleNamespace(delta=delta, finish_reason=finish_reason)], + usage=None, + ) + + +def test_default_base_url(): + assert MINIMAX_DEFAULT_BASE == "https://api.minimax.io/v1" + + +def test_init_uses_openai_chat_provider(minimax_provider): + assert isinstance(minimax_provider, OpenAIChatProvider) + assert minimax_provider._api_key == "test-minimax-key" + assert minimax_provider._base_url == MINIMAX_DEFAULT_BASE + assert minimax_provider._provider_name == "MINIMAX" + + +def test_build_request_body_uses_adaptive_thinking_and_max_completion_tokens( + minimax_provider, +): + request = MessagesRequest.model_validate( + { + "model": "MiniMax-M3", + "messages": [Message(role="user", content="Hello")], + "tools": [ + Tool( + name="echo", + description="Echo input", + input_schema={"type": "object", "properties": {}}, + ) + ], + "thinking": {"type": "enabled", "budget_tokens": 2048}, + } + ) + + body = minimax_provider._build_request_body(request) + + assert body["model"] == "MiniMax-M3" + assert body["tools"][0]["function"]["name"] == "echo" + assert body["max_completion_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS + assert "max_tokens" not in body + assert body["extra_body"]["reasoning_split"] is True + assert body["extra_body"]["thinking"] == {"type": "adaptive"} + + +def test_build_request_body_honors_no_thinking(minimax_provider): + request = MessagesRequest( + model="MiniMax-M3", + messages=[Message(role="user", content="Hello")], + ) + + body = minimax_provider._build_request_body(request, thinking_enabled=False) + + assert body["extra_body"]["thinking"] == {"type": "disabled"} + + +@pytest.mark.asyncio +async def test_lists_models_from_openai_models_endpoint(minimax_provider): + minimax_provider._client.models.list = AsyncMock( + return_value=SimpleNamespace( + data=[SimpleNamespace(id="MiniMax-M3"), SimpleNamespace(id="MiniMax-M2.7")] + ) + ) + + assert await minimax_provider.list_model_ids() == frozenset( + {"MiniMax-M3", "MiniMax-M2.7"} + ) + + +@pytest.mark.asyncio +async def test_stream_preserves_reasoning_content(minimax_provider): + request = MessagesRequest( + model="MiniMax-M3", + messages=[Message(role="user", content="hi")], + ) + stream = AsyncStream( + [ + _chunk(reasoning_content="plan"), + _chunk(content="done", finish_reason="stop"), + ] + ) + + with patch.object( + minimax_provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream, + ) as create: + events = [event async for event in minimax_provider.stream_response(request)] + + parsed = parse_sse_text("".join(events)) + assert thinking_content(parsed) == "plan" + assert text_content(parsed) == "done" + assert create.await_args is not None + assert create.await_args.kwargs["extra_body"]["reasoning_split"] is True + assert stream.closed + + +@pytest.mark.asyncio +async def test_cleanup_closes_openai_client(minimax_provider): + minimax_provider._client = MagicMock() + minimax_provider._client.close = AsyncMock() + + await minimax_provider.cleanup() + + minimax_provider._client.close.assert_awaited_once() diff --git a/tests/providers/test_mistral.py b/tests/providers/test_mistral.py new file mode 100644 index 0000000..b59dfc7 --- /dev/null +++ b/tests/providers/test_mistral.py @@ -0,0 +1,764 @@ +"""Tests for Mistral La Plateforme provider.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import openai +import pytest +from httpx import Request, Response + +from free_claude_code.config.provider_catalog import MISTRAL_DEFAULT_BASE +from free_claude_code.core.failures import ExecutionFailure +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.mistral import MistralProvider +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter + + +def make_request(**overrides): + return make_messages_request("devstral-small-latest", **overrides) + + +@pytest.fixture +def mistral_config(): + return ProviderConfig( + api_key="test_mistral_key", + base_url=MISTRAL_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ) + + +@pytest.fixture +def mistral_provider(mistral_config): + return MistralProvider(mistral_config, rate_limiter=passthrough_rate_limiter()) + + +def test_init(mistral_config): + """Test provider initialization.""" + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_openai: + provider = MistralProvider( + mistral_config, rate_limiter=passthrough_rate_limiter() + ) + assert provider._api_key == "test_mistral_key" + assert provider._base_url == MISTRAL_DEFAULT_BASE + mock_openai.assert_called_once() + + +def test_default_base_url(): + assert MISTRAL_DEFAULT_BASE == "https://api.mistral.ai/v1" + + +def test_build_request_body_basic(mistral_provider): + """Basic request body conversion works for Mistral.""" + req = make_request() + body = mistral_provider._build_request_body(req) + + assert body["model"] == "devstral-small-latest" + assert body["messages"][0]["role"] == "system" + assert body["reasoning_effort"] == "high" + + +def test_build_request_body_replays_prior_thinking_as_mistral_chunks( + mistral_provider, +): + req = make_request( + system=None, + messages=[ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Need the tool."}, + {"type": "text", "text": "Calling the tool."}, + { + "type": "tool_use", + "id": "toolu_1", + "name": "echo", + "input": {"value": "x"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_1", + "content": "result", + } + ], + }, + ], + ) + + body = mistral_provider._build_request_body(req) + + assistant = body["messages"][0] + assert "reasoning_content" not in assistant + assert assistant["content"] == [ + { + "type": "thinking", + "thinking": [{"type": "text", "text": "Need the tool."}], + }, + {"type": "text", "text": "Calling the tool."}, + ] + assert assistant["tool_calls"][0]["id"] == "toolu_1" + + +def test_build_request_body_preserves_tools_tool_choice_and_params(mistral_provider): + req = make_request( + tools=[ + { + "name": "echo", + "description": "Echo a value", + "input_schema": { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + }, + } + ], + tool_choice={"type": "tool", "name": "echo"}, + stop_sequences=["STOP"], + ) + + body = mistral_provider._build_request_body(req) + + assert body["max_tokens"] == 100 + assert body["temperature"] == 0.5 + assert body["top_p"] == 0.9 + assert body["stop"] == ["STOP"] + assert body["tools"][0]["function"]["name"] == "echo" + assert body["tool_choice"] == {"type": "function", "function": {"name": "echo"}} + + +def test_build_request_body_global_disable_blocks_reasoning_mapping(): + """Global disable disables reasoning replay in the converter.""" + provider = MistralProvider( + ProviderConfig( + api_key="test_mistral_key", + base_url=MISTRAL_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=False, + ), + rate_limiter=passthrough_rate_limiter(), + ) + req = make_request() + body = provider._build_request_body(req) + + assert "reasoning_effort" not in body + assert all("reasoning_content" not in m for m in body.get("messages", [])) + + +def test_build_request_body_thinking_disabled_strips_prior_mistral_thinking(): + provider = MistralProvider( + ProviderConfig( + api_key="test_mistral_key", + base_url=MISTRAL_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=False, + ), + rate_limiter=passthrough_rate_limiter(), + ) + req = make_request( + system=None, + messages=[ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Hidden."}, + {"type": "text", "text": "Visible."}, + ], + }, + ], + ) + + body = provider._build_request_body(req) + + assert "reasoning_effort" not in body + assert body["messages"][0]["content"] == "Visible." + + +@pytest.mark.asyncio +async def test_stream_response_text(mistral_provider): + """Text content deltas are emitted as text blocks.""" + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content="Hello back!", + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + mistral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in mistral_provider.stream_response(req)] + + assert any( + '"text_delta"' in event and "Hello back!" in event for event in events + ) + + +@pytest.mark.asyncio +async def test_stream_response_reasoning_content(mistral_provider): + """reasoning_content deltas are emitted as thinking blocks.""" + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=None, + reasoning_content="Thinking...", + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + mistral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in mistral_provider.stream_response(req)] + + assert any( + '"thinking_delta"' in event and "Thinking..." in event for event in events + ) + + +@pytest.mark.asyncio +async def test_stream_response_native_mistral_thinking_chunk(mistral_provider): + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=[ + { + "type": "thinking", + "thinking": [{"type": "text", "text": "Native thought."}], + } + ], + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + mistral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in mistral_provider.stream_response(req)] + + assert any( + '"thinking_delta"' in event and "Native thought." in event for event in events + ) + + +@pytest.mark.asyncio +async def test_stream_response_native_mistral_text_chunk(mistral_provider): + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=[{"type": "text", "text": "Native text."}], + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + mistral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in mistral_provider.stream_response(req)] + + assert any('"text_delta"' in event and "Native text." in event for event in events) + + +@pytest.mark.asyncio +async def test_stream_response_preserves_native_thinking_and_string_text( + mistral_provider, +): + req = make_request() + + mock_chunk = SimpleNamespace( + choices=[ + SimpleNamespace( + delta=SimpleNamespace( + content="Visible token.", + thinking="Native thought.", + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ), + ], + usage=MagicMock(completion_tokens=2, prompt_tokens=10), + ) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + mistral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in mistral_provider.stream_response(req)] + + event_text = "\n".join(events) + assert '"thinking_delta"' in event_text + assert "Native thought." in event_text + assert '"text_delta"' in event_text + assert "Visible token." in event_text + + +@pytest.mark.asyncio +async def test_stream_response_preserves_native_reasoning_and_string_text( + mistral_provider, +): + req = make_request() + + mock_chunk = SimpleNamespace( + choices=[ + SimpleNamespace( + delta=SimpleNamespace( + content="Visible token.", + reasoning="Native reasoning.", + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ], + usage=MagicMock(completion_tokens=2, prompt_tokens=10), + ) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + mistral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in mistral_provider.stream_response(req)] + + event_text = "\n".join(events) + assert "Native reasoning." in event_text + assert "Visible token." in event_text + + +@pytest.mark.asyncio +async def test_stream_response_preserves_mixed_native_content_array( + mistral_provider, +): + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=[ + { + "type": "thinking", + "thinking": [{"type": "text", "text": "Native thought."}], + }, + {"type": "text", "text": "Native text."}, + ], + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + mistral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in mistral_provider.stream_response(req)] + + event_text = "\n".join(events) + assert "Native thought." in event_text + assert "Native text." in event_text + + +@pytest.mark.asyncio +async def test_stream_response_suppresses_native_mistral_thinking_when_disabled( + mistral_provider, +): + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=[ + { + "type": "thinking", + "thinking": [{"type": "text", "text": "Hidden."}], + }, + {"type": "text", "text": "Visible."}, + ], + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + mistral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [ + event + async for event in mistral_provider.stream_response( + req, thinking_enabled=False + ) + ] + + event_text = "\n".join(events) + assert "Hidden." not in event_text + assert "Visible." in event_text + + +@pytest.mark.asyncio +async def test_stream_response_retries_without_mistral_reasoning_on_rejection( + mistral_provider, +): + req = make_request( + system=None, + messages=[ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Need the tool."}, + { + "type": "tool_use", + "id": "toolu_reasoning", + "name": "echo", + "input": {"value": "FCC_TOOL"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_reasoning", + "content": "result", + } + ], + }, + ], + ) + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content="Recovered", reasoning_content=None, tool_calls=None + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + error = _make_bad_request_error("Unsupported field: reasoning_effort") + + with patch.object( + mistral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = [error, mock_stream()] + + events = [e async for e in mistral_provider.stream_response(req)] + + assert mock_create.await_count == 2 + first_call = mock_create.await_args_list[0].kwargs + second_call = mock_create.await_args_list[1].kwargs + assert first_call["reasoning_effort"] == "high" + assert first_call["messages"][0]["content"][0]["type"] == "thinking" + assert "reasoning_effort" not in second_call + assert second_call["messages"][0]["content"] == "" + assert second_call["messages"][0]["tool_calls"][0]["id"] == "toolu_reasoning" + assert any("Recovered" in event for event in events) + assert any("message_stop" in event for event in events) + + +@pytest.mark.asyncio +async def test_stream_response_reasoning_retry_preserves_visible_text_and_tools( + mistral_provider, +): + req = make_request( + system=None, + messages=[ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Need the tool."}, + {"type": "text", "text": "Visible history."}, + { + "type": "tool_use", + "id": "toolu_reasoning", + "name": "echo", + "input": {"value": "FCC_TOOL"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_reasoning", + "content": "result", + } + ], + }, + ], + ) + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content="Recovered", reasoning_content=None), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + error = _make_bad_request_error("Unsupported field: reasoning_effort") + + with patch.object( + mistral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = [error, mock_stream()] + + events = [e async for e in mistral_provider.stream_response(req)] + + second_call = mock_create.await_args_list[1].kwargs + assert second_call["messages"][0]["content"] == "Visible history." + assert second_call["messages"][0]["tool_calls"][0]["id"] == "toolu_reasoning" + assert any("Recovered" in event for event in events) + + +@pytest.mark.asyncio +async def test_stream_response_retries_on_mistral_422_reasoning_rejection( + mistral_provider, +): + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content="Recovered", reasoning_content=None), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + error = _StatusError( + "body.messages.assistant.thinking extra_forbidden", + status_code=422, + body={"detail": [{"loc": ["body", "reasoning_effort"]}]}, + ) + + with patch.object( + mistral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = [error, mock_stream()] + + events = [e async for e in mistral_provider.stream_response(req)] + + assert mock_create.await_count == 2 + assert "reasoning_effort" not in mock_create.await_args_list[1].kwargs + assert any("Recovered" in event for event in events) + + +@pytest.mark.asyncio +async def test_stream_response_retries_when_model_disables_reasoning_input( + mistral_provider, +): + req = make_request( + system=None, + messages=[ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Need context."}, + {"type": "text", "text": "Visible history."}, + ], + } + ], + ) + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content="Recovered", reasoning_content=None), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + error = _StatusError( + "Reasoning input is not enabled for this model", + status_code=400, + body={ + "object": "error", + "message": "Reasoning input is not enabled for this model", + "type": "invalid_request_invalid_args", + "code": "3051", + }, + ) + + with patch.object( + mistral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = [error, mock_stream()] + + events = [e async for e in mistral_provider.stream_response(req)] + + assert mock_create.await_count == 2 + second_call = mock_create.await_args_list[1].kwargs + assert "reasoning_effort" not in second_call + assert second_call["messages"][0]["content"] == "Visible history." + assert any("Recovered" in event for event in events) + + +@pytest.mark.asyncio +async def test_stream_response_unrelated_bad_request_does_not_retry(mistral_provider): + req = make_request() + error = _make_bad_request_error("Unsupported field: top_k") + + with patch.object( + mistral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = error + + with pytest.raises(ExecutionFailure) as exc_info: + [e async for e in mistral_provider.stream_response(req)] + + assert mock_create.await_count == 1 + assert "Invalid request sent to provider" in exc_info.value.message + + +@pytest.mark.asyncio +async def test_stream_response_generic_thinking_error_does_not_retry( + mistral_provider, +): + req = make_request() + error = _make_bad_request_error("The model was thinking, but top_k is unsupported") + + with patch.object( + mistral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = error + + with pytest.raises(ExecutionFailure) as exc_info: + [e async for e in mistral_provider.stream_response(req)] + + assert mock_create.await_count == 1 + assert "Invalid request sent to provider" in exc_info.value.message + + +def test_retry_body_without_reasoning_returns_none(mistral_provider): + body = {"model": "x", "messages": [{"role": "user", "content": "hi"}]} + + assert ( + mistral_provider._get_retry_request_body( + _make_bad_request_error("Unsupported field: reasoning_effort"), body + ) + is None + ) + + +@pytest.mark.asyncio +async def test_cleanup(mistral_provider): + """cleanup closes the OpenAI client.""" + mistral_provider._client = AsyncMock() + + await mistral_provider.cleanup() + + mistral_provider._client.close.assert_called_once() + + +def _make_bad_request_error(message: str) -> openai.BadRequestError: + request = Request("POST", "https://api.mistral.ai/v1/chat/completions") + response = Response(400, request=request) + body = {"error": {"message": message}} + return openai.BadRequestError(message, response=response, body=body) + + +class _StatusError(Exception): + def __init__(self, message: str, *, status_code: int, body: dict): + super().__init__(message) + self.status_code = status_code + self.body = body diff --git a/tests/providers/test_model_validation.py b/tests/providers/test_model_validation.py new file mode 100644 index 0000000..233e860 --- /dev/null +++ b/tests/providers/test_model_validation.py @@ -0,0 +1,540 @@ +import asyncio +from collections.abc import AsyncIterator +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from free_claude_code.application.errors import ApplicationUnavailableError +from free_claude_code.application.model_metadata import ProviderModelInfo +from free_claude_code.config.nim import NimSettings +from free_claude_code.config.provider_catalog import ( + DEEPSEEK_DEFAULT_BASE, + NVIDIA_NIM_DEFAULT_BASE, + OPENROUTER_DEFAULT_BASE, + WAFER_DEFAULT_BASE, +) +from free_claude_code.config.settings import Settings +from free_claude_code.providers.base import BaseProvider, ProviderConfig +from free_claude_code.providers.deepseek import DeepSeekProvider +from free_claude_code.providers.model_listing import ModelListResponseError +from free_claude_code.providers.nvidia_nim import NvidiaNimProvider +from free_claude_code.providers.open_router import OpenRouterProvider +from free_claude_code.providers.openai_chat import OpenAIChatProvider +from free_claude_code.providers.runtime import ProviderRuntime +from free_claude_code.providers.runtime.model_cache import ProviderModelCache +from free_claude_code.runtime.provider_manager import ProviderRuntimeManager +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +def _settings( + *, + model: str = "nvidia_nim/nim-model", + model_opus: str | None = None, + model_sonnet: str | None = None, + model_haiku: str | None = None, + nvidia_nim_api_key: str = "", + open_router_api_key: str = "", + deepseek_api_key: str = "", + wafer_api_key: str = "", + opencode_api_key: str = "", + zai_api_key: str = "", +) -> Settings: + return Settings.model_construct( + model=model, + model_opus=model_opus, + model_sonnet=model_sonnet, + model_haiku=model_haiku, + nvidia_nim_api_key=nvidia_nim_api_key, + open_router_api_key=open_router_api_key, + deepseek_api_key=deepseek_api_key, + wafer_api_key=wafer_api_key, + opencode_api_key=opencode_api_key, + zai_api_key=zai_api_key, + log_api_error_tracebacks=False, + ) + + +def _manager( + settings: Settings, + providers: dict[str, BaseProvider] | None = None, +) -> ProviderRuntimeManager: + providers = providers or {} + return ProviderRuntimeManager( + settings, + runtime_factory=lambda snapshot: ProviderRuntime(snapshot, dict(providers)), + ) + + +@pytest.mark.asyncio +async def test_nim_lists_openai_compatible_model_ids() -> None: + config = ProviderConfig(api_key="test-key", base_url=NVIDIA_NIM_DEFAULT_BASE) + with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"): + provider = NvidiaNimProvider( + config, nim_settings=NimSettings(), rate_limiter=passthrough_rate_limiter() + ) + + with patch.object( + provider._client.models, + "list", + new_callable=AsyncMock, + return_value=SimpleNamespace(data=[SimpleNamespace(id="nvidia/model")]), + ): + assert await provider.list_model_ids() == frozenset({"nvidia/model"}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "provider", + [ + profiled_provider( + "llamacpp", + ProviderConfig(api_key="llamacpp", base_url="http://localhost:8080/v1"), + rate_limiter=passthrough_rate_limiter(), + ), + profiled_provider( + "ollama", + ProviderConfig(api_key="ollama", base_url="http://localhost:11434"), + rate_limiter=passthrough_rate_limiter(), + ), + ], +) +async def test_local_openai_chat_providers_list_model_ids( + provider: OpenAIChatProvider, +) -> None: + with patch.object( + provider._client.models, + "list", + new_callable=AsyncMock, + return_value=SimpleNamespace(data=[SimpleNamespace(id="local/model")]), + ) as mock_list: + assert await provider.list_model_ids() == frozenset({"local/model"}) + + mock_list.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_deepseek_lists_models_from_root_endpoint() -> None: + provider = DeepSeekProvider( + ProviderConfig(api_key="deepseek-key", base_url=DEEPSEEK_DEFAULT_BASE), + rate_limiter=passthrough_rate_limiter(), + ) + with patch.object( + provider._client.models, + "list", + new_callable=AsyncMock, + return_value=SimpleNamespace(data=[SimpleNamespace(id="deepseek-chat")]), + ) as mock_list: + assert await provider.list_model_ids() == frozenset({"deepseek-chat"}) + + mock_list.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_wafer_lists_models_from_default_models_endpoint() -> None: + provider = profiled_provider( + "wafer", + ProviderConfig(api_key="wafer-key", base_url=WAFER_DEFAULT_BASE), + rate_limiter=passthrough_rate_limiter(), + ) + with patch.object( + provider._client.models, + "list", + new_callable=AsyncMock, + return_value=SimpleNamespace(data=[SimpleNamespace(id="DeepSeek-V4-Pro")]), + ) as mock_list: + assert await provider.list_model_ids() == frozenset({"DeepSeek-V4-Pro"}) + + mock_list.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_openrouter_lists_only_tool_capable_models() -> None: + provider = OpenRouterProvider( + ProviderConfig(api_key="open-router-key", base_url=OPENROUTER_DEFAULT_BASE), + rate_limiter=passthrough_rate_limiter(), + ) + with patch.object( + provider._client.models, + "list", + new_callable=AsyncMock, + return_value=SimpleNamespace( + data=[ + SimpleNamespace( + id="tool-model", + supported_parameters=["tools", "max_tokens"], + ), + SimpleNamespace( + id="tool-choice-model", + supported_parameters=["tool_choice"], + ), + SimpleNamespace( + id="chat-only", + supported_parameters=["max_tokens", "temperature"], + ), + SimpleNamespace(id="missing-metadata", supported_parameters=None), + ] + ), + ) as mock_list: + assert await provider.list_model_ids() == frozenset( + {"tool-model", "tool-choice-model"} + ) + + mock_list.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_openrouter_lists_tool_metadata_with_thinking_support() -> None: + provider = OpenRouterProvider( + ProviderConfig(api_key="open-router-key", base_url=OPENROUTER_DEFAULT_BASE), + rate_limiter=passthrough_rate_limiter(), + ) + with patch.object( + provider._client.models, + "list", + new_callable=AsyncMock, + return_value=SimpleNamespace( + data=[ + SimpleNamespace( + id="reasoning-tool-model", + supported_parameters=[ + "tools", + "reasoning", + "include_reasoning", + ], + ), + SimpleNamespace( + id="plain-tool-model", + supported_parameters=["tool_choice", "include_reasoning"], + ), + SimpleNamespace( + id="chat-only", + supported_parameters=["reasoning", "max_tokens"], + ), + ] + ), + ): + infos = await provider.list_model_infos() + + assert infos == frozenset( + { + ProviderModelInfo("reasoning-tool-model", supports_thinking=True), + ProviderModelInfo("plain-tool-model", supports_thinking=False), + } + ) + + +@pytest.mark.asyncio +async def test_openrouter_lists_empty_set_when_no_tool_capable_models() -> None: + provider = OpenRouterProvider( + ProviderConfig(api_key="open-router-key", base_url=OPENROUTER_DEFAULT_BASE), + rate_limiter=passthrough_rate_limiter(), + ) + with patch.object( + provider._client.models, + "list", + new_callable=AsyncMock, + return_value=SimpleNamespace( + data=[ + SimpleNamespace(id="chat-only", supported_parameters=["max_tokens"]), + SimpleNamespace(id="missing-metadata", supported_parameters=None), + ] + ), + ): + assert await provider.list_model_ids() == frozenset() + + +@pytest.mark.asyncio +async def test_openrouter_model_metadata_rejects_malformed_ids() -> None: + provider = OpenRouterProvider( + ProviderConfig(api_key="open-router-key", base_url=OPENROUTER_DEFAULT_BASE), + rate_limiter=passthrough_rate_limiter(), + ) + with ( + patch.object( + provider._client.models, + "list", + new_callable=AsyncMock, + return_value=SimpleNamespace( + data=[SimpleNamespace(supported_parameters=["tools", "reasoning"])] + ), + ), + pytest.raises(ModelListResponseError, match="malformed"), + ): + await provider.list_model_infos() + + +@pytest.mark.asyncio +async def test_model_listing_rejects_malformed_payload() -> None: + provider = profiled_provider( + "llamacpp", + ProviderConfig(api_key="llamacpp", base_url="http://localhost:8080/v1"), + rate_limiter=passthrough_rate_limiter(), + ) + with ( + patch.object( + provider._client.models, + "list", + new_callable=AsyncMock, + return_value=SimpleNamespace(data=[SimpleNamespace()]), + ), + pytest.raises(ModelListResponseError, match="malformed"), + ): + await provider.list_model_ids() + + +@pytest.mark.asyncio +async def test_model_listing_propagates_upstream_errors() -> None: + provider = profiled_provider( + "llamacpp", + ProviderConfig(api_key="llamacpp", base_url="http://localhost:8080/v1"), + rate_limiter=passthrough_rate_limiter(), + ) + with ( + patch.object( + provider._client.models, + "list", + new_callable=AsyncMock, + side_effect=RuntimeError("upstream unavailable"), + ), + pytest.raises(RuntimeError, match="upstream unavailable"), + ): + await provider.list_model_ids() + + +class FakeProvider(BaseProvider): + def __init__( + self, + model_ids: frozenset[str] | None = None, + *, + model_infos: frozenset[ProviderModelInfo] | None = None, + error: BaseException | None = None, + started: asyncio.Event | None = None, + peer_started: asyncio.Event | None = None, + ): + super().__init__( + ProviderConfig(api_key="test", base_url="https://test.invalid") + ) + self._model_ids = model_ids or frozenset() + self._model_infos = model_infos + self._error = error + self._started = started + self._peer_started = peer_started + self.cleaned = False + + def preflight_stream( + self, request: Any, *, thinking_enabled: bool | None = None + ) -> None: + return None + + async def cleanup(self) -> None: + self.cleaned = True + + async def _before_model_list(self) -> None: + if self._started is not None: + self._started.set() + if self._peer_started is not None: + await self._peer_started.wait() + if self._error is not None: + raise self._error + + async def list_model_ids(self) -> frozenset[str]: + await self._before_model_list() + if self._model_infos is not None: + return frozenset(info.model_id for info in self._model_infos) + return self._model_ids + + async def list_model_infos(self) -> frozenset[ProviderModelInfo]: + await self._before_model_list() + if self._model_infos is not None: + return self._model_infos + return frozenset(ProviderModelInfo(model_id) for model_id in self._model_ids) + + async def stream_response( + self, + request: Any, + input_tokens: int = 0, + *, + request_id: str | None = None, + thinking_enabled: bool | None = None, + ) -> AsyncIterator[str]: + if False: + yield "" + + +@pytest.mark.asyncio +async def test_runtime_validation_succeeds_for_all_configured_models() -> None: + settings = _settings(model_opus="open_router/anthropic/claude-opus") + runtime = _manager( + settings, + { + "nvidia_nim": FakeProvider(frozenset({"nim-model"})), + "open_router": FakeProvider(frozenset({"anthropic/claude-opus"})), + }, + ) + + await runtime.validate_configured_models() + + assert runtime.cached_model_ids() == { + "nvidia_nim": frozenset({"nim-model"}), + "open_router": frozenset({"anthropic/claude-opus"}), + } + + +@pytest.mark.asyncio +async def test_runtime_validation_reports_missing_model_with_sources() -> None: + settings = _settings(model_sonnet="nvidia_nim/nim-model") + runtime = _manager( + settings, + {"nvidia_nim": FakeProvider(frozenset({"different-model"}))}, + ) + + with pytest.raises(ApplicationUnavailableError) as exc_info: + await runtime.validate_configured_models() + + message = exc_info.value.message + assert "sources=MODEL,MODEL_SONNET" in message + assert "provider=nvidia_nim" in message + assert "model=nim-model" in message + assert "problem=missing model" in message + + +@pytest.mark.asyncio +async def test_runtime_validation_aggregates_multiple_failures() -> None: + settings = _settings(model_opus="open_router/anthropic/claude-opus") + runtime = _manager( + settings, + { + "nvidia_nim": FakeProvider(frozenset({"different-model"})), + "open_router": FakeProvider( + error=ModelListResponseError("bad model-list shape") + ), + }, + ) + + with pytest.raises(ApplicationUnavailableError) as exc_info: + await runtime.validate_configured_models() + + message = exc_info.value.message + assert "sources=MODEL provider=nvidia_nim model=nim-model" in message + assert "problem=missing model" in message + assert "sources=MODEL_OPUS provider=open_router model=anthropic/claude-opus" in ( + message + ) + assert "problem=malformed model-list response" in message + + +@pytest.mark.asyncio +async def test_runtime_validation_queries_providers_concurrently() -> None: + nim_started = asyncio.Event() + router_started = asyncio.Event() + settings = _settings(model_opus="open_router/anthropic/claude-opus") + runtime = _manager( + settings, + { + "nvidia_nim": FakeProvider( + frozenset({"nim-model"}), + started=nim_started, + peer_started=router_started, + ), + "open_router": FakeProvider( + frozenset({"anthropic/claude-opus"}), + started=router_started, + peer_started=nim_started, + ), + }, + ) + + await asyncio.wait_for(runtime.validate_configured_models(), timeout=1.0) + + +@pytest.mark.asyncio +async def test_runtime_refresh_model_list_cache_uses_configured_remote_keys_and_referenced_local() -> ( + None +): + settings = _settings( + model="lmstudio/local-qwen", + open_router_api_key="open-router-key", + ) + runtime = _manager( + settings, + { + "open_router": FakeProvider(frozenset({"anthropic/claude-sonnet"})), + "lmstudio": FakeProvider(frozenset({"local-qwen"})), + "ollama": FakeProvider(frozenset({"llama3.1"})), + }, + ) + + await runtime.refresh_model_list_cache() + + assert runtime.cached_model_ids() == { + "open_router": frozenset({"anthropic/claude-sonnet"}), + "lmstudio": frozenset({"local-qwen"}), + } + + +@pytest.mark.asyncio +async def test_runtime_refresh_model_list_cache_keeps_prior_cache_on_failure() -> None: + settings = _settings( + model="nvidia_nim/cached-model", + nvidia_nim_api_key="nim-key", + ) + runtime = _manager( + settings, + {"nvidia_nim": FakeProvider(error=RuntimeError("upstream down"))}, + ) + runtime.cache_model_infos( + "nvidia_nim", + {ProviderModelInfo("cached-model")}, + ) + + await runtime.refresh_model_list_cache() + + assert runtime.cached_model_ids() == {"nvidia_nim": frozenset({"cached-model"})} + + +def test_runtime_metadata_cache_exposes_ids_and_prefixed_infos() -> None: + cache = ProviderModelCache() + cache.cache_model_infos( + "open_router", + { + ProviderModelInfo("reasoning-model", supports_thinking=True), + ProviderModelInfo("plain-model", supports_thinking=False), + }, + ) + + assert cache.cached_model_ids() == { + "open_router": frozenset({"reasoning-model", "plain-model"}) + } + assert ( + cache.cached_model_supports_thinking("open_router", "reasoning-model") is True + ) + assert cache.cached_model_supports_thinking("open_router", "plain-model") is False + assert cache.cached_prefixed_model_infos() == ( + ProviderModelInfo("open_router/plain-model", supports_thinking=False), + ProviderModelInfo("open_router/reasoning-model", supports_thinking=True), + ) + + +def test_runtime_model_id_cache_keeps_unknown_thinking_support() -> None: + cache = ProviderModelCache() + cache.cache_model_ids("open_router", {"plain-model"}) + + assert cache.cached_model_ids() == {"open_router": frozenset({"plain-model"})} + assert cache.cached_model_supports_thinking("open_router", "plain-model") is None + assert cache.cached_prefixed_model_infos() == ( + ProviderModelInfo("open_router/plain-model", supports_thinking=None), + ) + + +def test_runtime_cached_prefixed_model_refs_are_deterministic() -> None: + cache = ProviderModelCache() + cache.cache_model_ids("deepseek", {"deepseek-chat"}) + cache.cache_model_ids("open_router", {"z-model", "a-model"}) + + assert cache.cached_prefixed_model_refs() == ( + "open_router/a-model", + "open_router/z-model", + "deepseek/deepseek-chat", + ) diff --git a/tests/providers/test_nim_request_clone.py b/tests/providers/test_nim_request_clone.py new file mode 100644 index 0000000..0e82509 --- /dev/null +++ b/tests/providers/test_nim_request_clone.py @@ -0,0 +1,42 @@ +"""Tests for NVIDIA NIM request body cloning helpers.""" + +from copy import deepcopy + +from free_claude_code.providers.nvidia_nim.retry import ( + clone_body_without_reasoning_budget, +) + + +def test_clone_body_without_reasoning_budget_strips_top_level_and_nested(): + body: dict = { + "model": "x", + "extra_body": { + "reasoning_budget": 99, + "chat_template_kwargs": {"reasoning_budget": 42, "thinking": True}, + "top_k": 1, + }, + } + original_extra = deepcopy(body["extra_body"]) + out = clone_body_without_reasoning_budget(body) + + assert out is not None + assert out["extra_body"]["chat_template_kwargs"] == {"thinking": True} + assert "reasoning_budget" not in out["extra_body"] + assert body["extra_body"] == original_extra + + +def test_clone_body_without_reasoning_budget_returns_none_when_unchanged(): + body = {"model": "x", "extra_body": {"top_k": 3}} + assert clone_body_without_reasoning_budget(body) is None + + +def test_clone_body_without_reasoning_budget_returns_none_without_extra_body(): + assert clone_body_without_reasoning_budget({"model": "y"}) is None + + +def test_clone_body_drops_empty_extra_body_after_strip(): + body = {"model": "z", "extra_body": {"reasoning_budget": 7}} + out = clone_body_without_reasoning_budget(body) + assert out is not None + assert "extra_body" not in out + assert "extra_body" in body diff --git a/tests/providers/test_nvidia_nim.py b/tests/providers/test_nvidia_nim.py new file mode 100644 index 0000000..499f37a --- /dev/null +++ b/tests/providers/test_nvidia_nim.py @@ -0,0 +1,933 @@ +import json +from dataclasses import replace +from unittest.mock import AsyncMock, MagicMock, patch + +import openai +import pytest +from httpx import Request, Response + +from free_claude_code.config.nim import NimSettings +from free_claude_code.config.provider_catalog import NVIDIA_NIM_DEFAULT_BASE +from free_claude_code.core.failures import ExecutionFailure +from free_claude_code.providers.nvidia_nim import NvidiaNimProvider +from free_claude_code.providers.nvidia_nim.tool_schema import ( + NIM_TOOL_ARGUMENT_ALIASES_KEY, +) +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter + + +def message(role, content): + return {"role": role, "content": content} + + +def tool(name, description, input_schema): + return {"name": name, "description": description, "input_schema": input_schema} + + +def block(**fields): + return fields + + +def make_request(**overrides): + model = overrides.pop("model", "test-model") + overrides.setdefault("stop_sequences", ["STOP"]) + return make_messages_request(model, **overrides) + + +def _input_json_deltas(events): + deltas = [] + for event in events: + if "event: content_block_delta" not in event: + continue + for line in event.splitlines(): + if not line.startswith("data: "): + continue + payload = json.loads(line[6:]) + delta = payload.get("delta", {}) + if delta.get("type") == "input_json_delta": + deltas.append(delta.get("partial_json", "")) + return deltas + + +def _tool_call_chunk( + *, + name, + arguments, + tool_id="call_1", + index=0, + finish_reason=None, +): + mock_tc = MagicMock() + mock_tc.index = index + mock_tc.id = tool_id + mock_tc.function.name = name + mock_tc.function.arguments = arguments + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content=None, reasoning_content="", tool_calls=[mock_tc]), + finish_reason=finish_reason, + ) + ] + mock_chunk.usage = None + return mock_chunk + + +def _make_bad_request_error(message: str) -> openai.BadRequestError: + response = Response( + status_code=400, + request=Request("POST", f"{NVIDIA_NIM_DEFAULT_BASE}/chat/completions"), + ) + body = {"error": {"message": message, "type": "BadRequestError", "code": 400}} + return openai.BadRequestError(message, response=response, body=body) + + +def _make_internal_server_error(message: str) -> openai.InternalServerError: + response = Response( + status_code=500, + request=Request("POST", f"{NVIDIA_NIM_DEFAULT_BASE}/chat/completions"), + ) + body = { + "error": { + "message": message, + "type": "internal_server_error", + "code": 500, + } + } + return openai.InternalServerError(message, response=response, body=body) + + +@pytest.mark.asyncio +async def test_init(provider_config): + """Test provider initialization.""" + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_openai: + provider = NvidiaNimProvider( + provider_config, + nim_settings=NimSettings(), + rate_limiter=passthrough_rate_limiter(), + ) + 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 free_claude_code.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( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_openai: + NvidiaNimProvider( + config, nim_settings=NimSettings(), rate_limiter=passthrough_rate_limiter() + ) + 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.""" + provider = NvidiaNimProvider( + provider_config, + nim_settings=NimSettings(), + rate_limiter=passthrough_rate_limiter(), + ) + req = make_request() + 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 ctk["reasoning_budget"] == body["max_tokens"] + assert "reasoning_budget" not in body["extra_body"] + + +@pytest.mark.asyncio +async def test_build_request_body_omits_reasoning_when_globally_disabled( + provider_config, +): + provider = NvidiaNimProvider( + replace(provider_config, enable_thinking=False), + nim_settings=NimSettings(), + rate_limiter=passthrough_rate_limiter(), + ) + req = make_request() + body = provider._build_request_body(req) + + extra = body.get("extra_body", {}) + assert "chat_template_kwargs" not in extra + assert "reasoning_budget" not in extra + + +@pytest.mark.asyncio +async def test_build_request_body_omits_reasoning_when_request_disables_thinking( + provider_config, +): + provider = NvidiaNimProvider( + provider_config, + nim_settings=NimSettings(), + rate_limiter=passthrough_rate_limiter(), + ) + req = make_request() + req.thinking.enabled = False + body = provider._build_request_body(req) + + extra = body.get("extra_body", {}) + assert "chat_template_kwargs" not in extra + assert "reasoning_budget" not in extra + + +def test_preflight_and_build_request_issue_206_post_tool_text(nim_provider): + """Regression: assistant message with tool_use then text plus tool results (GitHub #206).""" + tool_id = "toolu_issue_206" + req = make_request( + messages=[ + message("user", "Use echo once."), + message( + "assistant", + [ + block( + type="tool_use", + id=tool_id, + name="echo_smoke", + input={"value": "FCC_206"}, + ), + block( + type="text", + text="Commentary after the tool row.", + ), + ], + ), + message( + "user", + [ + block(type="tool_result", tool_use_id=tool_id, content="FCC_206"), + block(type="text", text="What was echoed?"), + ], + ), + ], + ) + nim_provider.preflight_stream(req, thinking_enabled=False) + body = nim_provider._build_request_body(req, thinking_enabled=False) + assert "messages" in body + assert any(m.get("role") == "tool" for m in body["messages"]) + + +@pytest.mark.asyncio +async def test_stream_response_text(nim_provider): + """Test streaming text response.""" + req = make_request() + + # 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 = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content=None, reasoning_content="Thinking..."), + finish_reason=None, + ) + ] + mock_chunk.usage = None + stop_chunk = MagicMock() + stop_chunk.choices = [ + MagicMock( + delta=MagicMock(content=None, reasoning_content=None, tool_calls=None), + finish_reason="stop", + ) + ] + stop_chunk.usage = None + + async def mock_stream(): + yield mock_chunk + yield stop_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_stream_response_suppresses_thinking_when_disabled(provider_config): + provider = NvidiaNimProvider( + replace(provider_config, enable_thinking=False), + nim_settings=NimSettings(), + rate_limiter=passthrough_rate_limiter(), + ) + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content="secretAnswer", reasoning_content="Thinking..." + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = None + + async def mock_stream(): + yield mock_chunk + + with patch.object( + provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [e async for e in provider.stream_response(req)] + + event_text = "".join(events) + assert "thinking_delta" not in event_text + assert "Thinking..." not in event_text + assert "secret" not in event_text + assert "Answer" in event_text + + +def _make_bad_request_error(message: str) -> openai.BadRequestError: + response = Response(status_code=400, request=Request("POST", "http://test")) + body = {"error": {"message": message}} + return openai.BadRequestError(message, response=response, body=body) + + +@pytest.mark.asyncio +async def test_stream_response_retries_without_chat_template(provider_config): + provider = NvidiaNimProvider( + provider_config, + nim_settings=NimSettings(chat_template="custom_template"), + rate_limiter=passthrough_rate_limiter(), + ) + req = make_request(model="mistralai/mixtral-8x7b-instruct-v0.1") + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content="OK", reasoning_content=""), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2) + + async def mock_stream(): + yield mock_chunk + + first_error = _make_bad_request_error( + "chat_template is not supported for Mistral tokenizers." + ) + + with patch.object( + provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = [first_error, mock_stream()] + + events = [e async for e in provider.stream_response(req)] + + assert mock_create.await_count == 2 + + first_extra = mock_create.call_args_list[0].kwargs["extra_body"] + second_extra = mock_create.call_args_list[1].kwargs["extra_body"] + + assert first_extra["chat_template"] == "custom_template" + assert first_extra["chat_template_kwargs"] == { + "thinking": True, + "enable_thinking": True, + "reasoning_budget": 100, + } + assert "reasoning_budget" not in first_extra + + assert "chat_template" not in second_extra + assert "chat_template_kwargs" not in second_extra + assert "reasoning_budget" not in second_extra + + event_text = "".join(events) + assert "event: error" not in event_text + assert "OK" in event_text + + +@pytest.mark.asyncio +async def test_stream_response_retries_without_chat_template_kwargs_issue_993( + provider_config, +): + provider = NvidiaNimProvider( + provider_config, + nim_settings=NimSettings(), + rate_limiter=passthrough_rate_limiter(), + ) + req = make_request(model="mistralai/mistral-small-4-119b-2603") + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content="OK", reasoning_content=""), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2) + + async def mock_stream(): + yield mock_chunk + + first_error = _make_bad_request_error( + "chat_template is not supported for Mistral tokenizers." + ) + + with patch.object( + provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = [first_error, mock_stream()] + + events = [e async for e in provider.stream_response(req)] + + assert mock_create.await_count == 2 + + first_extra = mock_create.call_args_list[0].kwargs["extra_body"] + second_kwargs = mock_create.call_args_list[1].kwargs + + assert "chat_template" not in first_extra + assert first_extra["chat_template_kwargs"] == { + "thinking": True, + "enable_thinking": True, + "reasoning_budget": 100, + } + second_extra = second_kwargs.get("extra_body") or {} + assert "chat_template" not in second_extra + assert "chat_template_kwargs" not in second_extra + + event_text = "".join(events) + assert "event: error" not in event_text + assert "OK" in event_text + + +@pytest.mark.asyncio +async def test_stream_response_does_not_retry_unrelated_bad_request(provider_config): + provider = NvidiaNimProvider( + provider_config, + nim_settings=NimSettings(chat_template="custom_template"), + rate_limiter=passthrough_rate_limiter(), + ) + req = make_request(model="mistralai/mixtral-8x7b-instruct-v0.1") + + with patch.object( + provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = _make_bad_request_error("unrelated bad request") + + with pytest.raises(ExecutionFailure) as exc_info: + [e async for e in provider.stream_response(req)] + + assert mock_create.await_count == 1 + assert "Invalid request sent to provider" in exc_info.value.message + + +@pytest.mark.asyncio +async def test_tool_call_stream(nim_provider): + """Test streaming tool calls.""" + req = make_request() + + # 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] + + +@pytest.mark.asyncio +async def test_stream_response_restores_aliased_tool_arguments(nim_provider): + """NIM-safe argument aliases are restored before Anthropic SSE emission.""" + req = make_request( + tools=[ + tool( + "Grep", + "Search file contents", + { + "type": "object", + "properties": { + "pattern": {"type": "string"}, + "-A": {"type": "number"}, + "type": {"type": "string"}, + }, + "required": ["pattern"], + }, + ) + ] + ) + mock_chunk = _tool_call_chunk( + name="Grep", + arguments=json.dumps({"pattern": "needle", "-A": 2, "_fcc_arg_type": "py"}), + ) + + 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)] + + await_args = mock_create.await_args + assert await_args is not None + create_kwargs = await_args.kwargs + assert NIM_TOOL_ARGUMENT_ALIASES_KEY not in create_kwargs + properties = create_kwargs["tools"][0]["function"]["parameters"]["properties"] + assert "-A" in properties + assert "type" not in properties + assert "_fcc_arg_A" not in properties + assert "_fcc_arg_type" in properties + + deltas = _input_json_deltas(events) + assert len(deltas) == 1 + assert json.loads(deltas[0]) == {"pattern": "needle", "-A": 2, "type": "py"} + assert "_fcc_arg_type" not in deltas[0] + + +@pytest.mark.asyncio +async def test_stream_response_buffers_chunked_aliased_tool_arguments(nim_provider): + """Chunked aliased args are emitted once as restored Claude Code args.""" + req = make_request( + tools=[ + tool( + "Grep", + "Search file contents", + { + "type": "object", + "properties": { + "pattern": {"type": "string"}, + "type": {"type": "string"}, + }, + "required": ["pattern"], + }, + ) + ] + ) + first_chunk = _tool_call_chunk( + name="Grep", + arguments='{"pattern": "needle", ', + tool_id="call_chunked", + ) + second_chunk = _tool_call_chunk( + name=None, + arguments='"_fcc_arg_type": "py"}', + tool_id="call_chunked", + ) + + async def mock_stream(): + yield first_chunk + yield second_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)] + + deltas = _input_json_deltas(events) + assert len(deltas) == 1 + assert json.loads(deltas[0]) == {"pattern": "needle", "type": "py"} + + +@pytest.mark.asyncio +async def test_stream_response_restores_nested_aliased_tool_arguments(nim_provider): + req = make_request( + tools=[ + tool( + "NotionLike", + "Nested type schema", + { + "type": "object", + "properties": { + "parent": { + "type": "object", + "properties": { + "type": {"type": "string"}, + "id": {"type": "string"}, + }, + "required": ["type", "id"], + } + }, + "required": ["parent"], + }, + ) + ] + ) + mock_chunk = _tool_call_chunk( + name="NotionLike", + arguments=json.dumps( + {"parent": {"_fcc_arg_type": "page_id", "id": "page_123"}} + ), + ) + + 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)] + + deltas = _input_json_deltas(events) + assert len(deltas) == 1 + assert json.loads(deltas[0]) == {"parent": {"type": "page_id", "id": "page_123"}} + + +@pytest.mark.asyncio +async def test_stream_response_task_tool_still_forces_background_false(nim_provider): + req = make_request( + tools=[ + tool( + "Task", + "Run a subagent", + { + "type": "object", + "properties": { + "description": {"type": "string"}, + "prompt": {"type": "string"}, + "run_in_background": {"type": "boolean"}, + }, + "required": ["description", "prompt"], + }, + ) + ] + ) + mock_chunk = _tool_call_chunk( + name="Task", + arguments=json.dumps( + { + "description": "Inspect", + "prompt": "Read the marker", + "run_in_background": True, + } + ), + tool_id="call_task", + ) + + 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)] + + deltas = _input_json_deltas(events) + assert len(deltas) == 1 + assert json.loads(deltas[0])["run_in_background"] is False + + +@pytest.mark.asyncio +async def test_stream_response_retries_without_reasoning_budget(nim_provider): + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content="Recovered", reasoning_content=""), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5) + + async def mock_stream(): + yield mock_chunk + + error = _make_bad_request_error("Unsupported field: reasoning_budget") + + with patch.object( + nim_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = [error, mock_stream()] + + events = [e async for e in nim_provider.stream_response(req)] + + assert mock_create.await_count == 2 + first_call = mock_create.await_args_list[0].kwargs + second_call = mock_create.await_args_list[1].kwargs + assert ( + first_call["extra_body"]["chat_template_kwargs"]["reasoning_budget"] + == first_call["max_tokens"] + ) + assert "reasoning_budget" not in second_call["extra_body"] + assert "reasoning_budget" not in second_call["extra_body"]["chat_template_kwargs"] + assert second_call["extra_body"]["chat_template_kwargs"]["enable_thinking"] is True + assert any("Recovered" in event for event in events) + assert any("message_stop" in event for event in events) + + +@pytest.mark.asyncio +async def test_stream_response_retries_without_budget_for_thinking_token_error( + nim_provider, +): + req = make_request(model="meta/llama-3.3-70b-instruct") + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content="Recovered", reasoning_content=""), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5) + + async def mock_stream(): + yield mock_chunk + + error = _make_internal_server_error( + "ValueError: thinking_token_budget is set but reasoning_config is not " + "configured. Please set --reasoning-config to use thinking_token_budget." + ) + + with patch.object( + nim_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = [error, mock_stream()] + + events = [e async for e in nim_provider.stream_response(req)] + + assert mock_create.await_count == 2 + first_call = mock_create.await_args_list[0].kwargs + second_call = mock_create.await_args_list[1].kwargs + assert ( + first_call["extra_body"]["chat_template_kwargs"]["reasoning_budget"] + == first_call["max_tokens"] + ) + assert "reasoning_budget" not in second_call["extra_body"] + assert "reasoning_budget" not in second_call["extra_body"]["chat_template_kwargs"] + assert second_call["extra_body"]["chat_template_kwargs"]["thinking"] is True + assert second_call["extra_body"]["chat_template_kwargs"]["enable_thinking"] is True + assert any("Recovered" in event for event in events) + assert any("message_stop" in event for event in events) + + +@pytest.mark.asyncio +async def test_stream_response_retries_without_reasoning_content(nim_provider): + req = make_request( + system=None, + messages=[ + message( + "assistant", + [ + block(type="thinking", thinking="Need the tool."), + block( + type="tool_use", + id="toolu_reasoning", + name="echo_smoke", + input={"value": "FCC_TOOL"}, + ), + ], + ), + message( + "user", + [ + block( + type="tool_result", + tool_use_id="toolu_reasoning", + content="result", + ) + ], + ), + ], + ) + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content="Recovered", reasoning_content=""), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5) + + async def mock_stream(): + yield mock_chunk + + error = _make_bad_request_error("Unsupported field: reasoning_content") + + with patch.object( + nim_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = [error, mock_stream()] + + events = [e async for e in nim_provider.stream_response(req)] + + assert mock_create.await_count == 2 + first_call = mock_create.await_args_list[0].kwargs + second_call = mock_create.await_args_list[1].kwargs + assert first_call["messages"][0]["reasoning_content"] == "Need the tool." + assert "reasoning_content" not in second_call["messages"][0] + assert second_call["messages"][0]["tool_calls"][0]["id"] == "toolu_reasoning" + assert any("Recovered" in event for event in events) + assert any("message_stop" in event for event in events) + + +@pytest.mark.asyncio +async def test_stream_response_bad_request_without_reasoning_budget_does_not_retry( + nim_provider, +): + req = make_request() + error = _make_bad_request_error("Unsupported field: top_k") + + with patch.object( + nim_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = error + + with pytest.raises(ExecutionFailure) as exc_info: + [e async for e in nim_provider.stream_response(req)] + + assert mock_create.await_count == 1 + assert "Invalid request sent to provider" in exc_info.value.message + + +@pytest.mark.asyncio +async def test_stream_response_unrelated_internal_error_does_not_downgrade( + nim_provider, +): + req = make_request() + error = _make_internal_server_error("unrelated internal provider failure") + + with patch.object( + nim_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = error + + with pytest.raises(ExecutionFailure) as exc_info: + [e async for e in nim_provider.stream_response(req)] + + assert mock_create.await_count == 1 + assert "Provider API request failed" in exc_info.value.message + + +@pytest.mark.asyncio +async def test_stream_response_internal_reasoning_content_error_does_not_downgrade( + nim_provider, +): + req = make_request() + error = _make_internal_server_error( + "reasoning_content could not be processed by the upstream model" + ) + + with patch.object( + nim_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = error + + with pytest.raises(ExecutionFailure) as exc_info: + [e async for e in nim_provider.stream_response(req)] + + assert mock_create.await_count == 1 + assert "Provider API request failed" in exc_info.value.message diff --git a/tests/providers/test_nvidia_nim_degraded_retry.py b/tests/providers/test_nvidia_nim_degraded_retry.py new file mode 100644 index 0000000..55d3537 --- /dev/null +++ b/tests/providers/test_nvidia_nim_degraded_retry.py @@ -0,0 +1,287 @@ +"""NVIDIA Cloud Function deployment failures use provider-owned retry semantics.""" + +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import httpx +import openai +import pytest + +from free_claude_code.config.nim import NimSettings +from free_claude_code.core.failures import ExecutionFailure, FailureKind +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.failure_policy import ( + overloaded_provider_failure, + retryable_upstream_status, +) +from free_claude_code.providers.nvidia_nim import NvidiaNimProvider +from free_claude_code.providers.open_router import OpenRouterProvider +from free_claude_code.providers.rate_limit import ( + DEFAULT_UPSTREAM_MAX_RETRIES, + UPSTREAM_TRANSIENT_TOTAL_ATTEMPTS, + ProviderRateLimiter, +) +from tests.providers.request_factory import make_messages_request + +_FUNCTION_ID = "87ea0ddc-cff1-4bca-bf8b-3bd98a35ddd0" +_DEGRADED_DETAIL = f"Function id '{_FUNCTION_ID}': DEGRADED function cannot be invoked" + + +def _config(base_url: str) -> ProviderConfig: + return ProviderConfig( + api_key="test_key", + base_url=base_url, + rate_limit=1_000_000, + rate_window=1, + max_concurrency=1_000, + http_read_timeout=30.0, + http_write_timeout=15.0, + http_connect_timeout=5.0, + ) + + +def _limiter() -> ProviderRateLimiter: + return ProviderRateLimiter( + rate_limit=1_000_000, + rate_window=1.0, + max_concurrency=1_000, + ) + + +def _bad_request( + detail: str = _DEGRADED_DETAIL, + *, + body_extra: dict[str, str] | None = None, +) -> openai.BadRequestError: + request = httpx.Request( + "POST", "https://integrate.api.nvidia.com/v1/chat/completions" + ) + response = httpx.Response(400, request=request) + body: dict[str, object] = { + "status": 400, + "title": "Bad Request", + "detail": detail, + } + if body_extra is not None: + body.update(body_extra) + return openai.BadRequestError("Bad Request", response=response, body=body) + + +def _successful_stream(text: str = "Recovered"): + chunk = MagicMock() + chunk.choices = [ + MagicMock( + delta=MagicMock(content=text, reasoning_content=""), + finish_reason="stop", + ) + ] + chunk.usage = None + + async def stream(): + yield chunk + + return stream() + + +def _nim(limiter: ProviderRateLimiter) -> NvidiaNimProvider: + return NvidiaNimProvider( + _config("https://integrate.api.nvidia.com/v1"), + nim_settings=NimSettings(), + rate_limiter=limiter, + ) + + +@pytest.mark.asyncio +async def test_degraded_function_retries_unchanged_request_then_succeeds() -> None: + limiter = _limiter() + provider = _nim(limiter) + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + side_effect=[_bad_request(), _successful_stream()], + ) as create, + patch.object(limiter, "extend_reactive_block") as extend_block, + patch( + "free_claude_code.providers.rate_limit.asyncio.sleep", + new_callable=AsyncMock, + ) as sleep, + ): + events = [ + event + async for event in provider.stream_response( + make_messages_request(), request_id="req_recovered" + ) + ] + + assert create.await_count == 2 + assert create.call_args_list[0].kwargs == create.call_args_list[1].kwargs + extend_block.assert_called_once() + sleep.assert_awaited_once() + event_text = "".join(events) + assert "Recovered" in event_text + assert "event: message_stop" in event_text + assert "event: error" not in event_text + + +@pytest.mark.asyncio +async def test_degraded_function_exhaustion_is_detailed_redacted_overload() -> None: + limiter = _limiter() + provider = _nim(limiter) + error = _bad_request( + body_extra={ + "authorization": "Bearer NIM_AUTH_SECRET", + "api_key": "NIM_API_SECRET", + } + ) + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + side_effect=error, + ) as create, + patch.object(limiter, "extend_reactive_block") as extend_block, + patch( + "free_claude_code.providers.rate_limit.asyncio.sleep", + new_callable=AsyncMock, + ) as sleep, + patch("free_claude_code.providers.openai_chat.provider.trace_event") as trace, + pytest.raises(ExecutionFailure) as exc_info, + ): + [ + event + async for event in provider.stream_response( + make_messages_request(), request_id="req_degraded" + ) + ] + + assert create.await_count == UPSTREAM_TRANSIENT_TOTAL_ATTEMPTS + assert extend_block.call_count == DEFAULT_UPSTREAM_MAX_RETRIES + assert sleep.await_count == DEFAULT_UPSTREAM_MAX_RETRIES + + failure = exc_info.value + assert failure.kind is FailureKind.OVERLOADED + assert failure.status_code == 529 + assert failure.retryable is True + assert "Upstream provider NIM returned HTTP 400." in failure.message + assert _DEGRADED_DETAIL in failure.message + assert "Request ID: req_degraded" in failure.message + assert "NIM_AUTH_SECRET" not in failure.message + assert "NIM_API_SECRET" not in failure.message + + error_traces = [ + call.kwargs + for call in trace.call_args_list + if call.kwargs.get("event") == "provider.response.error" + ] + assert error_traces[-1]["exc_type"] == "BadRequestError" + assert error_traces[-1]["failure_kind"] == "overloaded" + assert error_traces[-1]["status_code"] == 529 + assert error_traces[-1]["provider_retryable"] is True + assert "error_message" not in error_traces[-1] + + +@pytest.mark.parametrize( + "detail", + [ + "Unsupported field: top_k", + "Validation failed: DEGRADED function cannot be invoked", + f"Function id '{_FUNCTION_ID}': DEGRADING function cannot be invoked", + f"Function id '{_FUNCTION_ID}': DEGRADED function is waiting", + ], +) +@pytest.mark.asyncio +async def test_unrelated_nim_bad_request_is_not_retried(detail: str) -> None: + limiter = _limiter() + provider = _nim(limiter) + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + side_effect=_bad_request(detail), + ) as create, + patch.object(limiter, "extend_reactive_block") as extend_block, + patch( + "free_claude_code.providers.rate_limit.asyncio.sleep", + new_callable=AsyncMock, + ) as sleep, + pytest.raises(ExecutionFailure) as exc_info, + ): + [event async for event in provider.stream_response(make_messages_request())] + + assert create.await_count == 1 + extend_block.assert_not_called() + sleep.assert_not_awaited() + assert exc_info.value.kind is FailureKind.INVALID_REQUEST + assert exc_info.value.status_code == 400 + assert exc_info.value.retryable is False + + +@pytest.mark.asyncio +async def test_degraded_wording_remains_non_retryable_for_other_providers() -> None: + limiter = _limiter() + provider = OpenRouterProvider( + _config("https://openrouter.ai/api/v1"), rate_limiter=limiter + ) + error = _bad_request() + + assert retryable_upstream_status(error) is None + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + side_effect=error, + ) as create, + patch.object(limiter, "extend_reactive_block") as extend_block, + patch( + "free_claude_code.providers.rate_limit.asyncio.sleep", + new_callable=AsyncMock, + ) as sleep, + pytest.raises(ExecutionFailure) as exc_info, + ): + [event async for event in provider.stream_response(make_messages_request())] + + assert create.await_count == 1 + extend_block.assert_not_called() + sleep.assert_not_awaited() + assert exc_info.value.kind is FailureKind.INVALID_REQUEST + assert exc_info.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_limiter_override_preserves_raw_exception_after_exhaustion() -> None: + limiter = _limiter() + errors = (_bad_request(), _bad_request()) + attempts = 0 + + async def fail() -> None: + nonlocal attempts + error = errors[attempts] + attempts += 1 + raise error + + override = Mock(return_value=overloaded_provider_failure()) + with ( + patch.object(limiter, "extend_reactive_block"), + patch( + "free_claude_code.providers.rate_limit.asyncio.sleep", + new_callable=AsyncMock, + ), + pytest.raises(openai.BadRequestError) as exc_info, + ): + await limiter.execute_with_retry( + fail, + provider_failure_override=override, + max_retries=1, + ) + + assert attempts == 2 + assert override.call_count == 2 + assert exc_info.value is errors[-1] diff --git a/tests/providers/test_nvidia_nim_request.py b/tests/providers/test_nvidia_nim_request.py new file mode 100644 index 0000000..3fed380 --- /dev/null +++ b/tests/providers/test_nvidia_nim_request.py @@ -0,0 +1,541 @@ +"""Tests for NVIDIA NIM request policy helpers.""" + +from copy import deepcopy +from typing import Any + +import pytest + +from free_claude_code.config.nim import NimSettings +from free_claude_code.core.anthropic import set_if_not_none +from free_claude_code.core.anthropic.models import MessagesRequest, Tool +from free_claude_code.providers.nvidia_nim.request_options import ( + _set_extra, +) +from free_claude_code.providers.nvidia_nim.request_options import ( + build_nim_request_body as build_request_body, +) +from free_claude_code.providers.nvidia_nim.retry import ( + clone_body_without_chat_template, + clone_body_without_reasoning_content, +) +from free_claude_code.providers.nvidia_nim.tool_schema import ( + NIM_TOOL_ARGUMENT_ALIASES_KEY, + body_without_nim_tool_argument_aliases, + nim_tool_argument_aliases_from_body, +) +from tests.providers.request_factory import make_messages_request + +GREP_SCHEMA_FROM_SERVER_LOG: dict[str, Any] = { + "type": "object", + "properties": { + "pattern": {"type": "string", "description": "The regular expression"}, + "path": {"type": "string", "description": "File or directory to search"}, + "glob": {"type": "string", "description": "Glob to filter files"}, + "output_mode": { + "type": "string", + "enum": ["content", "files_with_matches", "count"], + }, + "-A": {"type": "number", "description": "Lines after match"}, + "-B": {"type": "number", "description": "Lines before match"}, + "-C": {"type": "number", "description": "Lines around match"}, + "-i": {"type": "boolean", "description": "Case insensitive"}, + "-n": {"type": "boolean", "description": "Show line numbers"}, + "type": {"type": "string", "description": "File type to search"}, + }, + "additionalProperties": False, + "required": ["pattern"], +} + + +@pytest.fixture +def req() -> MessagesRequest: + return make_messages_request( + model="test", + messages=[{"role": "user", "content": "hi"}], + max_tokens=100, + system=None, + temperature=None, + top_p=None, + stop_sequences=None, + tools=None, + extra_body=None, + top_k=None, + thinking=None, + ) + + +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, thinking_enabled=True) + 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, thinking_enabled=True) + assert body["presence_penalty"] == 0.5 + + def test_include_stop_str_in_output_not_sent(self, req): + body = build_request_body(req, NimSettings(), thinking_enabled=True) + 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, thinking_enabled=True) + assert body["parallel_tool_calls"] is False + + def test_tool_schema_boolean_subschemas_are_removed_without_mutating_request( + self, req + ): + tool_schema = { + "type": "object", + "properties": { + "query": {"type": "string", "default": False}, + "blocked": False, + "nested": {"type": "object", "additionalProperties": False}, + "choice": {"anyOf": [False, {"type": "string"}]}, + }, + "additionalProperties": False, + "required": ["query"], + } + req.tools = [ + Tool( + name="search", + description="search", + input_schema=tool_schema, + ) + ] + + body = build_request_body(req, NimSettings(), thinking_enabled=False) + + parameters = body["tools"][0]["function"]["parameters"] + properties = parameters["properties"] + assert "additionalProperties" not in parameters + assert "blocked" not in properties + assert "additionalProperties" not in properties["nested"] + assert properties["choice"]["anyOf"] == [{"type": "string"}] + assert properties["query"]["default"] is False + assert tool_schema["additionalProperties"] is False + assert tool_schema["properties"]["nested"]["additionalProperties"] is False + + def test_grep_schema_type_parameter_is_aliased_without_mutating_request(self, req): + tool_schema = deepcopy(GREP_SCHEMA_FROM_SERVER_LOG) + tool_schema["properties"]["_fcc_arg_type"] = { + "type": "string", + "description": "Existing safe property that collides with the alias", + } + tool_schema["required"] = ["pattern", "-A", "_fcc_arg_type"] + original_schema = deepcopy(tool_schema) + req.tools = [ + Tool( + name="Grep", + description="Search file contents", + input_schema=tool_schema, + ) + ] + + body = build_request_body(req, NimSettings(), thinking_enabled=False) + + parameters = body["tools"][0]["function"]["parameters"] + properties = parameters["properties"] + aliases = body[NIM_TOOL_ARGUMENT_ALIASES_KEY]["Grep"] + assert "additionalProperties" not in parameters + assert properties["-A"] == original_schema["properties"]["-A"] + assert properties["-B"] == original_schema["properties"]["-B"] + assert properties["-C"] == original_schema["properties"]["-C"] + assert properties["-i"] == original_schema["properties"]["-i"] + assert properties["-n"] == original_schema["properties"]["-n"] + assert "type" not in properties + assert properties["pattern"] == original_schema["properties"]["pattern"] + assert properties["output_mode"]["enum"] == [ + "content", + "files_with_matches", + "count", + ] + assert ( + properties["_fcc_arg_type"] + == original_schema["properties"]["_fcc_arg_type"] + ) + assert aliases == {"_fcc_arg_type_2": "type"} + assert properties["_fcc_arg_type_2"] == original_schema["properties"]["type"] + assert "-A" in parameters["required"] + assert "_fcc_arg_type" in parameters["required"] + assert tool_schema == original_schema + + def test_safe_tool_schema_does_not_add_alias_metadata(self, req): + tool_schema = { + "type": "object", + "properties": { + "pattern": {"type": "string"}, + "path": {"type": "string"}, + "output_mode": {"type": "string", "enum": ["content", "count"]}, + }, + "required": ["pattern"], + } + req.tools = [ + Tool( + name="Glob", + description="Find files", + input_schema=tool_schema, + ) + ] + + body = build_request_body(req, NimSettings(), thinking_enabled=False) + + assert NIM_TOOL_ARGUMENT_ALIASES_KEY not in body + parameters = body["tools"][0]["function"]["parameters"] + assert parameters["properties"] == tool_schema["properties"] + assert parameters["required"] == ["pattern"] + + def test_nested_schema_keyword_properties_are_aliased_without_mutating_request( + self, req + ): + tool_schema = { + "type": "object", + "properties": { + "parent": { + "type": "object", + "properties": { + "type": {"type": "string", "enum": ["page_id"]}, + "id": {"type": "string"}, + }, + "required": ["type", "id"], + } + }, + "required": ["parent"], + } + original_schema = deepcopy(tool_schema) + req.tools = [ + Tool( + name="NotionLike", + description="Nested type schema", + input_schema=tool_schema, + ) + ] + + body = build_request_body(req, NimSettings(), thinking_enabled=False) + + aliases = body[NIM_TOOL_ARGUMENT_ALIASES_KEY]["NotionLike"] + parent = body["tools"][0]["function"]["parameters"]["properties"]["parent"] + parent_properties = parent["properties"] + assert "type" not in parent_properties + assert parent_properties["_fcc_arg_type"] == { + "type": "string", + "enum": ["page_id"], + } + assert parent["required"] == ["_fcc_arg_type", "id"] + assert aliases == {"_fcc_arg_type": "type"} + assert tool_schema == original_schema + + def test_private_alias_metadata_is_stripped_without_mutating_body(self): + body = { + "model": "test", + NIM_TOOL_ARGUMENT_ALIASES_KEY: {"Grep": {"_fcc_arg_A": "-A"}}, + } + + upstream_body = body_without_nim_tool_argument_aliases(body) + + assert NIM_TOOL_ARGUMENT_ALIASES_KEY not in upstream_body + assert body[NIM_TOOL_ARGUMENT_ALIASES_KEY] == {"Grep": {"_fcc_arg_A": "-A"}} + assert nim_tool_argument_aliases_from_body(body) == { + "Grep": {"_fcc_arg_A": "-A"} + } + + def test_reasoning_params_in_extra_body(self): + req = make_messages_request( + model="test", + messages=[{"role": "user", "content": "hi"}], + max_tokens=100, + system=None, + temperature=None, + top_p=None, + stop_sequences=None, + tools=None, + tool_choice=None, + extra_body=None, + top_k=None, + thinking=None, + ) + + nim = NimSettings() + body = build_request_body(req, nim, thinking_enabled=True) + extra = body["extra_body"] + assert extra["chat_template_kwargs"] == { + "thinking": True, + "enable_thinking": True, + "reasoning_budget": body["max_tokens"], + } + assert "reasoning_budget" not in extra + + def test_clone_body_without_chat_template(self): + body = { + "model": "test", + "extra_body": { + "chat_template": "custom_template", + "chat_template_kwargs": { + "thinking": True, + "enable_thinking": True, + "reasoning_budget": 100, + }, + "ignore_eos": False, + }, + } + + cloned = clone_body_without_chat_template(body) + + assert cloned is not None + assert "chat_template" not in cloned["extra_body"] + assert "chat_template_kwargs" not in cloned["extra_body"] + assert cloned["extra_body"]["ignore_eos"] is False + assert body["extra_body"]["chat_template"] == "custom_template" + assert body["extra_body"]["chat_template_kwargs"] == { + "thinking": True, + "enable_thinking": True, + "reasoning_budget": 100, + } + + def test_clone_body_without_chat_template_kwargs_only(self): + body = { + "model": "test", + "extra_body": { + "chat_template_kwargs": { + "thinking": True, + "enable_thinking": True, + "reasoning_budget": 100, + }, + "ignore_eos": False, + }, + } + + cloned = clone_body_without_chat_template(body) + + assert cloned is not None + assert "chat_template" not in cloned["extra_body"] + assert "chat_template_kwargs" not in cloned["extra_body"] + assert cloned["extra_body"]["ignore_eos"] is False + + def test_clone_body_without_chat_template_returns_none_when_unchanged(self): + body = {"model": "test", "extra_body": {"ignore_eos": False}} + + assert clone_body_without_chat_template(body) is None + + def test_no_chat_template_kwargs_when_thinking_disabled(self): + req = make_messages_request( + model="test", + messages=[{"role": "user", "content": "hi"}], + max_tokens=100, + system=None, + temperature=None, + top_p=None, + stop_sequences=None, + tools=None, + tool_choice=None, + extra_body=None, + top_k=None, + thinking=None, + ) + + nim = NimSettings() + body = build_request_body(req, nim, thinking_enabled=False) + extra = body.get("extra_body", {}) + assert "chat_template_kwargs" not in extra + assert "reasoning_budget" not in extra + + def test_reasoning_budget_respects_existing_chat_template_kwargs(self): + req = make_messages_request( + model="test", + messages=[{"role": "user", "content": "hi"}], + max_tokens=100, + system=None, + temperature=None, + top_p=None, + stop_sequences=None, + tools=None, + tool_choice=None, + top_k=None, + extra_body={ + "chat_template_kwargs": { + "enable_thinking": False, + "custom": "value", + } + }, + thinking=None, + ) + + body = build_request_body(req, NimSettings(), thinking_enabled=True) + assert body["extra_body"]["chat_template_kwargs"] == { + "enable_thinking": False, + "custom": "value", + "reasoning_budget": body["max_tokens"], + } + + def test_chat_template_fields_present_for_mistral_model(self): + req = make_messages_request( + model="mistralai/mixtral-8x7b-instruct-v0.1", + messages=[{"role": "user", "content": "hi"}], + max_tokens=100, + system=None, + temperature=None, + top_p=None, + stop_sequences=None, + tools=None, + tool_choice=None, + extra_body=None, + top_k=None, + thinking=None, + ) + + nim = NimSettings(chat_template="custom_template") + body = build_request_body(req, nim, thinking_enabled=True) + extra = body.get("extra_body", {}) + assert extra["chat_template_kwargs"] == { + "thinking": True, + "enable_thinking": True, + "reasoning_budget": body["max_tokens"], + } + assert extra["chat_template"] == "custom_template" + + def test_no_reasoning_params_in_extra_body(self): + req = make_messages_request( + model="test", + messages=[{"role": "user", "content": "hi"}], + max_tokens=100, + system=None, + temperature=None, + top_p=None, + stop_sequences=None, + tools=None, + tool_choice=None, + extra_body=None, + top_k=None, + thinking=None, + ) + + nim = NimSettings() + body = build_request_body(req, nim, thinking_enabled=False) + 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 + + def test_assistant_thinking_blocks_removed_when_disabled(self): + req = make_messages_request( + model="test", + messages=[ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "secret"}, + {"type": "text", "text": "answer"}, + ], + } + ], + max_tokens=100, + system=None, + temperature=None, + top_p=None, + stop_sequences=None, + tools=None, + tool_choice=None, + extra_body=None, + top_k=None, + thinking=None, + ) + + body = build_request_body(req, NimSettings(), thinking_enabled=False) + assert "" not in body["messages"][0]["content"] + assert "answer" in body["messages"][0]["content"] + + def test_assistant_thinking_replayed_as_reasoning_content_when_enabled(self): + req = make_messages_request( + model="test", + messages=[ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "secret"}, + {"type": "text", "text": "answer"}, + ], + } + ], + max_tokens=100, + system=None, + temperature=None, + top_p=None, + stop_sequences=None, + tools=None, + tool_choice=None, + extra_body=None, + top_k=None, + thinking=None, + ) + + body = build_request_body(req, NimSettings(), thinking_enabled=True) + assistant = body["messages"][0] + assert assistant["reasoning_content"] == "secret" + assert assistant["content"] == "answer" + assert "" not in assistant["content"] + + def test_clone_body_without_reasoning_content(self): + body = { + "model": "test", + "messages": [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "answer", + "reasoning_content": "secret", + }, + ], + } + + cloned = clone_body_without_reasoning_content(body) + + assert cloned is not None + assert "reasoning_content" not in cloned["messages"][1] + assert body["messages"][1]["reasoning_content"] == "secret" + + def test_clone_body_without_reasoning_content_returns_none_when_unchanged(self): + body = {"model": "test", "messages": [{"role": "user", "content": "hi"}]} + + assert clone_body_without_reasoning_content(body) is None diff --git a/tests/providers/test_ollama.py b/tests/providers/test_ollama.py new file mode 100644 index 0000000..9153c41 --- /dev/null +++ b/tests/providers/test_ollama.py @@ -0,0 +1,91 @@ +"""Tests for the Ollama OpenAI-compatible provider.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.config.provider_catalog import OLLAMA_DEFAULT_BASE +from free_claude_code.core.anthropic.stream_contracts import parse_sse_text +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.openai_chat import OpenAIChatProvider +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter, profiled_provider + +OLLAMA_MODEL = "llama3.1:8b" + + +def _provider(base_url: str = OLLAMA_DEFAULT_BASE) -> OpenAIChatProvider: + return profiled_provider( + "ollama", + ProviderConfig(api_key="ollama", base_url=base_url), + rate_limiter=passthrough_rate_limiter(), + ) + + +@pytest.mark.parametrize( + ("configured", "expected"), + [ + ("http://localhost:11434", "http://localhost:11434/v1"), + ("http://localhost:11434/", "http://localhost:11434/v1"), + ("http://localhost:11434/v1", "http://localhost:11434/v1"), + ], +) +def test_init_normalizes_openai_base_url(configured: str, expected: str) -> None: + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as openai_client: + provider = _provider(configured) + + assert provider._provider_name == "OLLAMA" + assert provider._base_url == expected + assert provider._api_key == "ollama" + assert openai_client.call_args.kwargs["base_url"] == expected + + +def test_build_request_body_uses_openai_chat_shape() -> None: + body = _provider()._build_request_body(make_messages_request(OLLAMA_MODEL)) + + assert body["model"] == OLLAMA_MODEL + assert body["messages"][0]["role"] == "system" + assert "thinking" not in body + assert "extra_body" not in body + + +@pytest.mark.asyncio +async def test_stream_response_uses_shared_openai_chat_provider() -> None: + provider = _provider() + chunk = MagicMock() + chunk.choices = [ + MagicMock( + delta=MagicMock( + content="Hello from Ollama", + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + chunk.usage = MagicMock(prompt_tokens=8, completion_tokens=4) + + async def stream(): + yield chunk + + with patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream(), + ) as create: + output = "".join( + [ + event + async for event in provider.stream_response( + make_messages_request(OLLAMA_MODEL) + ) + ] + ) + + assert create.call_args.kwargs["stream"] is True + assert create.call_args.kwargs["model"] == OLLAMA_MODEL + assert "Hello from Ollama" in output + assert parse_sse_text(output)[-1].event == "message_stop" diff --git a/tests/providers/test_open_router.py b/tests/providers/test_open_router.py new file mode 100644 index 0000000..7e484ca --- /dev/null +++ b/tests/providers/test_open_router.py @@ -0,0 +1,225 @@ +"""Tests for the OpenRouter OpenAI-chat provider.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.application.errors import InvalidRequestError +from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.anthropic.stream_contracts import ( + parse_sse_text, + text_content, +) +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.open_router import OpenRouterProvider +from free_claude_code.providers.openai_chat import OpenAIChatProvider +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter + + +class AsyncStream: + def __init__(self, chunks): + self._chunks = chunks + self.closed = False + + def __aiter__(self): + return self._iter() + + async def _iter(self): + for chunk in self._chunks: + yield chunk + + async def aclose(self): + self.closed = True + + +def make_request(**overrides): + return make_messages_request("moonshotai/kimi-k2.6:free", **overrides) + + +@pytest.fixture +def open_router_provider(): + return OpenRouterProvider( + ProviderConfig( + api_key="test_openrouter_key", + base_url="https://openrouter.ai/api/v1", + rate_limit=10, + rate_window=60, + ), + rate_limiter=passthrough_rate_limiter(), + ) + + +def _chunk( + *, + content: str | None = None, + reasoning_content: str | None = None, + reasoning_details: list[dict] | None = None, + finish_reason: str | None = None, +): + delta = SimpleNamespace( + content=content, + reasoning_content=reasoning_content, + tool_calls=None, + ) + if reasoning_details is not None: + delta.reasoning_details = reasoning_details + choice = SimpleNamespace(delta=delta, finish_reason=finish_reason) + return SimpleNamespace(choices=[choice], usage=None) + + +def test_init_uses_openai_chat_provider(open_router_provider): + assert isinstance(open_router_provider, OpenAIChatProvider) + assert open_router_provider._api_key == "test_openrouter_key" + assert open_router_provider._base_url == "https://openrouter.ai/api/v1" + + +def test_build_request_body_uses_openai_chat_shape(open_router_provider): + body = open_router_provider._build_request_body(make_request()) + + assert body["model"] == "moonshotai/kimi-k2.6:free" + assert body["temperature"] == 0.5 + assert body["messages"] == [ + {"role": "system", "content": "System prompt"}, + {"role": "user", "content": "Hello"}, + ] + assert body["max_tokens"] == 100 + assert body["extra_body"]["reasoning"] == {"enabled": True} + + +def test_build_request_body_default_max_tokens(open_router_provider): + body = open_router_provider._build_request_body(make_request(max_tokens=None)) + + assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS + + +def test_openrouter_extra_body_rejects_overriding_reserved_fields( + open_router_provider, +): + with pytest.raises(InvalidRequestError, match="model"): + open_router_provider._build_request_body( + make_request(extra_body={"model": "hijack"}) + ) + + +def test_openrouter_extra_body_allows_provider_keys(open_router_provider): + body = open_router_provider._build_request_body( + make_request(extra_body={"transforms": ["no-web"], "plugins": []}), + thinking_enabled=False, + ) + + assert body["extra_body"] == {"transforms": ["no-web"], "plugins": []} + + +def test_build_request_body_omits_reasoning_when_thinking_disabled( + open_router_provider, +): + body = open_router_provider._build_request_body( + make_request(thinking={"type": "disabled"}) + ) + + assert "extra_body" not in body + + +def test_build_request_body_maps_thinking_budget_to_reasoning_max_tokens( + open_router_provider, +): + body = open_router_provider._build_request_body( + make_request(thinking={"type": "enabled", "budget_tokens": 4096}) + ) + + assert body["extra_body"]["reasoning"] == {"enabled": True, "max_tokens": 4096} + + +def test_build_request_body_replays_openrouter_reasoning_details( + open_router_provider, +): + detail = {"type": "reasoning.encrypted", "data": "opaque"} + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "redacted_thinking", + "data": '{"type":"reasoning.encrypted","data":"opaque"}', + }, + {"type": "text", "text": "Need a tool."}, + ], + }, + {"role": "user", "content": "continue"}, + ], + } + ) + + body = open_router_provider._build_request_body(request) + + assistant = next(msg for msg in body["messages"] if msg["role"] == "assistant") + assert assistant["reasoning_details"] == [detail] + + +@pytest.mark.asyncio +async def test_stream_maps_reasoning_content_and_details(open_router_provider): + redacted = {"type": "reasoning.encrypted", "data": "opaque"} + stream = AsyncStream( + [ + _chunk(reasoning_content="plan "), + _chunk(reasoning_details=[redacted]), + _chunk(content="done", finish_reason="stop"), + ] + ) + with patch.object( + open_router_provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream, + ): + events = [ + event + async for event in open_router_provider.stream_response(make_request()) + ] + + event_text = "".join(events) + assert "thinking_delta" in event_text + assert "plan " in event_text + assert "redacted_thinking" in event_text + assert "opaque" in event_text + assert "done" in text_content(parse_sse_text(event_text)) + assert stream.closed + + +@pytest.mark.asyncio +async def test_model_infos_filter_tool_models_and_thinking_metadata( + open_router_provider, +): + open_router_provider._client.models.list = AsyncMock( + return_value=SimpleNamespace( + data=[ + SimpleNamespace( + id="tool-model", + supported_parameters=["tools", "reasoning"], + ), + SimpleNamespace(id="plain-model", supported_parameters=[]), + ] + ) + ) + + infos = await open_router_provider.list_model_infos() + + assert {(info.model_id, info.supports_thinking) for info in infos} == { + ("tool-model", True) + } + + +@pytest.mark.asyncio +async def test_cleanup_closes_openai_client(open_router_provider): + open_router_provider._client = MagicMock() + open_router_provider._client.close = AsyncMock() + + await open_router_provider.cleanup() + + open_router_provider._client.close.assert_awaited_once() diff --git a/tests/providers/test_openai_chat_output_cap.py b/tests/providers/test_openai_chat_output_cap.py new file mode 100644 index 0000000..9170330 --- /dev/null +++ b/tests/providers/test_openai_chat_output_cap.py @@ -0,0 +1,191 @@ +"""Tests for OpenAI-compatible output-token cap recovery (issue #955). + +Covers the pure parse/clamp helpers and the provider behavior that clamps +``max_completion_tokens``/``max_tokens`` to the upstream maximum, retries once, +and learns the cap so later requests clamp proactively. +""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from free_claude_code.config.provider_catalog import GROQ_DEFAULT_BASE +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.openai_chat.output_cap import ( + clamp_output_tokens, + parse_output_token_cap, +) +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +class _BadRequest(Exception): + """Stand-in for openai.BadRequestError (status_code + optional JSON body).""" + + def __init__(self, message: str, body: object | None = None): + super().__init__(message) + self.status_code = 400 + self.body = body + + +# --------------------------------------------------------------------------- # +# Pure helpers +# --------------------------------------------------------------------------- # + + +def test_parse_cap_from_groq_message(): + error = _BadRequest( + "max_completion_tokens must be less than or equal to 40960, the maximum " + "value for max_completion_tokens is less than the context_window for this model" + ) + assert parse_output_token_cap(error) == 40960 + + +@pytest.mark.parametrize( + "message,expected", + [ + ("max_tokens: maximum value is 8192", 8192), + ("max_tokens must not exceed 16000", 16000), + ("`max_completion_tokens` <= 4096 required", 4096), + ("max_tokens at most 2048 allowed", 2048), + ("maximum allowed value of 32768 for max_tokens", 32768), + ], +) +def test_parse_cap_various_phrasings(message, expected): + assert parse_output_token_cap(_BadRequest(message)) == expected + + +def test_parse_cap_reads_json_body(): + error = _BadRequest( + "invalid request", + body={"error": {"param": "max_completion_tokens", "message": "<= 12000"}}, + ) + assert parse_output_token_cap(error) == 12000 + + +def test_parse_cap_ignores_non_400(): + error = _BadRequest("max_tokens must be less than or equal to 40960") + error.status_code = 500 + assert parse_output_token_cap(error) is None + + +def test_parse_cap_ignores_unrelated_400(): + assert parse_output_token_cap(_BadRequest("temperature must be <= 2")) is None + + +def test_parse_cap_returns_none_without_number(): + assert ( + parse_output_token_cap(_BadRequest("max_tokens is larger than allowed")) is None + ) + + +def test_clamp_reduces_max_completion_tokens(): + assert clamp_output_tokens({"max_completion_tokens": 64000}, 40960) == { + "max_completion_tokens": 40960 + } + + +def test_clamp_reduces_max_tokens(): + assert clamp_output_tokens({"max_tokens": 100000}, 8192) == {"max_tokens": 8192} + + +def test_clamp_noop_when_within_cap_returns_none(): + assert clamp_output_tokens({"max_completion_tokens": 1000}, 40960) is None + + +def test_clamp_does_not_mutate_input(): + body = {"max_tokens": 99999, "model": "m"} + clamped = clamp_output_tokens(body, 8192) + assert body["max_tokens"] == 99999 + assert clamped is not None + assert clamped["max_tokens"] == 8192 + + +def test_clamp_ignores_bool_values(): + assert clamp_output_tokens({"max_tokens": True}, 8192) is None + + +# --------------------------------------------------------------------------- # +# Provider integration (via Groq's profile, which uses max_completion_tokens) +# --------------------------------------------------------------------------- # + + +@pytest.fixture +def groq_provider(): + return profiled_provider( + "groq", + ProviderConfig( + api_key="test_groq_key", + base_url=GROQ_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=False, + ), + rate_limiter=passthrough_rate_limiter(), + ) + + +@pytest.mark.asyncio +async def test_create_stream_clamps_and_learns_on_cap_rejection(groq_provider): + body = groq_provider._build_request_body( + make_messages_request( + "llama-3.3-70b-versatile", + max_tokens=64000, + thinking={"enabled": False}, + ) + ) + assert body["max_completion_tokens"] == 64000 + model = body["model"] + + error = _BadRequest("max_completion_tokens must be less than or equal to 40960") + create = AsyncMock(side_effect=[error, object()]) + + with patch.object(groq_provider._client.chat.completions, "create", create): + _stream, used_body = await groq_provider._create_stream(body) + + assert create.call_count == 2 + assert create.call_args_list[1].kwargs["max_completion_tokens"] == 40960 + assert used_body["max_completion_tokens"] == 40960 + assert groq_provider._model_output_caps[model] == 40960 + + +@pytest.mark.asyncio +async def test_learned_cap_clamps_next_request_without_a_400(groq_provider): + body = groq_provider._build_request_body( + make_messages_request( + "llama-3.3-70b-versatile", + max_tokens=64000, + thinking={"enabled": False}, + ) + ) + model = body["model"] + groq_provider._model_output_caps[model] = 40960 + + create = AsyncMock(return_value=object()) + with patch.object(groq_provider._client.chat.completions, "create", create): + _stream, used_body = await groq_provider._create_stream(body) + + assert create.call_count == 1 + assert create.call_args.kwargs["max_completion_tokens"] == 40960 + assert used_body["max_completion_tokens"] == 40960 + + +@pytest.mark.asyncio +async def test_unrelated_400_is_not_clamped_and_propagates(groq_provider): + body = groq_provider._build_request_body( + make_messages_request( + "llama-3.3-70b-versatile", + max_tokens=100, + thinking={"enabled": False}, + ) + ) + create = AsyncMock(side_effect=_BadRequest("messages: invalid role 'wizard'")) + + with ( + patch.object(groq_provider._client.chat.completions, "create", create), + pytest.raises(Exception, match="wizard"), + ): + await groq_provider._create_stream(body) + + assert create.call_count == 1 + assert groq_provider._model_output_caps == {} diff --git a/tests/providers/test_openai_chat_usage.py b/tests/providers/test_openai_chat_usage.py new file mode 100644 index 0000000..b05a12e --- /dev/null +++ b/tests/providers/test_openai_chat_usage.py @@ -0,0 +1,214 @@ +"""OpenAI-chat streamed usage helper tests.""" + +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, patch + +import openai +import pytest +from httpx import Request, Response + +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.anthropic.stream_contracts import parse_sse_text +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.openai_chat import ( + OpenAIChatProfile, + OpenAIChatProvider, + OpenAIChatRequestPolicy, +) +from free_claude_code.providers.openai_chat.usage import ( + clone_without_stream_usage, + is_stream_usage_rejection, + request_stream_usage, + usage_int, +) +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter + + +class _UsageTestProvider(OpenAIChatProvider): + def __init__(self): + super().__init__( + ProviderConfig( + api_key="test_key", + base_url="https://provider.example/v1", + rate_limit=100, + rate_window=60, + ), + profile=OpenAIChatProfile( + OpenAIChatRequestPolicy(provider_name="USAGE_TEST") + ), + rate_limiter=passthrough_rate_limiter(), + ) + + def _build_request_body( + self, request: MessagesRequest, thinking_enabled: bool | None = None + ) -> dict: + return {"model": request.model, "messages": [{"role": "user", "content": "x"}]} + + +def _bad_request(message: str, body: object | None = None) -> openai.BadRequestError: + response = Response( + 400, + request=Request("POST", "https://provider.example/v1/chat/completions"), + ) + return openai.BadRequestError(message, response=response, body=body) + + +async def _stream(chunks): + for chunk in chunks: + yield chunk + + +def _chunk( + *, + content: str | None = None, + finish_reason: str | None = None, + usage: Any = None, +): + if content is None and finish_reason is None: + return SimpleNamespace(choices=[], usage=usage) + return SimpleNamespace( + choices=[ + SimpleNamespace( + delta=SimpleNamespace( + content=content, + reasoning_content=None, + tool_calls=None, + ), + finish_reason=finish_reason, + ) + ], + usage=usage, + ) + + +def test_request_stream_usage_adds_stream_options_when_absent(): + body = {"model": "m"} + + request_stream_usage(body) + + assert body["stream_options"] == {"include_usage": True} + + +def test_request_stream_usage_preserves_existing_stream_options(): + stream_options = {"foo": "bar"} + body = {"model": "m", "stream_options": stream_options} + + request_stream_usage(body) + + assert body["stream_options"] == {"foo": "bar", "include_usage": True} + assert body["stream_options"] is stream_options + + +def test_clone_without_stream_usage_removes_only_include_usage(): + body = { + "model": "m", + "stream_options": {"foo": "bar", "include_usage": True}, + } + + retry_body = clone_without_stream_usage(body) + + assert retry_body == {"model": "m", "stream_options": {"foo": "bar"}} + assert body["stream_options"] == {"foo": "bar", "include_usage": True} + + +def test_clone_without_stream_usage_drops_empty_stream_options(): + body = {"model": "m", "stream_options": {"include_usage": True}} + + retry_body = clone_without_stream_usage(body) + + assert retry_body == {"model": "m"} + + +def test_usage_int_reads_dict_object_and_model_extra(): + assert usage_int({"prompt_tokens": 11}, "prompt_tokens") == 11 + assert usage_int(SimpleNamespace(completion_tokens=7), "completion_tokens") == 7 + assert ( + usage_int( + SimpleNamespace(model_extra={"prompt_cache_hit_tokens": 3}), + "prompt_cache_hit_tokens", + ) + == 3 + ) + assert usage_int(SimpleNamespace(prompt_tokens=None), "prompt_tokens") is None + assert usage_int({"prompt_tokens": True}, "prompt_tokens") is None + + +def test_stream_usage_rejection_matches_usage_option_400(): + error = _bad_request( + "Unrecognized request argument supplied: stream_options", + {"error": {"message": "stream_options is unsupported"}}, + ) + + assert is_stream_usage_rejection(error) + + +def test_stream_usage_rejection_does_not_match_unrelated_400(): + error = _bad_request( + "messages: invalid role", + {"error": {"message": "messages contains invalid role"}}, + ) + + assert not is_stream_usage_rejection(error) + + +@pytest.mark.asyncio +async def test_openai_chat_stream_requests_usage_and_uses_provider_prompt_tokens(): + provider = _UsageTestProvider() + request = make_messages_request(model="m") + usage = SimpleNamespace(prompt_tokens=22, completion_tokens=4) + create = AsyncMock( + return_value=_stream( + [ + _chunk(content="hello"), + _chunk(finish_reason="stop"), + _chunk(usage=usage), + ] + ) + ) + + with patch.object(provider._client.chat.completions, "create", create): + events = [ + event async for event in provider.stream_response(request, input_tokens=7) + ] + + create.assert_awaited_once() + await_args = create.await_args + assert await_args is not None + assert await_args.kwargs["stream_options"] == {"include_usage": True} + parsed = parse_sse_text("".join(events)) + start_usage = next( + event.data["message"]["usage"] + for event in parsed + if event.event == "message_start" + ) + final_usage = next( + event.data["usage"] for event in parsed if event.event == "message_delta" + ) + assert start_usage["input_tokens"] == 7 + assert final_usage == {"input_tokens": 22, "output_tokens": 4} + + +@pytest.mark.asyncio +async def test_openai_chat_stream_retries_without_usage_when_option_is_rejected(): + provider = _UsageTestProvider() + body = {"model": "m", "messages": [{"role": "user", "content": "x"}]} + request_stream_usage(body) + create = AsyncMock( + side_effect=[ + _bad_request( + "stream_options is unsupported", + {"error": {"message": "stream_options is unsupported"}}, + ), + object(), + ] + ) + + with patch.object(provider._client.chat.completions, "create", create): + _stream_obj, used_body = await provider._create_stream(body) + + assert create.await_count == 2 + assert create.await_args_list[0].kwargs["stream_options"] == {"include_usage": True} + assert "stream_options" not in create.await_args_list[1].kwargs + assert "stream_options" not in used_body diff --git a/tests/providers/test_openai_compat_5xx_retry.py b/tests/providers/test_openai_compat_5xx_retry.py new file mode 100644 index 0000000..af8fff0 --- /dev/null +++ b/tests/providers/test_openai_compat_5xx_retry.py @@ -0,0 +1,208 @@ +"""OpenAI-chat providers route upstream 5xx and 429 through the same retry path.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import openai +import pytest +from httpx import Request, Response + +from free_claude_code.config.nim import NimSettings +from free_claude_code.core.failures import ExecutionFailure +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.nvidia_nim import NvidiaNimProvider +from tests.providers.request_factory import make_messages_request +from tests.providers.support import retrying_rate_limiter + + +def _internal_5xx(code: int) -> openai.InternalServerError: + return openai.InternalServerError( + "unavailable", + response=Response(code, request=Request("POST", "http://x")), + body={}, + ) + + +def _connection_error(message: str = "connect failed") -> openai.APIConnectionError: + error = openai.APIConnectionError( + request=Request("POST", "https://test.api.nvidia.com/v1/chat/completions") + ) + error.__cause__ = httpx.ConnectError(message) + return error + + +@pytest.mark.parametrize("status_code", [500, 502, 503, 504]) +@pytest.mark.asyncio +async def test_nim_stream_retries_on_openai_5xx_then_streams(status_code): + config = ProviderConfig( + api_key="test_key", + base_url="https://test.api.nvidia.com/v1", + rate_limit=100, + rate_window=60, + http_read_timeout=600.0, + http_write_timeout=15.0, + http_connect_timeout=5.0, + ) + provider = NvidiaNimProvider( + config, + nim_settings=NimSettings(), + rate_limiter=retrying_rate_limiter(), + ) + req = make_messages_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content="Hi", reasoning_content=""), + finish_reason="stop", + ) + ] + mock_chunk.usage = None + + async def mock_stream(): + yield mock_chunk + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + ) as mock_create, + ): + mock_create.side_effect = [_internal_5xx(status_code), mock_stream()] + events = [e async for e in provider.stream_response(req)] + + assert mock_create.await_count == 2 + assert any("Hi" in e for e in events) + + +@pytest.mark.asyncio +async def test_nim_stream_retries_on_pre_stream_connection_error_then_streams(): + config = ProviderConfig( + api_key="test_key", + base_url="https://test.api.nvidia.com/v1", + rate_limit=100, + rate_window=60, + http_read_timeout=600.0, + http_write_timeout=15.0, + http_connect_timeout=5.0, + ) + provider = NvidiaNimProvider( + config, + nim_settings=NimSettings(), + rate_limiter=retrying_rate_limiter(), + ) + req = make_messages_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content="Recovered", reasoning_content=""), + finish_reason="stop", + ) + ] + mock_chunk.usage = None + + async def mock_stream(): + yield mock_chunk + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + ) as mock_create, + ): + mock_create.side_effect = [_connection_error(), mock_stream()] + events = [e async for e in provider.stream_response(req)] + + assert mock_create.await_count == 2 + assert any("Recovered" in e for e in events) + + +@pytest.mark.asyncio +async def test_nim_stream_connection_error_exhausted_emits_cause_chain(): + config = ProviderConfig( + api_key="test_key", + base_url="https://test.api.nvidia.com/v1", + rate_limit=100, + rate_window=60, + http_read_timeout=600.0, + http_write_timeout=15.0, + http_connect_timeout=5.0, + ) + provider = NvidiaNimProvider( + config, + nim_settings=NimSettings(), + rate_limiter=retrying_rate_limiter(), + ) + req = make_messages_request() + error = _connection_error("upstream disconnected") + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + side_effect=error, + ) as mock_create, + patch("free_claude_code.providers.openai_chat.provider.trace_event") as trace, + pytest.raises(ExecutionFailure) as exc_info, + ): + [e async for e in provider.stream_response(req, request_id="req_conn")] + + assert mock_create.await_count == 5 + error_traces = [ + call.kwargs + for call in trace.call_args_list + if call.kwargs.get("event") == "provider.response.error" + ] + assert error_traces[-1]["request_id"] == "req_conn" + assert error_traces[-1]["exc_type"] == "APIConnectionError" + assert "error_message" not in error_traces[-1] + assert "Caused by:\nConnectError: upstream disconnected" in exc_info.value.message + + +@pytest.mark.parametrize( + ("status_code", "expect_substr"), + [ + (500, "provider api request failed"), + (502, "temporarily unavailable"), + (503, "temporarily unavailable"), + (504, "temporarily unavailable"), + ], +) +@pytest.mark.asyncio +async def test_nim_stream_openai_5xx_exhausted_emits_user_message( + status_code, + expect_substr, +): + config = ProviderConfig( + api_key="test_key", + base_url="https://test.api.nvidia.com/v1", + rate_limit=100, + rate_window=60, + http_read_timeout=600.0, + http_write_timeout=15.0, + http_connect_timeout=5.0, + ) + provider = NvidiaNimProvider( + config, + nim_settings=NimSettings(), + rate_limiter=retrying_rate_limiter(), + ) + req = make_messages_request() + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + ) as mock_create, + ): + mock_create.side_effect = _internal_5xx(status_code) + with pytest.raises(ExecutionFailure) as exc_info: + [e async for e in provider.stream_response(req)] + + assert mock_create.await_count == 5 + assert expect_substr in exc_info.value.message.lower() diff --git a/tests/providers/test_opencode.py b/tests/providers/test_opencode.py new file mode 100644 index 0000000..2b8ff76 --- /dev/null +++ b/tests/providers/test_opencode.py @@ -0,0 +1,40 @@ +"""Tests for the OpenCode OpenAI-compatible provider.""" + +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.providers.base import ProviderConfig +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +def test_build_request_body_preserves_empty_reasoning_content() -> None: + provider = profiled_provider( + "opencode", + ProviderConfig( + api_key="test_opencode_key", + base_url="https://example.invalid/v1", + rate_limit=1, + rate_window=1, + enable_thinking=True, + ), + rate_limiter=passthrough_rate_limiter(), + ) + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": "visible", + "reasoning_content": "", + } + ], + "thinking": {"type": "enabled"}, + } + ) + + body = provider._build_request_body(request) + + assert body["messages"][0] == { + "role": "assistant", + "content": "visible", + "reasoning_content": "", + } diff --git a/tests/providers/test_parsers.py b/tests/providers/test_parsers.py new file mode 100644 index 0000000..c382ccf --- /dev/null +++ b/tests/providers/test_parsers.py @@ -0,0 +1,514 @@ +import pytest + +from free_claude_code.core.anthropic 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(" endstest.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("a
bc")) + + 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_json_style_web_fetch_tool_call(): + parser = HeuristicToolParser() + text = ( + "Use WebFetch on the article.\n\n" + "{\n" + ' "url": "https://example.com/article",\n' + ' "prompt": "Summarize it."\n' + "}\n" + ) + + filtered, tools = parser.feed(text) + tools.extend(parser.flush()) + + assert filtered == "" + assert len(tools) == 1 + assert tools[0]["name"] == "WebFetch" + assert tools[0]["input"] == { + "url": "https://example.com/article", + "prompt": "Summarize it.", + } + + +def test_heuristic_tool_parser_json_style_web_search_tool_call(): + parser = HeuristicToolParser() + + filtered, tools = parser.feed('Use WebSearch {"query": "DeepSeek V4"}') + tools.extend(parser.flush()) + + assert filtered == "" + assert len(tools) == 1 + assert tools[0]["name"] == "WebSearch" + assert tools[0]["input"] == {"query": "DeepSeek V4"} + + +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/tests/providers/test_preflight_contract.py b/tests/providers/test_preflight_contract.py new file mode 100644 index 0000000..764d279 --- /dev/null +++ b/tests/providers/test_preflight_contract.py @@ -0,0 +1,62 @@ +"""The shared OpenAI-chat provider owns explicit request preflight.""" + +from collections.abc import AsyncIterator + +import pytest + +from free_claude_code.core.anthropic.models import Message, MessagesRequest +from free_claude_code.providers.base import BaseProvider, ProviderConfig +from free_claude_code.providers.openai_chat import OpenAIChatProvider + + +class RecordingOpenAIProvider(OpenAIChatProvider): + def __init__(self) -> None: + self.build_calls: list[tuple[MessagesRequest, bool | None]] = [] + + def _build_request_body( + self, request: MessagesRequest, thinking_enabled: bool | None = None + ) -> dict: + self.build_calls.append((request, thinking_enabled)) + return {} + + +class ProviderWithoutPreflight(BaseProvider): + async def cleanup(self) -> None: + return None + + async def list_model_ids(self) -> frozenset[str]: + return frozenset() + + async def stream_response( + self, + request: MessagesRequest, + input_tokens: int = 0, + *, + request_id: str | None = None, + thinking_enabled: bool | None = None, + ) -> AsyncIterator[str]: + if False: + yield "" + + +def test_provider_base_requires_an_explicit_preflight_implementation() -> None: + with pytest.raises(TypeError, match="preflight_stream"): + ProviderWithoutPreflight( + ProviderConfig(api_key="test", base_url="https://test.invalid") + ) + + +def test_openai_provider_owns_preflight() -> None: + assert OpenAIChatProvider.preflight_stream is not BaseProvider.preflight_stream + + +def test_provider_preflight_calls_builder_and_preserves_false() -> None: + provider = RecordingOpenAIProvider() + request = MessagesRequest( + model="test-model", + messages=[Message(role="user", content="hello")], + ) + + provider.preflight_stream(request, thinking_enabled=False) + + assert provider.build_calls == [(request, False)] diff --git a/tests/providers/test_provider_rate_limit.py b/tests/providers/test_provider_rate_limit.py new file mode 100644 index 0000000..61c6cc4 --- /dev/null +++ b/tests/providers/test_provider_rate_limit.py @@ -0,0 +1,753 @@ +import asyncio +import time +from unittest.mock import AsyncMock, patch + +import httpx +import openai +import pytest +from httpx import Request + +import free_claude_code.providers.rate_limit as rate_limit_module +from free_claude_code.providers.failure_policy import ( + retryable_upstream_status, + retryable_upstream_transport_error, +) +from free_claude_code.providers.rate_limit import ( + DEFAULT_UPSTREAM_MAX_RETRIES, + UPSTREAM_TRANSIENT_TOTAL_ATTEMPTS, + ProviderRateLimiter, +) + + +def test_upstream_transient_retry_total_attempts_is_five() -> None: + assert UPSTREAM_TRANSIENT_TOTAL_ATTEMPTS == 5 + assert DEFAULT_UPSTREAM_MAX_RETRIES == 4 + + +def _statusless_api_error(message: str, body: object | None) -> openai.APIError: + return openai.APIError(message, request=Request("POST", "http://x"), body=body) + + +def test_retryable_upstream_status_reads_statusless_api_error_body_status() -> None: + exc = _statusless_api_error( + "stream embedded error", + {"error": {"message": "internal failure", "code": 500}}, + ) + + assert retryable_upstream_status(exc) == 500 + + +def test_retryable_upstream_status_reads_statusless_resource_exhausted_text() -> None: + exc = _statusless_api_error( + "ResourceExhausted: limit reached while generating response", + {"error": {"message": "ResourceExhausted: limit reached"}}, + ) + + assert retryable_upstream_status(exc) == 503 + + +def test_retryable_upstream_transport_error_classifies_connection_failures() -> None: + request = Request("POST", "http://x") + assert retryable_upstream_transport_error( + openai.APIConnectionError(request=request) + ) + assert retryable_upstream_transport_error(httpx.ConnectError("connect failed")) + assert retryable_upstream_transport_error(httpx.ReadError("read failed")) + assert retryable_upstream_transport_error(httpx.WriteError("write failed")) + assert retryable_upstream_transport_error( + httpx.RemoteProtocolError("server disconnected") + ) + assert retryable_upstream_transport_error(TimeoutError("timed out")) + + +def test_retryable_upstream_transport_error_rejects_request_errors() -> None: + from httpx import Response + + request = Request("POST", "http://x") + assert not retryable_upstream_transport_error( + openai.BadRequestError( + "bad request", + response=Response(400, request=request), + body={}, + ) + ) + assert not retryable_upstream_transport_error( + httpx.HTTPStatusError( + "bad request", + request=request, + response=Response(400, request=request), + ) + ) + + +class TestProviderRateLimiter: + """Tests for providers.rate_limit.ProviderRateLimiter.""" + + @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 + limiter = ProviderRateLimiter(rate_limit=1, rate_window=0.25) + + start_time = time.monotonic() + + async def call_limiter(): + await limiter.wait_if_blocked() + return time.monotonic() + + # 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.monotonic() - 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 a deadline is extended. + Logic ported from verify_provider_limiter.py + """ + limiter = ProviderRateLimiter() + + start_time = time.monotonic() + + # Manually block for 1.5s + block_time = 1.5 + limiter.extend_reactive_block(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.monotonic() - 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_reactive_block_at_proactive_commit_retries_without_reserving( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + proactive_attempts = 0 + proactive_reservations = 0 + reactive_checks = 0 + + async def acquire_if_allowed(_allowed) -> bool: + nonlocal proactive_attempts, proactive_reservations + proactive_attempts += 1 + if proactive_attempts == 1: + return False + proactive_reservations += 1 + return True + + async def wait_reactively() -> bool: + nonlocal reactive_checks + reactive_checks += 1 + return reactive_checks == 2 + + monkeypatch.setattr( + limiter._proactive_limiter, + "acquire_if", + acquire_if_allowed, + ) + monkeypatch.setattr( + limiter, + "_wait_for_reactive_block", + wait_reactively, + ) + + assert await limiter.wait_if_blocked() is True + assert proactive_attempts == 2 + assert proactive_reservations == 1 + assert reactive_checks == 2 + + @pytest.mark.asyncio + async def test_reactive_wait_rechecks_an_extended_deadline( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + now = 0.0 + sleep_delays: list[float] = [] + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + + async def advance_time(delay: float) -> None: + nonlocal now + sleep_delays.append(delay) + if len(sleep_delays) == 1: + now = 5.0 + limiter.extend_reactive_block(10.0) + now = 10.0 + return + now += delay + + monkeypatch.setattr(rate_limit_module.time, "monotonic", lambda: now) + monkeypatch.setattr(rate_limit_module.asyncio, "sleep", advance_time) + monkeypatch.setattr( + limiter._proactive_limiter, + "acquire_if", + AsyncMock(return_value=True), + ) + limiter.extend_reactive_block(10.0) + + assert await limiter.wait_if_blocked() is True + assert sleep_delays == [10.0, 5.0] + assert limiter.is_blocked() is False + + @pytest.mark.asyncio + async def test_reactive_wait_propagates_cancellation( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + sleep_started = asyncio.Event() + + async def wait_forever(_delay: float) -> None: + sleep_started.set() + await asyncio.Event().wait() + + monkeypatch.setattr(rate_limit_module.asyncio, "sleep", wait_forever) + limiter.extend_reactive_block(60.0) + waiter = asyncio.create_task(limiter.wait_if_blocked()) + await sleep_started.wait() + + waiter.cancel() + + with pytest.raises(asyncio.CancelledError): + await waiter + assert limiter.is_blocked() is True + + @pytest.mark.asyncio + async def test_proactive_wait_propagates_cancellation( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + acquire_started = asyncio.Event() + + async def wait_forever(_allowed) -> bool: + acquire_started.set() + await asyncio.Event().wait() + return True + + monkeypatch.setattr(limiter._proactive_limiter, "acquire_if", wait_forever) + waiter = asyncio.create_task(limiter.wait_if_blocked()) + await acquire_started.wait() + + waiter.cancel() + + with pytest.raises(asyncio.CancelledError): + await waiter + + def test_shorter_reactive_backoff_does_not_shorten_existing_deadline( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + now = 100.0 + monkeypatch.setattr(rate_limit_module.time, "monotonic", lambda: now) + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + + limiter.extend_reactive_block(10.0) + now = 102.0 + limiter.extend_reactive_block(1.0) + + assert limiter.remaining_wait() == 8.0 + + @pytest.mark.parametrize("seconds", [0.0, -1.0]) + def test_reactive_backoff_duration_must_be_positive(self, seconds: float) -> None: + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + + with pytest.raises(ValueError, match="reactive block duration must be > 0"): + limiter.extend_reactive_block(seconds) + + @pytest.mark.asyncio + async def test_remaining_wait_when_not_blocked(self): + """remaining_wait() should return 0 when not blocked.""" + limiter = ProviderRateLimiter(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 = ProviderRateLimiter(rate_limit=100, rate_window=60) + limiter.extend_reactive_block(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 = ProviderRateLimiter(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.""" + limiter = ProviderRateLimiter(rate_limit=10000, rate_window=60) + + start = time.monotonic() + for _ in range(20): + await limiter.wait_if_blocked() + duration = time.monotonic() - 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_instances_are_independent(self): + """Each provider limiter owns independent reactive state.""" + limiter1 = ProviderRateLimiter(rate_limit=10, rate_window=1) + limiter2 = ProviderRateLimiter(rate_limit=10, rate_window=1) + + limiter1.extend_reactive_block(1) + + assert limiter1 is not limiter2 + assert limiter1.is_blocked() is True + assert limiter2.is_blocked() is False + + @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 = ProviderRateLimiter(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). + """ + rate_limit = 2 + rate_window = 0.5 + limiter = ProviderRateLimiter(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.""" + with pytest.raises(ValueError, match="rate_limit must be > 0"): + ProviderRateLimiter(rate_limit=0, rate_window=60) + + @pytest.mark.asyncio + async def test_init_rate_window_zero_raises(self): + """rate_window <= 0 raises ValueError.""" + with pytest.raises(ValueError, match="rate_window must be > 0"): + ProviderRateLimiter(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 + + limiter = ProviderRateLimiter(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 + + limiter = ProviderRateLimiter(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_execute_with_retry_succeeds_on_httpx_429(self): + """HTTP 429 as httpx.HTTPStatusError then success returns result.""" + import httpx + from httpx import Request, Response + + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + + call_count = 0 + + async def fail_then_ok(): + nonlocal call_count + call_count += 1 + if call_count == 1: + r = Response(429, request=Request("POST", "http://x"), text="slow") + raise httpx.HTTPStatusError( + "Too Many Requests", request=r.request, response=r + ) + 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.parametrize("status_code", [500, 502, 503, 504]) + @pytest.mark.asyncio + async def test_execute_with_retry_succeeds_on_openai_internal_server_error_5xx( + self, status_code + ): + """5xx as openai.InternalServerError then success.""" + import openai + from httpx import Request, Response + + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + + def make_upstream_error(): + return openai.InternalServerError( + "unavailable", + response=Response(status_code, 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_upstream_error() + 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.parametrize("status_code", [500, 502, 503, 504]) + @pytest.mark.asyncio + async def test_execute_with_retry_succeeds_on_httpx_5xx(self, status_code): + """HTTP 5xx as httpx.HTTPStatusError then success.""" + import httpx + from httpx import Request, Response + + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + + call_count = 0 + + async def fail_then_ok(): + nonlocal call_count + call_count += 1 + if call_count == 1: + r = Response( + status_code, request=Request("POST", "http://x"), text="error" + ) + raise httpx.HTTPStatusError( + "Server Error", request=r.request, response=r + ) + 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_execute_with_retry_succeeds_on_statusless_transient_api_error(self): + """Status-less SDK APIError transient markers participate in backoff retry.""" + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + + call_count = 0 + + async def fail_then_ok(): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise _statusless_api_error( + "ResourceExhausted: limit reached while generating response", + {"error": {"message": "ResourceExhausted: limit reached"}}, + ) + 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_execute_with_retry_succeeds_on_openai_connection_retry(self): + """Pre-response OpenAI SDK connection errors retry then succeed.""" + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + + call_count = 0 + request = Request("POST", "http://x") + + async def fail_then_ok(): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise openai.APIConnectionError(request=request) + return "ok" + + with ( + patch.object(limiter, "extend_reactive_block") as extend_block, + patch("asyncio.sleep", new_callable=AsyncMock) as sleep, + ): + 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 + extend_block.assert_not_called() + sleep.assert_awaited_once() + + @pytest.mark.asyncio + async def test_execute_with_retry_succeeds_on_httpx_transport_retry(self): + """Pre-response HTTPX transport errors retry then succeed.""" + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + + call_count = 0 + + async def fail_then_ok(): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise httpx.RemoteProtocolError("server disconnected") + return "ok" + + with ( + patch.object(limiter, "extend_reactive_block") as extend_block, + patch("asyncio.sleep", new_callable=AsyncMock) as sleep, + ): + 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 + extend_block.assert_not_called() + sleep.assert_awaited_once() + + @pytest.mark.asyncio + async def test_execute_with_retry_exhaust_transport_error_attempts(self): + """Transport retries exhaust after the shared 5 total attempts.""" + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + + call_count = 0 + + async def always_fail(): + nonlocal call_count + call_count += 1 + raise httpx.ConnectError("connect failed") + + with ( + patch.object(limiter, "extend_reactive_block") as extend_block, + patch("asyncio.sleep", new_callable=AsyncMock) as sleep, + pytest.raises(httpx.ConnectError), + ): + await limiter.execute_with_retry( + always_fail, + base_delay=0.01, + max_delay=0.1, + jitter=0, + ) + + assert call_count == UPSTREAM_TRANSIENT_TOTAL_ATTEMPTS + extend_block.assert_not_called() + assert sleep.await_count == DEFAULT_UPSTREAM_MAX_RETRIES + + @pytest.mark.parametrize("status_code", [500, 502, 503, 504]) + @pytest.mark.asyncio + async def test_execute_with_retry_exhaust_openai_5xx_raises(self, status_code): + """When all 5xx retries exhausted (OpenAI SDK), last InternalServerError is raised.""" + import openai + from httpx import Request, Response + + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + + exc = openai.InternalServerError( + "unavailable", + response=Response(status_code, request=Request("POST", "http://x")), + body={}, + ) + + async def always_fail(): + raise exc + + with pytest.raises(openai.InternalServerError): + await limiter.execute_with_retry( + always_fail, max_retries=2, base_delay=0.01, max_delay=0.1, jitter=0 + ) + + @pytest.mark.asyncio + async def test_execute_with_retry_httpx_400_raises_immediately(self): + """Non-retryable 4xx is not wrapped by execute_with_retry loop.""" + import httpx + from httpx import Request, Response + + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + + call_count = 0 + + async def bad_request(): + nonlocal call_count + call_count += 1 + r = Response(400, request=Request("POST", "http://x"), text="bad request") + raise httpx.HTTPStatusError("Bad Request", request=r.request, response=r) + + with pytest.raises(httpx.HTTPStatusError): + await limiter.execute_with_retry( + bad_request, + max_retries=2, + base_delay=0.01, + max_delay=0.1, + jitter=0, + ) + + assert call_count == 1 + + @pytest.mark.asyncio + async def test_max_concurrency_zero_raises(self): + """max_concurrency <= 0 raises ValueError.""" + with pytest.raises(ValueError, match="max_concurrency must be > 0"): + ProviderRateLimiter(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.""" + max_concurrency = 2 + limiter = ProviderRateLimiter( + 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.""" + limiter = ProviderRateLimiter(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_constructor_sets_max_concurrency(self): + """Constructor applies max_concurrency to an independent limiter.""" + limiter = ProviderRateLimiter(rate_limit=10, rate_window=60, max_concurrency=3) + assert limiter._concurrency_sem is not None + assert limiter._concurrency_sem._value == 3 + + @pytest.mark.asyncio + async def test_provider_owned_instances_are_isolated(self): + """Independent provider limiters do not share reactive block state.""" + nim = ProviderRateLimiter(rate_limit=10, rate_window=60) + openrouter = ProviderRateLimiter(rate_limit=20, rate_window=30) + + assert nim is not openrouter + nim.extend_reactive_block(1.0) + + assert nim.is_blocked() is True + assert openrouter.is_blocked() is False diff --git a/tests/providers/test_provider_runtime.py b/tests/providers/test_provider_runtime.py new file mode 100644 index 0000000..e18186c --- /dev/null +++ b/tests/providers/test_provider_runtime.py @@ -0,0 +1,534 @@ +import asyncio +import subprocess +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.application.errors import UnknownProviderError +from free_claude_code.config.nim import NimSettings +from free_claude_code.config.provider_catalog import ( + COHERE_DEFAULT_BASE, + GITHUB_MODELS_DEFAULT_BASE, + HUGGINGFACE_DEFAULT_BASE, + MINIMAX_DEFAULT_BASE, + PROVIDER_CATALOG, + SUPPORTED_PROVIDER_IDS, + VERCEL_AI_GATEWAY_DEFAULT_BASE, + ZAI_DEFAULT_BASE, +) +from free_claude_code.providers.cloudflare import CloudflareProvider +from free_claude_code.providers.deepseek import DeepSeekProvider +from free_claude_code.providers.gemini import GeminiProvider +from free_claude_code.providers.github_models import GitHubModelsProvider +from free_claude_code.providers.lmstudio import LMStudioProvider +from free_claude_code.providers.mistral import MistralProvider +from free_claude_code.providers.nvidia_nim import NvidiaNimProvider +from free_claude_code.providers.open_router import OpenRouterProvider +from free_claude_code.providers.openai_chat import ( + OPENAI_CHAT_PROFILES, + OpenAIChatProvider, +) +from free_claude_code.providers.rate_limit import ProviderRateLimiter +from free_claude_code.providers.runtime import ( + ProviderRuntime, + build_provider_config, + create_provider, +) + + +def _make_settings(**overrides): + mock = MagicMock() + mock.model = "nvidia_nim/meta/llama3" + mock.model_opus = None + mock.model_sonnet = None + mock.model_haiku = None + mock.nvidia_nim_api_key = "test_key" + mock.open_router_api_key = "test_openrouter_key" + mock.mistral_api_key = "test_mistral_key" + mock.codestral_api_key = "test_codestral_key" + mock.deepseek_api_key = "test_deepseek_key" + mock.wafer_api_key = "test_wafer_key" + mock.minimax_api_key = "test_minimax_key" + mock.opencode_api_key = "test_opencode_key" + mock.vercel_ai_gateway_api_key = "test_vercel_key" + mock.huggingface_api_key = "test_huggingface_key" + mock.cohere_api_key = "test_cohere_key" + mock.github_models_token = "test_github_models_token" + mock.zai_api_key = "test_zai_key" + mock.lm_studio_base_url = "http://localhost:1234/v1" + mock.llamacpp_base_url = "http://localhost:8080/v1" + mock.ollama_base_url = "http://localhost:11434" + mock.nvidia_nim_proxy = "" + mock.open_router_proxy = "" + mock.lmstudio_proxy = "" + mock.llamacpp_proxy = "" + mock.mistral_proxy = "" + mock.codestral_proxy = "" + mock.kimi_proxy = "" + mock.kimi_api_key = "test_kimi_key" + mock.wafer_proxy = "" + mock.minimax_proxy = "" + mock.opencode_proxy = "" + mock.opencode_go_proxy = "" + mock.vercel_ai_gateway_proxy = "" + mock.huggingface_proxy = "" + mock.cohere_proxy = "" + mock.github_models_proxy = "" + mock.zai_proxy = "" + mock.fireworks_proxy = "" + mock.fireworks_api_key = "test_fireworks_key" + mock.cloudflare_api_token = "test_cloudflare_token" + mock.cloudflare_account_id = "test_cloudflare_account" + mock.cloudflare_proxy = "" + mock.gemini_api_key = "" + mock.gemini_proxy = "" + mock.groq_api_key = "" + mock.groq_proxy = "" + mock.cerebras_api_key = "" + mock.cerebras_proxy = "" + mock.provider_rate_limit = 40 + mock.provider_rate_window = 60 + mock.provider_max_concurrency = 5 + mock.http_read_timeout = 300.0 + mock.http_write_timeout = 10.0 + mock.http_connect_timeout = 10.0 + mock.enable_model_thinking = True + mock.log_raw_sse_events = False + mock.log_api_error_tracebacks = False + mock.nim = NimSettings() + for key, value in overrides.items(): + setattr(mock, key, value) + return mock + + +def test_importing_runtime_does_not_eager_load_other_adapters() -> None: + """Runtime metadata must not import every provider adapter up front.""" + code = ( + "import sys\n" + "import free_claude_code.providers.runtime\n" + "assert 'free_claude_code.providers.open_router' not in sys.modules\n" + ) + proc = subprocess.run( + [sys.executable, "-c", code], + check=False, + capture_output=True, + text=True, + ) + assert proc.returncode == 0, proc.stderr or proc.stdout + + +def test_provider_catalog_covers_advertised_provider_ids(): + assert set(PROVIDER_CATALOG) == set(SUPPORTED_PROVIDER_IDS) + assert set(OPENAI_CHAT_PROFILES) < set(PROVIDER_CATALOG) + for descriptor in PROVIDER_CATALOG.values(): + assert descriptor.provider_id + + +def test_ollama_descriptor_uses_local_openai_endpoint_semantics(): + descriptor = PROVIDER_CATALOG["ollama"] + + assert descriptor.default_base_url == "http://localhost:11434" + assert descriptor.local is True + + +@pytest.mark.parametrize( + ("provider_id", "expected_api_key"), + [ + ("lmstudio", "lm-studio"), + ("llamacpp", "llamacpp"), + ("ollama", "ollama"), + ], +) +def test_local_provider_factory_resolves_catalog_static_credential( + provider_id: str, + expected_api_key: str, +) -> None: + descriptor = PROVIDER_CATALOG[provider_id] + settings = _make_settings() + + config = build_provider_config(descriptor, settings) + with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"): + provider = create_provider(provider_id, settings) + + assert config.api_key == expected_api_key + assert isinstance(provider, OpenAIChatProvider) + assert provider._api_key == expected_api_key + + +def test_zai_descriptor_uses_fixed_cloud_base_url(): + descriptor = PROVIDER_CATALOG["zai"] + + assert descriptor.default_base_url == ZAI_DEFAULT_BASE + assert descriptor.base_url_attr is None + + +def test_zai_provider_config_ignores_stale_base_url_setting(): + descriptor = PROVIDER_CATALOG["zai"] + + config = build_provider_config( + descriptor, + _make_settings(zai_base_url="https://custom.zai.invalid/v1"), + ) + + assert config.base_url == ZAI_DEFAULT_BASE + + +def test_minimax_descriptor_uses_expected_endpoint_and_credential(): + descriptor = PROVIDER_CATALOG["minimax"] + + assert descriptor.default_base_url == MINIMAX_DEFAULT_BASE + assert descriptor.credential_env == "MINIMAX_API_KEY" + + +def test_cloudflare_descriptor_uses_api_root_not_account_url(): + descriptor = PROVIDER_CATALOG["cloudflare"] + + assert descriptor.default_base_url == "https://api.cloudflare.com/client/v4" + assert descriptor.base_url_attr is None + + +def test_create_cloudflare_provider_uses_account_scoped_base_url(): + settings = _make_settings( + cloudflare_api_token="test_cloudflare_token", + cloudflare_account_id="test-account", + ) + + with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"): + provider = create_provider("cloudflare", settings) + + assert isinstance(provider, CloudflareProvider) + assert provider._base_url == ( + "https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1" + ) + + +def test_opencode_go_provider_config_uses_correct_base_url_and_name(): + with patch("httpx.AsyncClient"): + provider = create_provider("opencode_go", _make_settings()) + + assert isinstance(provider, OpenAIChatProvider) + assert provider._base_url == "https://opencode.ai/zen/go/v1" + assert provider._provider_name == "OPENCODE_GO" + assert provider._api_key == "test_opencode_key" + + +def test_opencode_go_catalog_uses_opencode_api_key() -> None: + desc = PROVIDER_CATALOG["opencode_go"] + + assert desc.credential_env == "OPENCODE_API_KEY" + assert desc.credential_attr == "opencode_api_key" + + +def test_build_provider_config_opencode_go_uses_opencode_api_key() -> None: + descriptor = PROVIDER_CATALOG["opencode_go"] + settings = _make_settings(opencode_api_key="shared-opencode-token") + + config = build_provider_config(descriptor, settings) + + assert config.api_key == "shared-opencode-token" + + +def test_vercel_descriptor_uses_openai_chat_gateway() -> None: + descriptor = PROVIDER_CATALOG["vercel"] + + assert descriptor.default_base_url == VERCEL_AI_GATEWAY_DEFAULT_BASE + assert descriptor.credential_env == "AI_GATEWAY_API_KEY" + assert descriptor.proxy_attr == "vercel_ai_gateway_proxy" + + +def test_huggingface_descriptor_uses_openai_chat_router() -> None: + descriptor = PROVIDER_CATALOG["huggingface"] + + assert descriptor.default_base_url == HUGGINGFACE_DEFAULT_BASE + assert descriptor.credential_env == "HUGGINGFACE_API_KEY" + assert descriptor.proxy_attr == "huggingface_proxy" + + +def test_cohere_descriptor_uses_openai_chat_compatibility_api() -> None: + descriptor = PROVIDER_CATALOG["cohere"] + + assert descriptor.default_base_url == COHERE_DEFAULT_BASE + assert descriptor.credential_env == "COHERE_API_KEY" + assert descriptor.proxy_attr == "cohere_proxy" + + +def test_github_models_descriptor_uses_openai_chat_inference_api() -> None: + descriptor = PROVIDER_CATALOG["github_models"] + + assert descriptor.default_base_url == GITHUB_MODELS_DEFAULT_BASE + assert descriptor.credential_env == "GITHUB_MODELS_TOKEN" + assert descriptor.proxy_attr == "github_models_proxy" + + +def test_build_provider_config_vercel_uses_gateway_key_and_proxy() -> None: + descriptor = PROVIDER_CATALOG["vercel"] + settings = _make_settings( + vercel_ai_gateway_api_key="vercel-token", + vercel_ai_gateway_proxy="http://proxy.test:8080", + ) + + config = build_provider_config(descriptor, settings) + + assert config.api_key == "vercel-token" + assert config.proxy == "http://proxy.test:8080" + + +def test_build_provider_config_huggingface_uses_api_key_and_proxy() -> None: + descriptor = PROVIDER_CATALOG["huggingface"] + settings = _make_settings( + huggingface_api_key="hf-token", + huggingface_proxy="http://proxy.test:8080", + ) + + config = build_provider_config(descriptor, settings) + + assert config.api_key == "hf-token" + assert config.proxy == "http://proxy.test:8080" + + +def test_build_provider_config_cohere_uses_api_key_and_proxy() -> None: + descriptor = PROVIDER_CATALOG["cohere"] + settings = _make_settings( + cohere_api_key="cohere-token", + cohere_proxy="http://proxy.test:8080", + ) + + config = build_provider_config(descriptor, settings) + + assert config.api_key == "cohere-token" + assert config.proxy == "http://proxy.test:8080" + + +def test_build_provider_config_github_models_uses_token_and_proxy() -> None: + descriptor = PROVIDER_CATALOG["github_models"] + settings = _make_settings( + github_models_token="github-token", + github_models_proxy="http://proxy.test:8080", + ) + + config = build_provider_config(descriptor, settings) + + assert config.api_key == "github-token" + assert config.proxy == "http://proxy.test:8080" + + +def test_create_provider_uses_openai_chat_openrouter_by_default(): + with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"): + provider = create_provider("open_router", _make_settings()) + + assert isinstance(provider, OpenRouterProvider) + + +def test_create_provider_instantiates_each_builtin(): + settings = _make_settings( + gemini_api_key="test_gemini_key", + groq_api_key="test_groq_key", + cerebras_api_key="test_cerebras_key", + fireworks_api_key="test_fireworks_key", + cloudflare_api_token="test_cloudflare_token", + cloudflare_account_id="test_cloudflare_account", + vercel_ai_gateway_api_key="test_vercel_key", + huggingface_api_key="test_huggingface_key", + cohere_api_key="test_cohere_key", + github_models_token="test_github_models_token", + kimi_api_key="test_kimi_key", + provider_rate_limit=7, + provider_rate_window=11, + provider_max_concurrency=3, + sambanova_api_key="test_sambanova_key", + ) + cases = { + "nvidia_nim": NvidiaNimProvider, + "open_router": OpenRouterProvider, + "mistral": MistralProvider, + "mistral_codestral": OpenAIChatProvider, + "deepseek": DeepSeekProvider, + "kimi": OpenAIChatProvider, + "minimax": OpenAIChatProvider, + "fireworks": OpenAIChatProvider, + "cloudflare": CloudflareProvider, + "lmstudio": LMStudioProvider, + "llamacpp": OpenAIChatProvider, + "ollama": OpenAIChatProvider, + "wafer": OpenAIChatProvider, + "opencode": OpenAIChatProvider, + "opencode_go": OpenAIChatProvider, + "vercel": OpenAIChatProvider, + "huggingface": OpenAIChatProvider, + "cohere": OpenAIChatProvider, + "github_models": GitHubModelsProvider, + "zai": OpenAIChatProvider, + "gemini": GeminiProvider, + "groq": OpenAIChatProvider, + "sambanova": OpenAIChatProvider, + "cerebras": OpenAIChatProvider, + } + sentinel_limiter = MagicMock(spec=ProviderRateLimiter) + + with ( + patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"), + patch("httpx.AsyncClient"), + patch( + "free_claude_code.providers.runtime.factory.ProviderRateLimiter", + return_value=sentinel_limiter, + ) as limiter_factory, + ): + for provider_id, provider_cls in cases.items(): + provider = create_provider(provider_id, settings) + + assert isinstance(provider, provider_cls) + assert provider._rate_limiter is sentinel_limiter + limiter_factory.assert_called_once_with( + rate_limit=7, + rate_window=11, + max_concurrency=3, + ) + limiter_factory.reset_mock() + + assert set(cases) == set(PROVIDER_CATALOG) + + +def test_provider_runtime_caches_by_provider_id(): + runtime = ProviderRuntime(_make_settings()) + + with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"): + first = runtime.resolve_provider("nvidia_nim") + second = runtime.resolve_provider("nvidia_nim") + + assert first is second + + +def test_provider_runtime_provider_owns_one_limiter() -> None: + runtime = ProviderRuntime(_make_settings()) + + with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"): + first = runtime.resolve_provider("nvidia_nim") + second = runtime.resolve_provider("nvidia_nim") + + assert isinstance(first, NvidiaNimProvider) + assert isinstance(second, NvidiaNimProvider) + assert first._rate_limiter is second._rate_limiter + + +def test_separate_provider_runtimes_never_share_limiters() -> None: + first_runtime = ProviderRuntime(_make_settings()) + second_runtime = ProviderRuntime(_make_settings()) + + with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"): + first = first_runtime.resolve_provider("nvidia_nim") + second = second_runtime.resolve_provider("nvidia_nim") + + assert isinstance(first, NvidiaNimProvider) + assert isinstance(second, NvidiaNimProvider) + assert first is not second + assert first._rate_limiter is not second._rate_limiter + + +def test_different_providers_in_one_runtime_have_independent_limiters() -> None: + runtime = ProviderRuntime(_make_settings()) + + with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"): + nim = runtime.resolve_provider("nvidia_nim") + open_router = runtime.resolve_provider("open_router") + + assert isinstance(nim, NvidiaNimProvider) + assert isinstance(open_router, OpenRouterProvider) + assert nim._rate_limiter is not open_router._rate_limiter + + +def test_unknown_provider_raises_unknown_provider_type_error(): + with pytest.raises(UnknownProviderError, match="Unknown provider_type"): + create_provider("unknown", _make_settings()) + + +@pytest.mark.asyncio +async def test_provider_runtime_cleanup_runs_all_even_if_one_fails() -> None: + """Successful providers leave the cache while failed providers remain retryable.""" + p1 = MagicMock() + p1.cleanup = AsyncMock(side_effect=RuntimeError("first")) + p2 = MagicMock() + p2.cleanup = AsyncMock() + runtime = ProviderRuntime(_make_settings(), {"a": p1, "b": p2}) + + with pytest.raises(RuntimeError, match="first"): + await runtime.cleanup() + + p1.cleanup.assert_awaited_once() + p2.cleanup.assert_awaited_once() + assert runtime.is_cached("a") + assert not runtime.is_cached("b") + + p1.cleanup = AsyncMock() + await runtime.cleanup() + + p1.cleanup.assert_awaited_once() + assert not runtime.is_cached("a") + + +@pytest.mark.asyncio +async def test_cancelled_cleanup_retains_current_and_unvisited_providers() -> None: + first = MagicMock() + second = MagicMock() + third = MagicMock() + second_started = asyncio.Event() + second_attempts = 0 + + async def cleanup_second() -> None: + nonlocal second_attempts + second_attempts += 1 + if second_attempts == 1: + second_started.set() + await asyncio.Event().wait() + + first.cleanup = AsyncMock() + second.cleanup = AsyncMock(side_effect=cleanup_second) + third.cleanup = AsyncMock() + runtime = ProviderRuntime( + _make_settings(), + {"first": first, "second": second, "third": third}, + ) + cleanup_task = asyncio.create_task(runtime.cleanup()) + await second_started.wait() + + cleanup_task.cancel() + with pytest.raises(asyncio.CancelledError): + await cleanup_task + + assert runtime.is_cached("first") is False + assert runtime.is_cached("second") is True + assert runtime.is_cached("third") is True + first.cleanup.assert_awaited_once_with() + third.cleanup.assert_not_awaited() + + await runtime.cleanup() + + first.cleanup.assert_awaited_once_with() + assert second.cleanup.await_count == 2 + third.cleanup.assert_awaited_once_with() + assert runtime.is_cached("first") is False + assert runtime.is_cached("second") is False + assert runtime.is_cached("third") is False + + +@pytest.mark.asyncio +async def test_provider_runtime_cleanup_exceptiongroup_on_multiple_failures() -> None: + p1 = MagicMock() + p1.cleanup = AsyncMock(side_effect=RuntimeError("a")) + p2 = MagicMock() + p2.cleanup = AsyncMock(side_effect=RuntimeError("b")) + runtime = ProviderRuntime(_make_settings(), {"x": p1, "y": p2}) + + with pytest.raises(ExceptionGroup) as exc_info: + await runtime.cleanup() + + assert len(exc_info.value.exceptions) == 2 + assert runtime.is_cached("x") + assert runtime.is_cached("y") + + p1.cleanup = AsyncMock() + p2.cleanup = AsyncMock() + await runtime.cleanup() + + assert not runtime.is_cached("x") + assert not runtime.is_cached("y") diff --git a/tests/providers/test_provider_transport_logging.py b/tests/providers/test_provider_transport_logging.py new file mode 100644 index 0000000..d8cf830 --- /dev/null +++ b/tests/providers/test_provider_transport_logging.py @@ -0,0 +1,104 @@ +"""Tests for credential-safe provider transport logging.""" + +import logging +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, patch + +import httpx +import openai +import pytest + +from free_claude_code.config.nim import NimSettings +from free_claude_code.core.failures import ExecutionFailure +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.nvidia_nim import NvidiaNimProvider +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter + + +def _provider(*, verbose: bool = False) -> NvidiaNimProvider: + return NvidiaNimProvider( + ProviderConfig( + api_key="k", + base_url="http://localhost:1/v1", + log_api_error_tracebacks=verbose, + ), + nim_settings=NimSettings(), + rate_limiter=passthrough_rate_limiter(), + ) + + +@asynccontextmanager +async def _noop_slot(): + yield + + +@pytest.mark.asyncio +async def test_stream_failure_default_logs_exclude_exception_text(caplog) -> None: + provider = _provider() + with ( + patch.object( + provider, + "_create_stream", + new_callable=AsyncMock, + side_effect=RuntimeError("SECRET_OPENAI_COMPAT"), + ), + patch.object(provider._rate_limiter, "concurrency_slot", _noop_slot), + caplog.at_level(logging.ERROR), + pytest.raises(ExecutionFailure), + ): + [event async for event in provider.stream_response(make_messages_request())] + + messages = " | ".join(record.getMessage() for record in caplog.records) + assert "SECRET_OPENAI_COMPAT" not in messages + assert "exc_type=RuntimeError" in messages + + +@pytest.mark.asyncio +async def test_stream_failure_default_logs_cause_types_only(caplog) -> None: + provider = _provider() + error = openai.APIConnectionError( + request=httpx.Request("POST", "http://localhost:1/v1/chat/completions") + ) + error.__cause__ = httpx.ConnectError("SECRET_CAUSE_DETAIL") + with ( + patch.object( + provider, + "_create_stream", + new_callable=AsyncMock, + side_effect=error, + ), + patch.object(provider._rate_limiter, "concurrency_slot", _noop_slot), + caplog.at_level(logging.ERROR), + pytest.raises(ExecutionFailure), + ): + [event async for event in provider.stream_response(make_messages_request())] + + messages = " | ".join(record.getMessage() for record in caplog.records) + assert "SECRET_CAUSE_DETAIL" not in messages + assert "exc_type=APIConnectionError" in messages + assert "cause_types=ConnectError" in messages + + +@pytest.mark.asyncio +async def test_stream_failure_verbose_traceback_redacts_credentials(caplog) -> None: + provider = _provider(verbose=True) + with ( + patch.object( + provider, + "_create_stream", + new_callable=AsyncMock, + side_effect=RuntimeError( + "api_key=SECRET_OPENAI_COMPAT useful traceback detail" + ), + ), + patch.object(provider._rate_limiter, "concurrency_slot", _noop_slot), + caplog.at_level(logging.ERROR), + pytest.raises(ExecutionFailure), + ): + [event async for event in provider.stream_response(make_messages_request())] + + messages = " | ".join(record.getMessage() for record in caplog.records) + assert "api_key=" in messages + assert "useful traceback detail" in messages + assert "SECRET_OPENAI_COMPAT" not in messages diff --git a/tests/providers/test_sambanova.py b/tests/providers/test_sambanova.py new file mode 100644 index 0000000..be20d0c --- /dev/null +++ b/tests/providers/test_sambanova.py @@ -0,0 +1,195 @@ +"""Tests for SambaNova Cloud (OpenAI-compatible) provider.""" + +from dataclasses import replace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.config.provider_catalog import SAMBANOVA_DEFAULT_BASE +from free_claude_code.providers.base import ProviderConfig +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +def make_request(**overrides): + return make_messages_request("Meta-Llama-3.3-70B-Instruct", **overrides) + + +@pytest.fixture +def sambanova_config(): + return ProviderConfig( + api_key="test_sambanova_key", + base_url=SAMBANOVA_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ) + + +@pytest.fixture +def sambanova_provider(sambanova_config): + return profiled_provider( + "sambanova", sambanova_config, rate_limiter=passthrough_rate_limiter() + ) + + +def test_default_base_url_constant(): + assert SAMBANOVA_DEFAULT_BASE == "https://api.sambanova.ai/v1" + + +def test_init_uses_default_base_url_and_api_key(sambanova_config): + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_openai: + provider = profiled_provider( + "sambanova", sambanova_config, rate_limiter=passthrough_rate_limiter() + ) + + assert provider._api_key == "test_sambanova_key" + assert provider._base_url == SAMBANOVA_DEFAULT_BASE + mock_openai.assert_called_once() + + +def test_init_strips_trailing_slash(sambanova_config): + config = replace(sambanova_config, base_url=f"{SAMBANOVA_DEFAULT_BASE}/") + + with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"): + provider = profiled_provider( + "sambanova", config, rate_limiter=passthrough_rate_limiter() + ) + + assert provider._base_url == SAMBANOVA_DEFAULT_BASE + + +def test_build_request_body_basic(sambanova_provider): + """Basic request body conversion attaches system message and keeps max_tokens.""" + body = sambanova_provider._build_request_body(make_request()) + + assert body["model"] == "Meta-Llama-3.3-70B-Instruct" + assert body["messages"][0]["role"] == "system" + assert body["max_tokens"] == 100 + assert "max_completion_tokens" not in body + + +def test_build_request_body_preserves_caller_extra_body(sambanova_provider): + req = make_request(extra_body={"metadata": {"user": "u1"}}) + + body = sambanova_provider._build_request_body(req) + + eb = body.get("extra_body") + assert isinstance(eb, dict) + assert eb.get("metadata") == {"user": "u1"} + + +@pytest.mark.asyncio +async def test_stream_response_text(sambanova_provider): + """Text content deltas are emitted as text blocks.""" + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content="Hello from SambaNova", + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + sambanova_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [ + event async for event in sambanova_provider.stream_response(make_request()) + ] + + assert any( + '"text_delta"' in event and "Hello from SambaNova" in event for event in events + ) + + +@pytest.mark.asyncio +async def test_stream_response_tool_call(sambanova_provider): + mock_tc = MagicMock() + mock_tc.index = 0 + mock_tc.id = "call_1" + mock_tc.function = MagicMock() + mock_tc.function.name = "Read" + mock_tc.function.arguments = '{"file_path":"a.py"}' + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content=None, reasoning_content=None, tool_calls=[mock_tc]), + finish_reason="tool_calls", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + sambanova_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [ + event async for event in sambanova_provider.stream_response(make_request()) + ] + + assert any( + '"content_block_start"' in event and '"tool_use"' in event for event in events + ) + assert any( + '"input_json_delta"' in event and "file_path" in event for event in events + ) + + +@pytest.mark.asyncio +async def test_stream_response_reasoning_content(sambanova_provider): + """reasoning_content deltas are emitted as thinking blocks.""" + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=None, + reasoning_content="Thinking via SambaNova", + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + sambanova_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [ + event async for event in sambanova_provider.stream_response(make_request()) + ] + + assert any( + '"thinking_delta"' in event and "Thinking via SambaNova" in event + for event in events + ) + + +@pytest.mark.asyncio +async def test_cleanup(sambanova_provider): + sambanova_provider._client = AsyncMock() + + await sambanova_provider.cleanup() + + sambanova_provider._client.close.assert_called_once() diff --git a/tests/providers/test_stream_recovery.py b/tests/providers/test_stream_recovery.py new file mode 100644 index 0000000..f0ad617 --- /dev/null +++ b/tests/providers/test_stream_recovery.py @@ -0,0 +1,259 @@ +"""Provider-owned stream retry and holdback policy.""" + +import httpx +import openai + +from free_claude_code.providers.stream_recovery import ( + EARLY_TRANSPARENT_MAX_RETRIES, + EARLY_TRANSPARENT_TOTAL_ATTEMPTS, + MIDSTREAM_RECOVERY_ATTEMPTS, + RecoveryController, + RecoveryFailureAction, + RecoveryHoldbackBuffer, + is_retryable_stream_error, +) + + +def _statusless_openai_api_error( + message: str, body: object | None = None +) -> openai.APIError: + return openai.APIError( + message, + request=httpx.Request("POST", "https://provider.test/messages"), + body=body, + ) + + +def test_early_transparent_retry_total_attempts_is_five() -> None: + assert EARLY_TRANSPARENT_TOTAL_ATTEMPTS == 5 + assert EARLY_TRANSPARENT_MAX_RETRIES == 4 + + +def test_midstream_recovery_attempts_total_is_five() -> None: + assert MIDSTREAM_RECOVERY_ATTEMPTS == 5 + + +def test_retryable_stream_error_classifies_transport_and_http_status() -> None: + assert is_retryable_stream_error(httpx.ReadError("cut off")) + + request = httpx.Request("GET", "https://example.test") + assert is_retryable_stream_error( + httpx.HTTPStatusError( + "server error", request=request, response=httpx.Response(503) + ) + ) + assert not is_retryable_stream_error( + httpx.HTTPStatusError( + "bad request", request=request, response=httpx.Response(400) + ) + ) + + +def test_stream_retry_preserves_timeout_scope() -> None: + request = httpx.Request("POST", "https://provider.test/messages") + + assert is_retryable_stream_error(httpx.ReadTimeout("read", request=request)) + assert not is_retryable_stream_error( + httpx.ConnectTimeout("connect", request=request) + ) + assert not is_retryable_stream_error(httpx.WriteTimeout("write", request=request)) + assert not is_retryable_stream_error(httpx.PoolTimeout("pool", request=request)) + + +def test_retryable_stream_error_classifies_statusless_api_error_body_status() -> None: + assert is_retryable_stream_error( + _statusless_openai_api_error( + "stream embedded error", + {"error": {"message": "internal failure", "code": 500}}, + ) + ) + + +def test_retryable_stream_error_classifies_statusless_internal_error_type() -> None: + assert is_retryable_stream_error( + _statusless_openai_api_error( + "stream embedded error", + {"error": {"message": "internal failure", "type": "internal_server_error"}}, + ) + ) + + +def test_retryable_stream_error_classifies_resource_exhausted_text() -> None: + assert is_retryable_stream_error( + _statusless_openai_api_error( + "ResourceExhausted: limit reached while generating response", + {"error": {"message": "ResourceExhausted: limit reached"}}, + ) + ) + + +def test_retryable_stream_error_does_not_retry_bad_request_status() -> None: + request = httpx.Request("POST", "https://provider.test/messages") + assert not is_retryable_stream_error( + openai.BadRequestError( + "bad request", + response=httpx.Response(400, request=request), + body={"error": {"message": "bad request"}}, + ) + ) + + +def test_recovery_controller_advances_early_retry_and_discards_holdback() -> None: + controller = RecoveryController(provider_name="TEST", request_id="REQ") + + assert controller.push("hidden") == [] + decision = controller.advance_failure( + httpx.ReadError("early cutoff"), + stream_opened=True, + generated_output=True, + complete_tool_salvageable=False, + ) + + assert decision.action == RecoveryFailureAction.EARLY_RETRY + assert decision.early_retry_attempt == 1 + assert controller.early_retries == 1 + assert not controller.committed + assert not controller.has_buffered + assert controller.flush() == [] + + +def test_recovery_controller_retries_statusless_transient_api_error() -> None: + controller = RecoveryController(provider_name="TEST", request_id="REQ") + + decision = controller.advance_failure( + _statusless_openai_api_error( + "ResourceExhausted: limit reached while generating response", + {"error": {"message": "ResourceExhausted: limit reached"}}, + ), + stream_opened=True, + generated_output=False, + complete_tool_salvageable=False, + ) + + assert decision.action == RecoveryFailureAction.EARLY_RETRY + assert decision.retryable + assert decision.early_retry_attempt == 1 + + +def test_recovery_controller_respects_early_retry_limit() -> None: + controller = RecoveryController(provider_name="TEST", request_id=None) + + for attempt in range(1, EARLY_TRANSPARENT_MAX_RETRIES + 1): + decision = controller.advance_failure( + httpx.ReadError("cutoff"), + stream_opened=True, + generated_output=False, + complete_tool_salvageable=False, + ) + assert decision.action == RecoveryFailureAction.EARLY_RETRY + assert decision.early_retry_attempt == attempt + + decision = controller.advance_failure( + httpx.ReadError("cutoff"), + stream_opened=True, + generated_output=False, + complete_tool_salvageable=False, + ) + + assert decision.action == RecoveryFailureAction.FINAL_ERROR + assert controller.early_retries == EARLY_TRANSPARENT_MAX_RETRIES + + +def test_recovery_controller_classifies_midstream_recovery_after_commit() -> None: + controller = RecoveryController(provider_name="TEST", request_id=None) + + assert controller.push("event: content_block_delta\n\n") == [] + assert controller.flush() == ["event: content_block_delta\n\n"] + decision = controller.advance_failure( + httpx.ReadError("midstream cutoff"), + stream_opened=True, + generated_output=True, + complete_tool_salvageable=False, + ) + + assert decision.action == RecoveryFailureAction.MIDSTREAM_RECOVERY + assert decision.retryable + assert decision.committed + assert controller.flush_uncommitted(decision) == [] + + +def test_recovery_controller_flushes_uncommitted_midstream_decision() -> None: + controller = RecoveryController(provider_name="TEST", request_id=None) + + assert controller.push("event: content_block_delta\n\n") == [] + decision = controller.advance_failure( + httpx.ReadError("midstream cutoff"), + stream_opened=True, + generated_output=True, + complete_tool_salvageable=True, + ) + + assert decision.action == RecoveryFailureAction.MIDSTREAM_RECOVERY + assert not decision.committed + assert decision.has_buffered + assert not controller.committed + assert controller.has_buffered + + assert controller.flush_uncommitted(decision) == ["event: content_block_delta\n\n"] + assert not decision.committed + assert decision.has_buffered + assert controller.committed + assert not controller.has_buffered + + +def test_recovery_controller_non_retryable_error_is_final() -> None: + request = httpx.Request("POST", "https://example.test/messages") + error = httpx.HTTPStatusError( + "bad request", + request=request, + response=httpx.Response(400, request=request), + ) + controller = RecoveryController(provider_name="TEST", request_id=None) + + decision = controller.advance_failure( + error, + stream_opened=True, + generated_output=True, + complete_tool_salvageable=False, + ) + + assert decision.action == RecoveryFailureAction.FINAL_ERROR + assert not decision.retryable + assert controller.early_retries == 0 + + +def test_holdback_buffers_until_delay_then_commits() -> None: + now = [10.0] + holdback = RecoveryHoldbackBuffer(holdback_seconds=0.75, now=lambda: now[0]) + + assert holdback.push("event: content_block_start\n\n") == [] + now[0] += 0.74 + assert holdback.push("event: content_block_delta\n\n") == [] + assert not holdback.committed + + now[0] += 0.01 + flushed = holdback.push("event: content_block_stop\n\n") + assert flushed == [ + "event: content_block_start\n\n", + "event: content_block_delta\n\n", + "event: content_block_stop\n\n", + ] + assert holdback.committed + assert holdback.push("event: message_stop\n\n") == ["event: message_stop\n\n"] + + +def test_holdback_flushes_at_internal_buffer_cap() -> None: + holdback = RecoveryHoldbackBuffer(max_bytes=5, now=lambda: 1.0) + + assert holdback.push("ab") == [] + assert holdback.push("cde") == ["ab", "cde"] + assert holdback.committed + + +def test_holdback_discard_drops_uncommitted_events() -> None: + holdback = RecoveryHoldbackBuffer(now=lambda: 1.0) + + assert holdback.push("hidden") == [] + holdback.discard() + + assert holdback.flush() == [] diff --git a/tests/providers/test_streaming_errors.py b/tests/providers/test_streaming_errors.py new file mode 100644 index 0000000..d0ae107 --- /dev/null +++ b/tests/providers/test_streaming_errors.py @@ -0,0 +1,1486 @@ +"""Tests for streaming error handling in providers/nvidia_nim/client.py.""" + +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import openai +import pytest + +from free_claude_code.config.nim import NimSettings +from free_claude_code.core.anthropic.stream_contracts import ( + parse_sse_text, +) +from free_claude_code.core.anthropic.streaming import ( + AnthropicStreamLedger, + make_text_recovery_body, +) +from free_claude_code.core.failures import ExecutionFailure +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.nvidia_nim import NvidiaNimProvider +from free_claude_code.providers.openai_chat.provider import ( + _OpenAIChatStreamRunner, +) +from free_claude_code.providers.openai_chat.tool_calls import ( + OpenAIToolCallAssembler, + has_committed_sse_output, + iter_heuristic_tool_use_sse, +) +from free_claude_code.providers.stream_recovery import ( + MIDSTREAM_RECOVERY_ATTEMPTS, + TruncatedProviderStreamError, +) +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter + + +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 + + +class ClosableAsyncStreamMock(AsyncStreamMock): + """Async stream mock that records cleanup.""" + + def __init__(self, chunks, error=None): + super().__init__(chunks, error=error) + self.closed = False + + async def aclose(self): + self.closed = True + + +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(), + rate_limiter=passthrough_rate_limiter(), + ) + + +def _make_tool_assembler(provider: NvidiaNimProvider) -> OpenAIToolCallAssembler: + return OpenAIToolCallAssembler( + record_extra_content=provider._record_tool_call_extra_content + ) + + +def _make_provider_with_thinking_enabled(enabled: bool): + """Create a provider instance with thinking explicitly enabled or disabled.""" + config = ProviderConfig( + api_key="test_key", + base_url="https://test.api.nvidia.com/v1", + rate_limit=10, + rate_window=60, + enable_thinking=enabled, + ) + return NvidiaNimProvider( + config, + nim_settings=NimSettings(), + rate_limiter=passthrough_rate_limiter(), + ) + + +def _make_request(model: str = "test-model", stream: bool = True, **overrides: object): + """Create a concrete request matching the original streaming-test defaults.""" + request_overrides: dict[str, object] = { + "messages": [], + "max_tokens": 4096, + "temperature": None, + "top_p": None, + "system": None, + "tools": None, + "extra_body": None, + "thinking": None, + "stream": stream, + } + request_overrides.update(overrides) + return make_messages_request(model, **request_overrides) + + +def _make_stream_runner( + provider: NvidiaNimProvider, + *, + request=None, + request_id: str | None = None, +) -> _OpenAIChatStreamRunner: + return _OpenAIChatStreamRunner( + provider, + request=request or _make_request(), + input_tokens=0, + request_id=request_id, + thinking_enabled=None, + ) + + +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 + + choice = MagicMock() + choice.delta = delta + choice.finish_reason = finish_reason + + chunk = MagicMock() + chunk.choices = [choice] + chunk.usage = None + return chunk + + +def _make_tool_calls_chunk(*, name: str, arguments: str, tool_id: str, index: int = 0): + """Single OpenAI-style tool_calls delta (starts a native streamed tool block).""" + tc = MagicMock() + tc.index = index + tc.id = tool_id + fn = MagicMock() + fn.name = name + fn.arguments = arguments + tc.function = fn + return _make_chunk(tool_calls=[tc]) + + +async def _collect_stream(provider, request): + """Collect all SSE events from a stream.""" + return [e async for e in provider.stream_response(request)] + + +async def _collect_stream_error(provider, request, **kwargs) -> ExecutionFailure: + with pytest.raises(ExecutionFailure) as exc_info: + [e async for e in provider.stream_response(request, **kwargs)] + return exc_info.value + + +async def _collect_stream_and_error( + provider, request, **kwargs +) -> tuple[list[str], ExecutionFailure]: + events: list[str] = [] + with pytest.raises(ExecutionFailure) as exc_info: + async for event in provider.stream_response(request, **kwargs): + events.extend((event,)) + return events, exc_info.value + + +def _assert_no_content_deltas_after_error_text( + events: list[str], error_substr: str +) -> None: + """After the error text delta, only block close + message tail events may follow.""" + parsed = parse_sse_text("".join(events)) + first_error_idx = None + for i, ev in enumerate(parsed): + if ev.event != "content_block_delta": + continue + delta = ev.data.get("delta", {}) + if delta.get("type") == "text_delta" and error_substr in str( + delta.get("text", "") + ): + first_error_idx = i + break + assert first_error_idx is not None, (error_substr, "".join(events)) + for ev in parsed[first_error_idx + 1 :]: + assert ev.event in ("content_block_stop", "message_delta", "message_stop"), ( + ev.event, + ev.data, + ) + + +def _assert_error_not_in_text_deltas_after_tool( + events: list[str], error_substr: str +) -> None: + """Transport errors after a native tool call must not use assistant text_delta (issue #206).""" + blob = "".join(events) + for ev in parse_sse_text(blob): + if ev.event != "content_block_delta": + continue + delta = ev.data.get("delta", {}) + if delta.get("type") == "text_delta" and error_substr in str( + delta.get("text", "") + ): + raise AssertionError( + f"error leaked as text_delta after tool_use: {ev.data!r} full={blob!r}" + ) + + +class TestStreamingExceptionHandling: + """Tests for error paths during stream_response.""" + + @pytest.mark.asyncio + async def test_pre_start_api_error_raises_provider_error(self): + """Before holdback commit, provider failures raise for API-level non-200.""" + provider = _make_provider() + request = _make_request() + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + side_effect=RuntimeError("API failed"), + ), + patch.object( + provider._rate_limiter, + "wait_if_blocked", + new_callable=AsyncMock, + return_value=False, + ), + ): + error = await _collect_stream_error(provider, request) + + assert "API failed" in error.message + + @pytest.mark.asyncio + async def test_read_timeout_with_empty_message_raises_fallback(self): + """ReadTimeout(TimeoutError()) should raise a 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._rate_limiter, + "wait_if_blocked", + new_callable=AsyncMock, + return_value=False, + ), + patch("asyncio.sleep", new_callable=AsyncMock), + ): + error = await _collect_stream_error( + provider, + request, + request_id="req_timeout123", + ) + + assert "timed out after" in error.message + assert "Request ID: req_timeout123" in error.message + + @pytest.mark.asyncio + async def test_error_after_precommit_partial_content_raises(self): + """Precommit partial text is discarded so the API can return non-200.""" + 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._rate_limiter, + "wait_if_blocked", + new_callable=AsyncMock, + return_value=False, + ), + ): + error = await _collect_stream_error(provider, request) + + assert "Connection lost" in error.message + + @pytest.mark.asyncio + async def test_error_after_native_tool_call_closes_block_then_raises(self): + """A provider closes tool state, then leaves terminal serialization to API.""" + provider = _make_provider() + request = _make_request() + tool_chunk = _make_tool_calls_chunk( + name="echo_smoke", arguments="{}", tool_id="call_206", index=0 + ) + stream_mock = AsyncStreamMock( + [tool_chunk], error=RuntimeError("Connection lost after tool") + ) + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream_mock, + ), + patch.object( + provider._rate_limiter, + "wait_if_blocked", + new_callable=AsyncMock, + return_value=False, + ), + ): + events, error = await _collect_stream_and_error(provider, request) + event_text = "".join(events) + parsed = parse_sse_text(event_text) + assert "tool_use" in event_text + assert parsed[-1].event == "content_block_stop" + assert "Connection lost after tool" in error.message + assert "Connection lost after tool" not in event_text + assert "event: error\n" not in event_text + assert "message_stop" not in event_text + _assert_error_not_in_text_deltas_after_tool( + events, "Connection lost after tool" + ) + + @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._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_upstream_completion_tokens_null_emits_int_usage(self): + """NIM/GLM may send usage.completion_tokens=null; final SSE must not use JSON null.""" + provider = _make_provider() + request = _make_request() + + delta = SimpleNamespace( + content="hello", + tool_calls=None, + reasoning_content=None, + ) + choice = SimpleNamespace(delta=delta, finish_reason="stop") + usage = SimpleNamespace(completion_tokens=None, prompt_tokens=None) + chunk = SimpleNamespace(choices=[choice], usage=usage) + stream_mock = AsyncStreamMock([chunk]) + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream_mock, + ), + patch.object( + provider._rate_limiter, + "wait_if_blocked", + new_callable=AsyncMock, + return_value=False, + ), + ): + events = await _collect_stream(provider, request) + + parsed = parse_sse_text("".join(events)) + delta_events = [e for e in parsed if e.event == "message_delta"] + assert len(delta_events) == 1 + usage_out = delta_events[0].data.get("usage", {}) + assert isinstance(usage_out.get("output_tokens"), int) + assert usage_out["output_tokens"] is not None + assert '"output_tokens": null' not in "".join(events) + + @pytest.mark.asyncio + async def test_reasoning_only_stream_emits_placeholder_text(self): + """When the model streams only ``reasoning_content`` (no ``content``), add text block. + + NIM / some templates may emit no main ``content``; a minimal text block matches + the empty-body placeholder and helps clients that expect a text segment. + """ + provider = _make_provider_with_thinking_enabled(True) + request = _make_request() + chunk1 = _make_chunk(reasoning_content="reasoning only from provider") + 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._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 '"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._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._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_with_empty_reasoning_content_starts_thinking_block_only(self): + """Empty reasoning_content is stateful but must not emit visible thinking text.""" + provider = _make_provider() + request = _make_request() + + chunk1 = _make_chunk(reasoning_content="") + 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._rate_limiter, + "wait_if_blocked", + new_callable=AsyncMock, + return_value=False, + ), + ): + events = await _collect_stream(provider, request) + + parsed = parse_sse_text("".join(events)) + thinking_starts = [ + event + for event in parsed + if event.event == "content_block_start" + and event.data["content_block"]["type"] == "thinking" + ] + thinking_deltas = [ + event + for event in parsed + if event.event == "content_block_delta" + and event.data["delta"]["type"] == "thinking_delta" + ] + assert len(thinking_starts) == 1 + assert thinking_deltas == [] + assert parsed[-1].event == "message_stop" + + @pytest.mark.asyncio + async def test_stream_with_reasoning_content_suppressed_when_disabled(self): + """reasoning deltas are stripped while normal text still streams.""" + provider = _make_provider_with_thinking_enabled(False) + request = _make_request() + + chunk1 = _make_chunk(reasoning_content="I think...") + chunk2 = _make_chunk(content="secretThe 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._rate_limiter, + "wait_if_blocked", + new_callable=AsyncMock, + return_value=False, + ), + ): + events = await _collect_stream(provider, request) + + event_text = "".join(events) + assert "thinking_delta" not in event_text + assert "I think..." not in event_text + assert "secret" not in event_text + assert "The answer" in event_text + + @pytest.mark.asyncio + async def test_stream_with_upstream_405_mentions_provider_name(self): + """HTTP 405s are surfaced as upstream method/endpoint rejections.""" + provider = _make_provider() + request = _make_request() + + response = httpx.Response( + status_code=405, + request=httpx.Request("POST", "https://example.com/v1/chat/completions"), + ) + error = httpx.HTTPStatusError( + "Method Not Allowed", + request=response.request, + response=response, + ) + + with patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + side_effect=error, + ): + stream_error = await _collect_stream_error( + provider, + request, + request_id="REQ405", + ) + + assert ( + "Upstream provider NIM rejected the request method or endpoint (HTTP 405)." + in stream_error.message + ) + assert "Request ID: REQ405" in stream_error.message + + @pytest.mark.asyncio + async def test_stream_with_openai_bad_request_surfaces_upstream_body(self): + """OpenAI SDK bodies should be raised so users can copy exact provider errors.""" + provider = _make_provider() + request = _make_request() + response = httpx.Response( + status_code=400, + request=httpx.Request("POST", "https://example.com/v1/chat/completions"), + ) + body = { + "error": { + "type": "BadRequest", + "message": "Thinking mode does not support this tool_choice", + } + } + error = openai.BadRequestError("Bad Request", response=response, body=body) + + with patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + side_effect=error, + ): + stream_error = await _collect_stream_error( + provider, + request, + request_id="REQ_BODY", + ) + + assert "Upstream provider NIM returned HTTP 400." in stream_error.message + assert "Category: BadRequest" in stream_error.message + assert "Thinking mode does not support this tool_choice" in stream_error.message + assert ( + '{"error":{"type":"BadRequest","message":"Thinking mode does not support this tool_choice"}}' + in stream_error.message + ) + assert "Request ID: REQ_BODY" in stream_error.message + + @pytest.mark.asyncio + async def test_error_after_native_tool_call_failure_includes_body(self): + """Detailed failure data survives after the provider closes tool state.""" + provider = _make_provider() + request = _make_request() + tool_chunk = _make_tool_calls_chunk( + name="echo_smoke", arguments="{}", tool_id="call_body", index=0 + ) + response = httpx.Response( + status_code=400, + request=httpx.Request("POST", "https://example.com/v1/chat/completions"), + ) + body = {"error": {"message": "bad after tool"}} + error = openai.BadRequestError("Bad Request", response=response, body=body) + stream_mock = AsyncStreamMock([tool_chunk], error=error) + + with patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream_mock, + ): + events, stream_error = await _collect_stream_and_error( + provider, + request, + request_id="REQ_TOOL_BODY", + ) + + event_text = "".join(events) + parsed = parse_sse_text(event_text) + assert "tool_use" in event_text + assert parsed[-1].event == "content_block_stop" + assert "event: error\n" not in event_text + assert "bad after tool" not in event_text + assert "Request ID: REQ_TOOL_BODY" not in event_text + assert "message_stop" not in event_text + assert "bad after tool" in stream_error.message + assert "Request ID: REQ_TOOL_BODY" in stream_error.message + _assert_error_not_in_text_deltas_after_tool(events, "bad after tool") + + @pytest.mark.asyncio + async def test_clean_eof_after_complete_tool_call_salvages_tool_use(self): + """A complete tool JSON payload missing finish_reason is committed as tool_use.""" + provider = _make_provider() + request = _make_request() + tool_chunk = _make_tool_calls_chunk( + name="echo_smoke", arguments='{"message":"ok"}', tool_id="call_eof" + ) + stream_mock = AsyncStreamMock([tool_chunk]) + + with patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream_mock, + ): + events = await _collect_stream(provider, request) + + parsed = parse_sse_text("".join(events)) + assert parsed[-1].event == "message_stop" + assert any( + event.event == "message_delta" + and event.data.get("delta", {}).get("stop_reason") == "tool_use" + for event in parsed + ) + assert not any(event.event == "error" for event in parsed) + + @pytest.mark.asyncio + @pytest.mark.parametrize("finish_reason", ["tool_calls", "stop"]) + async def test_heuristic_only_tool_stream_does_not_emit_fallback_text( + self, finish_reason + ): + """Text-parsed tool calls count as emitted tool output when finalizing.""" + provider = _make_provider() + request = _make_request() + heuristic_tool = ( + "● test.py" + "10" + ) + stream_mock = AsyncStreamMock( + [ + _make_chunk(content=heuristic_tool), + _make_chunk(finish_reason=finish_reason), + ] + ) + + with patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream_mock, + ): + events = await _collect_stream(provider, request) + + parsed = parse_sse_text("".join(events)) + assert any( + event.event == "content_block_start" + and event.data.get("content_block", {}).get("type") == "tool_use" + for event in parsed + ) + assert not any( + event.event == "content_block_delta" + and event.data.get("delta", {}).get("type") == "text_delta" + and event.data.get("delta", {}).get("text") == " " + for event in parsed + ) + assert any( + event.event == "message_delta" + and event.data.get("delta", {}).get("stop_reason") == "tool_use" + for event in parsed + ) + + @pytest.mark.asyncio + async def test_precommit_openai_holdback_retries_without_leaking_partial(self): + """A retryable early cutoff before holdback commit is retried invisibly.""" + provider = _make_provider() + request = _make_request() + first_stream = AsyncStreamMock( + [_make_chunk(content="hidden")], + error=httpx.ReadError("early cutoff"), + ) + second_stream = AsyncStreamMock( + [ + _make_chunk(content="visible"), + _make_chunk(finish_reason="stop"), + ] + ) + + with patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + side_effect=[first_stream, second_stream], + ) as mock_create: + events = await _collect_stream(provider, request) + + event_text = "".join(events) + assert mock_create.await_count == 2 + assert "hidden" not in event_text + assert "visible" in event_text + parsed = parse_sse_text(event_text) + assert parsed[0].event == "message_start" + assert sum(event.event == "message_start" for event in parsed) == 1 + assert parsed[-1].event == "message_stop" + + @pytest.mark.asyncio + async def test_clean_eof_after_text_continues_with_overlap_trim(self): + """A truncated text stream is continued and duplicate overlap is trimmed.""" + provider = _make_provider() + request = _make_request() + stream_mock = AsyncStreamMock([_make_chunk(content="hello wor")]) + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream_mock, + ), + patch.object( + _OpenAIChatStreamRunner, + "_collect_recovery_text", + new_callable=AsyncMock, + return_value=("world", ""), + ), + ): + events = await _collect_stream(provider, request) + + parsed = parse_sse_text("".join(events)) + text_deltas = [ + event.data.get("delta", {}).get("text", "") + for event in parsed + if event.event == "content_block_delta" + ] + assert text_deltas == ["hello wor", "ld"] + assert "".join(text_deltas) == "hello world" + assert any( + event.event == "message_delta" + and event.data.get("delta", {}).get("stop_reason") == "end_turn" + for event in parsed + ) + assert not any(event.event == "error" for event in parsed) + + @pytest.mark.asyncio + async def test_recovery_collect_text_requires_finish_reason(self): + """Recovery collectors reject truncated OpenAI-chat continuation streams.""" + streams = [ + ClosableAsyncStreamMock([_make_chunk(content=f"world {index}")]) + for index in range(MIDSTREAM_RECOVERY_ATTEMPTS) + ] + create_stream = AsyncMock(side_effect=[(stream, {}) for stream in streams]) + provider = _make_provider() + runner = _make_stream_runner(provider) + + with ( + patch.object(provider, "_create_stream", create_stream), + pytest.raises(TruncatedProviderStreamError), + ): + await runner._collect_recovery_text({"messages": []}) + + assert create_stream.await_count == MIDSTREAM_RECOVERY_ATTEMPTS + assert all(stream.closed for stream in streams) + + @pytest.mark.asyncio + async def test_recovery_collect_text_closes_retryable_failed_streams(self): + """Recovery collectors close failed stream attempts before retrying.""" + streams = [ + ClosableAsyncStreamMock( + [_make_chunk(content=f"partial {index}")], + error=TimeoutError("recovery cutoff"), + ) + for index in range(MIDSTREAM_RECOVERY_ATTEMPTS) + ] + create_stream = AsyncMock(side_effect=[(stream, {}) for stream in streams]) + provider = _make_provider() + runner = _make_stream_runner(provider) + + with ( + patch.object(provider, "_create_stream", create_stream), + pytest.raises(TimeoutError), + ): + await runner._collect_recovery_text({"messages": []}) + + assert create_stream.await_count == MIDSTREAM_RECOVERY_ATTEMPTS + assert all(stream.closed for stream in streams) + + @pytest.mark.asyncio + async def test_recovery_collect_text_accepts_finish_reason(self): + """Recovery collectors return text only after the upstream terminal marker.""" + stream = ClosableAsyncStreamMock( + [ + _make_chunk(content="world"), + _make_chunk(finish_reason="stop"), + ] + ) + create_stream = AsyncMock( + return_value=( + stream, + {}, + ) + ) + provider = _make_provider() + runner = _make_stream_runner(provider) + + with patch.object(provider, "_create_stream", create_stream): + result = await runner._collect_recovery_text({"messages": []}) + + assert result == ("world", "") + assert stream.closed is True + + def test_text_recovery_body_preserves_thinking_context(self): + """Continuation prompts include emitted thinking without provider-specific fields.""" + body = { + "messages": [{"role": "user", "content": "hello"}], + "tools": [{"name": "Read"}], + "tool_choice": {"type": "auto"}, + } + + recovery_body = make_text_recovery_body( + body, + partial_text="visible answer", + partial_thinking="hidden reasoning", + ) + + assert "tools" not in recovery_body + assert "tool_choice" not in recovery_body + assert recovery_body["messages"][-2] == { + "role": "assistant", + "content": "visible answer", + } + recovery_prompt = recovery_body["messages"][-1] + assert recovery_prompt["role"] == "user" + assert "hidden reasoning" in recovery_prompt["content"] + assert "reasoning_content" not in recovery_prompt + + @pytest.mark.asyncio + async def test_openai_text_recovery_passes_thinking_context(self): + """OpenAI-chat recovery call sites seed emitted thinking in the prompt.""" + runner = _make_stream_runner( + _make_provider(), request=_make_request(), request_id="req_recovery" + ) + ledger = AnthropicStreamLedger("msg_recovery", "model") + ledger.start_thinking_block() + ledger.emit_thinking_delta("hidden reasoning") + list(ledger.ensure_text_block()) + ledger.emit_text_delta("visible answer") + + with patch.object( + runner, + "_collect_recovery_text", + new_callable=AsyncMock, + return_value=("visible answer done", "hidden reasoning more"), + ) as mock_collect: + events = await runner._recovery_events( + body={"messages": [{"role": "user", "content": "hello"}]}, + ledger=ledger, + error=TimeoutError("cutoff"), + tool_argument_alias_buffers={}, + ) + + assert events is not None + assert mock_collect.await_args is not None + recovery_body = mock_collect.await_args.args[0] + assert "hidden reasoning" in recovery_body["messages"][-1]["content"] + + @pytest.mark.asyncio + async def test_primary_stream_closes_when_iteration_fails(self): + """OpenAI-chat main streams close after iterator failures.""" + provider = _make_provider() + request = _make_request() + stream = ClosableAsyncStreamMock( + [_make_chunk(content="partial")], + error=ValueError("provider stream failed"), + ) + + with patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream, + ): + error = await _collect_stream_error(provider, request) + + assert stream.closed is True + assert "provider stream failed" in error.message.lower() + + @pytest.mark.asyncio + async def test_truncated_recovery_stream_closes_block_then_raises(self): + """Partial recovery bytes never become success or provider-owned wire errors.""" + provider = _make_provider() + request = _make_request() + original_text = "hello wor" + ("x" * 70_000) + original_stream = AsyncStreamMock([_make_chunk(content=original_text)]) + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=original_stream, + ) as mock_create, + patch.object( + _OpenAIChatStreamRunner, + "_collect_recovery_text", + new_callable=AsyncMock, + side_effect=TruncatedProviderStreamError( + "Recovery stream ended without finish_reason." + ), + ) as mock_collect, + ): + events, error = await _collect_stream_and_error(provider, request) + + event_text = "".join(events) + assert mock_create.await_count == 1 + assert mock_collect.await_count == 1 + assert original_text in event_text + assert "world" not in event_text + assert "Provider stream ended without finish_reason." in error.message + assert "Provider stream ended without finish_reason." not in event_text + parsed = parse_sse_text(event_text) + assert parsed[-1].event == "content_block_stop" + assert not any(event.event == "error" for event in parsed) + assert not any(event.event == "message_stop" for event in parsed) + assert not any( + event.event == "content_block_delta" + and event.data.get("delta", {}).get("text") == "ld" + for event in parse_sse_text(event_text) + ) + + @pytest.mark.asyncio + async def test_incomplete_tool_call_repair_appends_schema_valid_suffix(self): + """A truncated tool JSON prefix is repaired append-only before tool_use tail.""" + provider = _make_provider() + request = _make_request( + tools=[ + { + "name": "echo_smoke", + "description": "Echo", + "input_schema": { + "type": "object", + "properties": {"message": {"type": "string"}}, + "required": ["message"], + "additionalProperties": False, + }, + } + ] + ) + tool_chunk = _make_tool_calls_chunk( + name="echo_smoke", arguments='{"message":', tool_id="call_repair" + ) + stream_mock = AsyncStreamMock([tool_chunk]) + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream_mock, + ), + patch.object( + _OpenAIChatStreamRunner, + "_collect_recovery_text", + new_callable=AsyncMock, + return_value=('"ok"}', ""), + ), + ): + events = await _collect_stream(provider, request) + + event_text = "".join(events) + parsed = parse_sse_text(event_text) + assert '"partial_json": "\\"ok\\"}"' in event_text + assert any( + event.event == "message_delta" + and event.data.get("delta", {}).get("stop_reason") == "tool_use" + for event in parsed + ) + assert not any(event.event == "error" for event in parsed) + + @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._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 OpenAI tool-call assembly.""" + + def test_heuristic_tool_use_sse_marks_committed_tool_output(self): + """Heuristic tool blocks are emitted content, even without OpenAI tool state.""" + from free_claude_code.core.anthropic import AnthropicStreamLedger + + ledger = AnthropicStreamLedger("msg_test", "test-model") + events = list( + iter_heuristic_tool_use_sse( + ledger, + { + "id": "toolu_heuristic", + "name": "Read", + "input": {"path": "test.py"}, + }, + ) + ) + + event_text = "".join(events) + assert "tool_use" in event_text + assert ledger.has_emitted_tool_block() + assert has_committed_sse_output(ledger) + + def test_tool_call_with_id(self): + """Tool call with id starts a tool block.""" + provider = _make_provider() + from free_claude_code.core.anthropic import AnthropicStreamLedger + + sse = AnthropicStreamLedger("msg_test", "test-model") + tc = { + "index": 0, + "id": "call_123", + "function": {"name": "search", "arguments": '{"q": "test"}'}, + } + events = list(_make_tool_assembler(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_id_arrives_before_name_still_emits_id_and_name(self): + """Split-stream tool: id (no name) then name then args; id preserved on start.""" + provider = _make_provider() + from free_claude_code.core.anthropic import AnthropicStreamLedger + + sse = AnthropicStreamLedger("msg_test", "test-model") + t1 = { + "index": 0, + "id": "call_split", + "function": {"name": None, "arguments": ""}, + } + t2 = { + "index": 0, + "id": "call_split", + "function": {"name": "Grep", "arguments": ""}, + } + t3 = { + "index": 0, + "id": "call_split", + "function": {"name": None, "arguments": "{}"}, + } + b1 = "".join(_make_tool_assembler(provider).process_tool_call(t1, sse)) + b2 = "".join(_make_tool_assembler(provider).process_tool_call(t2, sse)) + b3 = "".join(_make_tool_assembler(provider).process_tool_call(t3, sse)) + combined = b1 + b2 + b3 + assert "call_split" in combined + assert "Grep" in combined + assert b1 == "" + + def test_tool_call_arguments_buffered_until_name(self): + """Argument deltas before tool name are emitted after the block starts.""" + provider = _make_provider() + from free_claude_code.core.anthropic import AnthropicStreamLedger + + sse = AnthropicStreamLedger("msg_test", "test-model") + t1 = { + "index": 0, + "id": "call_buf", + "function": {"name": None, "arguments": '{"x":'}, + } + t2 = { + "index": 0, + "id": "call_buf", + "function": {"name": "Read", "arguments": "1}"}, + } + b1 = "".join(_make_tool_assembler(provider).process_tool_call(t1, sse)) + b2 = "".join(_make_tool_assembler(provider).process_tool_call(t2, sse)) + assert b1 == "" + combined = b2 + assert "Read" in combined + assert "call_buf" in combined + assert '{"x":' in combined or "partial_json" in combined + + def test_tool_call_without_id_generates_uuid(self): + """Tool call without id generates a uuid-based id.""" + provider = _make_provider() + from free_claude_code.core.anthropic import AnthropicStreamLedger + + sse = AnthropicStreamLedger("msg_test", "test-model") + tc = { + "index": 0, + "id": None, + "function": {"name": "test", "arguments": "{}"}, + } + events = list(_make_tool_assembler(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 free_claude_code.core.anthropic import AnthropicStreamLedger + + sse = AnthropicStreamLedger("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(_make_tool_assembler(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 free_claude_code.core.anthropic import AnthropicStreamLedger + + sse = AnthropicStreamLedger("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(_make_tool_assembler(provider).process_tool_call(tc1, sse)) + assert len(events1) > 0 + assert "false" not in "".join(events1).lower() + + events2 = list(_make_tool_assembler(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 free_claude_code.core.anthropic import AnthropicStreamLedger + + sse = AnthropicStreamLedger("msg_test", "test-model") + tc = { + "index": 0, + "id": "call_task2", + "function": {"name": "Task", "arguments": "not json"}, + } + events = list(_make_tool_assembler(provider).process_tool_call(tc, sse)) + assert len(events) > 0 + + with caplog.at_level("WARNING"): + flushed = list(_make_tool_assembler(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 free_claude_code.core.anthropic import AnthropicStreamLedger + + sse = AnthropicStreamLedger("msg_test", "test-model") + tc = { + "index": -1, + "id": "call_neg", + "function": {"name": "test", "arguments": "{}"}, + } + events = list(_make_tool_assembler(provider).process_tool_call(tc, sse)) + # Should not crash, should still emit events + assert len(events) > 0 + + def test_none_tool_index_defaults_to_zero(self): + """Gemini may stream tool_call deltas with a null index.""" + provider = _make_provider() + from free_claude_code.core.anthropic import AnthropicStreamLedger + + sse = AnthropicStreamLedger("msg_test", "test-model") + tc = { + "index": None, + "id": "call_none", + "function": {"name": "test", "arguments": "{}"}, + } + events = list(_make_tool_assembler(provider).process_tool_call(tc, sse)) + event_text = "".join(events) + + assert "tool_use" in event_text + assert "call_none" in event_text + + def test_tool_args_emitted_as_delta(self): + """Arguments are emitted as input_json_delta events.""" + provider = _make_provider() + from free_claude_code.core.anthropic import AnthropicStreamLedger + + sse = AnthropicStreamLedger("msg_test", "test-model") + tc = { + "index": 0, + "id": "call_args", + "function": {"name": "grep", "arguments": '{"pattern": "test"}'}, + } + events = list(_make_tool_assembler(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._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._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._rate_limiter, + "wait_if_blocked", + new_callable=AsyncMock, + return_value=False, + ), + ): + error = await _collect_stream_error(provider, request) + + assert "Connection reset" in error.message + + def test_stream_malformed_tool_args_chunked(self): + """Chunked tool args that never form valid JSON are flushed with {}.""" + provider = _make_provider() + from free_claude_code.core.anthropic import AnthropicStreamLedger + + sse = AnthropicStreamLedger("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(_make_tool_assembler(provider).process_tool_call(tc1, sse)) + events2 = list(_make_tool_assembler(provider).process_tool_call(tc2, sse)) + flushed = list(_make_tool_assembler(provider).flush_task_arg_buffers(sse)) + + event_text = "".join(events1 + events2 + flushed) + assert "tool_use" in event_text + assert "{}" in event_text + + +@pytest.mark.asyncio +async def test_openai_compat_stream_ends_with_contract_when_tool_name_never_arrives() -> ( + None +): + """Nameless / incomplete tool-call buffer must not break Anthropic stream contract.""" + provider = _make_provider() + request = _make_request() + tc0 = SimpleNamespace( + index=0, + id="call_inc", + function=SimpleNamespace(name=None, arguments="{}"), + ) + stream_mock = AsyncStreamMock([_make_chunk(tool_calls=[tc0])]) + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream_mock, + ), + patch.object( + provider._rate_limiter, + "wait_if_blocked", + new_callable=AsyncMock, + return_value=False, + ), + ): + error = await _collect_stream_error(provider, request) + + assert "Provider stream ended without finish_reason." in error.message diff --git a/tests/providers/test_subagent_interception.py b/tests/providers/test_subagent_interception.py new file mode 100644 index 0000000..f8e1e99 --- /dev/null +++ b/tests/providers/test_subagent_interception.py @@ -0,0 +1,67 @@ +import json +from unittest.mock import MagicMock + +import pytest + +from free_claude_code.config.nim import NimSettings +from free_claude_code.config.provider_catalog import NVIDIA_NIM_DEFAULT_BASE +from free_claude_code.core.anthropic import StreamBlockLedger +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.nvidia_nim import NvidiaNimProvider +from free_claude_code.providers.openai_chat.tool_calls import ( + OpenAIToolCallAssembler, +) +from tests.providers.support import passthrough_rate_limiter + + +@pytest.mark.asyncio +async def test_task_tool_interception(): + # Setup provider + config = ProviderConfig(api_key="test", base_url=NVIDIA_NIM_DEFAULT_BASE) + provider = NvidiaNimProvider( + config, + nim_settings=NimSettings(), + rate_limiter=passthrough_rate_limiter(), + ) + + # Mock request and stream ledger with real StreamBlockLedger + request = MagicMock() + request.model = "test-model" + + sse = MagicMock() + sse.blocks = StreamBlockLedger() + + # 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, + } + ), + }, + } + + tool_calls = OpenAIToolCallAssembler( + record_extra_content=provider._record_tool_call_extra_content + ) + + # Call the assembler (consume generator to trigger side effects) + list(tool_calls.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/tests/providers/test_vercel.py b/tests/providers/test_vercel.py new file mode 100644 index 0000000..d3c700d --- /dev/null +++ b/tests/providers/test_vercel.py @@ -0,0 +1,166 @@ +"""Tests for Vercel AI Gateway provider.""" + +from dataclasses import replace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.config.provider_catalog import VERCEL_AI_GATEWAY_DEFAULT_BASE +from free_claude_code.providers.base import ProviderConfig +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +def make_request(**overrides): + return make_messages_request("openai/gpt-5.5", **overrides) + + +@pytest.fixture +def vercel_config(): + return ProviderConfig( + api_key="test_vercel_key", + base_url=VERCEL_AI_GATEWAY_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ) + + +@pytest.fixture +def vercel_provider(vercel_config): + return profiled_provider( + "vercel", + vercel_config, + rate_limiter=passthrough_rate_limiter(), + ) + + +def test_default_base_url_constant(): + assert VERCEL_AI_GATEWAY_DEFAULT_BASE == "https://ai-gateway.vercel.sh/v1" + + +def test_init_uses_default_base_url_and_api_key(vercel_config): + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_openai: + provider = profiled_provider( + "vercel", + vercel_config, + rate_limiter=passthrough_rate_limiter(), + ) + + assert provider._api_key == "test_vercel_key" + assert provider._base_url == VERCEL_AI_GATEWAY_DEFAULT_BASE + mock_openai.assert_called_once() + + +def test_init_strips_trailing_slash(vercel_config): + config = replace(vercel_config, base_url=f"{VERCEL_AI_GATEWAY_DEFAULT_BASE}/") + + with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"): + provider = profiled_provider( + "vercel", + config, + rate_limiter=passthrough_rate_limiter(), + ) + + assert provider._base_url == VERCEL_AI_GATEWAY_DEFAULT_BASE + + +def test_build_request_body_keeps_max_tokens(vercel_provider): + with patch( + "free_claude_code.providers.openai_chat.request_policy.build_base_request_body" + ) as mock_convert: + mock_convert.return_value = { + "model": "openai/gpt-5.5", + "messages": [{"role": "user", "name": "alice", "content": "hi"}], + "max_tokens": 42, + } + + body = vercel_provider._build_request_body(make_request()) + + assert body["messages"][0].get("name") == "alice" + assert body["max_tokens"] == 42 + assert "max_completion_tokens" not in body + + +def test_build_request_body_preserves_caller_extra_body(vercel_provider): + req = make_request(extra_body={"providerOptions": {"openai": {"reasoning": "low"}}}) + + body = vercel_provider._build_request_body(req) + + assert body["extra_body"] == {"providerOptions": {"openai": {"reasoning": "low"}}} + + +@pytest.mark.asyncio +async def test_stream_response_text(vercel_provider): + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content="Hello from Vercel", + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + vercel_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [ + event async for event in vercel_provider.stream_response(make_request()) + ] + + assert any( + '"text_delta"' in event and "Hello from Vercel" in event for event in events + ) + + +@pytest.mark.asyncio +async def test_stream_response_reasoning_content(vercel_provider): + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=None, + reasoning_content="Thinking via gateway", + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + vercel_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [ + event async for event in vercel_provider.stream_response(make_request()) + ] + + assert any( + '"thinking_delta"' in event and "Thinking via gateway" in event + for event in events + ) + + +@pytest.mark.asyncio +async def test_cleanup(vercel_provider): + vercel_provider._client = AsyncMock() + + await vercel_provider.cleanup() + + vercel_provider._client.close.assert_called_once() diff --git a/tests/providers/test_wafer.py b/tests/providers/test_wafer.py new file mode 100644 index 0000000..96896a3 --- /dev/null +++ b/tests/providers/test_wafer.py @@ -0,0 +1,158 @@ +"""Tests for the Wafer OpenAI-chat provider.""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS +from free_claude_code.config.provider_catalog import WAFER_DEFAULT_BASE +from free_claude_code.core.anthropic.models import Message, MessagesRequest, Tool +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.openai_chat import ( + OPENAI_CHAT_PROFILES, + OpenAIChatProvider, +) +from free_claude_code.providers.rate_limit import ProviderRateLimiter +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +class CountingWaferProvider(OpenAIChatProvider): + def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter): + super().__init__( + config, + profile=OPENAI_CHAT_PROFILES["wafer"], + rate_limiter=rate_limiter, + ) + self.thinking_checks = 0 + + def _is_thinking_enabled( + self, request: Any, thinking_enabled: bool | None = None + ) -> bool: + self.thinking_checks += 1 + return super()._is_thinking_enabled(request, thinking_enabled) + + +@pytest.fixture +def wafer_config(): + return ProviderConfig( + api_key="test-wafer-key", + base_url=WAFER_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + ) + + +@pytest.fixture +def wafer_provider(wafer_config): + return profiled_provider( + "wafer", + wafer_config, + rate_limiter=passthrough_rate_limiter(), + ) + + +def test_default_base_url(): + assert WAFER_DEFAULT_BASE == "https://pass.wafer.ai/v1" + + +def test_init_uses_openai_chat_provider(wafer_provider): + assert isinstance(wafer_provider, OpenAIChatProvider) + assert wafer_provider._api_key == "test-wafer-key" + assert wafer_provider._base_url == WAFER_DEFAULT_BASE + assert wafer_provider._provider_name == "WAFER" + + +def test_build_request_body_openai_shape_and_defaults(wafer_provider): + request = MessagesRequest.model_validate( + { + "model": "DeepSeek-V4-Pro", + "messages": [Message(role="user", content="Hello")], + "tools": [ + Tool( + name="echo", + description="Echo input", + input_schema={"type": "object", "properties": {}}, + ) + ], + "thinking": {"type": "enabled", "budget_tokens": 2048}, + } + ) + + body = wafer_provider._build_request_body(request) + + assert body["model"] == "DeepSeek-V4-Pro" + assert body["messages"][0] == {"role": "user", "content": "Hello"} + assert body["tools"][0]["function"]["name"] == "echo" + assert body["extra_body"]["thinking"] == {"type": "enabled"} + assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS + + +def test_build_request_body_honors_effective_no_thinking(wafer_provider): + request = MessagesRequest.model_validate( + { + "model": "DeepSeek-V4-Pro", + "messages": [{"role": "user", "content": "Explore the codebase."}], + } + ) + + body = wafer_provider._build_request_body(request, thinking_enabled=False) + + assert body["extra_body"]["thinking"] == {"type": "disabled"} + + +def test_build_request_body_preserves_request_disabled_thinking(wafer_provider): + request = MessagesRequest.model_validate( + { + "model": "DeepSeek-V4-Pro", + "messages": [{"role": "user", "content": "Explore the codebase."}], + "thinking": {"type": "disabled"}, + } + ) + + body = wafer_provider._build_request_body(request, thinking_enabled=True) + + assert body["extra_body"]["thinking"] == {"type": "disabled"} + + +def test_build_request_body_resolves_thinking_once(wafer_config): + provider = CountingWaferProvider( + wafer_config, + rate_limiter=passthrough_rate_limiter(), + ) + request = MessagesRequest.model_validate( + { + "model": "DeepSeek-V4-Pro", + "messages": [{"role": "user", "content": "Explore the codebase."}], + } + ) + + body = provider._build_request_body(request, thinking_enabled=False) + + assert body["extra_body"]["thinking"] == {"type": "disabled"} + assert provider.thinking_checks == 1 + + +@pytest.mark.asyncio +async def test_lists_models_from_openai_models_endpoint(wafer_provider): + wafer_provider._client.models.list = AsyncMock( + return_value=MagicMock( + data=[MagicMock(id="DeepSeek-V4-Pro"), MagicMock(id="MiniMax-M2.7")] + ) + ) + + assert await wafer_provider.list_model_ids() == frozenset( + {"DeepSeek-V4-Pro", "MiniMax-M2.7"} + ) + + wafer_provider._client.models.list.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_cleanup_closes_openai_client(wafer_provider): + wafer_provider._client = MagicMock() + wafer_provider._client.close = AsyncMock() + + await wafer_provider.cleanup() + + wafer_provider._client.close.assert_awaited_once() diff --git a/tests/providers/test_zai.py b/tests/providers/test_zai.py new file mode 100644 index 0000000..364ee6b --- /dev/null +++ b/tests/providers/test_zai.py @@ -0,0 +1,123 @@ +"""Tests for the Z.ai OpenAI-chat Coding Plan provider.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from free_claude_code.application.errors import InvalidRequestError +from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS +from free_claude_code.config.provider_catalog import ZAI_DEFAULT_BASE +from free_claude_code.core.anthropic.models import Message, MessagesRequest +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.openai_chat import OpenAIChatProvider +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +@pytest.fixture +def zai_provider(): + return profiled_provider( + "zai", + ProviderConfig( + api_key="test_zai_key", + base_url=ZAI_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ), + rate_limiter=passthrough_rate_limiter(), + ) + + +def test_init_uses_openai_chat_coding_endpoint(zai_provider): + assert isinstance(zai_provider, OpenAIChatProvider) + assert zai_provider._api_key == "test_zai_key" + assert zai_provider._base_url == "https://api.z.ai/api/coding/paas/v4" + + +def test_build_request_body_openai_chat(zai_provider): + request = MessagesRequest( + model="glm-5.2", + max_tokens=100, + messages=[Message(role="user", content="Hello")], + ) + + body = zai_provider._build_request_body(request) + + assert body["model"] == "glm-5.2" + assert body["max_tokens"] == 100 + assert body["messages"] == [{"role": "user", "content": "Hello"}] + assert body["extra_body"]["thinking"] == { + "type": "enabled", + "clear_thinking": False, + } + + +def test_build_request_body_default_max_tokens(zai_provider): + request = MessagesRequest( + model="m", + messages=[Message(role="user", content="x")], + ) + + body = zai_provider._build_request_body(request) + + assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS + + +def test_build_request_body_rejects_caller_extra_body(zai_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "x"}], + "extra_body": {"x": 1}, + } + ) + + with pytest.raises(InvalidRequestError, match=r"Z\.ai Chat Completions"): + zai_provider._build_request_body(request) + + +def test_build_request_body_disables_zai_thinking(zai_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "x"}], + "thinking": {"type": "disabled"}, + } + ) + + body = zai_provider._build_request_body(request) + + assert body["extra_body"]["thinking"] == {"type": "disabled"} + + +def test_build_request_body_replays_prior_reasoning_content(zai_provider): + request = MessagesRequest.model_validate( + { + "model": "glm-5.2", + "messages": [ + { + "role": "assistant", + "content": [{"type": "thinking", "thinking": "prior"}], + }, + {"role": "user", "content": "continue"}, + ], + } + ) + + body = zai_provider._build_request_body(request) + + assert body["messages"][0]["reasoning_content"] == "prior" + assert body["extra_body"]["thinking"] == { + "type": "enabled", + "clear_thinking": False, + } + + +@pytest.mark.asyncio +async def test_cleanup_closes_openai_client(zai_provider): + zai_provider._client = MagicMock() + zai_provider._client.close = AsyncMock() + + await zai_provider.cleanup() + + zai_provider._client.close.assert_awaited_once() diff --git a/tests/runtime/test_application_runtime.py b/tests/runtime/test_application_runtime.py new file mode 100644 index 0000000..0113cf0 --- /dev/null +++ b/tests/runtime/test_application_runtime.py @@ -0,0 +1,937 @@ +import asyncio +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import free_claude_code.messaging.session.persistence as persistence_module +from free_claude_code.config.admin.persistence import PreparedAdminUpdate +from free_claude_code.config.settings import Settings +from free_claude_code.messaging.command_context import StopOutcome +from free_claude_code.messaging.platforms.ports import ( + InboundMessageHandler, + MessagingPlatformComponents, + MessagingStartupNotice, +) +from free_claude_code.messaging.session import SessionStore +from free_claude_code.messaging.workflow import MessagingWorkflow +from free_claude_code.providers.runtime import ProviderRuntime +from free_claude_code.runtime.application import ApplicationRuntime +from free_claude_code.runtime.provider_manager import ProviderRuntimeManager + + +class TrackingRuntime(ProviderRuntime): + def __init__(self, settings: Settings) -> None: + super().__init__(settings) + self.cleanup_calls = 0 + + async def cleanup(self) -> None: + self.cleanup_calls += 1 + await super().cleanup() + + +class TrackingFactory: + def __init__(self) -> None: + self.runtimes: list[TrackingRuntime] = [] + self.fail = False + self.events: list[str] = [] + + def __call__(self, settings: Settings) -> ProviderRuntime: + self.events.append(f"construct:{settings.model}") + if self.fail: + raise RuntimeError("candidate failed") + runtime = TrackingRuntime(settings) + self.runtimes.append(runtime) + return runtime + + +class TrackingTranscriber: + def __init__(self, events: list[str]) -> None: + self.events = events + self.close_calls = 0 + + async def transcribe(self, file_path: Path) -> str: + assert isinstance(file_path, Path) + return "transcribed" + + async def close(self) -> None: + self.close_calls += 1 + self.events.append("transcriber.close") + + +class FailingTranscriber(TrackingTranscriber): + async def close(self) -> None: + await super().close() + raise RuntimeError("transcriber close failed") + + +class CancelledTranscriber(TrackingTranscriber): + async def close(self) -> None: + await super().close() + raise asyncio.CancelledError + + +class CancellingOnceTranscriber(TrackingTranscriber): + async def close(self) -> None: + await super().close() + if self.close_calls == 1: + raise asyncio.CancelledError + + +class TrackingMessagingRuntime: + name = "tracking" + + def __init__( + self, + events: list[str], + *, + fail_quiesce_once: bool = False, + fail_close_once: bool = False, + ) -> None: + self.events = events + self.fail_quiesce_once = fail_quiesce_once + self.fail_close_once = fail_close_once + + async def start(self) -> None: + self.events.append("messaging.start") + + async def quiesce(self) -> None: + self.events.append("messaging.quiesce") + if self.fail_quiesce_once: + self.fail_quiesce_once = False + raise RuntimeError("quiesce failed") + + async def close(self) -> None: + self.events.append("messaging.close") + if self.fail_close_once: + self.fail_close_once = False + raise RuntimeError("close failed") + + def on_message(self, handler: InboundMessageHandler) -> None: + assert callable(handler) + + @property + def is_connected(self) -> bool: + return True + + +class PersistentlyFailingMessagingRuntime(TrackingMessagingRuntime): + def __init__(self, events: list[str]) -> None: + super().__init__(events) + self.fail_quiesce = True + + async def quiesce(self) -> None: + self.events.append("messaging.quiesce") + if self.fail_quiesce: + raise RuntimeError("quiesce failed") + + +def _settings(model: str, *, port: int = 8082) -> Settings: + return Settings().model_copy(update={"model": model, "port": port}) + + +def _prepared( + settings: Settings, + tmp_path, + *, + pending_fields: tuple[str, ...] = (), +) -> PreparedAdminUpdate: + return PreparedAdminUpdate( + target_values={"MODEL": settings.model}, + settings=settings, + errors=(), + pending_fields=pending_fields, + path=tmp_path / ".env", + ) + + +def _applied_response(pending_fields: tuple[str, ...] = ()) -> dict[str, object]: + return { + "applied": True, + "valid": True, + "errors": [], + "env_preview": "MODEL=updated\n", + "path": ".env", + "pending_fields": list(pending_fields), + } + + +@pytest.mark.asyncio +async def test_stop_all_maps_messaging_outcome_to_application_count() -> None: + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + runtime = ApplicationRuntime(manager, transcriber=None) + workflow = MagicMock() + workflow.stop_all_tasks = AsyncMock( + return_value=StopOutcome( + cancelled_count=3, + status_feedback_scopes=frozenset(), + fallback_required=False, + ) + ) + runtime._messaging_workflow = workflow + + result = await runtime.stop_all() + + assert result is not None + assert result.cancelled_count == 3 + workflow.stop_all_tasks.assert_awaited_once() + await manager.close() + + +@pytest.mark.asyncio +async def test_provider_apply_constructs_before_commit_then_publishes(tmp_path) -> None: + factory = TrackingFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/old"), + runtime_factory=factory, + ) + runtime = ApplicationRuntime(manager, transcriber=None) + prepared = _prepared(_settings("nvidia_nim/new"), tmp_path) + factory.events.clear() + + def commit(_prepared_update: PreparedAdminUpdate) -> dict[str, object]: + factory.events.append("commit") + assert manager.current_generation_id == 1 + return _applied_response() + + with ( + patch( + "free_claude_code.runtime.application.prepare_admin_update", + return_value=prepared, + ), + patch( + "free_claude_code.runtime.application.commit_prepared_admin_update", + side_effect=commit, + ), + ): + result = await runtime.apply_admin_config({"MODEL": "nvidia_nim/new"}) + + assert factory.events == ["construct:nvidia_nim/new", "commit"] + assert manager.current_generation_id == 2 + assert manager.current_settings().model == "nvidia_nim/new" + assert result["restart"] == { + "required": False, + "automatic": False, + "admin_url": None, + "fields": [], + } + await manager.close() + + +@pytest.mark.asyncio +async def test_candidate_failure_never_commits_and_preserves_current(tmp_path) -> None: + factory = TrackingFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/old"), + runtime_factory=factory, + ) + runtime = ApplicationRuntime(manager, transcriber=None) + prepared = _prepared(_settings("nvidia_nim/new"), tmp_path) + factory.fail = True + + with ( + patch( + "free_claude_code.runtime.application.prepare_admin_update", + return_value=prepared, + ), + patch( + "free_claude_code.runtime.application.commit_prepared_admin_update" + ) as commit, + pytest.raises(RuntimeError, match="candidate failed"), + ): + await runtime.apply_admin_config({"MODEL": "nvidia_nim/new"}) + + commit.assert_not_called() + assert manager.current_generation_id == 1 + assert manager.current_settings().model == "nvidia_nim/old" + await manager.close() + + +@pytest.mark.asyncio +async def test_persistence_failure_closes_candidate_and_preserves_current( + tmp_path, +) -> None: + factory = TrackingFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/old"), + runtime_factory=factory, + ) + runtime = ApplicationRuntime(manager, transcriber=None) + prepared = _prepared(_settings("nvidia_nim/new"), tmp_path) + + with ( + patch( + "free_claude_code.runtime.application.prepare_admin_update", + return_value=prepared, + ), + patch( + "free_claude_code.runtime.application.commit_prepared_admin_update", + side_effect=OSError("disk full"), + ), + pytest.raises(OSError, match="disk full"), + ): + await runtime.apply_admin_config({"MODEL": "nvidia_nim/new"}) + + assert manager.current_generation_id == 1 + assert factory.runtimes[0].cleanup_calls == 0 + assert factory.runtimes[1].cleanup_calls == 1 + await manager.close() + + +@pytest.mark.asyncio +async def test_restart_required_apply_commits_without_hot_publication(tmp_path) -> None: + factory = TrackingFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/old"), + runtime_factory=factory, + ) + restart = AsyncMock() + runtime = ApplicationRuntime( + manager, + transcriber=None, + restart_callback=restart, + ) + prepared = _prepared( + _settings("nvidia_nim/old", port=9090), + tmp_path, + pending_fields=("PORT",), + ) + + with ( + patch( + "free_claude_code.runtime.application.prepare_admin_update", + return_value=prepared, + ), + patch( + "free_claude_code.runtime.application.commit_prepared_admin_update", + return_value=_applied_response(("PORT",)), + ) as commit, + ): + result = await runtime.apply_admin_config({"PORT": "9090"}) + + commit.assert_called_once_with(prepared) + assert manager.current_generation_id == 1 + assert len(factory.runtimes) == 1 + assert result["restart"] == { + "required": True, + "automatic": True, + "admin_url": "http://127.0.0.1:9090/admin", + "fields": ["PORT"], + } + restart.assert_not_awaited() + await runtime.request_restart() + restart.assert_awaited_once() + await manager.close() + + +@pytest.mark.asyncio +async def test_close_drains_messaging_before_transcriber_and_is_idempotent() -> None: + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + transcriber = TrackingTranscriber(events) + runtime = ApplicationRuntime(manager, transcriber=transcriber) + runtime._messaging_runtime = TrackingMessagingRuntime(events) + workflow = MagicMock() + workflow.close = AsyncMock(side_effect=lambda: events.append("workflow.close")) + runtime._messaging_workflow = workflow + runtime._cli_manager = MagicMock() + + assert runtime.is_closed is False + assert await runtime.close() is True + assert await runtime.close() is True + + assert events == [ + "messaging.quiesce", + "workflow.close", + "messaging.close", + "transcriber.close", + ] + assert transcriber.close_calls == 1 + assert runtime._transcriber is None + assert runtime._messaging_runtime is None + assert runtime._messaging_workflow is None + assert runtime.is_closed is True + + +@pytest.mark.asyncio +async def test_close_retains_transcriber_ownership_when_close_fails() -> None: + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + transcriber = FailingTranscriber(events) + runtime = ApplicationRuntime(manager, transcriber=transcriber) + runtime._messaging_runtime = TrackingMessagingRuntime(events) + + assert await runtime.close() is False + + assert events == [ + "messaging.quiesce", + "messaging.close", + "transcriber.close", + ] + assert transcriber.close_calls == 1 + assert runtime._transcriber is transcriber + assert runtime._closed is False + await manager.close() + + +@pytest.mark.asyncio +async def test_close_retries_runtime_before_closing_later_resources() -> None: + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + transcriber = TrackingTranscriber(events) + runtime = ApplicationRuntime(manager, transcriber=transcriber) + messaging = TrackingMessagingRuntime(events, fail_close_once=True) + runtime._messaging_runtime = messaging + + assert await runtime.close() is False + + assert events == ["messaging.quiesce", "messaging.close"] + assert runtime._messaging_runtime is messaging + assert runtime._transcriber is transcriber + assert runtime._closed is False + + assert await runtime.close() is True + + assert events == [ + "messaging.quiesce", + "messaging.close", + "messaging.quiesce", + "messaging.close", + "transcriber.close", + ] + assert runtime._messaging_runtime is None + assert runtime._transcriber is None + assert runtime._closed is True + + +@pytest.mark.asyncio +async def test_close_retries_workflow_close_before_closing_delivery() -> None: + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + runtime = ApplicationRuntime(manager, transcriber=None) + messaging = TrackingMessagingRuntime(events) + workflow = MagicMock() + close_calls = 0 + + async def close_workflow() -> None: + nonlocal close_calls + close_calls += 1 + if close_calls == 1: + raise RuntimeError("drain failed") + events.append("workflow.close") + + workflow.close = AsyncMock(side_effect=close_workflow) + runtime._messaging_runtime = messaging + runtime._messaging_workflow = workflow + + assert await runtime.close() is False + + assert events == ["messaging.quiesce"] + assert runtime._messaging_runtime is messaging + assert runtime._messaging_workflow is workflow + assert runtime._closed is False + + assert await runtime.close() is True + + assert events == [ + "messaging.quiesce", + "messaging.quiesce", + "workflow.close", + "messaging.close", + ] + assert runtime._closed is True + + +@pytest.mark.asyncio +async def test_close_does_not_drain_workflow_until_ingress_is_quiescent() -> None: + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + runtime = ApplicationRuntime(manager, transcriber=None) + messaging = TrackingMessagingRuntime(events, fail_quiesce_once=True) + workflow = MagicMock() + workflow.close = AsyncMock(side_effect=lambda: events.append("workflow.close")) + runtime._messaging_runtime = messaging + runtime._messaging_workflow = workflow + + assert await runtime.close() is False + + workflow.close.assert_not_awaited() + assert runtime._messaging_runtime is messaging + assert runtime._messaging_workflow is workflow + + assert await runtime.close() is True + + assert events == [ + "messaging.quiesce", + "messaging.quiesce", + "workflow.close", + "messaging.close", + ] + assert runtime._closed is True + + +@pytest.mark.asyncio +async def test_close_retries_failed_persistence_before_closing_delivery() -> None: + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + runtime = ApplicationRuntime(manager, transcriber=None) + messaging = TrackingMessagingRuntime(events) + workflow = MagicMock() + close_calls = 0 + + async def close_workflow() -> None: + nonlocal close_calls + close_calls += 1 + if close_calls == 1: + raise RuntimeError("flush failed") + events.append("workflow.close") + + workflow.close = AsyncMock(side_effect=close_workflow) + runtime._messaging_runtime = messaging + runtime._messaging_workflow = workflow + + await runtime.close() + + assert runtime._messaging_workflow is workflow + assert runtime._messaging_runtime is messaging + assert "messaging.close" not in events + + await runtime.close() + + assert events == [ + "messaging.quiesce", + "messaging.quiesce", + "workflow.close", + "messaging.close", + ] + assert runtime._closed is True + + +@pytest.mark.asyncio +async def test_close_retries_real_workflow_persistence_without_losing_latest_state( + tmp_path: Path, +) -> None: + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + runtime = ApplicationRuntime(manager, transcriber=None) + messaging = TrackingMessagingRuntime(events) + cli_manager = MagicMock() + cli_manager.stop_all = AsyncMock() + outbound = MagicMock() + store_path = tmp_path / "sessions.json" + real_replace = persistence_module.os.replace + replace_calls = 0 + + def fail_first_replace(source: str, target: str) -> None: + nonlocal replace_calls + replace_calls += 1 + if replace_calls == 1: + raise OSError("replace failed once") + real_replace(source, target) + + with patch.object(persistence_module.threading, "Timer"): + store = SessionStore(storage_path=str(store_path)) + store.record_message_id( + "telegram", + "chat_1", + "old", + "out", + "status", + ) + store.flush_pending_save() + store.record_message_id( + "telegram", + "chat_1", + "latest", + "out", + "status", + ) + workflow = MessagingWorkflow(outbound, cli_manager, store) + runtime._messaging_runtime = messaging + runtime._messaging_workflow = workflow + runtime._cli_manager = cli_manager + + with patch.object( + persistence_module.os, + "replace", + side_effect=fail_first_replace, + ): + assert await runtime.close() is False + + assert runtime._messaging_runtime is messaging + assert runtime._messaging_workflow is workflow + assert runtime._cli_manager is cli_manager + assert runtime.is_closed is False + assert store.dirty is True + assert store.get_tracked_message_ids_for_chat("telegram", "chat_1") == [ + "old", + "latest", + ] + assert SessionStore( + storage_path=str(store_path) + ).get_tracked_message_ids_for_chat("telegram", "chat_1") == ["old"] + + assert await runtime.close() is True + + assert runtime._messaging_runtime is None + assert runtime._messaging_workflow is None + assert runtime._cli_manager is None + assert runtime.is_closed is True + assert store.dirty is False + assert SessionStore( + storage_path=str(store_path) + ).get_tracked_message_ids_for_chat("telegram", "chat_1") == [ + "old", + "latest", + ] + assert events == [ + "messaging.quiesce", + "messaging.quiesce", + "messaging.close", + ] + + +@pytest.mark.asyncio +async def test_cancelled_transcriber_close_retains_ownership() -> None: + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + transcriber = CancelledTranscriber(events) + runtime = ApplicationRuntime(manager, transcriber=transcriber) + + with pytest.raises(asyncio.CancelledError): + await runtime._cleanup_transcriber() + + assert transcriber.close_calls == 1 + assert runtime._transcriber is transcriber + await manager.close() + + +@pytest.mark.asyncio +async def test_cancelled_application_close_remains_retryable() -> None: + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + transcriber = CancellingOnceTranscriber(events) + runtime = ApplicationRuntime(manager, transcriber=transcriber) + + with pytest.raises(asyncio.CancelledError): + await runtime.close() + + assert runtime._closed is False + assert runtime._transcriber is transcriber + + await runtime.close() + + assert transcriber.close_calls == 2 + assert runtime._transcriber is None + assert runtime._closed is True + + +@pytest.mark.asyncio +async def test_startup_failure_closes_owned_transcriber() -> None: + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + transcriber = TrackingTranscriber(events) + runtime = ApplicationRuntime(manager, transcriber=transcriber) + + with ( + patch.object( + manager, + "validate_configured_models", + AsyncMock(side_effect=RuntimeError("startup failed")), + ), + pytest.raises(RuntimeError, match="startup failed"), + ): + await runtime.start() + + assert transcriber.close_calls == 1 + + +@pytest.mark.asyncio +async def test_startup_cancellation_cleans_partial_messaging_and_reraises() -> None: + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + transcriber = TrackingTranscriber(events) + runtime = ApplicationRuntime(manager, transcriber=transcriber) + messaging = TrackingMessagingRuntime(events) + entered = asyncio.Event() + + async def start_messaging() -> None: + runtime._messaging_runtime = messaging + entered.set() + await asyncio.Event().wait() + + with patch.object( + runtime, + "_start_messaging_if_configured", + side_effect=start_messaging, + ): + start_task = asyncio.create_task(runtime.start()) + await entered.wait() + start_task.cancel() + with pytest.raises(asyncio.CancelledError): + await start_task + + assert events == [ + "messaging.quiesce", + "messaging.close", + "transcriber.close", + ] + assert runtime._closed is True + assert runtime._messaging_runtime is None + assert runtime._transcriber is None + + +@pytest.mark.asyncio +async def test_public_start_retries_transient_partial_messaging_cleanup() -> None: + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + runtime = ApplicationRuntime(manager, transcriber=None) + messaging = TrackingMessagingRuntime(events, fail_quiesce_once=True) + workflow = MagicMock() + workflow.close = AsyncMock(side_effect=lambda: events.append("workflow.close")) + cli_manager = MagicMock() + components = MessagingPlatformComponents( + name="tracking", + runtime=messaging, + outbound=MagicMock(), + ) + startup_failure = RuntimeError("partial messaging startup failed") + + async def fail_after_publication( + published: MessagingPlatformComponents, + ) -> None: + assert published is components + runtime._messaging_runtime = messaging + runtime._messaging_workflow = workflow + runtime._cli_manager = cli_manager + raise startup_failure + + with ( + patch.object( + runtime, + "_validate_configured_models_best_effort", + AsyncMock(), + ), + patch.object(manager, "start_model_list_refresh"), + patch( + "free_claude_code.runtime.application.messaging_platform_factory.create_messaging_components", + return_value=components, + ), + patch.object( + runtime, + "_start_messaging_workflow", + side_effect=fail_after_publication, + ), + pytest.raises(RuntimeError, match="cleanup incomplete") as raised, + ): + await runtime.start() + + assert raised.value.__cause__ is startup_failure + assert events == [ + "messaging.quiesce", + "messaging.quiesce", + "workflow.close", + "messaging.close", + ] + workflow.close.assert_awaited_once() + assert runtime._messaging_runtime is None + assert runtime._messaging_workflow is None + assert runtime._cli_manager is None + assert runtime.is_closed is True + + +@pytest.mark.asyncio +async def test_public_start_retains_persistently_unclean_partial_messaging_graph() -> ( + None +): + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + transcriber = TrackingTranscriber(events) + runtime = ApplicationRuntime(manager, transcriber=transcriber) + messaging = PersistentlyFailingMessagingRuntime(events) + workflow = MagicMock() + workflow.close = AsyncMock(side_effect=lambda: events.append("workflow.close")) + cli_manager = MagicMock() + components = MessagingPlatformComponents( + name="tracking", + runtime=messaging, + outbound=MagicMock(), + ) + startup_failure = RuntimeError("partial messaging startup failed") + + async def fail_after_publication( + published: MessagingPlatformComponents, + ) -> None: + assert published is components + runtime._messaging_runtime = messaging + runtime._messaging_workflow = workflow + runtime._cli_manager = cli_manager + raise startup_failure + + with ( + patch.object( + runtime, + "_validate_configured_models_best_effort", + AsyncMock(), + ), + patch.object(manager, "start_model_list_refresh"), + patch( + "free_claude_code.runtime.application.messaging_platform_factory.create_messaging_components", + return_value=components, + ), + patch.object( + runtime, + "_start_messaging_workflow", + side_effect=fail_after_publication, + ), + pytest.raises(RuntimeError, match="cleanup incomplete") as raised, + ): + await runtime.start() + + assert raised.value.__cause__ is startup_failure + assert events == ["messaging.quiesce", "messaging.quiesce"] + workflow.close.assert_not_awaited() + assert transcriber.close_calls == 0 + assert runtime._messaging_runtime is messaging + assert runtime._messaging_workflow is workflow + assert runtime._cli_manager is cli_manager + assert runtime._transcriber is transcriber + assert runtime._provider_manager_closed is False + assert runtime.is_closed is False + + messaging.fail_quiesce = False + assert await runtime.close() is True + + assert events == [ + "messaging.quiesce", + "messaging.quiesce", + "messaging.quiesce", + "workflow.close", + "messaging.close", + "transcriber.close", + ] + assert runtime.is_closed is True + + +@pytest.mark.asyncio +async def test_messaging_start_failure_is_nonfatal_after_complete_cleanup() -> None: + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + runtime = ApplicationRuntime(manager, transcriber=None) + + with ( + patch( + "free_claude_code.runtime.application.messaging_platform_factory.create_messaging_components", + side_effect=RuntimeError("messaging unavailable"), + ), + patch.object(runtime, "_cleanup_messaging", AsyncMock(return_value=True)), + ): + await runtime._start_messaging_if_configured() + + await manager.close() + + +@pytest.mark.asyncio +async def test_messaging_start_failure_fails_closed_when_cleanup_is_incomplete() -> ( + None +): + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + runtime = ApplicationRuntime(manager, transcriber=None) + + with ( + patch( + "free_claude_code.runtime.application.messaging_platform_factory.create_messaging_components", + side_effect=RuntimeError("messaging unavailable"), + ), + patch.object(runtime, "_cleanup_messaging", AsyncMock(return_value=False)), + pytest.raises(RuntimeError, match="cleanup incomplete") as exc_info, + ): + await runtime._start_messaging_if_configured() + + assert isinstance(exc_info.value.__cause__, RuntimeError) + await manager.close() + + +@pytest.mark.asyncio +async def test_composition_records_runtime_before_workspace_setup() -> None: + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + runtime = ApplicationRuntime(manager, transcriber=None) + messaging = TrackingMessagingRuntime(events) + components = MessagingPlatformComponents( + name="tracking", + runtime=messaging, + outbound=MagicMock(), + ) + + with ( + patch( + "free_claude_code.runtime.application.os.makedirs", + side_effect=OSError("workspace failed"), + ), + pytest.raises(OSError, match="workspace failed"), + ): + await runtime._start_messaging_workflow(components) + + assert runtime._messaging_runtime is messaging + + await runtime.close() + + assert events == ["messaging.quiesce", "messaging.close"] + assert runtime._closed is True + + +@pytest.mark.asyncio +async def test_composition_publishes_startup_notice_after_runtime_and_repair() -> None: + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + runtime = ApplicationRuntime(manager, transcriber=None) + messaging = TrackingMessagingRuntime(events) + notice = MessagingStartupNotice( + chat_id="chat", + transport_label="test transport", + ) + components = MessagingPlatformComponents( + name="tracking", + runtime=messaging, + outbound=MagicMock(), + startup_notice=notice, + ) + workflow = MagicMock() + workflow.handle_message = AsyncMock() + workflow.restore.side_effect = lambda: events.append("workflow.restore") + workflow.repair_restored_statuses = AsyncMock( + side_effect=lambda: events.append("workflow.repair") + ) + workflow.publish_startup_notice = AsyncMock( + side_effect=lambda published: events.append("workflow.notice") + ) + workflow.close = AsyncMock() + cli_manager = MagicMock() + + with ( + patch( + "free_claude_code.runtime.application.cli_managed.ManagedClaudeSessionManager", + return_value=cli_manager, + ) as manager_constructor, + patch("free_claude_code.runtime.application.messaging_session.SessionStore"), + patch( + "free_claude_code.runtime.application.messaging_workflow_module.MessagingWorkflow", + return_value=workflow, + ), + ): + await runtime._start_messaging_workflow(components) + + assert events == [ + "workflow.restore", + "messaging.start", + "workflow.repair", + "workflow.notice", + ] + workflow.publish_startup_notice.assert_awaited_once_with(notice) + assert manager_constructor.call_args.kwargs["proxy_root_url"] == ( + "http://127.0.0.1:8082" + ) + assert "api_url" not in manager_constructor.call_args.kwargs + assert "plans_directory" not in manager_constructor.call_args.kwargs + + assert await runtime.close() is True diff --git a/tests/runtime/test_provider_manager.py b/tests/runtime/test_provider_manager.py new file mode 100644 index 0000000..77d59ae --- /dev/null +++ b/tests/runtime/test_provider_manager.py @@ -0,0 +1,635 @@ +import asyncio +from typing import cast +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.application.errors import ApplicationUnavailableError +from free_claude_code.application.model_metadata import ProviderModelInfo +from free_claude_code.config.settings import Settings +from free_claude_code.providers.base import BaseProvider +from free_claude_code.providers.nvidia_nim import NvidiaNimProvider +from free_claude_code.providers.runtime import ProviderRuntime +from free_claude_code.runtime.provider_manager import ProviderRuntimeManager + + +class FakeRuntime(ProviderRuntime): + def __init__(self, settings: Settings) -> None: + self.settings = settings + self.cleanup_calls = 0 + self.cleanup_error: Exception | None = None + self.cleanup_started: asyncio.Event | None = None + self.cleanup_release: asyncio.Event | None = None + self.provider = MagicMock() + self.provider.list_model_infos = AsyncMock(return_value=frozenset()) + + def is_cached(self, provider_id: str) -> bool: + return provider_id == "cached" + + def resolve_provider(self, provider_id: str) -> BaseProvider: + return cast(BaseProvider, self.provider) + + async def cleanup(self) -> None: + self.cleanup_calls += 1 + if self.cleanup_started is not None: + self.cleanup_started.set() + if self.cleanup_release is not None: + await self.cleanup_release.wait() + if self.cleanup_error is not None: + raise self.cleanup_error + + +class RuntimeFactory: + def __init__(self) -> None: + self.runtimes: list[FakeRuntime] = [] + self.error: Exception | None = None + + def __call__(self, settings: Settings) -> ProviderRuntime: + if self.error is not None: + raise self.error + runtime = FakeRuntime(settings) + self.runtimes.append(runtime) + return runtime + + +def _settings(model: str) -> Settings: + return Settings().model_copy(update={"model": model}) + + +@pytest.mark.asyncio +async def test_startup_generation_lease_and_shutdown_close_exactly_once() -> None: + factory = RuntimeFactory() + settings = _settings("nvidia_nim/one") + manager = ProviderRuntimeManager(settings, runtime_factory=factory) + + lease = await manager.acquire() + + assert lease.generation_id == 1 + assert lease.settings is settings + assert lease.is_provider_cached("cached") is True + assert lease.resolve_provider("nvidia_nim") is factory.runtimes[0].provider + await lease.release() + await lease.release() + await manager.close() + await manager.close() + + assert factory.runtimes[0].cleanup_calls == 1 + with pytest.raises(ApplicationUnavailableError, match="shutting down"): + await manager.acquire() + + +@pytest.mark.asyncio +async def test_replacement_keeps_leased_generation_open_until_final_release() -> None: + factory = RuntimeFactory() + first_settings = _settings("nvidia_nim/one") + second_settings = _settings("nvidia_nim/two") + manager = ProviderRuntimeManager(first_settings, runtime_factory=factory) + old_lease = await manager.acquire() + committed: list[str] = [] + + generation_id = await manager.replace( + second_settings, + commit=lambda: committed.append("persisted"), + ) + new_lease = await manager.acquire() + + assert generation_id == 2 + assert committed == ["persisted"] + assert new_lease.generation_id == 2 + assert new_lease.settings is second_settings + assert factory.runtimes[0].cleanup_calls == 0 + await new_lease.release() + await old_lease.release() + assert factory.runtimes[0].cleanup_calls == 1 + await manager.close() + assert factory.runtimes[1].cleanup_calls == 1 + + +@pytest.mark.asyncio +async def test_real_hot_replacement_owns_a_limiter_per_provider_generation() -> None: + first_settings = _settings("nvidia_nim/one") + second_settings = _settings("nvidia_nim/two") + clients: list[MagicMock] = [] + + def create_client(*_args: object, **_kwargs: object) -> MagicMock: + client = MagicMock() + client.close = AsyncMock() + clients.append(client) + return client + + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI", + side_effect=create_client, + ): + manager = ProviderRuntimeManager(first_settings) + old_lease = await manager.acquire() + old_provider = old_lease.resolve_provider("nvidia_nim") + refresh = AsyncMock() + + with patch.object(manager, "_refresh_generation", refresh): + await manager.replace(second_settings, commit=lambda: None) + new_lease = await manager.acquire() + new_provider = new_lease.resolve_provider("nvidia_nim") + await asyncio.sleep(0) + + assert isinstance(old_provider, NvidiaNimProvider) + assert isinstance(new_provider, NvidiaNimProvider) + assert new_provider is not old_provider + assert new_provider._rate_limiter is not old_provider._rate_limiter + assert old_lease.resolve_provider("nvidia_nim") is old_provider + clients[0].close.assert_not_awaited() + + await new_lease.release() + await old_lease.release() + + clients[0].close.assert_awaited_once() + clients[1].close.assert_not_awaited() + refresh.assert_awaited_once() + await manager.close() + + clients[1].close.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_replacement_closes_unleased_generation_immediately() -> None: + factory = RuntimeFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/one"), + runtime_factory=factory, + ) + + await manager.replace( + _settings("nvidia_nim/two"), + commit=lambda: None, + ) + + assert factory.runtimes[0].cleanup_calls == 1 + await manager.close() + + +@pytest.mark.asyncio +async def test_cancelled_replacement_does_not_cancel_owned_generation_cleanup() -> None: + factory = RuntimeFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/one"), + runtime_factory=factory, + ) + cleanup_started = asyncio.Event() + cleanup_release = asyncio.Event() + refresh_started = asyncio.Event() + factory.runtimes[0].cleanup_started = cleanup_started + factory.runtimes[0].cleanup_release = cleanup_release + + async def refresh(*_args: object, **_kwargs: object) -> None: + refresh_started.set() + await asyncio.Event().wait() + + with patch.object(manager, "_refresh_generation", side_effect=refresh): + replace_task = asyncio.create_task( + manager.replace( + _settings("nvidia_nim/two"), + commit=lambda: None, + ) + ) + await cleanup_started.wait() + await refresh_started.wait() + + replace_task.cancel() + with pytest.raises(asyncio.CancelledError): + await replace_task + + retired = manager._retired[1] + assert manager.current_generation_id == 2 + assert retired.cleanup_task is not None + assert not retired.cleanup_task.cancelled() + assert factory.runtimes[0].cleanup_calls == 1 + + close_task = asyncio.create_task(manager.close()) + await asyncio.sleep(0) + assert not close_task.done() + cleanup_release.set() + await close_task + + assert factory.runtimes[0].cleanup_calls == 1 + assert factory.runtimes[1].cleanup_calls == 1 + assert manager._retired == {} + + +@pytest.mark.asyncio +async def test_cancelled_final_lease_release_keeps_owned_cleanup_running() -> None: + factory = RuntimeFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/one"), + runtime_factory=factory, + ) + lease = await manager.acquire() + await manager.replace( + _settings("nvidia_nim/two"), + commit=lambda: None, + ) + cleanup_started = asyncio.Event() + cleanup_release = asyncio.Event() + factory.runtimes[0].cleanup_started = cleanup_started + factory.runtimes[0].cleanup_release = cleanup_release + + release_task = asyncio.create_task(lease.release()) + await cleanup_started.wait() + release_task.cancel() + + with pytest.raises(asyncio.CancelledError): + await release_task + + retired = manager._retired[1] + assert retired.active_leases == 0 + assert retired.cleanup_task is not None + assert not retired.cleanup_task.cancelled() + assert factory.runtimes[0].cleanup_calls == 1 + + close_task = asyncio.create_task(manager.close()) + await asyncio.sleep(0) + assert not close_task.done() + cleanup_release.set() + await close_task + + assert factory.runtimes[0].cleanup_calls == 1 + assert manager._retired == {} + + +@pytest.mark.asyncio +async def test_hot_cleanup_failure_keeps_published_replacement() -> None: + factory = RuntimeFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/one"), + runtime_factory=factory, + ) + factory.runtimes[0].cleanup_error = RuntimeError("cleanup failed") + + generation_id = await manager.replace( + _settings("nvidia_nim/two"), + commit=lambda: None, + ) + + assert generation_id == 2 + assert manager.current_generation_id == 2 + assert factory.runtimes[0].cleanup_calls == 1 + assert 1 in manager._retired + + factory.runtimes[0].cleanup_error = None + await manager.close() + + assert factory.runtimes[0].cleanup_calls == 2 + assert manager._retired == {} + + +@pytest.mark.asyncio +async def test_candidate_construction_failure_preserves_current_generation() -> None: + factory = RuntimeFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/one"), + runtime_factory=factory, + ) + factory.error = RuntimeError("cannot construct") + + with pytest.raises(RuntimeError, match="cannot construct"): + await manager.replace( + _settings("nvidia_nim/two"), + commit=lambda: None, + ) + + assert manager.current_generation_id == 1 + assert manager.current_settings().model == "nvidia_nim/one" + assert factory.runtimes[0].cleanup_calls == 0 + await manager.close() + + +@pytest.mark.asyncio +async def test_failed_candidate_cleanup_is_retried_at_shutdown() -> None: + factory = RuntimeFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/one"), + runtime_factory=factory, + ) + + def fail_commit() -> None: + factory.runtimes[1].cleanup_error = RuntimeError("private cleanup detail") + raise OSError("disk full") + + with pytest.raises(OSError, match="disk full"): + await manager.replace( + _settings("nvidia_nim/two"), + commit=fail_commit, + ) + + assert manager.current_generation_id == 1 + assert factory.runtimes[0].cleanup_calls == 0 + assert factory.runtimes[1].cleanup_calls == 1 + assert manager._unpublished == {factory.runtimes[1]} + + manager.cache_model_infos("nvidia_nim", {ProviderModelInfo("cached")}) + with pytest.raises( + RuntimeError, + match="One or more provider runtimes failed to close", + ) as exc_info: + await manager.close() + + assert "private cleanup detail" not in str(exc_info.value) + assert manager._closed is False + assert manager._unpublished == {factory.runtimes[1]} + assert manager.cached_model_ids() == {"nvidia_nim": frozenset({"cached"})} + + factory.runtimes[1].cleanup_error = None + await manager.close() + + assert factory.runtimes[0].cleanup_calls == 1 + assert factory.runtimes[1].cleanup_calls == 3 + assert manager._unpublished == set() + assert manager.cached_model_ids() == {} + assert manager._closed is True + + +@pytest.mark.asyncio +async def test_later_replacement_retries_failed_unpublished_candidate() -> None: + factory = RuntimeFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/one"), + runtime_factory=factory, + ) + + def fail_commit() -> None: + factory.runtimes[1].cleanup_error = RuntimeError("cleanup failed") + raise OSError("disk full") + + with pytest.raises(OSError, match="disk full"): + await manager.replace( + _settings("nvidia_nim/two"), + commit=fail_commit, + ) + + factory.runtimes[1].cleanup_error = None + generation_id = await manager.replace( + _settings("nvidia_nim/three"), + commit=lambda: None, + ) + + assert generation_id == 2 + assert factory.runtimes[1].cleanup_calls == 2 + assert manager._unpublished == set() + await manager.close() + + +@pytest.mark.asyncio +async def test_cancelled_candidate_cleanup_remains_owned_until_shutdown() -> None: + factory = RuntimeFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/one"), + runtime_factory=factory, + ) + cleanup_started = asyncio.Event() + cleanup_release = asyncio.Event() + + def fail_commit() -> None: + candidate = factory.runtimes[1] + candidate.cleanup_started = cleanup_started + candidate.cleanup_release = cleanup_release + raise OSError("disk full") + + replace_task = asyncio.create_task( + manager.replace( + _settings("nvidia_nim/two"), + commit=fail_commit, + ) + ) + await cleanup_started.wait() + replace_task.cancel() + + with pytest.raises(asyncio.CancelledError): + await replace_task + + assert manager._unpublished == {factory.runtimes[1]} + assert factory.runtimes[1].cleanup_calls == 1 + + cleanup_release.set() + await manager.close() + + assert factory.runtimes[1].cleanup_calls == 2 + assert manager._unpublished == set() + + +@pytest.mark.asyncio +async def test_concurrent_replacements_are_serialized_in_call_order() -> None: + factory = RuntimeFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/one"), + runtime_factory=factory, + ) + first_entered = asyncio.Event() + release_first = asyncio.Event() + cancel_calls = 0 + original_cancel = manager._cancel_refresh + + async def controlled_cancel() -> None: + nonlocal cancel_calls + cancel_calls += 1 + if cancel_calls == 1: + first_entered.set() + await release_first.wait() + await original_cancel() + + with patch.object(manager, "_cancel_refresh", side_effect=controlled_cancel): + first = asyncio.create_task( + manager.replace( + _settings("nvidia_nim/two"), + commit=lambda: None, + ) + ) + await first_entered.wait() + second = asyncio.create_task( + manager.replace( + _settings("nvidia_nim/three"), + commit=lambda: None, + ) + ) + await asyncio.sleep(0) + assert len(factory.runtimes) == 1 + assert not second.done() + release_first.set() + assert await asyncio.gather(first, second) == [2, 3] + + assert manager.current_settings().model == "nvidia_nim/three" + assert [runtime.cleanup_calls for runtime in factory.runtimes[:2]] == [1, 1] + await manager.close() + + +@pytest.mark.asyncio +async def test_shutdown_waits_for_active_lease_then_rejects_new_work() -> None: + factory = RuntimeFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/one"), + runtime_factory=factory, + ) + lease = await manager.acquire() + + close_task = asyncio.create_task(manager.close()) + await asyncio.sleep(0) + + assert not close_task.done() + with pytest.raises(ApplicationUnavailableError, match="shutting down"): + await manager.acquire() + await lease.release() + await close_task + assert factory.runtimes[0].cleanup_calls == 1 + + +@pytest.mark.asyncio +async def test_cancelled_shutdown_retains_generation_for_retry() -> None: + factory = RuntimeFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/one"), + runtime_factory=factory, + ) + lease = await manager.acquire() + close_task = asyncio.create_task(manager.close()) + await asyncio.sleep(0) + + close_task.cancel() + with pytest.raises(asyncio.CancelledError): + await close_task + + with pytest.raises(ApplicationUnavailableError, match="shutting down"): + await manager.acquire() + with pytest.raises(ApplicationUnavailableError, match="shutting down"): + await manager.replace(_settings("nvidia_nim/two"), commit=lambda: None) + assert factory.runtimes[0].cleanup_calls == 0 + + await lease.release() + await manager.close() + + assert factory.runtimes[0].cleanup_calls == 1 + assert manager._closed is True + + +@pytest.mark.asyncio +async def test_cancelled_shutdown_reuses_the_same_owned_cleanup_task() -> None: + factory = RuntimeFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/one"), + runtime_factory=factory, + ) + cleanup_started = asyncio.Event() + cleanup_release = asyncio.Event() + cleanup_calls = 0 + + async def cleanup() -> None: + nonlocal cleanup_calls + cleanup_calls += 1 + cleanup_started.set() + await cleanup_release.wait() + + with patch.object(factory.runtimes[0], "cleanup", side_effect=cleanup): + close_task = asyncio.create_task(manager.close()) + await cleanup_started.wait() + + close_task.cancel() + with pytest.raises(asyncio.CancelledError): + await close_task + + assert manager._closed is False + assert manager._retired + + retry_task = asyncio.create_task(manager.close()) + await asyncio.sleep(0) + assert not retry_task.done() + cleanup_release.set() + await retry_task + + assert cleanup_calls == 1 + assert manager._retired == {} + assert manager._closed is True + + +@pytest.mark.asyncio +async def test_failed_shutdown_cleanup_is_retryable() -> None: + factory = RuntimeFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/one"), + runtime_factory=factory, + ) + manager.cache_model_infos("nvidia_nim", {ProviderModelInfo("cached")}) + factory.runtimes[0].cleanup_error = RuntimeError("private provider detail") + + with pytest.raises( + RuntimeError, + match="One or more provider runtimes failed to close", + ) as exc_info: + await manager.close() + + assert "private provider detail" not in str(exc_info.value) + assert manager._closed is False + assert 1 in manager._retired + assert manager.cached_model_ids() == {"nvidia_nim": frozenset({"cached"})} + assert factory.runtimes[0].cleanup_calls == 1 + + factory.runtimes[0].cleanup_error = None + await manager.close() + + assert factory.runtimes[0].cleanup_calls == 2 + assert manager._retired == {} + assert manager.cached_model_ids() == {} + assert manager._closed is True + + +@pytest.mark.asyncio +async def test_application_catalog_survives_generation_replacement() -> None: + factory = RuntimeFactory() + manager = ProviderRuntimeManager( + _settings("open_router/one"), + runtime_factory=factory, + ) + manager.cache_model_infos( + "open_router", + {ProviderModelInfo("persisted", supports_thinking=True)}, + ) + + await manager.replace( + _settings("open_router/two"), + commit=lambda: None, + ) + + assert manager.cached_model_ids() == {"open_router": frozenset({"persisted"})} + assert manager.cached_model_supports_thinking("open_router", "persisted") is True + await manager.close() + + +@pytest.mark.asyncio +async def test_generation_lifecycle_traces_contain_minimal_correlation_fields() -> None: + factory = RuntimeFactory() + + with patch("free_claude_code.runtime.provider_manager.trace_event") as trace: + manager = ProviderRuntimeManager( + _settings("nvidia_nim/one"), + runtime_factory=factory, + ) + lease = await manager.acquire() + await manager.replace( + _settings("nvidia_nim/two"), + commit=lambda: None, + reason="test_replace", + ) + await lease.release() + await manager.close() + + events = [call.kwargs for call in trace.call_args_list] + names = [event["event"] for event in events] + assert names == [ + "provider_generation.published", + "provider_generation.published", + "provider_generation.retired", + "provider_generation.closed", + "provider_generation.retired", + "provider_generation.closed", + ] + assert events[1]["generation_id"] == 2 + assert events[1]["previous_generation_id"] == 1 + assert events[1]["reason"] == "test_replace" + assert events[2]["active_leases"] == 1 + assert events[3]["forced"] is False diff --git a/tests/scripts/test_ci_scripts.py b/tests/scripts/test_ci_scripts.py new file mode 100644 index 0000000..7f1f0d7 --- /dev/null +++ b/tests/scripts/test_ci_scripts.py @@ -0,0 +1,307 @@ +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def _script_text(name: str) -> str: + return (_repo_root() / "scripts" / name).read_text(encoding="utf-8") + + +def _braced_body(text: str, declaration: str) -> str: + start = text.index(declaration) + brace_start = text.index("{", start) + depth = 0 + + for index, char in enumerate(text[brace_start:], start=brace_start): + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return text[brace_start + 1 : index] + + raise AssertionError(f"Unclosed function body for {declaration}") + + +def _path_without_uv() -> str: + uv_names = ("uv", "uv.exe", "uv.cmd", "uv.bat") + entries = [] + for raw_entry in os.environ.get("PATH", "").split(os.pathsep): + if not raw_entry: + continue + entry = Path(raw_entry) + if any((entry / name).exists() for name in uv_names): + continue + entries.append(raw_entry) + return os.pathsep.join(entries) + + +def _shell_interpreter() -> str: + sh = shutil.which("sh") + if sh is None: + pytest.skip("sh is not available on this platform") + return sh + + +def _powershell_interpreter() -> str: + pwsh = shutil.which("pwsh") or shutil.which("powershell") + if pwsh is None: + pytest.skip("PowerShell is not available on this platform") + return pwsh + + +def test_ci_sh_runs_ci_checks_in_order() -> None: + text = _script_text("ci.sh") + legacy_future_import = "from __future__ import " + "annotations" + + assert 'CHECK_ORDER="suppressions ruff-format ruff-check ty pytest"' in text + assert "grep -rE" in text + assert "Fix the underlying type/import issue instead" in text + assert legacy_future_import in text + assert "legacy future annotations are not allowed" in text + assert "--exclude-dir=.venv" in text + assert "--exclude-dir=.git" in text + assert "uv run ruff format" in text + assert "uv run ruff format --check" not in text + assert "uv run ruff check --fix" in text + assert "uv run ty check" in text + assert "uv run pytest -v --tb=short" in text + assert "--only" in text + assert "--skip" in text + assert "--dry-run" in text + assert "uv is required but was not found on PATH" in text + assert "npm" not in text + assert "smoke/" not in text + assert "uv self update" not in text + + +def test_ci_sh_dry_run_does_not_require_uv() -> None: + result = subprocess.run( + [ + _shell_interpreter(), + str(_repo_root() / "scripts" / "ci.sh"), + "--only", + "pytest", + "--dry-run", + ], + cwd=_repo_root(), + env={**os.environ, "PATH": _path_without_uv()}, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0 + assert "+ uv run pytest -v --tb=short" in result.stdout + assert "uv is required" not in result.stderr + + +@pytest.mark.parametrize( + ("check_id", "command"), + [ + ("ruff-format", "+ uv run ruff format"), + ("ruff-check", "+ uv run ruff check --fix"), + ], +) +def test_ci_sh_dry_run_prints_local_ruff_repair_commands( + check_id: str, command: str +) -> None: + result = subprocess.run( + [ + _shell_interpreter(), + str(_repo_root() / "scripts" / "ci.sh"), + "--only", + check_id, + "--dry-run", + ], + cwd=_repo_root(), + env={**os.environ, "PATH": _path_without_uv()}, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0 + assert command in result.stdout + assert "uv is required" not in result.stderr + + +def test_ci_sh_suppression_only_does_not_require_uv() -> None: + result = subprocess.run( + [ + _shell_interpreter(), + str(_repo_root() / "scripts" / "ci.sh"), + "--only", + "suppressions", + ], + cwd=_repo_root(), + env={**os.environ, "PATH": _path_without_uv()}, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0 + assert "Ban suppressions and legacy annotations" in result.stdout + assert "uv is required" not in result.stderr + + +def test_ci_sh_is_tracked_executable() -> None: + result = subprocess.run( + ["git", "ls-files", "--stage", "scripts/ci.sh"], + cwd=_repo_root(), + text=True, + capture_output=True, + check=True, + ) + + assert result.stdout.startswith("100755 ") + + +def test_ci_sh_fail_fast_runs_checks_sequentially() -> None: + text = _script_text("ci.sh") + main = text[text.index('parse_args "$@"') :] + + suppress_index = text.index("run_suppressions()") + ruff_format_index = text.index("run_ruff_format()") + ruff_check_index = text.index("run_ruff_check()") + ty_index = text.index("run_ty()") + pytest_index = text.index("run_pytest()") + + assert ( + suppress_index < ruff_format_index < ruff_check_index < ty_index < pytest_index + ) + assert "for check_id in $CHECK_ORDER" in main + + +def test_ci_ps1_runs_ci_checks_in_order() -> None: + text = _script_text("ci.ps1") + legacy_future_import = "from __future__ import " + "annotations" + + assert '"suppressions"' in text + assert '"ruff-format"' in text + assert '"ruff-check"' in text + assert '"ty"' in text + assert '"pytest"' in text + assert "Select-String -Pattern" in text + assert "Fix the underlying type/import issue instead" in text + assert legacy_future_import in text + assert "legacy future annotations are not allowed" in text + assert ".venv" in text + assert ".git" in text + assert '"run", "ruff", "format"' in text + assert '"format", "--check"' not in text + assert '"run", "ruff", "check", "--fix"' in text + assert '"-v", "--tb=short"' in text + assert "-Only" in text + assert "-Skip" in text + assert "-DryRun" in text + assert "uv is required but was not found on PATH" in text + assert "npm" not in text + assert "smoke/" not in text + assert "uv self update" not in text + + +def test_ci_ps1_dry_run_does_not_require_uv() -> None: + result = subprocess.run( + [ + _powershell_interpreter(), + "-NoProfile", + "-File", + str(_repo_root() / "scripts" / "ci.ps1"), + "-Only", + "pytest", + "-DryRun", + ], + cwd=_repo_root(), + env={**os.environ, "PATH": _path_without_uv()}, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0 + assert "+ uv run pytest -v --tb=short" in result.stdout + assert "uv is required" not in result.stderr + + +@pytest.mark.parametrize( + ("check_id", "command"), + [ + ("ruff-format", "+ uv run ruff format"), + ("ruff-check", "+ uv run ruff check --fix"), + ], +) +def test_ci_ps1_dry_run_prints_local_ruff_repair_commands( + check_id: str, command: str +) -> None: + result = subprocess.run( + [ + _powershell_interpreter(), + "-NoProfile", + "-File", + str(_repo_root() / "scripts" / "ci.ps1"), + "-Only", + check_id, + "-DryRun", + ], + cwd=_repo_root(), + env={**os.environ, "PATH": _path_without_uv()}, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0 + assert command in result.stdout + assert "uv is required" not in result.stderr + + +def test_ci_ps1_suppression_only_does_not_require_uv() -> None: + result = subprocess.run( + [ + _powershell_interpreter(), + "-NoProfile", + "-File", + str(_repo_root() / "scripts" / "ci.ps1"), + "-Only", + "suppressions", + ], + cwd=_repo_root(), + env={**os.environ, "PATH": _path_without_uv()}, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0 + assert "Ban suppressions and legacy annotations" in result.stdout + assert "uv is required" not in result.stderr + + +def test_ci_ps1_fail_fast_runs_checks_sequentially() -> None: + text = _script_text("ci.ps1") + + assert "foreach ($checkId in $CheckOrder)" in text + assert "Invoke-SuppressionsCheck" in text + assert "Invoke-RuffFormatCheck" in text + assert "Invoke-RuffLintCheck" in text + assert "Invoke-TyCheck" in text + assert "Invoke-PytestCheck" in text + + suppress_index = text.index("function Invoke-SuppressionsCheck") + ruff_format_index = text.index("function Invoke-RuffFormatCheck") + ruff_check_index = text.index("function Invoke-RuffLintCheck") + ty_index = text.index("function Invoke-TyCheck") + pytest_index = text.index("function Invoke-PytestCheck") + + assert ( + suppress_index < ruff_format_index < ruff_check_index < ty_index < pytest_index + ) diff --git a/tests/scripts/test_installers.py b/tests/scripts/test_installers.py new file mode 100644 index 0000000..b4030a9 --- /dev/null +++ b/tests/scripts/test_installers.py @@ -0,0 +1,986 @@ +import os +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path + +import pytest + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def _write_executable(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + path.chmod(0o755) + + +def _braced_body(text: str, declaration: str) -> str: + start = text.index(declaration) + brace_start = text.index("{", start) + depth = 0 + for index, char in enumerate(text[brace_start:], start=brace_start): + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return text[brace_start + 1 : index] + raise AssertionError(f"Unclosed function body for {declaration}") + + +def _posix_command(name: str) -> str: + help_output = ( + ' echo " --extension, -e Load an extension"\n' + ' echo " --models Scope models"' + if name == "pi" + else " :" + ) + return f"""#!/bin/sh +echo "{name}:$*" >> "$CALL_LOG" +if [ "$FAIL_STEP" = "{name}-verify" ]; then + exit 31 +fi +if [ "${{1:-}}" = "--version" ]; then + echo "{name} 1.0.0" +fi +if [ "${{1:-}}" = "--help" ]; then +{help_output} +fi +""" + + +def _posix_npm_command() -> str: + return """#!/bin/sh +echo "npm:$*" >> "$CALL_LOG" +if [ "${1:-}" = "prefix" ] && [ "${2:-}" = "-g" ]; then + printf '%s\n' "$FAKE_NPM_PREFIX" + exit 0 +fi +if [ "${1:-}" = "config" ] && [ "${2:-}" = "get" ] && [ "${3:-}" = "prefix" ]; then + printf '%s\n' "$FAKE_NPM_PREFIX" + exit 0 +fi +exit 71 +""" + + +def _posix_uv_command(version: str) -> str: + return f"""#!/bin/sh +echo "uv:$*" >> "$CALL_LOG" +if [ "${{1:-}}" = "--version" ]; then + if [ "$FAIL_STEP" = "uv-verify" ]; then + exit 32 + fi + echo "uv {version}" + exit 0 +fi +if [ "${{1:-}}" = "tool" ] && [ "${{2:-}}" = "install" ]; then + if [ "$FAIL_STEP" = "fcc-install" ]; then + exit 33 + fi + mkdir -p "$FAKE_TOOL_BIN" + cp "$FAKE_FIXTURES/fcc-command.sh" "$FAKE_TOOL_BIN/fcc-server" + cp "$FAKE_FIXTURES/fcc-command.sh" "$FAKE_TOOL_BIN/fcc-claude" + cp "$FAKE_FIXTURES/fcc-command.sh" "$FAKE_TOOL_BIN/fcc-pi" + if [ "$FAIL_STEP" != "fcc-missing" ]; then + cp "$FAKE_FIXTURES/fcc-command.sh" "$FAKE_TOOL_BIN/fcc-codex" + fi + chmod +x "$FAKE_TOOL_BIN"/fcc-* + exit 0 +fi +if [ "${{1:-}}" = "tool" ] && [ "${{2:-}}" = "update-shell" ]; then + if [ "$FAIL_STEP" = "path-update" ]; then + exit 34 + fi + exit 0 +fi +if [ "${{1:-}}" = "tool" ] && [ "${{2:-}}" = "dir" ] && [ "${{3:-}}" = "--bin" ]; then + printf '%s\n' "$FAKE_TOOL_BIN" + exit 0 +fi +exit 35 +""" + + +@dataclass +class PosixHarness: + root: Path + bin_dir: Path + fixtures: Path + tool_bin: Path + log: Path + env: dict[str, str] + + def add_client(self, name: str) -> None: + _write_executable(self.bin_dir / name, _posix_command(name)) + + def add_unrelated_pi(self) -> None: + _write_executable(self.bin_dir / "pi", _posix_command("unrelated-pi")) + + def add_npm_prefix(self, prefix: Path) -> None: + prefix.mkdir(parents=True) + self.env["FAKE_NPM_PREFIX"] = str(prefix) + _write_executable(self.bin_dir / "npm", _posix_npm_command()) + + def add_uv(self, version: str) -> None: + _write_executable(self.bin_dir / "uv", _posix_uv_command(version)) + + def run(self, *args: str, fail_step: str = "") -> subprocess.CompletedProcess[str]: + env = self.env | {"FAIL_STEP": fail_step} + return subprocess.run( + ["/bin/sh", str(_repo_root() / "scripts" / "install.sh"), *args], + check=False, + capture_output=True, + text=True, + env=env, + ) + + def calls(self) -> list[str]: + if not self.log.exists(): + return [] + return self.log.read_text(encoding="utf-8").splitlines() + + +@pytest.fixture +def posix_harness(tmp_path: Path) -> PosixHarness: + if os.name == "nt": + pytest.skip("POSIX installer scenarios run on POSIX hosts") + + bin_dir = tmp_path / "bin" + fixtures = tmp_path / "fixtures" + tool_bin = tmp_path / "tool-bin" + home = tmp_path / "home" + log = tmp_path / "calls.log" + for path in (bin_dir, fixtures, tool_bin, home): + path.mkdir(parents=True) + + _write_executable( + bin_dir / "curl", + """#!/bin/sh +url="" +output="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) + shift + output=$1 + ;; + http*) + url=$1 + ;; + esac + shift +done +echo "download:$url" >> "$CALL_LOG" +case "$url:$FAIL_STEP" in + *claude.ai*:claude-download|*chatgpt.com*:codex-download|*pi.dev*:pi-download|*astral.sh*:uv-download) + exit 41 + ;; +esac +case "$url" in + *claude.ai*) source="$FAKE_FIXTURES/claude-installer.sh" ;; + *chatgpt.com*) source="$FAKE_FIXTURES/codex-installer.sh" ;; + *pi.dev*) source="$FAKE_FIXTURES/pi-installer.sh" ;; + *astral.sh*) source="$FAKE_FIXTURES/uv-installer.sh" ;; + *) exit 42 ;; +esac +cp "$source" "$output" +""", + ) + _write_executable( + fixtures / "claude-installer.sh", + """#!/bin/sh +echo "claude-install" >> "$CALL_LOG" +[ "$FAIL_STEP" = "claude-install" ] && exit 21 +mkdir -p "$HOME/.local/bin" +cp "$FAKE_FIXTURES/claude-command.sh" "$HOME/.local/bin/claude" +chmod +x "$HOME/.local/bin/claude" +""", + ) + _write_executable( + fixtures / "codex-installer.sh", + """#!/bin/sh +echo "codex-install:$CODEX_NON_INTERACTIVE" >> "$CALL_LOG" +[ "$FAIL_STEP" = "codex-install" ] && exit 22 +mkdir -p "$HOME/.local/bin" +cp "$FAKE_FIXTURES/codex-command.sh" "$HOME/.local/bin/codex" +chmod +x "$HOME/.local/bin/codex" +""", + ) + _write_executable( + fixtures / "pi-installer.sh", + """#!/bin/sh +echo "pi-install" >> "$CALL_LOG" +[ "$FAIL_STEP" = "pi-install" ] && exit 24 +if [ -n "${FAKE_NPM_PREFIX:-}" ]; then + pi_bin="$FAKE_NPM_PREFIX/bin" +else + pi_bin="$HOME/.local/bin" +fi +mkdir -p "$pi_bin" +cp "$FAKE_FIXTURES/pi-command.sh" "$pi_bin/pi" +chmod +x "$pi_bin/pi" +""", + ) + _write_executable( + fixtures / "uv-installer.sh", + """#!/bin/sh +echo "uv-install" >> "$CALL_LOG" +[ "$FAIL_STEP" = "uv-install" ] && exit 23 +mkdir -p "$HOME/.local/bin" +cp "$FAKE_FIXTURES/uv-command.sh" "$HOME/.local/bin/uv" +chmod +x "$HOME/.local/bin/uv" +""", + ) + _write_executable(fixtures / "claude-command.sh", _posix_command("claude")) + _write_executable(fixtures / "codex-command.sh", _posix_command("codex")) + _write_executable(fixtures / "pi-command.sh", _posix_command("pi")) + _write_executable(fixtures / "uv-command.sh", _posix_uv_command("0.11.28")) + _write_executable( + fixtures / "fcc-command.sh", + """#!/bin/sh +name=${0##*/} +echo "$name:$*" >> "$CALL_LOG" +if [ "$FAIL_STEP" = "fcc-verify" ]; then + exit 36 +fi +if [ "$name" = "fcc-server" ] && [ "${1:-}" = "--version" ]; then + echo "free-claude-code 3.5.18" +fi +""", + ) + + env = os.environ.copy() + env.update( + { + "PATH": f"{bin_dir}:/usr/bin:/bin", + "HOME": str(home), + "CALL_LOG": str(log), + "FAKE_FIXTURES": str(fixtures), + "FAKE_TOOL_BIN": str(tool_bin), + "FAIL_STEP": "", + } + ) + env.pop("XDG_BIN_HOME", None) + return PosixHarness(tmp_path, bin_dir, fixtures, tool_bin, log, env) + + +def test_install_sh_fresh_install_is_verified(posix_harness: PosixHarness) -> None: + result = posix_harness.run() + + assert result.returncode == 0, result.stderr + assert "Free Claude Code is installed and verified." in result.stdout + calls = posix_harness.calls() + assert calls.index("claude-install") < calls.index("claude:--version") + assert calls.index("codex-install:1") < calls.index("codex:--version") + assert calls.index("pi-install") < calls.index("pi:--version") + assert calls.index("uv-install") < calls.index("uv:--version") + assert any( + call.startswith( + "uv:tool install --force --refresh-package free-claude-code " + "--python 3.14.0 free-claude-code @ " + "https://github.com/Alishahryar1/free-claude-code/archive/refs/heads/main.zip" + ) + for call in calls + ) + assert not any(call.startswith("git:") for call in calls) + assert calls[-3:] == [ + "uv:tool update-shell", + "uv:tool dir --bin", + "fcc-server:--version", + ] + + +def test_install_sh_preserves_valid_existing_tools( + posix_harness: PosixHarness, +) -> None: + posix_harness.add_client("claude") + posix_harness.add_client("codex") + posix_harness.add_client("pi") + posix_harness.add_uv("0.11.7") + + result = posix_harness.run() + + assert result.returncode == 0, result.stderr + assert not any(call.startswith("download:") for call in posix_harness.calls()) + assert "leaving it unchanged" in result.stdout + + +def test_install_sh_replaces_unrelated_pi_command( + posix_harness: PosixHarness, +) -> None: + posix_harness.add_client("claude") + posix_harness.add_client("codex") + posix_harness.add_unrelated_pi() + posix_harness.add_uv("0.11.7") + + result = posix_harness.run() + + assert result.returncode == 0, result.stderr + assert "is not Pi Coding Agent; installing Pi" in result.stdout + assert "pi-install" in posix_harness.calls() + + +def test_install_sh_discovers_custom_pi_npm_prefix( + posix_harness: PosixHarness, +) -> None: + posix_harness.add_client("claude") + posix_harness.add_client("codex") + posix_harness.add_npm_prefix(posix_harness.root / "custom-npm") + posix_harness.add_uv("0.11.7") + + result = posix_harness.run() + + assert result.returncode == 0, result.stderr + calls = posix_harness.calls() + assert "npm:prefix -g" in calls + assert "pi:--help" in calls + assert "pi:--version" in calls + + +def test_install_sh_replaces_obsolete_uv(posix_harness: PosixHarness) -> None: + posix_harness.add_client("claude") + posix_harness.add_client("codex") + posix_harness.add_client("pi") + posix_harness.add_uv("0.5.9") + + result = posix_harness.run() + + assert result.returncode == 0, result.stderr + assert "uv 0.5.9 is below 0.11.0" in result.stdout + assert "uv-install" in posix_harness.calls() + + +@pytest.mark.parametrize( + "failure", + [ + "claude-download", + "claude-install", + "claude-verify", + "codex-download", + "codex-install", + "codex-verify", + "pi-download", + "pi-install", + "pi-verify", + "uv-download", + "uv-install", + "uv-verify", + "fcc-install", + "path-update", + "fcc-missing", + "fcc-verify", + ], +) +def test_install_sh_stops_without_success_on_each_failure( + posix_harness: PosixHarness, + failure: str, +) -> None: + result = posix_harness.run(fail_step=failure) + + assert result.returncode != 0 + assert "Free Claude Code is installed and verified." not in result.stdout + forbidden = { + "claude-download": "claude-install", + "claude-install": "claude:--version", + "claude-verify": "chatgpt.com", + "codex-download": "codex-install", + "codex-install": "codex:--version", + "codex-verify": "pi.dev", + "pi-download": "pi-install", + "pi-install": "pi:--version", + "pi-verify": "astral.sh", + "uv-download": "uv-install", + "uv-install": "uv:--version", + "uv-verify": "uv:tool install", + "fcc-install": "uv:tool update-shell", + "path-update": "uv:tool dir --bin", + "fcc-missing": "fcc-server:--version", + }.get(failure) + if forbidden is not None: + assert not any(forbidden in call for call in posix_harness.calls()) + + +def test_install_sh_dry_run_never_executes_commands( + posix_harness: PosixHarness, +) -> None: + result = posix_harness.run("--dry-run") + + assert result.returncode == 0, result.stderr + assert posix_harness.calls() == [] + assert "Dry run complete. No changes were made." in result.stdout + assert "Free Claude Code is installed and verified." not in result.stdout + + +def test_install_sh_rejects_broken_existing_client_without_replacing_it( + posix_harness: PosixHarness, +) -> None: + posix_harness.add_client("claude") + + result = posix_harness.run(fail_step="claude-verify") + + assert result.returncode != 0 + assert not any(call.startswith("download:") for call in posix_harness.calls()) + + +def test_install_sh_rejects_unparseable_existing_uv( + posix_harness: PosixHarness, +) -> None: + posix_harness.add_client("claude") + posix_harness.add_client("codex") + posix_harness.add_client("pi") + posix_harness.add_uv("not-a-version") + + result = posix_harness.run() + + assert result.returncode != 0 + assert not any("astral.sh" in call for call in posix_harness.calls()) + + +def test_install_sh_voice_flags_only_change_fcc_spec( + posix_harness: PosixHarness, +) -> None: + result = posix_harness.run("--voice-all", "--torch-backend", "cu130") + + assert result.returncode == 0, result.stderr + assert any( + "--torch-backend cu130 free-claude-code[voice,voice_local] @ " + "https://github.com/Alishahryar1/free-claude-code/archive/refs/heads/main.zip" + in call + for call in posix_harness.calls() + ) + + +def test_install_sh_rejects_invalid_options_before_mutation( + posix_harness: PosixHarness, +) -> None: + result = posix_harness.run("--torch-backend", "cu130") + + assert result.returncode != 0 + assert posix_harness.calls() == [] + + +def _powershells() -> tuple[str, ...]: + candidates = (shutil.which("pwsh"), shutil.which("powershell")) + return tuple(dict.fromkeys(path for path in candidates if path is not None)) + + +def _batch_client(name: str) -> str: + help_output = ( + "echo --extension, -e ^ Load an extension\n" + "echo --models ^ Scope models" + if name == "pi" + else "rem no product help" + ) + return f"""@echo off +echo {name}:%*>>"%CALL_LOG%" +if "%FAIL_STEP%"=="{name}-verify" exit /b 51 +if "%1"=="--version" echo {name} 1.0.0 +if "%1"=="--help" ( +{help_output} +) +exit /b 0 +""" + + +def _batch_npm() -> str: + return r"""@echo off +echo npm:%*>>"%CALL_LOG%" +if "%1"=="prefix" if "%2"=="-g" echo %FAKE_NPM_PREFIX%& exit /b 0 +if "%1"=="config" if "%2"=="get" if "%3"=="prefix" echo %FAKE_NPM_PREFIX%& exit /b 0 +exit /b 71 +""" + + +def _batch_uv(version: str) -> str: + return rf"""@echo off +echo uv:%*>>"%CALL_LOG%" +if "%1"=="--version" goto version +if "%1"=="tool" if "%2"=="install" goto install +if "%1"=="tool" if "%2"=="update-shell" goto update_shell +if "%1"=="tool" if "%2"=="dir" if "%3"=="--bin" goto tool_bin +exit /b 59 +:version +if "%FAIL_STEP%"=="uv-verify" exit /b 52 +echo uv {version} +exit /b 0 +:install +if "%FAIL_STEP%"=="fcc-install" exit /b 53 +if not exist "%FAKE_TOOL_BIN%" mkdir "%FAKE_TOOL_BIN%" +copy /y "%FAKE_FIXTURES%\fcc-command.cmd" "%FAKE_TOOL_BIN%\fcc-server.cmd" >nul +copy /y "%FAKE_FIXTURES%\fcc-command.cmd" "%FAKE_TOOL_BIN%\fcc-claude.cmd" >nul +copy /y "%FAKE_FIXTURES%\fcc-command.cmd" "%FAKE_TOOL_BIN%\fcc-pi.cmd" >nul +if not "%FAIL_STEP%"=="fcc-missing" copy /y "%FAKE_FIXTURES%\fcc-command.cmd" "%FAKE_TOOL_BIN%\fcc-codex.cmd" >nul +exit /b 0 +:update_shell +if "%FAIL_STEP%"=="path-update" exit /b 54 +exit /b 0 +:tool_bin +echo %FAKE_TOOL_BIN% +exit /b 0 +""" + + +@dataclass +class PowerShellHarness: + root: Path + bin_dir: Path + fixtures: Path + tool_bin: Path + log: Path + env: dict[str, str] + powershell: str + wrapper: Path + + def add_client(self, name: str) -> None: + _write_executable(self.bin_dir / f"{name}.cmd", _batch_client(name)) + + def add_unrelated_pi(self) -> None: + _write_executable(self.bin_dir / "pi.cmd", _batch_client("unrelated-pi")) + + def add_npm_prefix(self, prefix: Path) -> None: + prefix.mkdir(parents=True) + self.env["FAKE_NPM_PREFIX"] = str(prefix) + _write_executable(self.bin_dir / "npm.cmd", _batch_npm()) + + def add_uv(self, version: str) -> None: + _write_executable(self.bin_dir / "uv.cmd", _batch_uv(version)) + + def run(self, *args: str, fail_step: str = "") -> subprocess.CompletedProcess[str]: + env = self.env | {"FAIL_STEP": fail_step} + return subprocess.run( + [ + self.powershell, + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(self.wrapper), + *args, + ], + check=False, + capture_output=True, + text=True, + env=env, + ) + + def calls(self) -> list[str]: + if not self.log.exists(): + return [] + return self.log.read_text(encoding="utf-8").splitlines() + + +@pytest.fixture( + params=_powershells() or (None,), + ids=lambda path: Path(path).name if path is not None else "unavailable", +) +def powershell_harness( + tmp_path: Path, + request: pytest.FixtureRequest, +) -> PowerShellHarness: + powershell = request.param + if powershell is None or os.name != "nt": + pytest.skip("PowerShell installer scenarios run on Windows hosts") + + bin_dir = tmp_path / "bin" + fixtures = tmp_path / "fixtures" + tool_bin = tmp_path / "tool-bin" + home = tmp_path / "home" + local_app_data = tmp_path / "local-app-data" + app_data = tmp_path / "app-data" + log = tmp_path / "calls.log" + for path in (bin_dir, fixtures, tool_bin, home, local_app_data, app_data): + path.mkdir(parents=True) + + (fixtures / "claude-command.cmd").write_text( + _batch_client("claude"), encoding="utf-8" + ) + (fixtures / "codex-command.cmd").write_text( + _batch_client("codex"), encoding="utf-8" + ) + (fixtures / "pi-command.cmd").write_text(_batch_client("pi"), encoding="utf-8") + (fixtures / "uv-command.cmd").write_text(_batch_uv("0.11.28"), encoding="utf-8") + (fixtures / "fcc-command.cmd").write_text( + """@echo off +for %%I in ("%~f0") do set "FCC_NAME=%%~nI" +echo %FCC_NAME%:%*>>"%CALL_LOG%" +if "%FAIL_STEP%"=="fcc-verify" exit /b 55 +if "%FCC_NAME%"=="fcc-server" if "%1"=="--version" echo free-claude-code 3.5.18 +exit /b 0 +""", + encoding="utf-8", + ) + (fixtures / "claude-installer.ps1").write_text( + r"""if ($env:FAIL_STEP -eq "claude-install") { exit 61 } +$bin = Join-Path $env:USERPROFILE ".local\bin" +New-Item -ItemType Directory -Force -Path $bin | Out-Null +Copy-Item (Join-Path $env:FAKE_FIXTURES "claude-command.cmd") (Join-Path $bin "claude.cmd") -Force +Add-Content -LiteralPath $env:CALL_LOG -Value "claude-install" +""", + encoding="utf-8", + ) + (fixtures / "codex-installer.ps1").write_text( + r"""if ($env:FAIL_STEP -eq "codex-install") { exit 62 } +$bin = Join-Path $env:LOCALAPPDATA "Programs\OpenAI\Codex\bin" +New-Item -ItemType Directory -Force -Path $bin | Out-Null +Copy-Item (Join-Path $env:FAKE_FIXTURES "codex-command.cmd") (Join-Path $bin "codex.cmd") -Force +Add-Content -LiteralPath $env:CALL_LOG -Value "codex-install:$env:CODEX_NON_INTERACTIVE" +""", + encoding="utf-8", + ) + (fixtures / "pi-installer.ps1").write_text( + r"""if ($env:FAIL_STEP -eq "pi-install") { exit 64 } +$bin = if ($env:FAKE_NPM_PREFIX) { $env:FAKE_NPM_PREFIX } else { Join-Path $env:APPDATA "npm" } +New-Item -ItemType Directory -Force -Path $bin | Out-Null +Copy-Item (Join-Path $env:FAKE_FIXTURES "pi-command.cmd") (Join-Path $bin "pi.cmd") -Force +Add-Content -LiteralPath $env:CALL_LOG -Value "pi-install" +""", + encoding="utf-8", + ) + (fixtures / "uv-installer.ps1").write_text( + r"""if ($env:FAIL_STEP -eq "uv-install") { exit 63 } +$bin = Join-Path $env:USERPROFILE ".local\bin" +New-Item -ItemType Directory -Force -Path $bin | Out-Null +Copy-Item (Join-Path $env:FAKE_FIXTURES "uv-command.cmd") (Join-Path $bin "uv.cmd") -Force +Add-Content -LiteralPath $env:CALL_LOG -Value "uv-install" +""", + encoding="utf-8", + ) + + wrapper = tmp_path / "run-installer.ps1" + wrapper.write_text( + """Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +function Invoke-RestMethod { + [CmdletBinding()] + param([string] $Uri, [string] $OutFile) + + Add-Content -LiteralPath $env:CALL_LOG -Value "download:$Uri" + if ( + ($env:FAIL_STEP -eq "claude-download" -and $Uri.Contains("claude.ai")) -or + ($env:FAIL_STEP -eq "codex-download" -and $Uri.Contains("chatgpt.com")) -or + ($env:FAIL_STEP -eq "pi-download" -and $Uri.Contains("pi.dev")) -or + ($env:FAIL_STEP -eq "uv-download" -and $Uri.Contains("astral.sh")) + ) { + throw "simulated download failure" + } + if ($Uri.Contains("claude.ai")) { + $source = Join-Path $env:FAKE_FIXTURES "claude-installer.ps1" + } + elseif ($Uri.Contains("chatgpt.com")) { + $source = Join-Path $env:FAKE_FIXTURES "codex-installer.ps1" + } + elseif ($Uri.Contains("pi.dev")) { + $source = Join-Path $env:FAKE_FIXTURES "pi-installer.ps1" + } + elseif ($Uri.Contains("astral.sh")) { + $source = Join-Path $env:FAKE_FIXTURES "uv-installer.ps1" + } + else { + throw "unexpected installer URL: $Uri" + } + Copy-Item -LiteralPath $source -Destination $OutFile -Force +} +$installer = [scriptblock]::Create([IO.File]::ReadAllText($env:FCC_INSTALLER)) +& $installer @args +""", + encoding="utf-8", + ) + + system_root = os.environ["SYSTEMROOT"] + env = os.environ.copy() + env.update( + { + "PATH": os.pathsep.join( + [str(bin_dir), str(Path(system_root) / "System32"), system_root] + ), + "PATHEXT": ".COM;.EXE;.BAT;.CMD", + "USERPROFILE": str(home), + "LOCALAPPDATA": str(local_app_data), + "APPDATA": str(app_data), + "CALL_LOG": str(log), + "FAKE_FIXTURES": str(fixtures), + "FAKE_TOOL_BIN": str(tool_bin), + "FCC_INSTALLER": str(_repo_root() / "scripts" / "install.ps1"), + "FAIL_STEP": "", + } + ) + return PowerShellHarness( + tmp_path, bin_dir, fixtures, tool_bin, log, env, powershell, wrapper + ) + + +def test_install_ps1_fresh_install_is_verified( + powershell_harness: PowerShellHarness, +) -> None: + result = powershell_harness.run() + + assert result.returncode == 0, result.stderr + assert "Free Claude Code is installed and verified." in result.stdout + calls = powershell_harness.calls() + assert calls.index("claude-install") < calls.index("claude:--version") + assert calls.index("codex-install:1") < calls.index("codex:--version") + assert calls.index("pi-install") < calls.index("pi:--version") + assert calls.index("uv-install") < calls.index("uv:--version") + assert any( + call.startswith( + "uv:tool install --force --refresh-package free-claude-code " + '--python 3.14.0 "free-claude-code @ ' + 'https://github.com/Alishahryar1/free-claude-code/archive/refs/heads/main.zip"' + ) + for call in calls + ) + assert not any(call.startswith("git:") for call in calls) + assert calls[-3:] == [ + "uv:tool update-shell", + "uv:tool dir --bin", + "fcc-server:--version", + ] + + +def test_install_ps1_preserves_valid_existing_tools( + powershell_harness: PowerShellHarness, +) -> None: + powershell_harness.add_client("claude") + powershell_harness.add_client("codex") + powershell_harness.add_client("pi") + powershell_harness.add_uv("0.11.7") + + result = powershell_harness.run() + + assert result.returncode == 0, result.stderr + assert not any(call.startswith("download:") for call in powershell_harness.calls()) + assert "leaving it unchanged" in result.stdout + + +def test_install_ps1_replaces_unrelated_pi_command( + powershell_harness: PowerShellHarness, +) -> None: + powershell_harness.add_client("claude") + powershell_harness.add_client("codex") + powershell_harness.add_unrelated_pi() + powershell_harness.add_uv("0.11.7") + + result = powershell_harness.run() + + assert result.returncode == 0, result.stderr + assert "is not Pi Coding Agent; installing Pi" in result.stdout + assert "pi-install" in powershell_harness.calls() + + +def test_install_ps1_discovers_custom_pi_npm_prefix( + powershell_harness: PowerShellHarness, +) -> None: + powershell_harness.add_client("claude") + powershell_harness.add_client("codex") + powershell_harness.add_npm_prefix(powershell_harness.root / "custom-npm") + powershell_harness.add_uv("0.11.7") + + result = powershell_harness.run() + + assert result.returncode == 0, result.stderr + calls = powershell_harness.calls() + assert "npm:prefix -g" in calls + assert "pi:--help" in calls + assert "pi:--version" in calls + + +def test_install_ps1_replaces_obsolete_uv( + powershell_harness: PowerShellHarness, +) -> None: + powershell_harness.add_client("claude") + powershell_harness.add_client("codex") + powershell_harness.add_client("pi") + powershell_harness.add_uv("0.5.9") + + result = powershell_harness.run() + + assert result.returncode == 0, result.stderr + assert "uv 0.5.9 is below 0.11.0" in result.stdout + assert "uv-install" in powershell_harness.calls() + + +@pytest.mark.parametrize( + "failure", + [ + "claude-download", + "claude-install", + "claude-verify", + "codex-download", + "codex-install", + "codex-verify", + "pi-download", + "pi-install", + "pi-verify", + "uv-download", + "uv-install", + "uv-verify", + "fcc-install", + "path-update", + "fcc-missing", + "fcc-verify", + ], +) +def test_install_ps1_stops_without_success_on_each_failure( + powershell_harness: PowerShellHarness, + failure: str, +) -> None: + result = powershell_harness.run(fail_step=failure) + + assert result.returncode != 0 + assert "Free Claude Code is installed and verified." not in result.stdout + forbidden = { + "claude-download": "claude-install", + "claude-install": "claude:--version", + "claude-verify": "chatgpt.com", + "codex-download": "codex-install", + "codex-install": "codex:--version", + "codex-verify": "pi.dev", + "pi-download": "pi-install", + "pi-install": "pi:--version", + "pi-verify": "astral.sh", + "uv-download": "uv-install", + "uv-install": "uv:--version", + "uv-verify": "uv:tool install", + "fcc-install": "uv:tool update-shell", + "path-update": "uv:tool dir --bin", + "fcc-missing": "fcc-server:--version", + }.get(failure) + if forbidden is not None: + assert not any(forbidden in call for call in powershell_harness.calls()) + + +def test_install_ps1_dry_run_never_executes_commands( + powershell_harness: PowerShellHarness, +) -> None: + result = subprocess.run( + [ + powershell_harness.powershell, + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(_repo_root() / "scripts" / "install.ps1"), + "-DryRun", + ], + check=False, + capture_output=True, + text=True, + env=powershell_harness.env, + ) + + assert result.returncode == 0, result.stderr + assert powershell_harness.calls() == [] + assert "Dry run complete. No changes were made." in result.stdout + assert "Free Claude Code is installed and verified." not in result.stdout + + +def test_install_ps1_rejects_broken_existing_client_without_replacing_it( + powershell_harness: PowerShellHarness, +) -> None: + powershell_harness.add_client("claude") + + result = powershell_harness.run(fail_step="claude-verify") + + assert result.returncode != 0 + assert not any(call.startswith("download:") for call in powershell_harness.calls()) + + +def test_install_ps1_rejects_unparseable_existing_uv( + powershell_harness: PowerShellHarness, +) -> None: + powershell_harness.add_client("claude") + powershell_harness.add_client("codex") + powershell_harness.add_client("pi") + powershell_harness.add_uv("not-a-version") + + result = powershell_harness.run() + + assert result.returncode != 0 + assert not any("astral.sh" in call for call in powershell_harness.calls()) + + +def test_install_ps1_voice_flags_only_change_fcc_spec( + powershell_harness: PowerShellHarness, +) -> None: + result = powershell_harness.run("-VoiceAll", "-TorchBackend", "cu130") + + assert result.returncode == 0, result.stderr + assert any( + '--torch-backend cu130 "free-claude-code[voice,voice_local] @ ' + 'https://github.com/Alishahryar1/free-claude-code/archive/refs/heads/main.zip"' + in call + for call in powershell_harness.calls() + ) + + +def test_installers_use_native_clients_and_single_python_selection() -> None: + shell = (_repo_root() / "scripts" / "install.sh").read_text(encoding="utf-8") + powershell = (_repo_root() / "scripts" / "install.ps1").read_text(encoding="utf-8") + + for text in (shell, powershell): + assert "@anthropic-ai/claude-code" not in text + assert "@openai/codex" not in text + assert "@earendil-works/pi-coding-agent" not in text + assert "git+" not in text + assert "git --version" not in text + assert ( + "https://github.com/Alishahryar1/free-claude-code/archive/refs/heads/main.zip" + in text + ) + assert "python install" not in text + assert "--refresh-package" in text + assert "tool update-shell" in text + assert "--python" in text + + assert "https://pi.dev/install.sh" in shell + assert "https://pi.dev/install.ps1" in powershell + + +def test_readme_install_section_has_no_manual_git_prerequisite() -> None: + readme = (_repo_root() / "README.md").read_text(encoding="utf-8") + install_section = readme.split("### 1. Install Or Update", 1)[1].split( + "### 2. Start The Server", 1 + )[0] + + assert "Install Git" not in install_section + assert "official native installers" not in install_section + + +@pytest.mark.parametrize("powershell", _powershells()) +def test_install_ps1_falls_back_when_pshome_executable_is_unavailable( + tmp_path: Path, + powershell: str, +) -> None: + text = (_repo_root() / "scripts" / "install.ps1").read_text(encoding="utf-8") + body = _braced_body(text, "function Get-PowerShellExecutable") + fallback = tmp_path / "fallback" / "powershell.exe" + script = tmp_path / "test-powershell-resolution.ps1" + script.write_text( + f"""Set-StrictMode -Version Latest +function Get-ApplicationCommand {{ + param([string] $Name) + return [pscustomobject] @{{ Source = {str(fallback)!r} }} +}} +function Get-PowerShellExecutable {{ +{body} +}} +$resolved = Get-PowerShellExecutable -PowerShellHome {str(tmp_path / "missing")!r} +if ($resolved -ne {str(fallback)!r}) {{ + throw "Unexpected fallback: $resolved" +}} +""", + encoding="utf-8", + ) + + result = subprocess.run( + [powershell, "-NoProfile", "-File", str(script)], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr diff --git a/tests/scripts/test_uninstallers.py b/tests/scripts/test_uninstallers.py new file mode 100644 index 0000000..ebe2287 --- /dev/null +++ b/tests/scripts/test_uninstallers.py @@ -0,0 +1,509 @@ +import os +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path + +import pytest + +FCC_COMMANDS = ( + "fcc-server", + "fcc-claude", + "fcc-codex", + "fcc-pi", + "fcc-init", + "free-claude-code", +) + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def _write_executable(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + path.chmod(0o755) + + +def _powershells() -> tuple[str, ...]: + candidates = (shutil.which("pwsh"), shutil.which("powershell")) + return tuple(dict.fromkeys(path for path in candidates if path is not None)) + + +@dataclass +class PosixUninstallHarness: + home: Path + bin_dir: Path + tool_bin: Path + fcc_home: Path + log: Path + env: dict[str, str] + + def run( + self, + *args: str, + fail_step: str = "", + include_uv: bool = True, + ) -> subprocess.CompletedProcess[str]: + uv = self.bin_dir / "uv" + if not include_uv and uv.exists(): + uv.unlink() + return subprocess.run( + ["/bin/sh", str(_repo_root() / "scripts" / "uninstall.sh"), *args], + check=False, + capture_output=True, + text=True, + env=self.env | {"FAIL_STEP": fail_step}, + ) + + def calls(self) -> list[str]: + if not self.log.exists(): + return [] + return self.log.read_text(encoding="utf-8").splitlines() + + def remove_entry_points(self) -> None: + for name in FCC_COMMANDS: + (self.tool_bin / name).unlink(missing_ok=True) + + +@pytest.fixture +def posix_uninstall_harness(tmp_path: Path) -> PosixUninstallHarness: + if os.name == "nt": + pytest.skip("POSIX uninstaller scenarios run on POSIX hosts") + + home = tmp_path / "home" + bin_dir = home / ".local" / "bin" + tool_bin = tmp_path / "tool-bin" + fcc_home = home / ".fcc" + log = tmp_path / "calls.log" + for path in (bin_dir, tool_bin, fcc_home): + path.mkdir(parents=True) + (fcc_home / "config.json").write_text("{}", encoding="utf-8") + for name in FCC_COMMANDS: + _write_executable(tool_bin / name, "#!/bin/sh\nexit 0\n") + + _write_executable(bin_dir / "claude", "#!/bin/sh\nexit 0\n") + _write_executable(bin_dir / "codex", "#!/bin/sh\nexit 0\n") + _write_executable(bin_dir / "pi", "#!/bin/sh\nexit 0\n") + _write_executable( + bin_dir / "uv", + """#!/bin/sh +echo "uv:$*" >> "$CALL_LOG" +if [ "${1:-}" = "tool" ] && [ "${2:-}" = "dir" ] && [ "${3:-}" = "--bin" ]; then + if [ "$FAIL_STEP" = "tool-dir" ]; then + echo "tool directory unavailable" >&2 + exit 41 + fi + printf '%s\n' "$FAKE_TOOL_BIN" + exit 0 +fi +if [ "${1:-}" = "tool" ] && [ "${2:-}" = "uninstall" ]; then + if [ "$FAIL_STEP" = "uninstall" ]; then + echo "permission denied while removing tool" >&2 + exit 42 + fi + if [ "$FAIL_STEP" = "missing" ] || [ "$FAIL_STEP" = "stale-entrypoint" ]; then + echo 'Tool `free-claude-code` is not installed' >&2 + exit 2 + fi + for name in fcc-server fcc-claude fcc-codex fcc-pi fcc-init free-claude-code; do + /bin/rm -f "$FAKE_TOOL_BIN/$name" + done + echo "Uninstalled free-claude-code" + exit 0 +fi +exit 43 +""", + ) + _write_executable( + bin_dir / "rm", + """#!/bin/sh +echo "rm:$*" >> "$CALL_LOG" +if [ "$FAIL_STEP" = "purge" ]; then + echo "simulated purge failure" >&2 + exit 44 +fi +exec /bin/rm "$@" +""", + ) + + env = os.environ.copy() + env.update( + { + "HOME": str(home), + "PATH": f"{bin_dir}:/usr/bin:/bin", + "CALL_LOG": str(log), + "FAKE_TOOL_BIN": str(tool_bin), + "FAIL_STEP": "", + } + ) + env.pop("XDG_BIN_HOME", None) + return PosixUninstallHarness(home, bin_dir, tool_bin, fcc_home, log, env) + + +def test_uninstall_sh_removes_and_verifies_only_fcc( + posix_uninstall_harness: PosixUninstallHarness, +) -> None: + result = posix_uninstall_harness.run() + + assert result.returncode == 0, result.stderr + assert "Free Claude Code has been removed and verified." in result.stdout + assert not posix_uninstall_harness.fcc_home.exists() + assert all( + not (posix_uninstall_harness.tool_bin / name).exists() for name in FCC_COMMANDS + ) + assert (posix_uninstall_harness.bin_dir / "uv").exists() + assert (posix_uninstall_harness.bin_dir / "claude").exists() + assert (posix_uninstall_harness.bin_dir / "codex").exists() + assert (posix_uninstall_harness.bin_dir / "pi").exists() + assert posix_uninstall_harness.calls() == [ + "uv:tool dir --bin", + "uv:tool uninstall free-claude-code", + f"rm:-rf {posix_uninstall_harness.fcc_home}", + ] + + +def test_uninstall_sh_is_idempotent_when_tool_is_already_absent( + posix_uninstall_harness: PosixUninstallHarness, +) -> None: + posix_uninstall_harness.remove_entry_points() + + result = posix_uninstall_harness.run(fail_step="missing") + + assert result.returncode == 0, result.stderr + assert not posix_uninstall_harness.fcc_home.exists() + assert "already absent" in result.stdout + + +@pytest.mark.parametrize("failure", ["tool-dir", "uninstall", "stale-entrypoint"]) +def test_uninstall_sh_preserves_config_when_tool_removal_is_unconfirmed( + posix_uninstall_harness: PosixUninstallHarness, + failure: str, +) -> None: + result = posix_uninstall_harness.run(fail_step=failure) + + assert result.returncode != 0 + assert posix_uninstall_harness.fcc_home.exists() + assert "Free Claude Code has been removed and verified." not in result.stdout + assert not any(call.startswith("rm:") for call in posix_uninstall_harness.calls()) + + +def test_uninstall_sh_requires_uv_before_deleting_config( + posix_uninstall_harness: PosixUninstallHarness, +) -> None: + result = posix_uninstall_harness.run(include_uv=False) + + assert result.returncode != 0 + assert posix_uninstall_harness.fcc_home.exists() + assert "uv is required" in result.stderr + assert posix_uninstall_harness.calls() == [] + + +def test_uninstall_sh_reports_purge_failure_after_verified_tool_removal( + posix_uninstall_harness: PosixUninstallHarness, +) -> None: + result = posix_uninstall_harness.run(fail_step="purge") + + assert result.returncode != 0 + assert posix_uninstall_harness.fcc_home.exists() + assert all( + not (posix_uninstall_harness.tool_bin / name).exists() for name in FCC_COMMANDS + ) + assert "Free Claude Code has been removed and verified." not in result.stdout + + +def test_uninstall_sh_dry_run_is_non_mutating( + posix_uninstall_harness: PosixUninstallHarness, +) -> None: + result = posix_uninstall_harness.run("--dry-run") + + assert result.returncode == 0, result.stderr + assert posix_uninstall_harness.fcc_home.exists() + assert all( + (posix_uninstall_harness.tool_bin / name).exists() for name in FCC_COMMANDS + ) + assert posix_uninstall_harness.calls() == [] + assert "Dry run complete. No changes were made." in result.stdout + + +def test_uninstall_sh_rejects_invalid_options_before_mutation( + posix_uninstall_harness: PosixUninstallHarness, +) -> None: + result = posix_uninstall_harness.run("--unknown") + + assert result.returncode != 0 + assert posix_uninstall_harness.fcc_home.exists() + assert posix_uninstall_harness.calls() == [] + + +@dataclass +class PowerShellUninstallHarness: + home: Path + bin_dir: Path + tool_bin: Path + fcc_home: Path + log: Path + env: dict[str, str] + powershell: str + wrapper: Path + + def run( + self, + *, + fail_step: str = "", + include_uv: bool = True, + dry_run: bool = False, + ) -> subprocess.CompletedProcess[str]: + uv = self.bin_dir / "uv.cmd" + if not include_uv and uv.exists(): + uv.unlink() + return subprocess.run( + [ + self.powershell, + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(self.wrapper), + ], + check=False, + capture_output=True, + text=True, + env=self.env + | { + "FAIL_STEP": fail_step, + "UNINSTALL_DRY_RUN": "1" if dry_run else "0", + }, + ) + + def calls(self) -> list[str]: + if not self.log.exists(): + return [] + return self.log.read_text(encoding="utf-8").splitlines() + + def remove_entry_points(self) -> None: + for name in FCC_COMMANDS: + (self.tool_bin / f"{name}.cmd").unlink(missing_ok=True) + + +@pytest.fixture( + params=_powershells() or (None,), + ids=lambda path: Path(path).name if path is not None else "unavailable", +) +def powershell_uninstall_harness( + tmp_path: Path, + request: pytest.FixtureRequest, +) -> PowerShellUninstallHarness: + powershell = request.param + if powershell is None or os.name != "nt": + pytest.skip("PowerShell uninstaller scenarios run on Windows hosts") + + home = tmp_path / "home" + bin_dir = home / ".local" / "bin" + tool_bin = tmp_path / "tool-bin" + fcc_home = home / ".fcc" + log = tmp_path / "calls.log" + for path in (bin_dir, tool_bin, fcc_home): + path.mkdir(parents=True) + (fcc_home / "config.json").write_text("{}", encoding="utf-8") + for name in FCC_COMMANDS: + (tool_bin / f"{name}.cmd").write_text( + "@echo off\nexit /b 0\n", encoding="utf-8" + ) + for name in ("claude", "codex", "pi"): + (bin_dir / f"{name}.cmd").write_text("@echo off\nexit /b 0\n", encoding="utf-8") + + uv_commands = " ".join(FCC_COMMANDS) + (bin_dir / "uv.cmd").write_text( + rf"""@echo off +echo uv:%*>>"%CALL_LOG%" +if "%1"=="tool" if "%2"=="dir" if "%3"=="--bin" goto tool_bin +if "%1"=="tool" if "%2"=="uninstall" goto uninstall +exit /b 53 +:tool_bin +if "%FAIL_STEP%"=="tool-dir" echo tool directory unavailable 1>&2 & exit /b 51 +echo %FAKE_TOOL_BIN% +exit /b 0 +:uninstall +if "%FAIL_STEP%"=="uninstall" echo permission denied while removing tool 1>&2 & exit /b 52 +if "%FAIL_STEP%"=="missing" echo Tool `free-claude-code` is not installed 1>&2 & exit /b 2 +if "%FAIL_STEP%"=="stale-entrypoint" echo Tool `free-claude-code` is not installed 1>&2 & exit /b 2 +for %%C in ({uv_commands}) do del /q "%FAKE_TOOL_BIN%\%%C.cmd" 2>nul +echo Uninstalled free-claude-code +exit /b 0 +""", + encoding="utf-8", + ) + + wrapper = tmp_path / "run-uninstaller.ps1" + wrapper.write_text( + r"""Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +function Remove-Item { + [CmdletBinding()] + param( + [string] $LiteralPath, + [switch] $Recurse, + [switch] $Force + ) + Add-Content -LiteralPath $env:CALL_LOG -Value "remove:$LiteralPath" + if ($env:FAIL_STEP -eq "purge") { + throw "simulated purge failure" + } + Microsoft.PowerShell.Management\Remove-Item @PSBoundParameters +} +$installer = [scriptblock]::Create([IO.File]::ReadAllText($env:FCC_UNINSTALLER)) +if ($env:UNINSTALL_DRY_RUN -eq "1") { + & $installer -DryRun +} +else { + & $installer +} +""", + encoding="utf-8", + ) + + system_root = os.environ["SYSTEMROOT"] + env = os.environ.copy() + env.update( + { + "PATH": os.pathsep.join( + [str(bin_dir), str(Path(system_root) / "System32"), system_root] + ), + "PATHEXT": ".COM;.EXE;.BAT;.CMD", + "HOME": str(home), + "USERPROFILE": str(home), + "CALL_LOG": str(log), + "FAKE_TOOL_BIN": str(tool_bin), + "FCC_UNINSTALLER": str(_repo_root() / "scripts" / "uninstall.ps1"), + "FAIL_STEP": "", + "UNINSTALL_DRY_RUN": "0", + } + ) + return PowerShellUninstallHarness( + home, bin_dir, tool_bin, fcc_home, log, env, powershell, wrapper + ) + + +def test_uninstall_ps1_removes_and_verifies_only_fcc( + powershell_uninstall_harness: PowerShellUninstallHarness, +) -> None: + result = powershell_uninstall_harness.run() + + assert result.returncode == 0, result.stderr + assert "Free Claude Code has been removed and verified." in result.stdout + assert not powershell_uninstall_harness.fcc_home.exists() + assert all( + not (powershell_uninstall_harness.tool_bin / f"{name}.cmd").exists() + for name in FCC_COMMANDS + ) + assert (powershell_uninstall_harness.bin_dir / "uv.cmd").exists() + assert (powershell_uninstall_harness.bin_dir / "claude.cmd").exists() + assert (powershell_uninstall_harness.bin_dir / "codex.cmd").exists() + assert (powershell_uninstall_harness.bin_dir / "pi.cmd").exists() + assert powershell_uninstall_harness.calls() == [ + "uv:tool dir --bin", + "uv:tool uninstall free-claude-code", + f"remove:{powershell_uninstall_harness.fcc_home}", + ] + + +def test_uninstall_ps1_is_idempotent_when_tool_is_already_absent( + powershell_uninstall_harness: PowerShellUninstallHarness, +) -> None: + powershell_uninstall_harness.remove_entry_points() + + result = powershell_uninstall_harness.run(fail_step="missing") + + assert result.returncode == 0, result.stderr + assert not powershell_uninstall_harness.fcc_home.exists() + assert "already absent" in result.stdout + + +@pytest.mark.parametrize("failure", ["tool-dir", "uninstall", "stale-entrypoint"]) +def test_uninstall_ps1_preserves_config_when_tool_removal_is_unconfirmed( + powershell_uninstall_harness: PowerShellUninstallHarness, + failure: str, +) -> None: + result = powershell_uninstall_harness.run(fail_step=failure) + + assert result.returncode != 0 + assert powershell_uninstall_harness.fcc_home.exists() + assert "Free Claude Code has been removed and verified." not in result.stdout + assert not any( + call.startswith("remove:") for call in powershell_uninstall_harness.calls() + ) + + +def test_uninstall_ps1_requires_uv_before_deleting_config( + powershell_uninstall_harness: PowerShellUninstallHarness, +) -> None: + result = powershell_uninstall_harness.run(include_uv=False) + + assert result.returncode != 0 + assert powershell_uninstall_harness.fcc_home.exists() + assert "uv is required" in result.stderr + assert powershell_uninstall_harness.calls() == [] + + +def test_uninstall_ps1_reports_purge_failure_after_verified_tool_removal( + powershell_uninstall_harness: PowerShellUninstallHarness, +) -> None: + result = powershell_uninstall_harness.run(fail_step="purge") + + assert result.returncode != 0 + assert powershell_uninstall_harness.fcc_home.exists() + assert all( + not (powershell_uninstall_harness.tool_bin / f"{name}.cmd").exists() + for name in FCC_COMMANDS + ) + assert "Free Claude Code has been removed and verified." not in result.stdout + + +def test_uninstall_ps1_dry_run_is_non_mutating( + powershell_uninstall_harness: PowerShellUninstallHarness, +) -> None: + result = powershell_uninstall_harness.run(dry_run=True) + + assert result.returncode == 0, result.stderr + assert powershell_uninstall_harness.fcc_home.exists() + assert all( + (powershell_uninstall_harness.tool_bin / f"{name}.cmd").exists() + for name in FCC_COMMANDS + ) + assert powershell_uninstall_harness.calls() == [] + assert "Dry run complete. No changes were made." in result.stdout + + +def test_uninstallers_guard_running_commands_and_preserve_shared_owners() -> None: + shell = (_repo_root() / "scripts" / "uninstall.sh").read_text(encoding="utf-8") + powershell = (_repo_root() / "scripts" / "uninstall.ps1").read_text( + encoding="utf-8" + ) + + assert "pgrep" in shell + assert "Get-Process" in powershell + for text in (shell, powershell): + for command in FCC_COMMANDS: + assert command in text + assert "npm uninstall" not in text + assert "uv self uninstall" not in text + assert "uv python uninstall" not in text + assert "is not installed" in text + assert "no tool" not in text + assert "nothing to uninstall" not in text + + +def test_readme_uninstall_uses_raw_urls_and_verification_contract() -> None: + text = (_repo_root() / "README.md").read_text(encoding="utf-8") + + assert ( + 'curl -fsSL "https://raw.githubusercontent.com/' + 'Alishahryar1/free-claude-code/main/scripts/uninstall.sh" | sh' + ) in text + assert ( + '& ([scriptblock]::Create((irm "https://raw.githubusercontent.com/' + 'Alishahryar1/free-claude-code/main/scripts/uninstall.ps1")))' + ) in text + assert "verifies every FCC command is gone" in text diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..9586f2c --- /dev/null +++ b/uv.lock @@ -0,0 +1,2505 @@ +version = 1 +revision = 3 +requires-python = ">=3.14.0" + +[[package]] +name = "accelerate" +version = "1.14.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/8d/75/94cd5d389649578aca399e5aa822637eec18319a1dadc400ffe2f9a7493f/accelerate-1.14.0.tar.gz", hash = "sha256:41b9c4377a54e0b460a959b0defa1b736e4ca0a2373252d9a539964c2afe3c8d", size = 412167, upload-time = "2026-06-11T13:45:52.326Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/db/253133d7e7cb40d3af384bb2f5c0b4a2b7fdcffbc95c688cc67a20a3c103/accelerate-1.14.0-py3-none-any.whl", hash = "sha256:e94390c2863b873be18f623f9df48a0d8fe5eff13ea7f1a00092b0a7904888c6", size = 389246, upload-time = "2026-06-11T13:45:50.477Z" }, +] + +[[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.14.1" +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/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, + { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, + { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, + { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, + { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, + { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, + { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, + { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +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" }, +] +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/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" }, + { name = "standard-sunau" }, +] +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/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/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/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/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 = "cuda-toolkit" +version = "13.0.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl", hash = "sha256:b198824cf2f54003f50d64ada3a0f184b42ca0846c1c94192fa269ecd97a66eb", size = 2364, upload-time = "2025-12-19T23:24:07.328Z" }, +] + +[package.optional-dependencies] +cudart = [ + { name = "nvidia-cuda-runtime" }, +] +cufft = [ + { name = "nvidia-cufft" }, +] +cufile = [ + { name = "nvidia-cufile" }, +] +cupti = [ + { name = "nvidia-cuda-cupti" }, +] +curand = [ + { name = "nvidia-curand" }, +] +cusolver = [ + { name = "nvidia-cusolver" }, +] +cusparse = [ + { name = "nvidia-cusparse" }, +] +nvjitlink = [ + { name = "nvidia-nvjitlink" }, +] +nvrtc = [ + { name = "nvidia-cuda-nvrtc" }, +] +nvtx = [ + { name = "nvidia-nvtx" }, +] + +[[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.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "audioop-lts" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/57/9a2d9abdabdc9db8ef28ce0cf4129669e1c8717ba28d607b5ba357c4de3b/discord_py-2.7.1.tar.gz", hash = "sha256:24d5e6a45535152e4b98148a9dd6b550d25dc2c9fb41b6d670319411641249da", size = 1106326, upload-time = "2026-03-03T18:40:46.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/a7/17208c3b3f92319e7fad259f1c6d5a5baf8fd0654c54846ced329f83c3eb/discord_py-2.7.1-py3-none-any.whl", hash = "sha256:849dca2c63b171146f3a7f3f8acc04248098e9e6203412ce3cf2745f284f7439", size = 1227550, upload-time = "2026-03-03T18:40:44.492Z" }, +] + +[[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.139.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/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "email-validator" }, + { name = "fastapi-cli", extra = ["standard"] }, + { name = "fastar" }, + { 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.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/0f/0aeb3fc50046617702acc0078b277b58367fd62eb727b9ec733ae0e8bbcc/fastar-0.11.0.tar.gz", hash = "sha256:aa7f100f7313c03fdb20f1385927ba95671071ba308ad0c1763fef295e1895ce", size = 70238, upload-time = "2026-04-13T17:11:17.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/cd/3644c48ecac456f928c12d47ec3bed36c36555b17c3859856f1ff860265d/fastar-0.11.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:71375bd6f03c2a43eb47bd949ea38ff45434917f9cdac79675c5b9f60de4fa73", size = 707860, upload-time = "2026-04-13T17:10:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/dee04476ae3626b2b040a60ad84628f77e1ffd8444232f2426b0ca1e0d7e/fastar-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:eddfd9cab16e19ae247fe44bf992cb403ccfe27d3931d6de29a4695d95ad386c", size = 628216, upload-time = "2026-04-13T17:09:45.355Z" }, + { url = "https://files.pythonhosted.org/packages/dc/5e/9395c7353d079cb4f5be0f7982ce0dc9f2e7dec5fd175eef466729d6023a/fastar-0.11.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7c371f1d4386c699018bb64eb2fa785feacf32785559049d2bb72fe4af023f53", size = 864378, upload-time = "2026-04-13T17:09:14.611Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/1e4f67148223ff219612b6281a6000357abbcc2417964fa5c83f11d68fce/fastar-0.11.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cad7fa41e3e66554387481c1a09365e4638becd322904932674159d5f4046728", size = 760921, upload-time = "2026-04-13T17:07:59.138Z" }, + { url = "https://files.pythonhosted.org/packages/0f/82/09d11fb6d12f17993ffaf32ffd30c3c121a11e2966e84f19fb6f66430118/fastar-0.11.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf36652fa71b83761717c9899b98732498f8a2cb6327ff16bbf07f6be85c3437", size = 757012, upload-time = "2026-04-13T17:08:14.186Z" }, + { url = "https://files.pythonhosted.org/packages/52/1f/5aeeacc4cb65615e2c9292cd9c5b0cd6fb6d2e6ee472ca6adc6c1b1b22ef/fastar-0.11.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f68ff8c17833053da4841720e95edde80ce45bb994b6b7d51418dddaac70ee47", size = 924510, upload-time = "2026-04-13T17:08:28.741Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1a/1e5bdabbeaf2e856928956292609f2ff6a650f94480fb8afaca30229e483/fastar-0.11.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4563ed37a12ea1cdc398af8571258d24b988bf342b7b3bf5451bd5891243280c", size = 816602, upload-time = "2026-04-13T17:08:59.461Z" }, + { url = "https://files.pythonhosted.org/packages/87/24/f960147910da3bed41a3adfcb026e17d5f50f4cf467a3324237a7088f61a/fastar-0.11.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cee63c9875cba3b70dc44338c560facc5d6e763047dcc4a30501f9a68cf5f890", size = 819452, upload-time = "2026-04-13T17:09:29.926Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f4/3e77d7901d5707fd7f8a352e153c8ae09ea974e6fabad0b7c4eb9944b8d4/fastar-0.11.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:bd76bfffae6d0a91f4ac4a612f721e7aec108db97dccdd120ae063cd66959f27", size = 885254, upload-time = "2026-04-13T17:08:44.285Z" }, + { url = "https://files.pythonhosted.org/packages/47/01/1585edd5ec47782ae93cd94edf05828e0ab02ef00aec00aea4194a600464/fastar-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f5b707501ec01c1bc0518f741f01d322e50c9adc19a451aa24f67a2316e9397", size = 971496, upload-time = "2026-04-13T17:10:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e9/6874c9d1236ded565a0bed54b320ac9f165f287b1d89490fb70f9f323c81/fastar-0.11.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:37c0b5a88a657839aad98b0a6c9e4ac4c2c15d6b49c44ee3935c6b08e9d3e479", size = 1034685, upload-time = "2026-04-13T17:10:34.063Z" }, + { url = "https://files.pythonhosted.org/packages/14/d8/4ab20613ce2983427aee958e39be878dba874aa227c530a845e32429c4f6/fastar-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6c55f536c62a6efb180c1af0d5182948bff576bbfe6276e8e1359c9c7d2215d8", size = 1072675, upload-time = "2026-04-13T17:10:50.53Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ae/5ac3b7c20ce4b08f011dd2b979f96caabe64f9b10b157f211ea91bdfadca/fastar-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3082eeca59e189b9039335862f4c2780c0c8871d656bfdf559db4414a105b251", size = 1029330, upload-time = "2026-04-13T17:11:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e7/37cd6a1d4e288292170b64e19d79ecce2a7de8bb76790323399a2abc4619/fastar-0.11.0-cp314-cp314-win32.whl", hash = "sha256:b201a0a4e29f9fec2a177e13154b8725ec65ab9f83bd6415483efaa2aa18344b", size = 453940, upload-time = "2026-04-13T17:11:48.713Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1c/795c878b1ee29d79021cf8ed81f18f2b25ccde58453b0d34b9bdc7e025ea/fastar-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:868fddb26072a43e870a8819134b9f80ee602931be5a76e6fb873e04da343637", size = 486334, upload-time = "2026-04-13T17:11:34.882Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a4/113f104301df8bddcc0b3775b611a30cb7610baa3add933c7ccac9386467/fastar-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:3db39c9cc42abb0c780a26b299f24dfbc8be455985e969e15336d70d7b2f833b", size = 461534, upload-time = "2026-04-13T17:11:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a6/5c5f2c2c8e0c63e56a5636ebc7721589c889e94c0092cec7eb28ae7207e6/fastar-0.11.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:49c3299dec5e125e7ebaa27545714da9c7391777366015427e0ae62d548b442b", size = 707156, upload-time = "2026-04-13T17:10:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/df/f7/982c01b61f0fc135ad2b16d01e6d0ee53cf8791e68827f5f7c5a65b2e5b1/fastar-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3328ed1ed56d31f5198350b17dd60449b8d6b9d47abb4688bab6aef4450a165b", size = 627032, upload-time = "2026-04-13T17:09:46.978Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c3/38f1dac77ae0c71c37b176277c96d830796b8ce2fe69705f917829b53829/fastar-0.11.0-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bd3eca3bbfec84a614bcb4143b4ad4f784d0895babc26cfc88436af88ca23c7a", size = 864403, upload-time = "2026-04-13T17:09:16.58Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/e69c363bdb3e5a5848e937b662b5469581ee6682c51bc1c0556494773929/fastar-0.11.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff86a967acb0d621dd24063dda090daa67bf4993b9570e97fe156de88a9006ca", size = 759480, upload-time = "2026-04-13T17:08:00.599Z" }, + { url = "https://files.pythonhosted.org/packages/3b/29/4d8737590c2a6357d614d7cc7288e8f68e7e449680b8922997cc4349e65e/fastar-0.11.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:86eaf7c0e985d93a7734168be2fb232b2a8cca53e41431c2782d7c12b12c03b1", size = 756219, upload-time = "2026-04-13T17:08:15.699Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ec/400de7b3b7d48801908f19cf5462177104395799472671b3e8152b2b04ca/fastar-0.11.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91f07b0b8eb67e2f177733a1f884edad7dfb9f8977ffef15927b20cb9604027d", size = 923669, upload-time = "2026-04-13T17:08:30.574Z" }, + { url = "https://files.pythonhosted.org/packages/5d/01/8926c53da923fed7ab4b96e7fbf7f73b663beb4f02095b654d6fab46f9ad/fastar-0.11.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f85c896885eb4abf1a635d54dea22cac6ae48d04fc2ea26ae652fcf1febe1220", size = 815729, upload-time = "2026-04-13T17:09:01.204Z" }, + { url = "https://files.pythonhosted.org/packages/89/f0/5fef4c7946e352651b504b1a4235dac3505e7cfd24020788ab50552e84bf/fastar-0.11.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:075c07095c8de4b774ba8f28b9c0a02b1a2cd254da50cbe464dd3bb2432e9158", size = 819812, upload-time = "2026-04-13T17:09:31.907Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c8/0ebc3298b4a45e7bddc50b169ae6a6f5b80c939394d4befe6e60de535ee7/fastar-0.11.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:07f028933820c65750baf3383b807ecce1cd9385cf00ce192b79d263ad6b856c", size = 884074, upload-time = "2026-04-13T17:08:45.802Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9f/7baa4cdff8d6fbca41fa5c764b48a941fed8a9ec6c4cc92de65895a28299/fastar-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:039f875efa0f01fa43c20bf4e2fc7305489c61d0ac76eda991acfba7820a0e63", size = 969450, upload-time = "2026-04-13T17:10:18.667Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dc/1ebbfb58a47056ba866494f19efbcdd2ba2897096b94f36e796594b4d05b/fastar-0.11.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:fff12452a9a5c6814a012445f26365541cc3d99dcca61f09762e6a389f7a32ea", size = 1033775, upload-time = "2026-04-13T17:10:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/c2/5f/ce4e3914066f08c99eb8c32952cc07c1a013e81b1db1b0f598130bf6b974/fastar-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2bf733e09f942b6fa876efe30a90508d1f4caef5630c00fb2a84fba355873712", size = 1072158, upload-time = "2026-04-13T17:10:52.497Z" }, + { url = "https://files.pythonhosted.org/packages/03/2a/6bca72992c84151c387cc6558f3867f5ebe5fb3684ee6fa9b76280ba4b8e/fastar-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d1531fa848fdd3677d2dce0a4b436ea64d9ae38fb8babe2ddbc180dd153cb7a3", size = 1028577, upload-time = "2026-04-13T17:11:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/83/18/7a7c15657a3da5569b26fc51cde6a80f8d84cb54b3b1aea6d74a103db4ad/fastar-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:5744551bc67c6fc6581cbd0e34a0fd6e2cd0bd30b43e94b1c3119cf35064b162", size = 453601, upload-time = "2026-04-13T17:11:53.726Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d8/331b59a6de279f3ad75c10c02c40a12f21d64a437d9c3d6f1af2dcbd7a76/fastar-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f4ce44e3b56c47cf38244b98d29f269b259740a580c47a2552efa5b96a5458fb", size = 486436, upload-time = "2026-04-13T17:11:40.089Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fd/5390ec4f49100f3ecb9968a392f9e6d039f1e3fe0ecd28443716ff01e589/fastar-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:76c1359314355eafbc6989f20fb1ad565a3d10200117923b9da765a17e2f6f11", size = 461049, upload-time = "2026-04-13T17:11:25.918Z" }, +] + +[[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 = "4.0.0" +source = { editable = "." } +dependencies = [ + { name = "aiohttp" }, + { name = "discord-py" }, + { name = "fastapi", extra = ["standard"] }, + { name = "httpx", extra = ["socks"] }, + { name = "jsonschema" }, + { 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 = ">=1.14.0" }, + { name = "aiohttp", specifier = ">=3.14.1" }, + { name = "discord-py", specifier = ">=2.7.1" }, + { name = "fastapi", extras = ["standard"], specifier = ">=0.139.0" }, + { name = "grpcio", marker = "extra == 'voice'", specifier = ">=1.81.1" }, + { name = "grpcio-tools", marker = "extra == 'voice'", specifier = ">=1.81.1" }, + { name = "httpx", extras = ["socks"], specifier = ">=0.28.1" }, + { name = "jsonschema", specifier = ">=4.25.0" }, + { name = "librosa", marker = "extra == 'voice-local'", specifier = ">=0.10.0" }, + { name = "loguru", specifier = ">=0.7.0" }, + { name = "markdown-it-py", specifier = ">=4.2.0" }, + { name = "nvidia-riva-client", marker = "extra == 'voice'", specifier = ">=2.26.0" }, + { name = "openai", specifier = ">=2.44.0" }, + { name = "pydantic", specifier = ">=2.13.4" }, + { name = "pydantic-settings", specifier = ">=2.14.2" }, + { name = "python-dotenv", specifier = ">=1.2.2" }, + { name = "python-telegram-bot", specifier = ">=22.8" }, + { name = "tiktoken", specifier = ">=0.13.0" }, + { name = "torch", marker = "extra == 'voice-local'", specifier = ">=2.12.1", index = "https://download.pytorch.org/whl/cu130" }, + { name = "transformers", marker = "extra == 'voice-local'", specifier = ">=5.13.0" }, + { name = "uvicorn", specifier = ">=0.50.0" }, +] +provides-extras = ["voice", "voice-local"] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.1.1" }, + { name = "pytest-asyncio", specifier = ">=1.4.0" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "pytest-xdist", specifier = ">=3.8.0" }, + { name = "ruff", specifier = ">=0.15.20" }, + { name = "ty", specifier = ">=0.0.56" }, +] + +[[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/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.81.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/b5/1ff353970a87eda4c98251e34d2dfd214abd4982dc89119c9252a2a482d2/grpcio-1.81.1.tar.gz", hash = "sha256:6fa10a767143a5e82e8eaab53918af0cd8909a57a27f8cb2288b80a613ac671b", size = 13026582, upload-time = "2026-06-11T12:46:51.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/58/19414622b1bf6981bc9c05a365bd548e71876c89000083b3af489251e9c0/grpcio-1.81.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:506f48f2f9c29b143fca3dad7b0d518c188b6c9648c75a2ae6e2d9f2c13a060b", size = 6055336, upload-time = "2026-06-11T12:46:20.557Z" }, + { url = "https://files.pythonhosted.org/packages/32/f1/2ec88adb92b0eba970dd0e0e7dd086341daa3c75eba4f735f9e44bf684b0/grpcio-1.81.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d865db4a6318e1c1bea83292e0ed231090538fc4ca45425b0f0480eb338bbc6e", size = 12056279, upload-time = "2026-06-11T12:46:24.255Z" }, + { url = "https://files.pythonhosted.org/packages/41/36/e8c5f8c6ec71de73733695ebc809e98b178b534ec6d8eaa31a7ebab4ad4c/grpcio-1.81.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2aa72e3ce1770317ef534f63d397b55e130725f5149bd36077c3b539019db27", size = 6608225, upload-time = "2026-06-11T12:46:27.601Z" }, + { url = "https://files.pythonhosted.org/packages/30/22/96fc577a845ab093326d9ab1adb874bd4936c8cf98ac8ed2f3db13a0a2fb/grpcio-1.81.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0490c30c261eded63f3f354979f9dc4502a9fb944cccb60cd9dc85f5a7349854", size = 7306576, upload-time = "2026-06-11T12:46:30.514Z" }, + { url = "https://files.pythonhosted.org/packages/76/7b/61dab5d5969f28d97fb1009cead1df0a5cd987d3315e1b37f18a4449f8bc/grpcio-1.81.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:410482da976329fe5f4067270401b12cf2bd552ff8020f054ecfaddb5475f9d6", size = 6812165, upload-time = "2026-06-11T12:46:33.699Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/6e501929d4f5f96462fd82fd9f0f06e5f9612207582b862868d68757b27d/grpcio-1.81.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3657301562ac3cb8018d30d0d3ebfa39932239f7b5703422057ef14b69949f5", size = 7422962, upload-time = "2026-06-11T12:46:36.511Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7e/f2157589e66daa78ebb3165942d05a08bdea93b9d11c2bc1e172aef89685/grpcio-1.81.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:24c8e57504c8f45b237e40b99262d181071e5099a07053695b75d97bb53053a0", size = 8408176, upload-time = "2026-06-11T12:46:39.803Z" }, + { url = "https://files.pythonhosted.org/packages/da/df/c6717fef716e00d235ffb96123baf6dce76d6004f6233fa767c502861460/grpcio-1.81.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b427c19380991a4eaab2f6144b64b99b412043314c6bf4ab544f97bb31ee4190", size = 7846681, upload-time = "2026-06-11T12:46:43.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/84/3502e9f210a6a5c4438c8aca3f88edd2e04f6a27f3d41b26cf0a0024b096/grpcio-1.81.1-cp314-cp314-win32.whl", hash = "sha256:61233fe8951e5c85dff81c2458b6528624760166946b5b47ea150a589168411f", size = 4264615, upload-time = "2026-06-11T12:46:45.741Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/4af731ff7492c68a96e4c71bfd0f4590acde92b31c6fe4894e6465c10ff6/grpcio-1.81.1-cp314-cp314-win_amd64.whl", hash = "sha256:3768a5ff1b2125e6f552e561b6b2dca0e64982d8949689b4df145cf8b98d7821", size = 5070275, upload-time = "2026-06-11T12:46:48.486Z" }, +] + +[[package]] +name = "grpcio-tools" +version = "1.81.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "protobuf" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/b3/1c5951352d6777fd7f99a0ccee04617fdfd8a5dbf2918a1f58c8b2b280b8/grpcio_tools-1.81.1.tar.gz", hash = "sha256:a22a3870180927fdd84e2b27d079ef5b7f5f8c6110181b6736afc17a463481f1", size = 6236155, upload-time = "2026-06-11T12:51:21.235Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/08/e581ad42ae517a61172285047e4d710e2ac75f2f1915f7c91f284254e6d5/grpcio_tools-1.81.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:7d168ea26390717d0462c0d0408331dc98a60fc7f7e6118afac9b73f5a66d87c", size = 2585944, upload-time = "2026-06-11T12:50:54.528Z" }, + { url = "https://files.pythonhosted.org/packages/78/c8/200d90ebad685af7eea5ff7e0360c504dd01ec053fe0f1f9c4abe3ea2d5a/grpcio_tools-1.81.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:43c528655b226375013036692d8db4cd59060c1f41dd62c77f4d17b69f6ce828", size = 5813492, upload-time = "2026-06-11T12:50:57.291Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/60da2a1af37aa8eb47308cec24d9f7709a8976fdec3a53fd35b56b358326/grpcio_tools-1.81.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a9c6fcc68c9d5a208967bfe4fd3224d3c3be9a950c3e827e8f4b17e15c2dc555", size = 2634991, upload-time = "2026-06-11T12:50:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7f/dede28b579ae9bf9079ba1aa913e8088d1dc0cdbe21c85caa22f0790cad2/grpcio_tools-1.81.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:a987c85dcbe1b32066d7acd46266d1a428aecbd629331bf5b853e74c835bf876", size = 2957913, upload-time = "2026-06-11T12:51:02.31Z" }, + { url = "https://files.pythonhosted.org/packages/4c/38/4de2118adb58ec7ffba65ec623b5836db769665c192517cbf187db3f6145/grpcio_tools-1.81.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a882382507bb5ec6d7edc9648053dfd3bc8f9285cde56a6fa9b9a83b4bd07f1c", size = 2697709, upload-time = "2026-06-11T12:51:05.016Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e1/762ced51059e4f694fd337ecae491581d42a4e61dcb0415d8c5c60e6ddcb/grpcio_tools-1.81.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7746e508d4239a02f7e93638be5bc0ebb0120ddb796f7506aaae9d47a4599d97", size = 3151884, upload-time = "2026-06-11T12:51:07.593Z" }, + { url = "https://files.pythonhosted.org/packages/19/d8/9823090dc801e7229944874e7429c3b98e741ac778d8dc373f60240e1c43/grpcio_tools-1.81.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fc3d2a41a7a4467fa03b391394fffada9291fe8feebc8679b526f6bc36942b25", size = 3710404, upload-time = "2026-06-11T12:51:10.172Z" }, + { url = "https://files.pythonhosted.org/packages/64/4e/4eae98d02148cb6f9f452f09942afba407afa6851e6c1fddc5ae9ec0b4ed/grpcio_tools-1.81.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:21bb3ba90e6d8df1ff663d4ee39a4e5b25a64e8ed4902476ca9ded0954d3917a", size = 3370525, upload-time = "2026-06-11T12:51:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/e0/3e/2206e597a128da6a03a6106d2eaf2c3e72c7d80843d4be933e3a3d10d02a/grpcio_tools-1.81.1-cp314-cp314-win32.whl", hash = "sha256:3dca56016d90a710c4d9861bae793dc089c1430a90c79ce672e948ddb65fa539", size = 1030582, upload-time = "2026-06-11T12:51:14.906Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f2/bbeef86c687225b7bbc7c0acdfbd25c8bcaa3f5b1c941db053e5c3d9e859/grpcio_tools-1.81.1-cp314-cp314-win_amd64.whl", hash = "sha256:cb08172b7b629e75cb33866928d319a3196540a725eaab628ba721007140f1af", size = 1207490, upload-time = "2026-06-11T12:51:17.598Z" }, +] + +[[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.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/92/ec9ad04d0b5728dca387a45af7bc98fbb0d73b2118759f5f6038b61a57e8/hf_xet-1.4.3.tar.gz", hash = "sha256:8ddedb73c8c08928c793df2f3401ec26f95be7f7e516a7bee2fbb546f6676113", size = 670477, upload-time = "2026-03-31T22:40:07.874Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/36/3e8f85ca9fe09b8de2b2e10c63b3b3353d7dda88a0b3d426dffbe7b8313b/hf_xet-1.4.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5251d5ece3a81815bae9abab41cf7ddb7bcb8f56411bce0827f4a3071c92fdc6", size = 3801019, upload-time = "2026-03-31T22:39:56.651Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9c/defb6cb1de28bccb7bd8d95f6e60f72a3d3fa4cb3d0329c26fb9a488bfe7/hf_xet-1.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1feb0f3abeacee143367c326a128a2e2b60868ec12a36c225afb1d6c5a05e6d2", size = 3558746, upload-time = "2026-03-31T22:39:54.766Z" }, + { url = "https://files.pythonhosted.org/packages/c1/bd/8d001191893178ff8e826e46ad5299446e62b93cd164e17b0ffea08832ec/hf_xet-1.4.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8b301fc150290ca90b4fccd079829b84bb4786747584ae08b94b4577d82fb791", size = 4207692, upload-time = "2026-03-31T22:39:46.246Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/6790b402803250e9936435613d3a78b9aaeee7973439f0918848dde58309/hf_xet-1.4.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d972fbe95ddc0d3c0fc49b31a8a69f47db35c1e3699bf316421705741aab6653", size = 3986281, upload-time = "2026-03-31T22:39:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/51/56/ea62552fe53db652a9099eda600b032d75554d0e86c12a73824bfedef88b/hf_xet-1.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c5b48db1ee344a805a1b9bd2cda9b6b65fe77ed3787bd6e87ad5521141d317cd", size = 4187414, upload-time = "2026-03-31T22:40:04.951Z" }, + { url = "https://files.pythonhosted.org/packages/7d/f5/bc1456d4638061bea997e6d2db60a1a613d7b200e0755965ec312dc1ef79/hf_xet-1.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:22bdc1f5fb8b15bf2831440b91d1c9bbceeb7e10c81a12e8d75889996a5c9da8", size = 4424368, upload-time = "2026-03-31T22:40:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/e4/76/ab597bae87e1f06d18d3ecb8ed7f0d3c9a37037fc32ce76233d369273c64/hf_xet-1.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:0392c79b7cf48418cd61478c1a925246cf10639f4cd9d94368d8ca1e8df9ea07", size = 3672280, upload-time = "2026-03-31T22:40:16.401Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/2e462d34e23a09a74d73785dbed71cc5dbad82a72eee2ad60a72a554155d/hf_xet-1.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:681c92a07796325778a79d76c67011764ecc9042a8c3579332b61b63ae512075", size = 3528945, upload-time = "2026-03-31T22:40:14.995Z" }, + { url = "https://files.pythonhosted.org/packages/ac/9f/9c23e4a447b8f83120798f9279d0297a4d1360bdbf59ef49ebec78fe2545/hf_xet-1.4.3-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d0da85329eaf196e03e90b84c2d0aca53bd4573d097a75f99609e80775f98025", size = 3805048, upload-time = "2026-03-31T22:39:53.105Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f8/7aacb8e5f4a7899d39c787b5984e912e6c18b11be136ef13947d7a66d265/hf_xet-1.4.3-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e23717ce4186b265f69afa66e6f0069fe7efbf331546f5c313d00e123dc84583", size = 3562178, upload-time = "2026-03-31T22:39:51.295Z" }, + { url = "https://files.pythonhosted.org/packages/df/9a/a24b26dc8a65f0ecc0fe5be981a19e61e7ca963b85e062c083f3a9100529/hf_xet-1.4.3-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc360b70c815bf340ed56c7b8c63aacf11762a4b099b2fe2c9bd6d6068668c08", size = 4212320, upload-time = "2026-03-31T22:39:42.922Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/46d493db155d2ee2801b71fb1b0fd67696359047fdd8caee2c914cc50c79/hf_xet-1.4.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:39f2d2e9654cd9b4319885733993807aab6de9dfbd34c42f0b78338d6617421f", size = 3991546, upload-time = "2026-03-31T22:39:41.335Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f5/067363e1c96c6b17256910830d1b54099d06287e10f4ec6ec4e7e08371fc/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:49ad8a8cead2b56051aa84d7fce3e1335efe68df3cf6c058f22a65513885baac", size = 4193200, upload-time = "2026-03-31T22:40:01.936Z" }, + { url = "https://files.pythonhosted.org/packages/42/4b/53951592882d9c23080c7644542fda34a3813104e9e11fa1a7d82d419cb8/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7716d62015477a70ea272d2d68cd7cad140f61c52ee452e133e139abfe2c17ba", size = 4429392, upload-time = "2026-03-31T22:40:03.492Z" }, + { url = "https://files.pythonhosted.org/packages/8a/21/75a6c175b4e79662ad8e62f46a40ce341d8d6b206b06b4320d07d55b188c/hf_xet-1.4.3-cp37-abi3-win_amd64.whl", hash = "sha256:6b591fcad34e272a5b02607485e4f2a1334aebf1bc6d16ce8eb1eb8978ac2021", size = 3677359, upload-time = "2026-03-31T22:40:13.619Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7c/44314ecd0e89f8b2b51c9d9e5e7a60a9c1c82024ac471d415860557d3cd8/hf_xet-1.4.3-cp37-abi3-win_arm64.whl", hash = "sha256:7c2c7e20bcfcc946dc67187c203463f5e932e395845d098cc2a93f5b67ca0b47", size = 3533664, upload-time = "2026-03-31T22:40:12.152Z" }, +] + +[[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.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[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.optional-dependencies] +socks = [ + { name = "socksio" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.12.0" +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 = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/52/1b54cb569509c725a32c1315261ac9fd0e6b91bbbf74d86fca10d3376164/huggingface_hub-1.12.0.tar.gz", hash = "sha256:7c3fe85e24b652334e5d456d7a812cd9a071e75630fac4365d9165ab5e4a34b6", size = 763091, upload-time = "2026-04-24T13:32:08.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/2b/ef03ddb96bd1123503c2bd6932001020292deea649e9bf4caa2cb65a85bf/huggingface_hub-1.12.0-py3-none-any.whl", hash = "sha256:d74939969585ee35748bd66de09baf84099d461bda7287cd9043bfb99b0e424d", size = 646806, upload-time = "2026-04-24T13:32:06.717Z" }, +] + +[[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/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" }, +] + +[[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 = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "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" }, + { name = "standard-sunau" }, + { 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/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.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[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/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/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/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/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/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.20.0.48" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/edb9c0ae051602c3ccaffe424256463636d639e27d7f302dde9975ef9e7a/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0c45dd8eeb50b603f07995b1b300c62ffe6a1980482b82b3bcf94a4ca9d49304", size = 366173588, upload-time = "2026-03-09T19:29:34.474Z" }, +] + +[[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.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/e1/cdc1797eadf82d3a9a575a19b33fdc871a97edbec42c00b5b5e914f4aff4/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f", size = 221051344, upload-time = "2025-09-05T18:49:51.289Z" }, + { url = "https://files.pythonhosted.org/packages/34/7d/2661f2fb3ac4302f3a246f5fc030213ac60c1fe0bce84f9783dbd831dbb7/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0", size = 170148586, upload-time = "2025-09-05T18:50:50.248Z" }, +] + +[[package]] +name = "nvidia-nccl-cu13" +version = "2.29.7" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/0d/daf50d44177ee0cbc7ff0a0c91eb5ff676c82be42f9a970bc7597f440c3a/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:674a12383e3c38a1bcccae7d4f3633b37852230b6047883cb2f4c2d1b36d9bf5", size = 206014712, upload-time = "2026-03-03T05:34:20.843Z" }, + { url = "https://files.pythonhosted.org/packages/67/f4/58e4e91b6919367c7aafb8e36fce9aad1a3047e536bf7e2fd560927d3a4c/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:edd81538446786ec3b73972543e53bb43bcaf0bfc8ef76cb679fcc390ffe136d", size = 205976000, upload-time = "2026-03-03T05:36:24.472Z" }, +] + +[[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.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "grpcio-tools" }, + { name = "protobuf" }, + { name = "websockets" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/53/0988b79ee818cb2b8792abeda5a75156946b64da7a2ef2719b9d2068a34f/nvidia_riva_client-2.26.0-py3-none-any.whl", hash = "sha256:16ffc98266fa7be7261e0675de6b7028e7f973c2ac3dfd679668148ff497cc0c", size = 57539, upload-time = "2026-05-28T06:11:43.205Z" }, +] + +[[package]] +name = "openai" +version = "2.44.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/49/f5/7c7cb955305cb41f7f3c5fd7e0e38bf6bbf2658468863d4b7b868a5cb8df/openai-2.44.0.tar.gz", hash = "sha256:68a5a5ffad82b8ff7d451c437529fb64f7c3b8123aaf0c021966a882d9e3947d", size = 988753, upload-time = "2026-06-24T20:56:02.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/f4/561ed79fd94876160018a5e75254cfcb9b0e62d4dded9dcb20072e86d623/openai-2.44.0-py3-none-any.whl", hash = "sha256:0a2a3ab2e29aeda368700f662ff9ba0f9df17ba4c54577a64e08b8115a3cc0ad", size = 1366216, upload-time = "2026-06-24T20:55:58.882Z" }, +] + +[[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/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/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.13.4" +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/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, +] + +[[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.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[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.1.1" +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/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[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.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[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.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpcore" }, + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/77/153517bb1ac1bba670c6fb1dbf09e1fd0730494b1705934e715391413a0d/python_telegram_bot-22.8.tar.gz", hash = "sha256:f9d3847fcb23ee603477e442800b33bb4adf851a73e0619d2050be879decf1ef", size = 1551700, upload-time = "2026-06-12T08:10:29.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/7c/ed7d4dd94280bd434173cae9f7a7aedaaab9af128ae4f494423a5687c820/python_telegram_bot-22.8-py3-none-any.whl", hash = "sha256:42373918097f1b837cc4e717d588c19ea79651497ec712bb5b0c76e5e63c50e1", size = 769397, upload-time = "2026-06-12T08:10:27.066Z" }, +] + +[[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/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 = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[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/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/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 = "rpds-py" +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, + { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" }, + { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" }, + { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" }, + { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" }, + { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" }, + { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" }, + { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" }, + { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" }, + { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" }, + { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +] + +[[package]] +name = "safetensors" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/06/f955dbbb1859e3bd23c8ac6141af5106e7ad5fedec4a3a6e3d60f94b7001/safetensors-0.8.0.tar.gz", hash = "sha256:fabaf3e0f18a6618d9b36560682562157f77c2b71fcffc7b432be2baed9d753d", size = 325846, upload-time = "2026-06-09T07:52:25.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/a0/f718cda65b05407d228f97602cf60dca269c979867aa5beb25410de26cd3/safetensors-0.8.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c554f85858e05226d3c2828e32395e677434685d6d94594a41643361c5e837f0", size = 473568, upload-time = "2026-06-09T07:52:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25", size = 484562, upload-time = "2026-06-09T07:52:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/65a7de0af421317bb36a067241e4235fff194eed60b961ed6d3f59a3fc60/safetensors-0.8.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a46e5ff292c356d6991e60942ba7f79817682d3a2cef0702136448cb9c4d235", size = 502844, upload-time = "2026-06-09T07:52:07.624Z" }, + { url = "https://files.pythonhosted.org/packages/91/4f/3175c9d75634e0e0dda0082794193521035edd7c70a6f212bf33ca06ddf4/safetensors-0.8.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4124502b78f03534117c848f87a39b8f31e577b15eff423bf8bfb95f2a8c30d0", size = 511823, upload-time = "2026-06-09T07:52:09.565Z" }, + { url = "https://files.pythonhosted.org/packages/20/87/846c289e7aa2299eff406335717cf43ce8777194ece8aad75772e0411615/safetensors-0.8.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bc0a787ba8a35be368ee3574edfa2b1ad389eebd0a72e482ae275490e3f6c98", size = 633461, upload-time = "2026-06-09T07:52:11.128Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/8d64d9df2c45d5ded401df889d0ad90882804ca172d79ec4f0df8f727fe0/safetensors-0.8.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:040070828e36dc8e122178bbbd5830ff9e97920affb84cbe0f46442497bed358", size = 545148, upload-time = "2026-06-09T07:52:13.603Z" }, + { url = "https://files.pythonhosted.org/packages/28/50/f203ff3a3ddfe19308efc83c5a3a29ed02bf786732ec35e68bf9162f3365/safetensors-0.8.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd6f3f93c9a0a7cc2788ee63fb763353d4bd2e89b0751bc78fcf7dda00bea774", size = 516040, upload-time = "2026-06-09T07:52:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/46/fb/cdaed17ceb2948784fd9c36b6fd3e951b608547cea81a48e8ee6f8cfdfcb/safetensors-0.8.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:fcdd41ec4628fee5799f807c73c353629130fbd942aa23d83c623dd6c9d52d78", size = 513832, upload-time = "2026-06-09T07:52:12.37Z" }, + { url = "https://files.pythonhosted.org/packages/0d/49/1e15de264dcc3b77943d2d0c56a95809956883b1c2d6d585c792523f180b/safetensors-0.8.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e9f537aa183a38ace122d27303dcd986b26bd2a7591f9181d7f0c396f4677ca", size = 559930, upload-time = "2026-06-09T07:52:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/2a/43/bf38443278eab4b1be1fce2931e2b012ad9cb7df52ada751d0aab8f7659a/safetensors-0.8.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87eec7ffed2b809f05a398a8becb7d013f19f7837cd15d9748580d6cf30dbaf4", size = 678670, upload-time = "2026-06-09T07:52:20.032Z" }, + { url = "https://files.pythonhosted.org/packages/72/e3/68cd3fa5b48488e84add63e04cb12f3bc28ae4638c06d4508c6e88823d0e/safetensors-0.8.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4a95ae2b05d7726d751da4ebf626a2ca782b706e101bd894c95bc2450b1cffcc", size = 786679, upload-time = "2026-06-09T07:52:21.322Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/1c19c509d56e01f4fbb3d0a2e597450f6cc04d1d56cf52defb0a62dfd715/safetensors-0.8.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae091f16662658bdc019a4ff6cb4c085bb7d725eb5978b183ffd265863b6d2d", size = 765683, upload-time = "2026-06-09T07:52:22.594Z" }, + { url = "https://files.pythonhosted.org/packages/27/43/41c1621732edd934d868a00d1b891584c892a7b62a9aab82ea5a0a5623ee/safetensors-0.8.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8e080062fcde23be189565e1c3305d16751a218ecf9412c8601e64204eb6f846", size = 722361, upload-time = "2026-06-09T07:52:23.924Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3f/73ccf82579412b4a71c4ca673f10b5f1f888d7cf5af7fe24f27d30307be4/safetensors-0.8.0-cp310-abi3-win32.whl", hash = "sha256:2ddf52eac562eda224f99acfa7889d02968c1fd59a5b011ae7d8137c37e9c02d", size = 342401, upload-time = "2026-06-09T07:52:28.895Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6d/3fba214c1e5e0f69991677ec3bc17023f0421776975e1de0c682dca475e2/safetensors-0.8.0-cp310-abi3-win_amd64.whl", hash = "sha256:096ec1a98435df7beb08853bb5aa9081a84f23d0adc67ed1a0a10550f608373f", size = 355540, upload-time = "2026-06-09T07:52:27.832Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fc/7eedc3510d97878876e32774eebbeb61c43f148a96e915c84229a3e967aa/safetensors-0.8.0-cp310-abi3-win_arm64.whl", hash = "sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452", size = 340500, upload-time = "2026-06-09T07:52:26.745Z" }, +] + +[[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/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/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 = "socksio" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, +] + +[[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" }, + { name = "standard-chunk" }, +] +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" }, +] +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" }, +] +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.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/e5/5f3cb2159769d0f4324c0e9e87f9de3c4b1cd45848a96b2eb3566ad5ca77/tiktoken-0.13.0.tar.gz", hash = "sha256:c9435714c3a84c2319499de9a300c0e604449dd0799ff246458b3bb6a7f433c1", size = 38986, upload-time = "2026-05-15T04:51:27.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/93/0dd6adca026a616c3a92974566b43381eea4b475ce1f36c062b8271a9ac5/tiktoken-0.13.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaaaef47c2406277181d2086484c317bf7fc433e2d5d03ff94f56b0dcec87471", size = 1034977, upload-time = "2026-05-15T04:51:00.957Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5ec6e6bc5b30bed6d93f7f2162d8f6b32437b3ba27cb527cfe004f6109c9/tiktoken-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ca8b310bd93b3772cb1b7922d915446864860f562bdfe4825c63a0aed3fb28cd", size = 983635, upload-time = "2026-05-15T04:51:02.629Z" }, + { url = "https://files.pythonhosted.org/packages/94/b0/c8ae9aff00d625c50659b4513e707a0462c4bf5d4d6cc1b802103225c02e/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:32e0c12305105002c047b3bb1070b0dd9a73b0cb3b2856a8972b810e7a4f5881", size = 1116036, upload-time = "2026-05-15T04:51:04.082Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/6a5dddd1d0a6018ecb389bd0353e6b4a515eb4d2286611bd0ace1937b9e1/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5ba5fd62507a932d1241346179e3b39bc7bf7408f03c272652d93b3bedf5db24", size = 1135544, upload-time = "2026-05-15T04:51:05.229Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b8/585032b4384b2f7dcdaddcb52865c83a701a420d09e3c2b4a2be1c450c57/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d108bc2d470fc53c8ecd24f2c0fd2b5f98c33e87cdb6aa2e9b8c5dced703d273", size = 1182217, upload-time = "2026-05-15T04:51:06.517Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b6/993ff1ded3958215fd341a847b8e5ffeb5de473f435296870d314fc91ac4/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cb99cb5127449f58d0a2d5f5ccfb390d8dbdfd919c221246caaee29d8725ed51", size = 1239404, upload-time = "2026-05-15T04:51:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3d/fef7e06e3b33e7538db0ced734cf9fe23b6832d2ac4990c119c377aec55e/tiktoken-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:115c4f26ffa11caac8b54eea35c2ad38c612c20a48d35dd15d70a02ac6f51f58", size = 918686, upload-time = "2026-05-15T04:51:08.925Z" }, + { url = "https://files.pythonhosted.org/packages/c1/82/a7fc44582bc32ab00de988a2299bf77c077f59068b233109e34b7d6ca7e6/tiktoken-0.13.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:472527e9132952f2fbf77cd290658bacf003d4d5a3fabc18e5fbd407cbae4d9b", size = 1034454, upload-time = "2026-05-15T04:51:10.035Z" }, + { url = "https://files.pythonhosted.org/packages/37/d0/24d8a890c14f432a05cea669c17bebeaa99f96a7c79523b590f564246411/tiktoken-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e2f67d27c9626cdd25fe33d9313c5cdb3d8d82da646b68d6eb8e7e9c20e6448", size = 982976, upload-time = "2026-05-15T04:51:11.23Z" }, + { url = "https://files.pythonhosted.org/packages/49/b7/2ab43f62788a9266187a9bfc1d3af99ad83e5eaa25fbef168a69cd5ad14f/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2b920b35805cd64585a37c3dc7ce65fba4d2d36016be01e1d7942482ca29093a", size = 1115526, upload-time = "2026-05-15T04:51:12.608Z" }, + { url = "https://files.pythonhosted.org/packages/64/39/1494321ed323ce7a14d88e3cd6cb9058625977df1c6961ddc492bd10a9f3/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:493af3aa28a4aaf2e3d2600a2ee717252c9bf5ab38fff94eb5a02db5ab77e5ad", size = 1136466, upload-time = "2026-05-15T04:51:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/96/d9/dfd086aa2d918c563a140720e0ce296cada1634efd2783d5cf51e05f984e/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6644c9c2b5cf3916f5a3641d7d12fdb3f006a7b3d9ff6acdaec44e29ab1ff91e", size = 1181863, upload-time = "2026-05-15T04:51:15.025Z" }, + { url = "https://files.pythonhosted.org/packages/2f/68/a18b4f307086954fdae32714cb4f85562e34f9d34ab206e61f1816aa6018/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cb65b60b9408563676d874a3a4ee573370066f0dc4e29d84e82e989c6517424", size = 1239218, upload-time = "2026-05-15T04:51:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/16/5b/f2aa703a4fc5d2dff73460a7d46cc2f3f44aa0f3dd8eeb20d2a0ecf68862/tiktoken-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:85b78cc3a2c3d48723ca751fa981f1fedccd54194ca0471b957364353a898b07", size = 918110, upload-time = "2026-05-15T04:51:17.237Z" }, +] + +[[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.12.1+cu130" +source = { registry = "https://download.pytorch.org/whl/cu130" } +dependencies = [ + { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "nvidia-cublas", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "triton", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.1%2Bcu130-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:d6770341450d042a2998be19b5f7e431ab27281d356148a6eba659f5afff7c3e", upload-time = "2026-06-18T02:44:24Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.1%2Bcu130-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:c84c5988b3e416669ed3790d772479af5934d1788119288d8bddd9c4cb207285", upload-time = "2026-06-18T02:44:58Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.1%2Bcu130-cp314-cp314-win_amd64.whl", hash = "sha256:d1cd8a4fd0556b2604db5447d9298323b8fba1ba67501fb3d8b22485c764a3a6", upload-time = "2026-06-18T02:46:20Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.1%2Bcu130-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:3d9cbeffed603075484270c34344e9506d86850af734d1e2eee13a8ba4bd690c", upload-time = "2026-06-18T02:47:40Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.1%2Bcu130-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:0b4a8bdc9a89d1109bf4056d56e1c3ed05966273056d983a91f1cf597c25bea6", upload-time = "2026-06-18T02:48:07Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.1%2Bcu130-cp314-cp314t-win_amd64.whl", hash = "sha256:046caae1457ec8a88256536958538dffb173c419320e3478f5b8d676038ec0d8", upload-time = "2026-06-18T02:49:24Z" }, +] + +[[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.13.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" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/e1/720ff7ff666b04279fea5bb7ac3ef8675e98f0ddbc1b8cb8bc9f3889d62e/transformers-5.13.0.tar.gz", hash = "sha256:940c1428e42a4238f9ccf0cd41e63c590701aa63c19fd2ce3d7d602222d68495", size = 9195801, upload-time = "2026-07-03T16:05:39.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/3a/d99704c5effe10c6339c98cb236259161103e159bb99a78468b6729572ec/transformers-5.13.0-py3-none-any.whl", hash = "sha256:8adbc1d20bd5463cd6876b2eb7cb31971e1065788e7dc6bc12bab597a7c504b7", size = 11503730, upload-time = "2026-07-03T16:05:35.569Z" }, +] + +[[package]] +name = "triton" +version = "3.7.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/71/e01aa7ad573883ed9456f130226babdec70b005e098c4d6226a6238e761b/triton-3.7.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe4ea396a06171f1f1f58cbd39c70b09294398f7dd7c620939bab54ad6f934fa", size = 184705764, upload-time = "2026-06-17T20:03:59.064Z" }, + { url = "https://files.pythonhosted.org/packages/a4/09/5683146fda6a2b569deb78ccfd8fbfea8bfe55f726b081c0a6bb18dd6f28/triton-3.7.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2020153b08280415ec0da6607834e79166442147e78e144df06b508c75b186d2", size = 197729537, upload-time = "2026-06-17T19:53:35.516Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/448220c3092019f9fdfab39ec47985968181d67da34b44f6a7f6280a5cbb/triton-3.7.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c58e4c61f0c73b5dba3b5d19b4a7093c32f90dc18b2a7f121a7c16ccd31107b7", size = 184814760, upload-time = "2026-06-17T20:04:04.984Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ac/229b7d4589d2e5937310e72c6d46e89599d16a4a12b479ffa1499fee8eb8/triton-3.7.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10ba85fa2cca4a2fbdeb36bf1cb082f2c252bda55bf9fccd74f65ec5bc647e68", size = 197824404, upload-time = "2026-06-17T19:53:42.772Z" }, +] + +[[package]] +name = "ty" +version = "0.0.56" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/55/07/fb29aea5235b0aa8ecfc4d1cc6ddf9fba8b863d67d96c6d345694d644c43/ty-0.0.56.tar.gz", hash = "sha256:84d114dc3796361c0fc72945016eabd74d46b9ee64f198cb0e485719704681e5", size = 6050123, upload-time = "2026-07-01T16:44:56.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/48/bce79e7ca5c1cc529d3e0d37ddd1121aea4b68a4f749974ad1cc77161871/ty-0.0.56-py3-none-linux_armv6l.whl", hash = "sha256:186d4a53e15747c947e1ec3d7eec8e345d8e40a1ca10e634c585db52497e87dd", size = 11643066, upload-time = "2026-07-01T16:44:18.374Z" }, + { url = "https://files.pythonhosted.org/packages/80/d1/22555d8a1d719661f10050f3865d877bbf497da908961c75fe22217dd18a/ty-0.0.56-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aae1a980fd9535da0469b7ba2b2e1b54a907743a5e0f442dd57eee9f5bfd034c", size = 11407487, upload-time = "2026-07-01T16:44:20.956Z" }, + { url = "https://files.pythonhosted.org/packages/cf/2d/b3b7a74ce8bc59ef48843ad80179bb0d9598bbd6cfc0d11d519bdf6b1352/ty-0.0.56-py3-none-macosx_11_0_arm64.whl", hash = "sha256:afd3058c0a6c5f241e814734f133008c93ee805f61c9cf4ce7412b8822b5d9ad", size = 10962270, upload-time = "2026-07-01T16:44:22.959Z" }, + { url = "https://files.pythonhosted.org/packages/64/ac/6c2fd7de0304a8a7218a756af74f7e62a5e8540fdb175e0a869e51042345/ty-0.0.56-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:058b52f7a823ac13aae3cae30809dd6b5145794b64d8478f9ef38c75d79b4483", size = 11471406, upload-time = "2026-07-01T16:44:25.327Z" }, + { url = "https://files.pythonhosted.org/packages/50/b6/11d861156861c03c7726b74558f9a0e0092661aff83a4fda1279df28c425/ty-0.0.56-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c66e00c1522add1f2bbdd2e45828c953b35c306b7bef03ec9169c75a63699a0", size = 11445612, upload-time = "2026-07-01T16:44:27.531Z" }, + { url = "https://files.pythonhosted.org/packages/fb/ba/09df108582090f3c0770ec4bc8675affed60248f6793a78d909be16211d9/ty-0.0.56-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40903d71c669a30691b5a5d5728056c7877a1bd6be4f233a38883a8b28cf34d7", size = 12093889, upload-time = "2026-07-01T16:44:29.548Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f7/dbb4b4ccb69cd64c209ae55b1ab788ace8222c2bc1f6845be9e7cbedbf25/ty-0.0.56-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63fe3947fe0c46c69a7d950e6832ee70a9ec17321fefbff3d2e3c20baf9e5bd0", size = 12666337, upload-time = "2026-07-01T16:44:31.586Z" }, + { url = "https://files.pythonhosted.org/packages/86/e9/73f903fe4a3d9ea02f26f57c1eb07e3b1029ec92b0e8c2364718893440e3/ty-0.0.56-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71a0c1a72f9854532e710e119b6871ffe4542c8a65146f1f65dcd78fecd885b4", size = 12280247, upload-time = "2026-07-01T16:44:33.637Z" }, + { url = "https://files.pythonhosted.org/packages/d6/90/cebd222495832f1a00dcd321ba25f3cab804221a4991b992c2178bec68ee/ty-0.0.56-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70d1665596494e24d8ebd198438872b5a56ec3cae5f2bcf6c673be797acc4e3c", size = 11991107, upload-time = "2026-07-01T16:44:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/b7/07/8f7337a07250f42d975cdb6decf47fc5b421e6c7da5e3e7be1e85f63a7e5/ty-0.0.56-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:778f99e51558afc1dbbe48ee38ab6aae7b31390ed8c1a1ef1499b295e9f1e82f", size = 12298970, upload-time = "2026-07-01T16:44:38.243Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b9/a52cd59034a48f5f18c6b155cc2cc36861d874b6d0af204b12c898024c3d/ty-0.0.56-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:867bc5708e0066bb4ff6c7db524bd5deea2676c62bfe71d3303138b3be850af0", size = 11425683, upload-time = "2026-07-01T16:44:40.473Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2e/48e42d33357d52eefb695c0c3fcfc96879b73668a7447d1d1e0ad774fedc/ty-0.0.56-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a6012f4189c928edb330a37deb9930f982380bd4aa7c4b8e0428eec9651c7551", size = 11469258, upload-time = "2026-07-01T16:44:42.513Z" }, + { url = "https://files.pythonhosted.org/packages/d5/01/ad1b4138be1e3fa97863af3925aa2134f17a593240c35dc38c3429fb5ad1/ty-0.0.56-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8ee83de1a7ff4cc32837ec06134ce391d441bc5b35ecd8d3cfe053f120f3e4c1", size = 11758736, upload-time = "2026-07-01T16:44:44.567Z" }, + { url = "https://files.pythonhosted.org/packages/09/34/9d81967ff240eaa57e9249728ef7b7790747cf6d3c9a98ec86b2cfdcc8ee/ty-0.0.56-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:62619b3b0e2c6248ef30d3f0e2f2217ae9893040585be07f32324242f197cd6f", size = 12100242, upload-time = "2026-07-01T16:44:46.584Z" }, + { url = "https://files.pythonhosted.org/packages/c3/36/f51d4666d2de6cf33c1f3a1fcc4bb6b70b197dd6ceaa491eef71d78fe8e8/ty-0.0.56-py3-none-win32.whl", hash = "sha256:b30687bb5cd9729d34c889a289edf32770388d9bb05243e534e723fb45e0381b", size = 11093759, upload-time = "2026-07-01T16:44:49.171Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b4/8fb5d4acfa4afb152245b20fa263069a7547bd1f8e4bfca4eda280c897d7/ty-0.0.56-py3-none-win_amd64.whl", hash = "sha256:ad4c8c47b6f4e3f9ed3fc0b1a5d650088d229e17dd8f63c1826d6bbe94cc3235", size = 12100327, upload-time = "2026-07-01T16:44:51.26Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fc/6a183e71edde90d0c35c2303f23f7a45b6891d1a2c45daf7b8f869831e19/ty-0.0.56-py3-none-win_arm64.whl", hash = "sha256:57538f273d444a5f1293fa7860e967178afe3917611fc5eff16b64e1204fe0d6", size = 11538780, upload-time = "2026-07-01T16:44:53.8Z" }, +] + +[[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 = "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.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/41/06cce5dbb9f77591512957710ac709e60b12e6216a2f2d0d607fd49706e8/uvicorn-0.50.0.tar.gz", hash = "sha256:0c92e1bc2259cb7faa4fcef774a5966588f2e88542744550b66799fba10b76f1", size = 93257, upload-time = "2026-07-04T05:03:26.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/3a/eb70620ca2bf8213603d5c731460687c49fee38b0072f0b4a637781f0a53/uvicorn-0.50.0-py3-none-any.whl", hash = "sha256:05f0eb19edf38208f79f43df8a63081b48df31b0cd1e5997be957a4dc97d1b19", size = 72716, upload-time = "2026-07-04T05:03:24.848Z" }, +] + +[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/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/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/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/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" }, +]