diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..4170ddae0f7e51a24c46f293aa66203340dfd6ed --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +.git +.git/ +.gitignore +.vscode/ +.env +.venv +venv/ +env/ +__pycache__/ +*.pyc +*.log +*.DS_Store +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +htmlcov/ +coverage.xml +deprecated_javascript_version/ +memory-bank/ +logs/ +outputs/ +work/ +auth_profiles/**/*.json +auth_profiles/key.txt +config/profile_usage.json +config/cooldown_status.json +static/frontend/node_modules/ +static/frontend/dist/ +# auth_profiles/ # Handled by volume mount or Hugging Face Secrets +# certs/ # Handled by volume mount or generated in container +# logs/ # Supervisord logs to stdout/stderr diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..ca09b622fd9c30245f92f0e5fa36bfaf6aa4ed1a --- /dev/null +++ b/.env.example @@ -0,0 +1,414 @@ +# AI Studio Proxy API Configuration Template +# Copy this file to .env and modify as needed + +# ============================================================================= +# 1. Server Port Configuration +# ============================================================================= + +# FastAPI Main Service Port +# The port where the OpenAI-compatible API will listen. +PORT=2048 + +# Streaming Proxy Service Port +# Set to 0 to disable the streaming proxy service. +STREAM_PORT=3120 + +# GUI Launcher Default Port Configuration +# These are only used for default suggestions in the launcher. +DEFAULT_FASTAPI_PORT=2048 +DEFAULT_CAMOUFOX_PORT=9222 + +# ============================================================================= +# 2. Proxy Configuration +# ============================================================================= + +# Unified Proxy Configuration (Recommended) +# High priority; configures HTTP_PROXY, HTTPS_PROXY, and internal browser proxy. +UNIFIED_PROXY_CONFIG=http://127.0.0.1:7890 + +# Legacy Proxy Settings (Only used if UNIFIED_PROXY_CONFIG is not set) +# HTTP_PROXY=http://127.0.0.1:7890 +# HTTPS_PROXY=http://127.0.0.1:7890 + +# Proxy Bypass List (Separated by semicolons) +# NO_PROXY=localhost;127.0.0.1;*.local + +# ============================================================================= +# 3. Logging & Debugging +# ============================================================================= + +# Server Log Level (DEBUG, INFO, WARNING, ERROR, CRITICAL) +SERVER_LOG_LEVEL=INFO + +# Whether to redirect print output to logs +SERVER_REDIRECT_PRINT=false + +# Enable Debug and Trace Logs +DEBUG_LOGS_ENABLED=false +TRACE_LOGS_ENABLED=false + +# JSON Structured Logging +JSON_LOGS=false + +# Log Rotation Configuration +LOG_FILE_MAX_BYTES=10485760 +LOG_FILE_BACKUP_COUNT=5 + +# ============================================================================= +# 4. Authentication Configuration +# ============================================================================= + +# Auto-save Authentication Information +# Set to true to automatically save auth status (cookies/localStorage) after login. +AUTO_SAVE_AUTH=false + +# Auth Save Timeout (seconds) +# Timeout while waiting for user to provide a filename for the saved auth. +AUTH_SAVE_TIMEOUT=30 + +# Auto Rotate Auth Profile (true/false) +# Automatically switch to a different auth profile when quota is exceeded or issues occur. +AUTO_ROTATE_AUTH_PROFILE=true + +# Auto Auth Rotation on Startup (true/false) +# Automatically select an available auth profile when starting. +AUTO_AUTH_ROTATION_ON_STARTUP=false + +# Auto Confirm Login +AUTO_CONFIRM_LOGIN=true + +# Quota Rotation Thresholds (Graceful Rotation) +# Soft Limit: Triggers "Rotation Pending". Current stream finishes, then rotates. +QUOTA_SOFT_LIMIT=850000 +# Hard Limit: Triggers immediate "Kill Signal" to prevent hard bans. +QUOTA_HARD_LIMIT=950000 + +# ----------------------------------------------------------------------------- +# Cookie Refresh Configuration +# ----------------------------------------------------------------------------- +# Automatically refresh and persist browser cookies to keep auth profiles fresh. +# This helps prevent cookie expiration issues during long-running sessions. + +# Enable automatic cookie refresh (default: true) +COOKIE_REFRESH_ENABLED=true + +# Periodic refresh interval in seconds (default: 1800 = 30 minutes) +COOKIE_REFRESH_INTERVAL_SECONDS=1800 + +# Enable cookie save after successful API requests (default: true) +COOKIE_REFRESH_ON_REQUEST_ENABLED=true + +# Number of successful requests between cookie saves (default: 10) +COOKIE_REFRESH_REQUEST_INTERVAL=10 + +# Enable cookie save on graceful shutdown (default: true) +COOKIE_REFRESH_ON_SHUTDOWN=true + +# ============================================================================= +# 5. Browser & Model Configuration +# ============================================================================= + +# Launch Mode (normal, debug, headless, virtual_display, direct_debug_no_browser) +LAUNCH_MODE=normal + +# Camoufox WebSocket Endpoint +# For connecting to existing external Camoufox/Chrome instance. +# CAMOUFOX_WS_ENDPOINT=ws://127.0.0.1:9222 + +# Quick Launch (Direct Launch) +# Skip launcher menu and use .env configuration directly. +DIRECT_LAUNCH=false + +# Only collect attachments from the current user's message (true/false) +ONLY_COLLECT_CURRENT_USER_ATTACHMENTS=false + +# Camoufox WebSocket Capture Timeout (seconds) +ENDPOINT_CAPTURE_TIMEOUT=45 + +# ============================================================================= +# 6. API Default Parameter Configuration +# ============================================================================= + +# Default Sampling Parameters +DEFAULT_TEMPERATURE=1.0 +DEFAULT_MAX_OUTPUT_TOKENS=65536 +DEFAULT_TOP_P=0.95 +DEFAULT_STOP_SEQUENCES=["User:"] + +# Thinking Model Configuration (e.g., gemini-2.0-flash-thinking) +ENABLE_THINKING_BUDGET=true +DEFAULT_THINKING_BUDGET=8192 + +# Thinking Budget Level Values (tokens) - Used for "low", "medium", "high" presets +THINKING_BUDGET_LOW=10923 +THINKING_BUDGET_MEDIUM=21845 +THINKING_BUDGET_HIGH=32768 + +# Gemini 3 Default Thinking Levels +DEFAULT_THINKING_LEVEL_PRO=high +DEFAULT_THINKING_LEVEL_FLASH=high + +# Control if disabling streaming also disables thinking budget (default behavior: false) +DISABLE_THINKING_BUDGET_ON_STREAMING_DISABLE=false + +# Feature Toggles (Google Search, URL Context) +ENABLE_GOOGLE_SEARCH=false +ENABLE_URL_CONTEXT=false + +# ============================================================================= +# Function Calling Configuration (Native vs Emulated) +# ============================================================================= +# This configures how OpenAI-compatible tool calls (tools parameter) are handled. +# NOTE: XML-based tools in prompts are NOT affected - they pass through as plain text. + +# Function Calling Mode: "auto" | "native" | "emulated" +# - "auto": (RECOMMENDED) Tries native first, auto-fallback to emulated on failure +# - "native": AI Studio UI-driven function calling (best reliability) +# - "emulated": Text-based prompt injection (legacy, backwards compatible) +# +# When to use each: +# - "auto": Best for most users - resilient with automatic fallback +# - "native": When you need maximum reliability and your models support it +# - "emulated": For older setups or if native mode causes issues +FUNCTION_CALLING_MODE=auto + +# Enable automatic fallback to emulated mode when native mode fails +# Only applies when FUNCTION_CALLING_MODE=native (auto mode always has fallback) +FUNCTION_CALLING_NATIVE_FALLBACK=true + +# Timeout for function calling UI operations (milliseconds) +FUNCTION_CALLING_UI_TIMEOUT=10000 + +# Native mode retry attempts before fallback +FUNCTION_CALLING_NATIVE_RETRY_COUNT=3 + +# Clear function definitions between requests (stateless behavior) +# Set to false if you want to reuse tool definitions across requests +FUNCTION_CALLING_CLEAR_BETWEEN_REQUESTS=true + +# ----------------------------------------------------------------------------- +# Function Calling Debug Logging (Master Switch) +# ----------------------------------------------------------------------------- +# When false, ALL function calling logs (console & modular) are disabled. +# Useful for production to minimize noise and disk I/O. +FUNCTION_CALLING_DEBUG=false + +# Enable function calling state caching for performance +# Reduces UI operations when same tools are used in subsequent requests +FUNCTION_CALLING_CACHE_ENABLED=true + +# Cache TTL in seconds (0 = no expiration within session) +FUNCTION_CALLING_CACHE_TTL=0 + +# ----------------------------------------------------------------------------- +# Function Calling Improvements (FC-001 to FC-004) +# ----------------------------------------------------------------------------- +# These settings control advanced function calling compatibility features. +# See docs/architecture/FUNCTION_CALLING_IMPROVEMENTS.md for details. + +# FC-001: thoughtSignature Support for Gemini 3 +# Add thoughtSignature to functionCall parts for Gemini 3 model compatibility. +# When replaying conversation history with tool calls, Gemini 3 may require +# this field for validation. Safe for older models (ignored if not needed). +FUNCTION_CALLING_THOUGHT_SIGNATURE=true + +# FC-004: Type Case Normalization (UPPERCASE types) +# Convert JSON Schema types to UPPERCASE (e.g., "string" -> "STRING"). +# The iBUHub/AIStudioToAPI project uses UPPERCASE types. +# WARNING: Set to false by default - requires UI verification before enabling. +# Only enable after confirming AI Studio UI accepts UPPERCASE types. +FUNCTION_CALLING_UPPERCASE_TYPES=false + +# ----------------------------------------------------------------------------- +# Modular Per-Component Logging (Active only if FUNCTION_CALLING_DEBUG=true) +# ----------------------------------------------------------------------------- +# Fine-grained logging with separate log files in logs/fc_debug/.log +# See docs/architecture/FC_DEBUG_LOGGING_DESIGN.md for full documentation. + +# Enable individual modules for targeted debugging. Only enabled modules +# will create log files. All default to false to minimize disk I/O. +# +# ORCHESTRATOR: Mode selection, fallback logic, high-level flow +# UI: Browser UI automation (toggle, dialog, paste) +# CACHE: Cache hits/misses/invalidation +# WIRE: Wire format parsing from network responses +# DOM: DOM-based function call extraction +# SCHEMA: Tool schema conversion and validation +# RESPONSE: Response formatting for OpenAI compatibility + +FC_DEBUG_ORCHESTRATOR=false +FC_DEBUG_UI=false +FC_DEBUG_CACHE=false +FC_DEBUG_WIRE=false +FC_DEBUG_DOM=false +FC_DEBUG_SCHEMA=false +FC_DEBUG_RESPONSE=false + +# ----------------------------------------------------------------------------- +# Per-Module Log Levels +# ----------------------------------------------------------------------------- +# Available levels: DEBUG, INFO, WARNING, ERROR, CRITICAL +# Only affects modules that are enabled above. +# Recommendation: Use DEBUG for troubleshooting, INFO for general monitoring. + +FC_DEBUG_LEVEL_ORCHESTRATOR=DEBUG +FC_DEBUG_LEVEL_UI=DEBUG +FC_DEBUG_LEVEL_CACHE=DEBUG +FC_DEBUG_LEVEL_WIRE=DEBUG +FC_DEBUG_LEVEL_DOM=DEBUG +FC_DEBUG_LEVEL_SCHEMA=DEBUG +FC_DEBUG_LEVEL_RESPONSE=DEBUG + +# ----------------------------------------------------------------------------- +# Log File Rotation +# ----------------------------------------------------------------------------- +# Configure rotation to prevent unbounded disk usage. +# Max file size in bytes (default: 5MB = 5242880) +# Backup count determines how many rotated files to keep. + +FC_DEBUG_LOG_MAX_BYTES=5242880 +FC_DEBUG_LOG_BACKUP_COUNT=3 + +# ----------------------------------------------------------------------------- +# Payload Truncation +# ----------------------------------------------------------------------------- +# Large payloads (tool definitions, arguments) can clutter logs. +# Enable truncation to keep logs readable while preserving key info. + +# Master switch for truncation +FC_DEBUG_TRUNCATE_ENABLED=true + +# Maximum characters for different payload types: +# - TOOL_DEF: Tool/function schema definitions (often 10KB+) +# - ARGS: Function call arguments +# - RESPONSE: Response bodies + +FC_DEBUG_TRUNCATE_MAX_TOOL_DEF=500 +FC_DEBUG_TRUNCATE_MAX_ARGS=1000 +FC_DEBUG_TRUNCATE_MAX_RESPONSE=2000 + +# ----------------------------------------------------------------------------- +# Combined Log (Optional) +# ----------------------------------------------------------------------------- +# Additionally write all FC debug logs to a single combined file. +# Useful for seeing cross-module request flow in one place. +# File: logs/fc_debug/fc_combined.log + +FC_DEBUG_COMBINED_LOG=false + +# ============================================================================= +# Quick Start Examples: +# ============================================================================= +# +# Example 1: Debug cache issues only +# FUNCTION_CALLING_DEBUG=true +# FC_DEBUG_CACHE=true +# +# Example 2: Full FC debugging with combined log +# FUNCTION_CALLING_DEBUG=true +# FC_DEBUG_ORCHESTRATOR=true +# FC_DEBUG_UI=true +# FC_DEBUG_CACHE=true +# FC_DEBUG_WIRE=true +# FC_DEBUG_DOM=true +# FC_DEBUG_SCHEMA=true +# FC_DEBUG_RESPONSE=true +# FC_DEBUG_COMBINED_LOG=true +# +# Example 3: Production monitoring (errors only) +# FUNCTION_CALLING_DEBUG=true +# FC_DEBUG_ORCHESTRATOR=true +# FC_DEBUG_LEVEL_ORCHESTRATOR=ERROR +# ============================================================================= + + +# ============================================================================= +# 7. Advanced Timeout Configuration (milliseconds) +# ============================================================================= + +# Response Completion Total Timeout +# Default: 600000 (10 minutes) +RESPONSE_COMPLETION_TIMEOUT=600000 + +# Initial Wait Time Before Polling +INITIAL_WAIT_MS_BEFORE_POLLING=500 + +# Polling Interval +POLLING_INTERVAL=300 +POLLING_INTERVAL_STREAM=180 + +# Silence Timeout (Base threshold for inactivity detection) +SILENCE_TIMEOUT_MS=60000 + +# Page Action Timeouts +POST_SPINNER_CHECK_DELAY_MS=500 +FINAL_STATE_CHECK_TIMEOUT_MS=1500 +POST_COMPLETION_BUFFER=700 + +# UI Generation Wait Configuration +UI_GENERATION_WAIT_TIMEOUT_MS=5000 +UI_GENERATION_CHECK_INTERVAL_MS=500 +UI_STABILIZATION_BUFFER_MS=500 + +# Clear Chat Related Timeouts +CLEAR_CHAT_VERIFY_TIMEOUT_MS=4000 +CLEAR_CHAT_VERIFY_INTERVAL_MS=4000 + +# Interaction Timeouts +CLICK_TIMEOUT_MS=3000 +CLIPBOARD_READ_TIMEOUT_MS=3000 +WAIT_FOR_ELEMENT_TIMEOUT_MS=10000 + +# Stream Related Configuration +PSEUDO_STREAM_DELAY=0.01 + +# ============================================================================= +# 8. GUI Launcher Configuration +# ============================================================================= + +# GUI Default Proxy and Port settings +GUI_DEFAULT_PROXY_ADDRESS=http://127.0.0.1:7890 +GUI_DEFAULT_STREAM_PORT=3120 +GUI_DEFAULT_HELPER_ENDPOINT= + +# ============================================================================= +# 9. Script Injection Configuration +# ============================================================================= + +# Whether to enable Tampermonkey script injection (Deprecated) +ENABLE_SCRIPT_INJECTION=false + +# Tampermonkey script file path (relative to project root) +USERSCRIPT_PATH=browser_utils/more_models.js + +# ============================================================================= +# 10. Miscellaneous System Config +# ============================================================================= + +# Model Metadata +MODEL_NAME=AI-Studio_Proxy_API +CHAT_COMPLETION_ID_PREFIX=chatcmpl- +AI_STUDIO_URL_PATTERN=aistudio.google.com/ +EXCLUDED_MODELS_FILENAME=excluded_models.txt + +# Internal URL Markers and Fallbacks +DEFAULT_FALLBACK_MODEL_ID=no model list +MODELS_ENDPOINT_URL_CONTAINS=MakerSuiteService/ListModels +USER_INPUT_START_MARKER_SERVER=__USER_INPUT_START__ +USER_INPUT_END_MARKER_SERVER=__USER_INPUT_END__ + +# ============================================================================= +# 11. Stream State and Error Suppression +# ============================================================================= + +# Stream Timeout Log State Configuration +STREAM_MAX_INITIAL_ERRORS=3 +STREAM_WARNING_INTERVAL_AFTER_SUPPRESS=60.0 +STREAM_SUPPRESS_DURATION_AFTER_INITIAL_BURST=400.0 + +# ============================================================================= +# 12. Frontend Build Configuration +# ============================================================================= + +# Skip frontend build check (for environments without Node.js/npm) +SKIP_FRONTEND_BUILD=false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..475d95d99af7a1f5ebb6ebd38ff3372dac4d75c6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,79 @@ +# Enforce LF line endings for all text files (cross-platform consistency) +* text=auto eol=lf + +# ============================================================================= +# TRANSLATION FORK MERGE STRATEGY +# ============================================================================= +# This is an English translation fork of a Chinese upstream repository. +# The README.md will always conflict because: +# - Fork: English README (intentional localization) +# - Upstream: Chinese README +# +# The 'merge=ours' strategy tells Git to always keep our (fork's) version +# during merges. This serves as additional protection alongside the workflow +# conflict handling in .github/workflows/upstream-sync.yml +# ============================================================================= +README.md merge=ours + +# Binary files (no line ending conversion) +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary +*.ttf binary +*.eot binary +*.pyc binary +*.pyd binary +*.so binary +*.dll binary +*.exe binary +*.zip binary +*.tar binary +*.gz binary +*.db binary +*.sqlite binary +*.sqlite3 binary + +# Python files (explicit LF) +*.py text eol=lf +*.pyi text eol=lf +*.pyx text eol=lf + +# Configuration files +*.toml text eol=lf +*.ini text eol=lf +*.cfg text eol=lf +*.conf text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +*.json text eol=lf +*.md text eol=lf +*.txt text eol=lf +.env* text eol=lf +.gitignore text eol=lf +.gitattributes text eol=lf + +# Shell scripts +*.sh text eol=lf +*.bash text eol=lf + +# Windows scripts (keep CRLF for Windows) +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# JavaScript/TypeScript +*.js text eol=lf +*.ts text eol=lf +*.jsx text eol=lf +*.tsx text eol=lf +*.css text eol=lf +*.scss text eol=lf +*.html text eol=lf + +# Lock files +poetry.lock text eol=lf +package-lock.json text eol=lf diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000000000000000000000000000000000000..4033fd71052909f13ccaae05c4475018a0e0876b --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,229 @@ +# PR Check Workflow +# Runs linting, type checking, and tests on pull requests and pushes to main +# +# NOTE: Lint and type check are set to continue-on-error because this is a fork +# of an upstream project with pre-existing code quality issues. These jobs will +# report issues but won't block the workflow from completing successfully. +# As code quality improves, these can be made blocking again. +# +# CACHING STRATEGY: +# - Poetry virtualenv: Cached per OS/Python version/poetry.lock hash +# - Pip cache: Cached per OS/Python version for faster package resolution +# - Playwright browsers: Cached per OS/Playwright version (test job only) +name: PR Check + +on: + push: + branches: [main] + pull_request: + branches: [main] + +# Cancel in-progress runs for the same branch +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + POETRY_VERSION: "1.8.3" + PYTHON_KEYRING_BACKEND: "keyring.backends.null.Keyring" + # Playwright browser cache location (Linux) + PLAYWRIGHT_BROWSERS_PATH: ~/.cache/ms-playwright + +jobs: + lint: + name: Lint (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + # Continue even if linting fails - reports issues without blocking + continue-on-error: true + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + version: ${{ env.POETRY_VERSION }} + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Cache pip downloads + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: pip-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }} + restore-keys: | + pip-${{ runner.os }}-${{ matrix.python-version }}- + + - name: Cache Poetry virtualenv + uses: actions/cache@v4 + id: cache-deps + with: + path: .venv + key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }} + restore-keys: | + venv-${{ runner.os }}-${{ matrix.python-version }}- + + - name: Install dependencies + run: poetry install --no-interaction --no-root + + - name: Run Ruff linting + run: poetry run ruff check . --output-format=github + + typecheck: + name: Type Check (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + # Continue even if type checking fails - reports issues without blocking + continue-on-error: true + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + version: ${{ env.POETRY_VERSION }} + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Cache pip downloads + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: pip-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }} + restore-keys: | + pip-${{ runner.os }}-${{ matrix.python-version }}- + + - name: Cache Poetry virtualenv + uses: actions/cache@v4 + id: cache-deps + with: + path: .venv + key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }} + restore-keys: | + venv-${{ runner.os }}-${{ matrix.python-version }}- + + - name: Install dependencies + run: poetry install --no-interaction --no-root + + - name: Run Pyright type checking + # Use exit 0 to always succeed - Pyright will still report issues in the log + run: poetry run pyright || true + + test: + name: Test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + timeout-minutes: 20 # Prevent stuck jobs from blocking indefinitely + # Remove dependency on lint/typecheck - tests should run regardless + # Tests are the most critical check for functionality + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + version: ${{ env.POETRY_VERSION }} + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Cache pip downloads + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: pip-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }} + restore-keys: | + pip-${{ runner.os }}-${{ matrix.python-version }}- + + - name: Cache Poetry virtualenv + uses: actions/cache@v4 + id: cache-deps + with: + path: .venv + key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }} + restore-keys: | + venv-${{ runner.os }}-${{ matrix.python-version }}- + + - name: Install dependencies + run: poetry install --no-interaction --no-root + + - name: Get Playwright version + id: playwright-version + run: | + PLAYWRIGHT_VERSION=$(poetry run python -c "import playwright; print(playwright.__version__)" 2>/dev/null || echo "unknown") + echo "version=$PLAYWRIGHT_VERSION" >> $GITHUB_OUTPUT + + - name: Cache Playwright browsers + uses: actions/cache@v4 + id: cache-playwright + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} + restore-keys: | + playwright-${{ runner.os }}- + + - name: Install Playwright browsers + if: steps.cache-playwright.outputs.cache-hit != 'true' + run: poetry run playwright install chromium --with-deps + + - name: Install Playwright system dependencies + if: steps.cache-playwright.outputs.cache-hit == 'true' + run: poetry run playwright install-deps chromium + + - name: Run pytest with coverage + run: | + poetry run pytest \ + -n auto \ + --dist=loadfile \ + -m "not integration" \ + --cov-report=xml \ + --cov-report=term-missing \ + --junitxml=test-results.xml + env: + LAUNCH_MODE: test + STREAM_PORT: "0" + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-report-${{ matrix.python-version }} + path: coverage.xml + retention-days: 7 + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-${{ matrix.python-version }} + path: test-results.xml + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000000000000000000000000000000000..41d976e18d34351142faf9c780541c1cee1b1f2c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,441 @@ +# Automated Release Workflow for AI Studio Proxy API +# Creates GitHub releases with source code archives when version tags are pushed, +# on pushes to main (nightly builds), or manually triggered via workflow_dispatch. +# +# Release Types: +# - Stable: Push a tag (git tag v0.1.0 && git push origin v0.1.0) +# - Nightly: Automatic on every push to main branch (rolling release) +# - Manual: Actions -> Release -> Run workflow -> Enter version (e.g., v0.2.0) +# The tag will be auto-created on the current HEAD if it doesn't exist. + +name: Release + +on: + # Trigger on pushes to main branch for nightly builds + push: + branches: + - main + tags: + - 'v*.*.*' # Matches v1.0.0, v2.1.3, etc. + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., v0.1.0 or 0.1.0)' + required: true + type: string + +# Ensure only one release workflow runs at a time +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: write # Required for creating releases + +jobs: + # =========================================================================== + # Stable Release Job + # =========================================================================== + # Runs on tag pushes (v*.*.*) or manual workflow_dispatch + # Creates versioned, stable releases + release: + name: Create Release + runs-on: ubuntu-latest + # Only run on tag pushes or manual triggers (not on main branch pushes) + if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' + + steps: + - name: Validate version format (manual trigger) + if: github.event_name == 'workflow_dispatch' + run: | + VERSION_RAW="${{ github.event.inputs.version }}" + + if [[ "$VERSION_RAW" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then + VERSION="v$VERSION_RAW" + else + VERSION="$VERSION_RAW" + fi + + if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then + echo "::error::Invalid version format '$VERSION_RAW'. Expected format: v1.2.3, 1.2.3, or pre-release variants like v1.2.3-beta.1" + exit 1 + fi + echo "Version format validated: $VERSION" + + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Full history for changelog generation + # For workflow_dispatch, checkout default branch first, then verify/checkout tag + # For tag pushes, checkout the tag directly + ref: ${{ github.event_name == 'workflow_dispatch' && '' || github.ref }} + + - name: Determine version + id: version + run: | + if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + VERSION_RAW="${{ github.event.inputs.version }}" + if [[ "$VERSION_RAW" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then + VERSION="v$VERSION_RAW" + else + VERSION="$VERSION_RAW" + fi + else + VERSION="${GITHUB_REF#refs/tags/}" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT + echo "Release version: $VERSION" + + - name: Setup Git for tagging (manual trigger) + if: github.event_name == 'workflow_dispatch' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Create or verify tag (manual trigger) + if: github.event_name == 'workflow_dispatch' + run: | + VERSION="${{ steps.version.outputs.version }}" + # Fetch all tags to ensure we have the latest + git fetch --tags --force + + if git rev-parse "refs/tags/$VERSION" >/dev/null 2>&1; then + echo "✓ Tag $VERSION already exists" + echo "Checking out existing tag..." + git checkout "refs/tags/$VERSION" + else + echo "Tag $VERSION does not exist, creating it on current HEAD..." + CURRENT_SHA=$(git rev-parse HEAD) + echo "Creating tag $VERSION at commit $CURRENT_SHA" + + # Create the tag locally + git tag -a "$VERSION" -m "Release $VERSION" + + # Push the tag to remote + git push origin "$VERSION" + + echo "✓ Tag $VERSION created and pushed successfully" + fi + + - name: Generate changelog + id: changelog + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${{ steps.version.outputs.version }}" + + # Find the previous tag for comparison + PREVIOUS_TAG=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+' | grep -v "^$VERSION$" | head -n 1) + + if [ -n "$PREVIOUS_TAG" ]; then + echo "Generating changelog from $PREVIOUS_TAG to $VERSION" + + # Generate commit log grouped by type + CHANGELOG=$(cat << 'CHANGELOG_EOF' + ## What's Changed + + CHANGELOG_EOF + ) + + TEMP_FILE=$(mktemp) + + while IFS= read -r commit_sha || [ -n "$commit_sha" ]; do + if [ -z "$commit_sha" ]; then + continue + fi + + commit_msg=$(git log -1 --pretty=format:"%s" "$commit_sha") + short_sha=$(git rev-parse --short "$commit_sha") + git_author=$(git log -1 --pretty=format:"%an" "$commit_sha") + + api_response=$(gh api "repos/${{ github.repository }}/commits/$commit_sha" 2>/dev/null || echo '{}') + github_username=$(echo "$api_response" | jq -r '.author.login // empty') + + if [ -n "$github_username" ]; then + echo "- ${commit_msg} by @${github_username} (${short_sha})" >> "$TEMP_FILE" + else + echo "- ${commit_msg} by ${git_author} (${short_sha})" >> "$TEMP_FILE" + fi + done < <(git log --pretty=format:"%H" "$PREVIOUS_TAG..$VERSION" 2>/dev/null || true) + + if [ -s "$TEMP_FILE" ]; then + CHANGELOG="$CHANGELOG + $(cat "$TEMP_FILE")" + else + CHANGELOG="$CHANGELOG + - Various improvements and updates" + fi + + rm -f "$TEMP_FILE" + + CHANGELOG="$CHANGELOG + + **Full Changelog**: https://github.com/${{ github.repository }}/compare/$PREVIOUS_TAG...$VERSION" + else + echo "No previous tag found, this appears to be the first release" + CHANGELOG="## What's Changed + + This is the first automated release in this repository. + + See the commit history for details." + + fi + + # Write to file for use in release + echo "$CHANGELOG" > CHANGELOG.md + echo "Changelog generated successfully" + + - name: Build release body + id: release_body + run: | + VERSION="${{ steps.version.outputs.version }}" + VERSION_NUMBER="${{ steps.version.outputs.version_number }}" + + cat > RELEASE_BODY.md << 'EOF' + ${{ steps.version.outputs.version }} brings improvements and updates to the AI Studio Proxy API. + + EOF + + # Append the generated changelog + cat CHANGELOG.md >> RELEASE_BODY.md + + cat >> RELEASE_BODY.md << 'EOF' + + --- + + ## Installation + + ### Quick Start + ```bash + # Clone the repository + git clone https://github.com/${{ github.repository }}.git + cd AIstudioProxyAPI + + # Install dependencies with Poetry + poetry install + + # Run the server + poetry run python server.py + ``` + + ### Docker + ```bash + docker-compose -f docker/docker-compose.yml up -d + ``` + + For full installation and configuration details, see the [README](https://github.com/${{ github.repository }}/blob/main/README.md). + + ## Source Code + + Source code archives (zip and tar.gz) are automatically attached below. + + --- + + **Thank you to all contributors!** + EOF + + echo "Release body generated successfully" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: "AI Studio Proxy API ${{ steps.version.outputs.version }}" + tag_name: ${{ steps.version.outputs.version }} + body_path: RELEASE_BODY.md + draft: false + prerelease: ${{ contains(steps.version.outputs.version, '-') }} + generate_release_notes: false # We generate our own + make_latest: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Release summary + run: | + VERSION="${{ steps.version.outputs.version }}" + echo "## Release Created Successfully!" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Version:** $VERSION" >> $GITHUB_STEP_SUMMARY + echo "**Release URL:** https://github.com/${{ github.repository }}/releases/tag/$VERSION" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Included Assets" >> $GITHUB_STEP_SUMMARY + echo "- Source code (zip)" >> $GITHUB_STEP_SUMMARY + echo "- Source code (tar.gz)" >> $GITHUB_STEP_SUMMARY + + # =========================================================================== + # Nightly Release Job + # =========================================================================== + # Runs on every push to main branch (not on tags) + # Creates/updates a rolling "nightly" release with the latest development code + nightly: + name: Create Nightly Release + runs-on: ubuntu-latest + # Only run on pushes to main branch, NOT on tag pushes + if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/') + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Full history for changelog generation + + - name: Get build info + id: build_info + run: | + # Get short commit SHA and date for versioning + SHORT_SHA=$(git rev-parse --short HEAD) + BUILD_DATE=$(date +'%Y-%m-%d') + BUILD_TIME=$(date +'%H:%M:%S UTC') + COMMIT_MSG=$(git log -1 --pretty=format:"%s") + + echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT + echo "build_date=$BUILD_DATE" >> $GITHUB_OUTPUT + echo "build_time=$BUILD_TIME" >> $GITHUB_OUTPUT + echo "commit_msg=$COMMIT_MSG" >> $GITHUB_OUTPUT + + echo "Build info: $SHORT_SHA @ $BUILD_DATE $BUILD_TIME" + + - name: Generate recent changes + id: changes + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Build nightly changelog from latest stable tag to HEAD + echo "## What's Changed" > NIGHTLY_CHANGES.md + echo "" >> NIGHTLY_CHANGES.md + + LATEST_STABLE_TAG=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1) + + if [ -n "$LATEST_STABLE_TAG" ]; then + echo "_Changes since ${LATEST_STABLE_TAG}_" >> NIGHTLY_CHANGES.md + echo "" >> NIGHTLY_CHANGES.md + echo "Using commit range: ${LATEST_STABLE_TAG}..HEAD" + else + echo "_No stable release tag found. Showing last 20 commits (first release scenario)._" >> NIGHTLY_CHANGES.md + echo "" >> NIGHTLY_CHANGES.md + echo "No stable tag found; falling back to last 20 commits" + fi + + TEMP_FILE=$(mktemp) + + while IFS= read -r commit_sha || [ -n "$commit_sha" ]; do + if [ -z "$commit_sha" ]; then + continue + fi + + commit_msg=$(git log -1 --pretty=format:"%s" "$commit_sha") + short_sha=$(git rev-parse --short "$commit_sha") + git_author=$(git log -1 --pretty=format:"%an" "$commit_sha") + + api_response=$(gh api "repos/${{ github.repository }}/commits/$commit_sha" 2>/dev/null || echo '{}') + github_username=$(echo "$api_response" | jq -r '.author.login // empty') + + if [ -n "$github_username" ]; then + echo "- ${commit_msg} by @${github_username} (${short_sha})" >> "$TEMP_FILE" + else + echo "- ${commit_msg} by ${git_author} (${short_sha})" >> "$TEMP_FILE" + fi + done < <( + if [ -n "$LATEST_STABLE_TAG" ]; then + git log --pretty=format:"%H" "${LATEST_STABLE_TAG}..HEAD" 2>/dev/null || true + else + git log --pretty=format:"%H" -20 2>/dev/null || true + fi + ) + + if [ -s "$TEMP_FILE" ]; then + cat "$TEMP_FILE" >> NIGHTLY_CHANGES.md + else + if [ -n "$LATEST_STABLE_TAG" ]; then + echo "- No commits found since ${LATEST_STABLE_TAG}" >> NIGHTLY_CHANGES.md + else + echo "- Various improvements and updates" >> NIGHTLY_CHANGES.md + fi + fi + + rm -f "$TEMP_FILE" + echo "" >> NIGHTLY_CHANGES.md + + echo "Recent changes generated" + + - name: Build nightly release body + run: | + cat > NIGHTLY_BODY.md << 'EOF' + ## ⚠️ Nightly Build (Development Version) + + **This is an automated nightly build from the `main` branch.** + + > **Warning**: This release may contain untested features, breaking changes, or bugs. + > For stable releases, please use a [versioned release](https://github.com/${{ github.repository }}/releases?q=v&expanded=true). + + ### Build Information + - **Commit**: `${{ steps.build_info.outputs.short_sha }}` + - **Date**: ${{ steps.build_info.outputs.build_date }} ${{ steps.build_info.outputs.build_time }} + - **Latest Change**: ${{ steps.build_info.outputs.commit_msg }} + + EOF + + cat NIGHTLY_CHANGES.md >> NIGHTLY_BODY.md + + cat >> NIGHTLY_BODY.md << 'EOF' + + --- + + ## Quick Start + + ```bash + # Clone the repository + git clone https://github.com/${{ github.repository }}.git + cd AIstudioProxyAPI + + # Install dependencies + poetry install + + # Run the server + poetry run python server.py + ``` + + --- + + *This nightly release is automatically updated on every push to the main branch.* + + **Thank you to all contributors!** + EOF + + echo "Nightly release body generated" + + - name: Delete existing nightly release + run: | + # Delete existing nightly release if it exists (to avoid accumulation) + echo "Checking for existing nightly release..." + if gh release view nightly &>/dev/null; then + echo "Deleting existing nightly release..." + gh release delete nightly --yes --cleanup-tag + echo "Existing nightly release deleted" + else + echo "No existing nightly release found" + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Create nightly release + uses: softprops/action-gh-release@v2 + with: + name: "Nightly Build (Latest)" + tag_name: nightly + body_path: NIGHTLY_BODY.md + draft: false + prerelease: true # Mark as prerelease since it's development code + generate_release_notes: false + make_latest: false # Don't mark nightly as "latest" - reserve that for stable releases + target_commitish: ${{ github.sha }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Nightly release summary + run: | + echo "## Nightly Release Updated!" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Commit:** ${{ steps.build_info.outputs.short_sha }}" >> $GITHUB_STEP_SUMMARY + echo "**Build Date:** ${{ steps.build_info.outputs.build_date }}" >> $GITHUB_STEP_SUMMARY + echo "**Release URL:** https://github.com/${{ github.repository }}/releases/tag/nightly" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml new file mode 100644 index 0000000000000000000000000000000000000000..2a639c573cfff370d62c0f3bea867e5393624056 --- /dev/null +++ b/.github/workflows/upstream-sync.yml @@ -0,0 +1,324 @@ +# ============================================================================= +# Upstream Sync Workflow +# ============================================================================= +# Automatically syncs this repository with the upstream repository. +# Creates a PR when new changes are detected from upstream. +# +# Local repository: current default branch (main) +# Upstream source: CJackHwang/AIstudioProxyAPI (main) +# +# CONFLICT HANDLING: +# README.md can conflict when this repository keeps custom docs/content. +# The workflow auto-resolves README.md conflicts by keeping local version. +# Other conflicts will fail the workflow and require manual intervention. +# ============================================================================= + +name: Sync with Upstream + +on: + # Run every 6 hours (at 00:00, 06:00, 12:00, 18:00 UTC) + # This ensures timely detection of upstream changes while avoiding excessive API calls + schedule: + - cron: '0 */6 * * *' + + # Allow manual trigger from GitHub Actions UI + workflow_dispatch: + inputs: + force_sync: + description: 'Force sync even if no new commits detected' + required: false + default: false + type: boolean + +# Ensure only one sync workflow runs at a time +concurrency: + group: upstream-sync + cancel-in-progress: true + +jobs: + sync: + name: Sync Repository with Upstream + runs-on: ubuntu-latest + + # Required permissions for creating PRs and pushing branches + permissions: + contents: write + pull-requests: write + + steps: + # ----------------------------------------------------------------------- + # Step 1: Checkout current repository + # ----------------------------------------------------------------------- + - name: Checkout Repository + uses: actions/checkout@v4 + with: + # Fetch all history to properly compare with upstream + fetch-depth: 0 + # Use the default GITHUB_TOKEN + token: ${{ secrets.GITHUB_TOKEN }} + + # ----------------------------------------------------------------------- + # Step 2: Configure Git identity for commits + # ----------------------------------------------------------------------- + - name: Configure Git Identity + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + # ----------------------------------------------------------------------- + # Step 3: Add upstream remote and fetch latest changes + # ----------------------------------------------------------------------- + - name: Add Upstream Remote + run: | + echo "Adding upstream remote..." + git remote add upstream https://github.com/CJackHwang/AIstudioProxyAPI.git + + echo "Fetching upstream changes..." + git fetch upstream main --tags + + echo "Upstream remote added and fetched successfully." + + # ----------------------------------------------------------------------- + # Step 4: Check for new commits from upstream + # ----------------------------------------------------------------------- + - name: Check for Upstream Changes + id: check_changes + run: | + echo "Comparing local main with upstream/main..." + + # Get commit counts + LOCAL_COMMIT=$(git rev-parse HEAD) + UPSTREAM_COMMIT=$(git rev-parse upstream/main) + + echo "Local HEAD: $LOCAL_COMMIT" + echo "Upstream HEAD: $UPSTREAM_COMMIT" + + # Count commits ahead and behind + COMMITS_BEHIND=$(git rev-list --count HEAD..upstream/main) + COMMITS_AHEAD=$(git rev-list --count upstream/main..HEAD) + + echo "Commits behind upstream: $COMMITS_BEHIND" + echo "Commits ahead of upstream: $COMMITS_AHEAD" + + # Determine if sync is needed + if [ "$COMMITS_BEHIND" -gt 0 ]; then + echo "has_changes=true" >> $GITHUB_OUTPUT + echo "commits_behind=$COMMITS_BEHIND" >> $GITHUB_OUTPUT + echo "::notice::Found $COMMITS_BEHIND new commit(s) from upstream" + else + echo "has_changes=false" >> $GITHUB_OUTPUT + echo "commits_behind=0" >> $GITHUB_OUTPUT + echo "::notice::Fork is up to date with upstream" + fi + + # Get the date for PR title + echo "sync_date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT + + # Get recent upstream commit messages for PR body + if [ "$COMMITS_BEHIND" -gt 0 ]; then + echo "Getting recent upstream commits..." + COMMIT_LOG=$(git log --oneline HEAD..upstream/main | head -20) + + # Write commit log to file for multi-line output + echo "$COMMIT_LOG" > /tmp/commit_log.txt + + # Use delimiter for multi-line output + { + echo 'commit_log<> $GITHUB_OUTPUT + fi + + # ----------------------------------------------------------------------- + # Step 5: Create sync branch with upstream changes + # ----------------------------------------------------------------------- + # CONFLICT RESOLUTION STRATEGY: + # 1. Attempt merge with upstream + # 2. If conflicts occur, check if ONLY expected files conflict (README.md) + # 3. Auto-resolve expected conflicts by keeping fork's version (--ours) + # 4. Fail only if unexpected files have conflicts + # ----------------------------------------------------------------------- + - name: Create Sync Branch + id: create_branch + if: steps.check_changes.outputs.has_changes == 'true' || github.event.inputs.force_sync == 'true' + run: | + BRANCH_NAME="sync/upstream-${{ steps.check_changes.outputs.sync_date }}" + echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT + + echo "Creating sync branch: $BRANCH_NAME" + + # Check if branch already exists remotely + if git ls-remote --heads origin "$BRANCH_NAME" | grep -q "$BRANCH_NAME"; then + echo "::warning::Sync branch already exists. Deleting and recreating..." + git push origin --delete "$BRANCH_NAME" || true + fi + + # Create new branch from current main + git checkout -b "$BRANCH_NAME" + + echo "Merging upstream/main into sync branch..." + + # ===================================================================== + # EXPECTED CONFLICT FILES (Translation Fork) + # These files are expected to conflict because they are intentionally + # different in this English fork vs the Chinese upstream. + # ===================================================================== + EXPECTED_CONFLICT_FILES="README.md" + + # Attempt merge with upstream + # Using --no-edit to auto-generate merge commit message + if git merge upstream/main --no-edit --allow-unrelated-histories; then + echo "merge_success=true" >> $GITHUB_OUTPUT + echo "auto_resolved=false" >> $GITHUB_OUTPUT + echo "::notice::Merge successful (no conflicts)" + else + echo "::warning::Merge conflicts detected. Checking if auto-resolvable..." + + # Get list of conflicting files + CONFLICTING_FILES=$(git diff --name-only --diff-filter=U) + echo "Conflicting files:" + echo "$CONFLICTING_FILES" + + # Check if all conflicts are in expected files + UNEXPECTED_CONFLICTS="" + for file in $CONFLICTING_FILES; do + IS_EXPECTED=false + for expected in $EXPECTED_CONFLICT_FILES; do + if [ "$file" = "$expected" ]; then + IS_EXPECTED=true + break + fi + done + + if [ "$IS_EXPECTED" = false ]; then + UNEXPECTED_CONFLICTS="$UNEXPECTED_CONFLICTS $file" + fi + done + + # Trim whitespace + UNEXPECTED_CONFLICTS=$(echo "$UNEXPECTED_CONFLICTS" | xargs) + + if [ -n "$UNEXPECTED_CONFLICTS" ]; then + # Unexpected conflicts found - fail the workflow + echo "merge_success=false" >> $GITHUB_OUTPUT + echo "auto_resolved=false" >> $GITHUB_OUTPUT + echo "::error::Unexpected conflicts in: $UNEXPECTED_CONFLICTS" + echo "::error::Manual intervention required for these files." + + # Abort the merge to leave clean state + git merge --abort + exit 1 + else + # Only expected conflicts - auto-resolve by keeping fork's version + echo "::notice::All conflicts are in expected files. Auto-resolving..." + + for file in $CONFLICTING_FILES; do + echo " Resolving $file by keeping fork's version (--ours)..." + git checkout --ours "$file" + git add "$file" + done + + # Complete the merge with resolved conflicts + git commit --no-edit + + echo "merge_success=true" >> $GITHUB_OUTPUT + echo "auto_resolved=true" >> $GITHUB_OUTPUT + echo "::notice::Merge successful (auto-resolved expected conflicts)" + fi + fi + + # ----------------------------------------------------------------------- + # Step 6: Push sync branch to origin + # ----------------------------------------------------------------------- + - name: Push Sync Branch + if: steps.create_branch.outputs.merge_success == 'true' + run: | + echo "Pushing sync branch to origin..." + git push origin "${{ steps.create_branch.outputs.branch_name }}" + echo "::notice::Sync branch pushed successfully" + + # ----------------------------------------------------------------------- + # Step 7: Create Pull Request + # ----------------------------------------------------------------------- + - name: Create Pull Request + if: steps.create_branch.outputs.merge_success == 'true' + uses: peter-evans/create-pull-request@v7 + id: create_pr + with: + token: ${{ secrets.GITHUB_TOKEN }} + branch: ${{ steps.create_branch.outputs.branch_name }} + base: main + title: "chore: Sync with upstream [${{ steps.check_changes.outputs.sync_date }}]" + body: | + ## Upstream Sync + + This PR syncs the fork with the upstream repository. + + ### Summary + - **Upstream Repository**: [CJackHwang/AIstudioProxyAPI](https://github.com/CJackHwang/AIstudioProxyAPI) + - **Commits Behind**: ${{ steps.check_changes.outputs.commits_behind }} + - **Sync Date**: ${{ steps.check_changes.outputs.sync_date }} + - **Auto-resolved Conflicts**: ${{ steps.create_branch.outputs.auto_resolved == 'true' && '✅ Yes (README.md kept from fork)' || '❌ None' }} + + ### Recent Upstream Commits + ``` + ${{ steps.check_changes.outputs.commit_log }} + ``` + + ### Review Notes + - Please review changes carefully before merging + - Check for any conflicts with fork-specific modifications + - Ensure translation/localization files are preserved + ${{ steps.create_branch.outputs.auto_resolved == 'true' && '- ⚠️ **README.md conflict was auto-resolved** - The English README from this fork was preserved' || '' }} + + --- + *This PR was automatically generated by the [Upstream Sync Workflow](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})* + labels: | + upstream-sync + automated + draft: false + delete-branch: false + + # ----------------------------------------------------------------------- + # Step 8: Output Results + # ----------------------------------------------------------------------- + - name: Output Results + if: always() + run: | + echo "==========================================" + echo "Upstream Sync Workflow Complete" + echo "==========================================" + echo "" + + if [ "${{ steps.check_changes.outputs.has_changes }}" == "true" ]; then + echo "Status: Changes detected from upstream" + echo "Commits behind: ${{ steps.check_changes.outputs.commits_behind }}" + + if [ "${{ steps.create_branch.outputs.merge_success }}" == "true" ]; then + echo "Merge: Successful" + + if [ "${{ steps.create_branch.outputs.auto_resolved }}" == "true" ]; then + echo "Conflicts: Auto-resolved (expected files only)" + echo " - README.md: Kept fork's English version" + else + echo "Conflicts: None" + fi + + echo "PR Created: Yes" + echo "Branch: ${{ steps.create_branch.outputs.branch_name }}" + else + echo "Merge: Failed (unexpected conflicts detected)" + echo "PR Created: No" + echo "" + echo "Manual intervention required to resolve conflicts." + echo "Expected conflict files (auto-resolved): README.md" + echo "Unexpected conflicts require manual resolution." + fi + else + echo "Status: Fork is up to date with upstream" + echo "No action required." + fi + + echo "" + echo "==========================================" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..c6eaba87397e9babc6a952902217fd2a8639ab14 --- /dev/null +++ b/.gitignore @@ -0,0 +1,296 @@ +# Logs +logs +*.log +certs/* +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +/upload_images +/upload_files + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov +coverage_*.txt +coverage_*.html + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env.development.local +.env.test.local +.env.production.local +.env.local + +# Windows artifacts +NUL + +# Test backup files +*.backup + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the next line if you're using Gatsby Cloud +# .gatsby/ +public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# macOS files +.DS_Store +.AppleDouble +.LSOverride + +# Thumbnails +._* + +# Files that might appear on external disk +.Spotlight-V100 +.Trashes + +# Temporary files created by editors +*~ +#*.swp + +# IDE config folders +.idea/ +.vscode/ + +# Custom +errors/ + +# Python +__pycache__/ +*.py[cod] +*$py.class + +# Python Libraries +*.egg-info/ +*.egg + +# Distribution / packaging +.Python +build/ +dist/ +part/ +sdist/ +*.manifest +*.spec +wheels/ + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +.testmondata* + +# Environments +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Jupyter Notebook +.ipynb_checkpoints +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Error snapshots directory (Python specific) +# New date-based structure: errors_py/YYYY-MM-DD/HH-MM-SS_reqid_errorname/ +errors_py/*/ +!errors_py/.gitkeep +logs/ +errors_py + +# Authentication Profiles (Sensitive) +auth_profiles/active/* +!auth_profiles/active/.gitkeep +auth_profiles/saved/* +!auth_profiles/saved/.gitkeep +auth_profiles/emergency/* +!auth_profiles/emergency/.gitkeep +config/profile_usage.json +config/cooldown_status.json +auth_profiles/locked +auth_profilesBackup + +# Camoufox/Playwright Profile Data (Assume these are generated/temporary) +camoufox_profile/ +chrome_temp_profile/ + +# Deprecated Javascript Version node_modules +deprecated_javascript_version/node_modules/ + +.roomodes +memory-bank/ +gui_config.json +.rooignore +.kiloignore +.kilocodeignore + +# key +key.txt + +# 脚本注入相关文件 +# 用户自定义的模型配置文件(保留示例文件) +browser_utils/model_configs.json +browser_utils/my_*.json +# 用户自定义的油猴脚本(如果不是默认的) +browser_utils/custom_*.js +browser_utils/my_*.js +# 临时生成的脚本文件 +browser_utils/generated_*.js +# Docker 环境的实际配置文件(保留示例文件) +docker/.env +docker/my_*.json + +monkeytype.sqlite3 + +# Pyright output +baseline_pyright.txt +pyright_*.txt + +# Ruff cache (explicit) +.ruff_cache/ + +# Temporary debug/output files +pyright_output.txt +pyright_utils_full.txt +temp_*.txt +temp_*.md +utils_errors.txt +# React frontend build artifacts and dependencies +static/frontend/node_modules/ +static/frontend/coverage/ +static/frontend/dist/ + +# GUI Launcher user config (contains user-specific settings) +gui/user_config.json + +.multi-instance-runtime/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000000000000000000000000000000000..eb141d2ad7b4eaf6499459122f6ae19efc62336b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,73 @@ +# 贡献指南 + +感谢你参与 AI Studio Proxy API 的改进。 + +## 本地开发准备 + +```bash +git clone https://github.com/CJackHwang/AIstudioProxyAPI.git +cd AIstudioProxyAPI +poetry install --with dev +``` + +## 提交前检查(必须) + +```bash +poetry run ruff check . +poetry run pyright +poetry run pytest +``` + +如涉及前端改动,请额外执行: + +```bash +cd static/frontend +npm ci +npm run build +npm run test +``` + +## 分支与提交规范 + +- 新功能:`feat/...` +- 缺陷修复:`fix/...` +- 文档改动:`docs/...` +- 重构:`refactor/...` + +建议使用 Conventional Commits: + +- `feat:` 新能力 +- `fix:` 缺陷修复 +- `docs:` 文档更新 +- `refactor:` 重构 +- `test:` 测试改进 +- `chore:` 工程性调整 + +## Pull Request 要求 + +- 说明变更动机、核心实现和影响范围。 +- 如涉及配置/接口变更,必须更新文档。 +- 引入新环境变量时,必须同步更新 `.env.example`。 +- 通过 CI 检查后再请求合并。 + +## CI/CD 工作流 + +- `PR Check`:运行 lint/typecheck/tests。 +- `Release`:tag 或手动触发发布。 +- `Sync with Upstream`:从上游仓库同步提交并自动建 PR。 + +## 参考文档 + +- [快速开始](docs/quick-start.md) +- [配置参考](docs/configuration-reference.md) +- [排障指南](docs/troubleshooting.md) +- [开发、测试与发布](docs/development-and-release.md) + +## Issue 反馈建议 + +请尽量提供: + +- 复现步骤 +- 期望行为与实际行为 +- Python 版本 / 操作系统 +- 相关日志(如 `logs/`、`errors_py/`) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..13373fdf412ce7195815fcf7bf49b4052ca93f25 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,98 @@ +# Hugging Face Spaces Dockerfile. +# The original Docker setup lives in docker/Dockerfile; this root Dockerfile is +# required by Spaces and keeps the public HTTP service on port 7860. +FROM python:3.10-slim-bookworm AS builder + +ARG DEBIAN_FRONTEND=noninteractive +ARG PROXY_ADDR + +RUN if [ -n "$PROXY_ADDR" ]; then \ + printf 'Acquire::http::Proxy "%s";\nAcquire::https::Proxy "%s";\n' "$PROXY_ADDR" "$PROXY_ADDR" > /etc/apt/apt.conf.d/99proxy; \ + fi && \ + apt-get update && \ + apt-get install -y --no-install-recommends curl && \ + apt-get clean && rm -rf /var/lib/apt/lists/* && \ + if [ -n "$PROXY_ADDR" ]; then rm -f /etc/apt/apt.conf.d/99proxy; fi + +ENV HTTP_PROXY=${PROXY_ADDR} +ENV HTTPS_PROXY=${PROXY_ADDR} +ENV POETRY_HOME="/opt/poetry" +ENV POETRY_VERSION=1.8.3 +ENV PATH="${POETRY_HOME}/bin:${PATH}" + +RUN curl -sSL https://install.python-poetry.org | python3 - --version ${POETRY_VERSION} + +WORKDIR /app_builder +COPY pyproject.toml poetry.lock ./ +RUN poetry config virtualenvs.create false --local && \ + poetry install --only main --no-root --no-interaction --no-ansi + +FROM python:3.10-slim-bookworm + +ARG DEBIAN_FRONTEND=noninteractive +ARG PROXY_ADDR + +ENV HTTP_PROXY=${PROXY_ADDR} +ENV HTTPS_PROXY=${PROXY_ADDR} + +RUN if [ -n "$PROXY_ADDR" ]; then \ + printf 'Acquire::http::Proxy "%s";\nAcquire::https::Proxy "%s";\n' "$PROXY_ADDR" "$PROXY_ADDR" > /etc/apt/apt.conf.d/99proxy; \ + fi && \ + apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates curl fonts-liberation \ + libasound2 libatk-bridge2.0-0 libatk1.0-0 libcups2 libdbus-1-3 \ + libdrm2 libgbm1 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 \ + libpangocairo-1.0-0 libu2f-udev libx11-6 libx11-xcb1 libxcb1 \ + libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 \ + libxrender1 libxtst6 && \ + apt-get clean && rm -rf /var/lib/apt/lists/* && \ + if [ -n "$PROXY_ADDR" ]; then rm -f /etc/apt/apt.conf.d/99proxy; fi + +RUN groupadd -r appgroup && useradd -r -g appgroup -s /bin/bash -d /app appuser + +WORKDIR /app + +COPY --from=builder /usr/local/lib/python3.10/site-packages/ /usr/local/lib/python3.10/site-packages/ +COPY --from=builder /usr/local/bin/ /usr/local/bin/ +COPY --from=builder /opt/poetry/bin/poetry /usr/local/bin/poetry +COPY . . + +RUN camoufox fetch && \ + python -m playwright install firefox && \ + python scripts/update_browserforge_data.py + +RUN mkdir -p /var/cache/camoufox && \ + if [ -d /root/.cache/camoufox ]; then cp -a /root/.cache/camoufox/. /var/cache/camoufox/; fi && \ + mkdir -p /app/.cache && \ + ln -s /var/cache/camoufox /app/.cache/camoufox && \ + mkdir -p /app/logs \ + /app/auth_profiles/active \ + /app/auth_profiles/saved \ + /app/auth_profiles/emergency \ + /app/certs \ + /app/browser_utils/custom_scripts \ + /home/appuser/.cache/ms-playwright \ + /home/appuser/.mozilla && \ + chown -R appuser:appgroup /app /home/appuser /var/cache/camoufox + +USER appuser + +ENV HOME=/app +ENV PLAYWRIGHT_BROWSERS_PATH=/home/appuser/.cache/ms-playwright +ENV PYTHONUNBUFFERED=1 +ENV PORT=7860 +ENV SERVER_PORT=7860 +ENV DEFAULT_FASTAPI_PORT=7860 +ENV DEFAULT_CAMOUFOX_PORT=9222 +ENV STREAM_PORT=3120 +ENV SERVER_LOG_LEVEL=INFO +ENV DEBUG_LOGS_ENABLED=false +ENV AUTO_CONFIRM_LOGIN=true +ENV INTERNAL_CAMOUFOX_PROXY="" +ENV HTTP_PROXY="" +ENV HTTPS_PROXY="" + +EXPOSE 7860 + +CMD ["python", "scripts/huggingface/entrypoint.py"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..0ad25db4bd1d86c452db3f9602ccdbe172438f52 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..33f16e5117461b0962a285da63e19e9a437b79ef --- /dev/null +++ b/README.md @@ -0,0 +1,214 @@ +--- +title: AI Studio Proxy API +sdk: docker +app_port: 7860 +license: agpl-3.0 +--- + +# AI Studio Proxy API + +将 Google AI Studio 网页界面转换为 OpenAI 兼容 API 的代理服务。通过 Camoufox + Playwright 自动化,提供稳定可控的 API 访问。 + +[![Star History Chart](https://api.star-history.com/svg?repos=CJackHwang/AIstudioProxyAPI&type=Date)](https://www.star-history.com/#CJackHwang/AIstudioProxyAPI&Date) + +--- + +## 主要特性 + +- **OpenAI 兼容 API**:支持 `/v1/chat/completions`、`/v1/models` +- **函数调用三模式**:`auto` / `native` / `emulated`,支持失败回退 +- **认证轮转与 Cookie 刷新**:支持 profile 自动轮转、周期刷新与关停保存 +- **启动链路完整**:CLI 启动器、内置 Web UI、桌面 GUI 启动器 +- **现代化前端**:内置设置页、状态检查与日志能力 +- **CI/CD 工作流**:PR 检查、Release、Upstream Sync + +## 系统要求 + +| 组件 | 要求 | 推荐 | +| --- | --- | --- | +| Python | >=3.9, <4.0 | 3.10+ / 3.11+ | +| 依赖管理 | Poetry | 最新版本 | +| Node.js | 前端构建需要 | LTS | +| 内存 | >=2GB | >=4GB | + +--- + +## 🚀 快速开始 + +### 1. 克隆并安装 + +```bash +git clone https://github.com/CJackHwang/AIstudioProxyAPI.git +cd AIstudioProxyAPI +poetry install --with dev +``` + +### 2. 配置环境 + +```bash +cp .env.example .env +``` + +建议先确认:`PORT`、`STREAM_PORT`、`UNIFIED_PROXY_CONFIG`、`LAUNCH_MODE`、`FUNCTION_CALLING_MODE`。 + +### 3. 首次认证并启动 + +```bash +# 首次建议 debug,完成登录并保存 auth +poetry run python launch_camoufox.py --debug + +# 日常建议 headless +poetry run python launch_camoufox.py --headless +``` + +### 快速测试 + +```bash +# 健康检查 +curl http://127.0.0.1:2048/health + +# 模型列表 +curl http://127.0.0.1:2048/v1/models + +# 聊天请求 +curl -X POST http://127.0.0.1:2048/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{"model":"gemini-2.5-pro","messages":[{"role":"user","content":"你好"}]}' +``` + +访问 `http://127.0.0.1:2048/` 使用内置 Web UI。 + +--- + +## 系统架构 + +```mermaid +graph TD + subgraph "用户端" + User["用户"] + WebUI["Web UI"] + APIClient["API 客户端"] + end + + subgraph "启动与配置" + Launcher["launch_camoufox.py"] + Env[".env 配置"] + end + + subgraph "核心服务" + FastAPI["FastAPI 应用
api_utils/"] + BrowserOps["页面控制与自动化
browser_utils/"] + StreamProxy["流式代理
stream/"] + end + + subgraph "外部依赖" + Camoufox["Camoufox 浏览器"] + AIStudio["Google AI Studio"] + end + + User --> Launcher + Launcher --> Env + WebUI --> FastAPI + APIClient --> FastAPI + FastAPI --> BrowserOps + FastAPI --> StreamProxy + BrowserOps --> Camoufox --> AIStudio + StreamProxy --> AIStudio +``` + +--- + +## 运行模式 + +| 命令 | 说明 | 场景 | +| --- | --- | --- | +| `python launch_camoufox.py --headless` | 无头模式 | 日常使用、服务器 | +| `python launch_camoufox.py --debug` | 调试模式 | 首次认证、故障排查 | +| `python launch_camoufox.py --virtual-display` | 虚拟显示 | Linux 无 GUI 环境 | + +--- + +## ⚙️ 配置 + +项目使用 `.env` 统一配置管理: + +```bash +cp .env.example .env +``` + +核心配置示例: + +| 配置 | 默认值 | 说明 | +| --- | --- | --- | +| `PORT` | `2048` | 主 API 端口 | +| `STREAM_PORT` | `3120` | 流式代理端口(`0` 关闭) | +| `UNIFIED_PROXY_CONFIG` | 空 | HTTP/HTTPS 代理 | +| `AUTO_ROTATE_AUTH_PROFILE` | `true` | 认证自动轮转 | +| `FUNCTION_CALLING_MODE` | `auto` | 函数调用模式 | + +详细项见:[配置参考](docs/configuration-reference.md) + +> 说明:配置默认值以 `.env.example` 为准;少数配置存在代码兜底默认值,详见配置参考中的说明。 + +--- + +## 📚 文档 + +- [文档总览](docs/README.md) +- [快速开始](docs/quick-start.md) +- [部署与运维指南](docs/deployment-and-operations.md) +- [API 使用说明](docs/api-usage.md) +- [函数调用模式](docs/function-calling.md) +- [认证轮转与 Cookie 刷新](docs/auth-rotation-cookie-refresh.md) +- [排障指南](docs/troubleshooting.md) +- [开发、测试与发布](docs/development-and-release.md) +- [多实例 Docker 管理器](scripts/multi-instance-manager/README.md) + +--- + +## 客户端配置示例 + +以 Open WebUI 为例: + +1. 进入设置 -> 连接 +2. API Base URL 填 `http://127.0.0.1:2048/v1` +3. 若你未配置 API Keys,可留空或填任意字符;若已配置,请填写有效 Key +4. 保存后即可对话 + +--- + +## 开发检查 + +```bash +poetry run ruff check . +poetry run pyright +poetry run pytest +``` + +前端构建: + +```bash +cd static/frontend +npm ci +npm run build +``` + +--- + +## 致谢 + +- **项目发起与主要开发**: [@CJackHwang](https://github.com/CJackHwang) +- **核心维护**(架构重构、测试体系): [@NikkeTryHard](https://github.com/NikkeTryHard) +- **功能完善、页面操作优化**: [@ayuayue](https://github.com/ayuayue) +- **实时流式功能优化**: [@luispater](https://github.com/luispater) +- **项目重构贡献**: [@yattin](https://github.com/yattin)(Holt) +- **下游维护分支致谢作者**: [@MasuRii](https://github.com/MasuRii) +- **社区支持**: [Linux.do 社区](https://linux.do/) + +## License + +[AGPLv3](LICENSE) + +## 支持作者 + +如果本项目对你有帮助,欢迎支持作者持续开发。 diff --git a/api_utils/__init__.py b/api_utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3d2632e5f2223dc4d28087fe703584ffb03b2f1a --- /dev/null +++ b/api_utils/__init__.py @@ -0,0 +1,74 @@ +""" +API Utilities Module +Provides FastAPI application initialization, route handlers, and utility functions +""" + +# Application initialization +from .app import create_app + +# Queue worker +from .queue_worker import queue_worker + +# Request processor +from .request_processor import ( + _process_request_refactored, # pyright: ignore[reportPrivateUsage] +) + +# Route handlers (aggregated from routers) +from .routers import ( + cancel_request, + chat_completions, + get_api_info, + get_queue_status, + health_check, + list_models, + read_index, + websocket_log_endpoint, +) +from .sse import ( + generate_sse_chunk, + generate_sse_error_chunk, + generate_sse_stop_chunk, +) + +# Utility functions +from .utils import prepare_combined_prompt +from .utils_ext.helper import use_helper_get_response +from .utils_ext.stream import ( + clear_stream_queue, + use_stream_response, +) +from .utils_ext.tokens import ( + calculate_usage_stats, + estimate_tokens, +) +from .utils_ext.validation import validate_chat_request + +__all__ = [ + # Application initialization + "create_app", + # Route handlers + "read_index", + "get_api_info", + "health_check", + "list_models", + "chat_completions", + "cancel_request", + "get_queue_status", + "websocket_log_endpoint", + # Utility functions + "generate_sse_chunk", + "generate_sse_stop_chunk", + "generate_sse_error_chunk", + "use_stream_response", + "clear_stream_queue", + "use_helper_get_response", + "validate_chat_request", + "prepare_combined_prompt", + "estimate_tokens", + "calculate_usage_stats", + # Request processor + "_process_request_refactored", + # Queue worker + "queue_worker", +] diff --git a/api_utils/app.py b/api_utils/app.py new file mode 100644 index 0000000000000000000000000000000000000000..93d7b2398e9e96992dcfe5256806caa33b7088c8 --- /dev/null +++ b/api_utils/app.py @@ -0,0 +1,454 @@ +""" +FastAPI application initialization and lifecycle management +""" + +import asyncio +import multiprocessing +import queue +import sys +import time +from asyncio import Lock, Queue +from contextlib import asynccontextmanager +from typing import Awaitable, Callable + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.types import ASGIApp + +import stream +from api_utils.server_state import state + +# --- browser_utils module imports --- +from browser_utils import ( + _close_page_logic, + _handle_initial_model_state_and_storage, + _initialize_page_logic, + enable_temporary_chat_mode, + load_excluded_models, +) + +# --- Configuration imports --- +from config import EXCLUDED_MODELS_FILENAME, NO_PROXY_ENV, get_environment_variable + +# --- logging_utils module imports --- +from logging_utils import restore_original_streams, setup_server_logging + +# --- models module imports --- +from models import WebSocketConnectionManager + +from . import auth_utils + +VERSION = "0.1.0" + + +# --- Lifespan Context Manager --- +def _setup_logging(): + log_level_env = get_environment_variable("SERVER_LOG_LEVEL", "INFO") + redirect_print_env = get_environment_variable("SERVER_REDIRECT_PRINT", "false") + state.log_ws_manager = WebSocketConnectionManager() + return setup_server_logging( + logger_instance=state.logger, + log_ws_manager=state.log_ws_manager, + log_level_name=log_level_env, + redirect_print_str=redirect_print_env, + ) + + +def _initialize_globals(): + from api_utils.server_state import state + + state.request_queue = Queue() + state.processing_lock = Lock() + state.model_switching_lock = Lock() + state.params_cache_lock = Lock() + + # Initialize model_list_fetch_event + state.model_list_fetch_event = asyncio.Event() + + auth_utils.initialize_keys() + + # Initialize Auth Rotation Lock + from config.global_state import GlobalState + + GlobalState.init_rotation_lock() + + state.logger.info("API keys and global locks initialized.") + + +def _initialize_proxy_settings(): + stream_port_env = get_environment_variable("STREAM_PORT") + if stream_port_env == "0": + proxy_server_url = get_environment_variable( + "HTTPS_PROXY" + ) or get_environment_variable("HTTP_PROXY") + else: + proxy_server_url = f"http://127.0.0.1:{stream_port_env or 3120}/" + + if proxy_server_url: + state.PLAYWRIGHT_PROXY_SETTINGS = {"server": proxy_server_url} + if NO_PROXY_ENV: + state.PLAYWRIGHT_PROXY_SETTINGS["bypass"] = NO_PROXY_ENV.replace(",", ";") + state.logger.info( + f"Playwright proxy settings configured: {state.PLAYWRIGHT_PROXY_SETTINGS}" + ) + else: + state.logger.info("No proxy configured for Playwright.") + + +async def _start_stream_proxy(): + stream_port_env = get_environment_variable("STREAM_PORT") + if stream_port_env != "0": + port = int(stream_port_env or 3120) + stream_proxy_server_env = ( + get_environment_variable("UNIFIED_PROXY_CONFIG") + or get_environment_variable("HTTPS_PROXY") + or get_environment_variable("HTTP_PROXY") + ) + state.logger.info( + f"Starting STREAM proxy on port {port} with upstream proxy: {stream_proxy_server_env}" + ) + state.STREAM_QUEUE = multiprocessing.Queue() + state.STREAM_PROCESS = multiprocessing.Process( + target=stream.start, + args=(state.STREAM_QUEUE, port, stream_proxy_server_env), + ) + state.STREAM_PROCESS.start() + state.logger.info("STREAM proxy process started. Waiting for 'READY' signal...") + + try: + ready_signal = await asyncio.to_thread(state.STREAM_QUEUE.get, timeout=15) + if ready_signal == "READY": + state.logger.info( + "[SUCCESS] Received 'READY' signal from STREAM proxy." + ) + else: + state.logger.warning( + f"Received unexpected signal from proxy: {ready_signal}" + ) + except queue.Empty: + state.logger.error( + "[ERROR] Timed out waiting for STREAM proxy to become ready. Startup will likely fail." + ) + raise RuntimeError("STREAM proxy failed to start in time.") + + +async def _initialize_browser_and_page(): + from playwright.async_api import async_playwright + + state.logger.info("Starting Playwright...") + state.playwright_manager = await async_playwright().start() + state.is_playwright_ready = True + state.logger.info("Playwright started.") + + ws_endpoint = get_environment_variable("CAMOUFOX_WS_ENDPOINT") + launch_mode = get_environment_variable("LAUNCH_MODE", "unknown") + + if not ws_endpoint and launch_mode != "direct_debug_no_browser": + raise ValueError("CAMOUFOX_WS_ENDPOINT environment variable is missing.") + + if ws_endpoint: + state.logger.info(f"Connecting to browser at: {ws_endpoint}") + state.browser_instance = await state.playwright_manager.firefox.connect( + ws_endpoint, timeout=30000 + ) + state.is_browser_connected = True + state.logger.info(f"Connected to browser: {state.browser_instance.version}") + + state.page_instance, state.is_page_ready = await _initialize_page_logic( + state.browser_instance + ) + if state.is_page_ready: + await _handle_initial_model_state_and_storage(state.page_instance) + await enable_temporary_chat_mode(state.page_instance) + state.logger.info("Page initialized successfully.") + else: + state.logger.error("Page initialization failed.") + state.page_instance = None + state.is_page_ready = False + state.current_ai_studio_model_id = None + + if not state.model_list_fetch_event.is_set(): + state.model_list_fetch_event.set() + + +async def _shutdown_resources(): + logger = state.logger + logger.info("Shutting down resources...") + + # Signal global shutdown if event exists + try: + from config import GlobalState + + if hasattr(GlobalState, "IS_SHUTTING_DOWN") and hasattr( + GlobalState.IS_SHUTTING_DOWN, "set" + ): + GlobalState.IS_SHUTTING_DOWN.set() + except Exception as e: + logger.debug(f"Failed to set IS_SHUTTING_DOWN: {e}") + + state.should_exit = True + + if state.STREAM_PROCESS: + try: + state.STREAM_PROCESS.terminate() + state.STREAM_PROCESS.join(timeout=3) + if state.STREAM_PROCESS.is_alive(): + logger.warning("STREAM proxy did not terminate, killing...") + state.STREAM_PROCESS.kill() + state.STREAM_PROCESS.join(timeout=1) + except Exception as e: + logger.error(f"Error terminating STREAM proxy: {e}") + finally: + if state.STREAM_QUEUE: + try: + state.STREAM_QUEUE.close() + state.STREAM_QUEUE.join_thread() + except Exception: + pass + state.STREAM_PROCESS = None + state.STREAM_QUEUE = None + logger.info("STREAM proxy terminated.") + + if state.worker_task and not state.worker_task.done(): + logger.info("Cancelling worker task...") + state.worker_task.cancel() + try: + await asyncio.wait_for(state.worker_task, timeout=2.0) + logger.info("Worker task cancelled.") + except asyncio.TimeoutError: + logger.warning("Worker task did not respond to cancellation within 2s.") + except asyncio.CancelledError: + logger.debug("Worker task cancellation acknowledged (CancelledError).") + except Exception as e: + logger.error(f"Error cancelling worker task: {e}") + finally: + state.worker_task = None + + if state.page_instance: + try: + await _close_page_logic() + except asyncio.CancelledError: + logger.debug("Page closure cancelled (CancelledError).") + except Exception as e: + logger.error(f"Error during page closure: {e}") + finally: + state.page_instance = None + state.is_page_ready = False + + if state.browser_instance: + try: + if state.browser_instance.is_connected(): + await state.browser_instance.close() + logger.info("Browser connection closed.") + except asyncio.CancelledError: + logger.debug("Browser closure cancelled (CancelledError).") + except Exception as e: + logger.error(f"Error during browser closure: {e}") + finally: + state.browser_instance = None + state.is_browser_connected = False + + if state.playwright_manager: + try: + await state.playwright_manager.stop() + logger.info("Playwright stopped.") + except asyncio.CancelledError: + logger.debug("Playwright stop cancelled (CancelledError).") + except Exception as e: + logger.error(f"Error stopping playwright: {e}") + finally: + state.playwright_manager = None + state.is_playwright_ready = False + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """FastAPI application lifecycle management""" + from .queue_worker import queue_worker + + original_streams = sys.stdout, sys.stderr + initial_stdout, initial_stderr = _setup_logging() + logger = state.logger + + _initialize_globals() + _initialize_proxy_settings() + load_excluded_models(EXCLUDED_MODELS_FILENAME) + + state.is_initializing = True + startup_start_time = time.time() + logger.info("Starting AI Studio Proxy Server...") + + try: + await _start_stream_proxy() + await _initialize_browser_and_page() + + launch_mode = get_environment_variable("LAUNCH_MODE", "unknown") + if state.is_page_ready or launch_mode == "direct_debug_no_browser": + state.worker_task = asyncio.create_task(queue_worker()) + logger.info("Request processing worker started.") + else: + raise RuntimeError("Failed to initialize browser/page, worker not started.") + + logger.info("[WATCHDOG] Starting Quota Watchdog Task...") + watchdog_func = state.quota_watchdog + if watchdog_func: + app.state.watchdog_task = asyncio.create_task(watchdog_func()) + else: + logger.warning( + "[WATCHDOG] Quota Watchdog function not found, task not started." + ) + + # Start periodic cookie refresh task + try: + from browser_utils.cookie_refresh import start_periodic_refresh + + cookie_refresh_task = start_periodic_refresh() + if cookie_refresh_task: + app.state.cookie_refresh_task = cookie_refresh_task + except Exception as e: + logger.warning(f"[COOKIE-REFRESH] Failed to start periodic refresh: {e}") + + startup_duration = time.time() - startup_start_time + logger.info(f"Server startup complete. (Took: {startup_duration:.2f}s)") + state.is_initializing = False + yield + except Exception as e: + logger.critical(f"Application startup failed: {e}", exc_info=True) + await _shutdown_resources() + raise RuntimeError(f"Application startup failed: {e}") from e + finally: + logger.info("Shutting down server...") + + # Stop periodic cookie refresh and save cookies before shutdown + if hasattr(app.state, "cookie_refresh_task"): + logger.info("[STOP] Stopping Cookie Refresh Task...") + try: + from browser_utils.cookie_refresh import ( + save_cookies_on_shutdown, + stop_periodic_refresh, + ) + + await stop_periodic_refresh() + # Save cookies one final time before shutdown + await save_cookies_on_shutdown() + except Exception as e: + logger.warning(f"[COOKIE-REFRESH] Shutdown save error: {e}") + + if hasattr(app.state, "watchdog_task"): + logger.info("[STOP] Stopping Quota Watchdog...") + task = app.state.watchdog_task + if hasattr(task, "cancel"): + task.cancel() + # Only await if it's actually an asyncio task or future + if isinstance(task, (asyncio.Task, asyncio.Future)): + try: + await task + except asyncio.CancelledError: + pass + + try: + await _shutdown_resources() + finally: + restore_original_streams(initial_stdout, initial_stderr) + restore_original_streams(*original_streams) + logger.info("Server shut down.") + + +class APIKeyAuthMiddleware(BaseHTTPMiddleware): + def __init__(self, app: ASGIApp): + super().__init__(app) + self.excluded_paths = [ + "/v1/models", + "/health", + "/docs", + "/openapi.json", + "/redoc", + "/favicon.ico", + ] + + async def dispatch( + self, request: Request, call_next: Callable[[Request], Awaitable] + ): + if not auth_utils.API_KEYS: + return await call_next(request) + if not request.url.path.startswith("/v1/"): + return await call_next(request) + for excluded_path in self.excluded_paths: + if request.url.path == excluded_path or request.url.path.startswith( + excluded_path + "/" + ): + return await call_next(request) + api_key = request.headers.get("Authorization") + if api_key and api_key.startswith("Bearer "): + api_key = api_key[7:] + if not api_key: + api_key = request.headers.get("X-API-Key") + if not api_key or not auth_utils.verify_api_key(api_key): + return JSONResponse( + status_code=401, + content={ + "error": { + "message": "Invalid or missing API key. Please provide a valid API key using 'Authorization: Bearer ' or 'X-API-Key: ' header.", + "type": "invalid_request_error", + "param": None, + "code": "invalid_api_key", + } + }, + ) + return await call_next(request) + + +def create_app() -> FastAPI: + """Create FastAPI application instance""" + app = FastAPI( + title="AI Studio Proxy Server (Integrated Mode)", + description="Proxy server interacting with AI Studio via Playwright.", + version=VERSION, + lifespan=lifespan, + ) + app.add_middleware(APIKeyAuthMiddleware) + from fastapi.responses import FileResponse + + from .routers import ( + add_api_key, + auth_files_router, + cancel_request, + chat_completions, + delete_api_key, + get_api_info, + get_api_keys, + get_queue_status, + health_check, + list_models, + model_capabilities_router, + ports_router, + proxy_router, + read_index, + serve_react_assets, + test_api_key, + websocket_log_endpoint, + ) + + app.get("/", response_class=FileResponse)(read_index) + app.get("/assets/{filename:path}")(serve_react_assets) + app.get("/api/info")(get_api_info) + app.get("/health")(health_check) + app.get("/v1/models")(list_models) + app.post("/v1/chat/completions")(chat_completions) + app.post("/v1/cancel/{req_id}")(cancel_request) + app.get("/v1/queue")(get_queue_status) + app.websocket("/ws/logs")(websocket_log_endpoint) + app.include_router(model_capabilities_router) + app.include_router(proxy_router) + app.include_router(auth_files_router) + app.include_router(ports_router) + from api_utils.routers import helper_router, server_router + + app.include_router(server_router) + app.include_router(helper_router) + app.get("/api/keys")(get_api_keys) + app.post("/api/keys")(add_api_key) + app.post("/api/keys/test")(test_api_key) + app.delete("/api/keys")(delete_api_key) + return app diff --git a/api_utils/auth_manager.py b/api_utils/auth_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..d82dea23b573e13495a903e6de3760eb8042d615 --- /dev/null +++ b/api_utils/auth_manager.py @@ -0,0 +1,89 @@ +import asyncio +import glob +import logging +import os +from typing import List, Optional, Set + +from launcher.config import SAVED_AUTH_DIR + +logger = logging.getLogger("AuthManager") + + +class AuthManager: + """ + Manages authentication profiles for rotation and error recovery. + """ + + def __init__(self) -> None: + self.failed_profiles: Set[str] = set() + self.current_profile: Optional[str] = None + + # Initialize with the profile from environment if available + initial_profile = os.environ.get("ACTIVE_AUTH_JSON_PATH") + if initial_profile: + self.current_profile = initial_profile + + async def get_available_profiles(self) -> List[str]: + """List all .json files in the saved auth directory.""" + if not os.path.exists(SAVED_AUTH_DIR): + logger.warning(f"Saved auth directory not found: {SAVED_AUTH_DIR}") + return [] + + loop = asyncio.get_running_loop() + pattern = os.path.join(SAVED_AUTH_DIR, "*.json") + # Run glob in executor to avoid blocking the event loop + profiles = await loop.run_in_executor(None, glob.glob, pattern) + return sorted(profiles) # Sort for deterministic order + + async def get_next_profile(self) -> str: + """ + Get the next available profile that hasn't failed yet. + Raises RuntimeError if no profiles are available. + """ + profiles = await self.get_available_profiles() + + # Get set of basenames for failed profiles (prevents duplicates due to path differences) + failed_basenames = {os.path.basename(p) for p in self.failed_profiles} + current_basename = ( + os.path.basename(self.current_profile) if self.current_profile else None + ) + + # Filter out failed profiles by basename comparison + available = [ + p + for p in profiles + if os.path.basename(p) not in failed_basenames + and os.path.basename(p) + != current_basename # Also exclude the current profile + ] + + if not available: + msg = f"All authentication profiles exhausted. Failed: {len(self.failed_profiles)}, Total: {len(profiles)}" + logger.critical(msg) + raise RuntimeError(msg) + + # Simple strategy: Pick the first available one. + next_profile = available[0] + self.current_profile = next_profile + logger.info(f"Switched to auth profile: {os.path.basename(next_profile)}") + return next_profile + + def mark_profile_failed(self, profile_path: Optional[str] = None) -> None: + """Mark a profile as failed so it won't be used again in this cycle.""" + target = profile_path or self.current_profile + if target: + self.failed_profiles.add(target) + logger.warning(f"Marked auth profile as failed: {os.path.basename(target)}") + else: + logger.warning( + "Attempted to mark profile failed but no profile provided or active." + ) + + def reset_failures(self) -> None: + """Reset the failure tracking.""" + self.failed_profiles.clear() + logger.info("Auth profile failure tracking reset.") + + +# Global instance +auth_manager = AuthManager() diff --git a/api_utils/auth_utils.py b/api_utils/auth_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..2ab814ed3f4ea2087976a7ac265a199b31580a95 --- /dev/null +++ b/api_utils/auth_utils.py @@ -0,0 +1,39 @@ +import os +from typing import Set + +API_KEYS: Set[str] = set() +KEY_FILE_PATH = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "auth_profiles", + "key.txt", +) + + +def load_api_keys(): + """Loads API keys from the key file into the API_KEYS set.""" + global API_KEYS + API_KEYS.clear() + if os.path.exists(KEY_FILE_PATH): + with open(KEY_FILE_PATH, "r") as f: + for line in f: + key = line.strip() + if key: + API_KEYS.add(key) + + +def initialize_keys(): + """Initializes API keys. Ensures key.txt exists and loads keys.""" + if not os.path.exists(KEY_FILE_PATH): + with open(KEY_FILE_PATH, "w"): + pass # Create an empty file + load_api_keys() + + +def verify_api_key(api_key_from_header: str) -> bool: + """ + Verifies the API key. + Returns True if API_KEYS is empty (no validation) or if the key is valid. + """ + if not API_KEYS: + return True + return api_key_from_header in API_KEYS diff --git a/api_utils/client_connection.py b/api_utils/client_connection.py new file mode 100644 index 0000000000000000000000000000000000000000..313db34b52eed5d32954ef5e0fccb3e0c5e7c917 --- /dev/null +++ b/api_utils/client_connection.py @@ -0,0 +1,196 @@ +import asyncio +from asyncio import Event, Task +from typing import Any, Callable, Coroutine, Dict, Tuple + +from fastapi import HTTPException, Request + +from models import ClientDisconnectedError + + +async def check_client_connection(req_id: str, http_request: Request) -> bool: + """ + Checks if the client is still connected. + Returns True if connected, False if disconnected. + """ + try: + if hasattr(http_request, "_receive"): + try: + # Use a very short timeout to check for disconnect message + # _receive is a private Starlette/FastAPI method that returns a coroutine + receive_obj = http_request # type: ignore[misc] + receive_coro: Coroutine[Any, Any, Dict[str, Any]] = ( + receive_obj._receive() + ) # type: ignore[misc] + receive_task: Task[Dict[str, Any]] = asyncio.create_task(receive_coro) + done, pending = await asyncio.wait([receive_task], timeout=0.01) + + if done: + message = receive_task.result() + if message.get("type") == "http.disconnect": + return False + else: + # Cancel the task if it didn't complete immediately + receive_task.cancel() + try: + await receive_task + except asyncio.CancelledError: + pass + # If it didn't complete immediately, proceed to fallback check + except asyncio.CancelledError: + raise + except Exception: + # If checking fails, proceed to fallback + pass + + # Fallback to is_disconnected() if available (Starlette/FastAPI) + # Wrap in wait_for to prevent infinite hang in some ASGI implementations + if hasattr(http_request, "is_disconnected"): + try: + # Handle both sync and async versions for better mock compatibility + res = http_request.is_disconnected() + if asyncio.iscoroutine(res): + if await asyncio.wait_for(res, timeout=0.01): + return False + elif res: + return False + except (asyncio.TimeoutError, asyncio.CancelledError): + # If it times out, it's likely still connected + return True + + return True + except asyncio.CancelledError: + raise + except Exception as e: + # Re-raise to allow caller to log/handle + raise e + + +async def enhanced_disconnect_monitor( + req_id: str, + http_request: Request, + completion_event: asyncio.Event, + logger: Any, +) -> bool: + """ + Monitors for client disconnect during streaming. + Returns True if disconnected, False otherwise. + """ + disconnect_detection_count = 0 + while not completion_event.is_set(): + try: + is_connected = await check_client_connection(req_id, http_request) + if not is_connected: + disconnect_detection_count += 1 + if disconnect_detection_count >= 3: + logger.info( + f"[{req_id}] Client disconnect confirmed during streaming." + ) + completion_event.set() + return True + else: + disconnect_detection_count = 0 + await asyncio.sleep(0.2) + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"[{req_id}] Error in enhanced_disconnect_monitor: {e}") + break + return False + + +async def non_streaming_disconnect_monitor( + req_id: str, + http_request: Request, + result_future: asyncio.Future, + logger: Any, +) -> bool: + """ + Monitors for client disconnect during non-streaming processing. + Returns True if disconnected, False otherwise. + """ + while not result_future.done(): + try: + is_connected = await check_client_connection(req_id, http_request) + if not is_connected: + logger.info( + f"[{req_id}] Client disconnect detected during non-streaming." + ) + if not result_future.done(): + result_future.set_exception( + HTTPException(status_code=499, detail="Client disconnected") + ) + return True + await asyncio.sleep(0.3) + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"[{req_id}] Error in non_streaming_disconnect_monitor: {e}") + break + return False + + +async def setup_disconnect_monitoring( + req_id: str, http_request: Request, result_future +) -> Tuple[Event, asyncio.Task, Callable]: + from api_utils.server_state import state + + logger = state.logger + + client_disconnected_event = Event() + disconnect_count = 0 + disconnect_threshold = 5 # Require 5 consecutive disconnect signals (1.5 seconds) + + async def check_disconnect_periodically(): + nonlocal disconnect_count + while not client_disconnected_event.is_set(): + try: + is_connected = await check_client_connection(req_id, http_request) + if not is_connected: + disconnect_count += 1 + if disconnect_count >= disconnect_threshold: + logger.info( + f"[{req_id}] Active detection of client disconnect (consecutive {disconnect_count} times)." + ) + client_disconnected_event.set() + if not result_future.done(): + result_future.set_exception( + HTTPException( + status_code=499, + detail=f"[{req_id}] Client closed the request", + ) + ) + break + else: + logger.debug( + f"[{req_id}] Active detection of potential disconnect (round {disconnect_count}/{disconnect_threshold})" + ) + else: + disconnect_count = 0 # Reset counter on successful connection + + await asyncio.sleep(0.3) + except asyncio.CancelledError: + # Task cancelled, exit gracefully + break + except Exception as e: + logger.error(f"(Disco Check Task) Error: {e}") + client_disconnected_event.set() + if not result_future.done(): + result_future.set_exception( + HTTPException( + status_code=500, + detail=f"[{req_id}] Internal disconnect checker error: {e}", + ) + ) + break + + disconnect_check_task = asyncio.create_task(check_disconnect_periodically()) + + def check_client_disconnected(stage: str = "") -> bool: + if client_disconnected_event.is_set(): + logger.info(f"Client disconnected detected at stage: '{stage}'") + raise ClientDisconnectedError( + f"[{req_id}] Client disconnected at stage: {stage}" + ) + return False + + return client_disconnected_event, disconnect_check_task, check_client_disconnected diff --git a/api_utils/common_utils.py b/api_utils/common_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..e196310c9a172a1d4e716e10f8aea19e9a61a1d9 --- /dev/null +++ b/api_utils/common_utils.py @@ -0,0 +1,6 @@ +import random + + +def random_id(length: int = 24) -> str: + charset = "abcdefghijklmnopqrstuvwxyz0123456789" + return "".join(random.choice(charset) for _ in range(length)) diff --git a/api_utils/context_init.py b/api_utils/context_init.py new file mode 100644 index 0000000000000000000000000000000000000000..d3f6293efab70970fc896446b4739a974fef43b3 --- /dev/null +++ b/api_utils/context_init.py @@ -0,0 +1,38 @@ +from typing import cast + +from logging_utils import set_request_id +from models import ChatCompletionRequest + +from .context_types import RequestContext + + +async def initialize_request_context( + req_id: str, request: ChatCompletionRequest +) -> RequestContext: + from api_utils.server_state import state + + set_request_id(req_id) + state.logger.debug( + f"[Request] Parameters: Model={request.model}, Stream={request.stream}" + ) + + context: RequestContext = cast( + RequestContext, + { + "logger": state.logger, + "page": state.page_instance, + "is_page_ready": state.is_page_ready, + "parsed_model_list": state.parsed_model_list, + "current_ai_studio_model_id": state.current_ai_studio_model_id, + "model_switching_lock": state.model_switching_lock, + "page_params_cache": state.page_params_cache, + "params_cache_lock": state.params_cache_lock, + "is_streaming": request.stream, + "model_actually_switched": False, + "requested_model": request.model, + "model_id_to_use": None, + "needs_model_switching": False, + }, + ) + + return context diff --git a/api_utils/context_types.py b/api_utils/context_types.py new file mode 100644 index 0000000000000000000000000000000000000000..c009369f8b22ce97008da4928625e19df685c054 --- /dev/null +++ b/api_utils/context_types.py @@ -0,0 +1,54 @@ +import logging +from asyncio import Future, Lock +from typing import TYPE_CHECKING, Any, Dict, List, Optional, TypedDict, Union + +from playwright.async_api import Page as AsyncPage + +if TYPE_CHECKING: + from fastapi import Request + from fastapi.responses import JSONResponse, StreamingResponse + + from models.chat import ChatCompletionRequest + + +class QueueItem(TypedDict): + """Type definition for items in the request queue. + + This defines the structure of each item put into the request_queue, + ensuring type safety for queue operations. + """ + + req_id: str + request_data: "ChatCompletionRequest" + http_request: "Request" + result_future: "Future[Union[JSONResponse, StreamingResponse]]" + enqueue_time: float + cancelled: bool + + +class RequestContext(TypedDict): + """Request context with all keys always present after initialization. + + All keys are required (always exist in the dict) after context_init.py initialization. + Optional[] types indicate that the VALUE can be None, not that the key might not exist. + """ + + # Core components (always set by context_init.py) + req_id: str + logger: logging.Logger + page: Optional[AsyncPage] # Value can be None if browser not ready + is_page_ready: bool + parsed_model_list: List[Dict[str, Any]] + current_ai_studio_model_id: Optional[str] # Value can be None initially + + # Locks (always set by server_state) + model_switching_lock: Lock + page_params_cache: Dict[str, Any] + params_cache_lock: Lock + + # Request-specific state (always initialized) + is_streaming: bool + model_actually_switched: bool + requested_model: Optional[str] # Value can be None if not specified + model_id_to_use: Optional[str] # Value set during model analysis + needs_model_switching: bool diff --git a/api_utils/dependencies.py b/api_utils/dependencies.py new file mode 100644 index 0000000000000000000000000000000000000000..5433cc41c69d1b64d68562fc5463369106e7fc7d --- /dev/null +++ b/api_utils/dependencies.py @@ -0,0 +1,169 @@ +""" +FastAPI Dependencies Module +""" + +import logging +from asyncio import Event, Lock, Queue +from typing import Any, Dict, List, Set + +from api_utils.context_types import QueueItem + + +def get_logger() -> logging.Logger: + from api_utils.server_state import state + + return state.logger + + +def get_log_ws_manager(): + from api_utils.server_state import state + + return state.log_ws_manager + + +def get_request_queue() -> "Queue[QueueItem]": + from typing import cast + + from api_utils.server_state import state + + return cast("Queue[QueueItem]", state.request_queue) + + +def get_processing_lock() -> Lock: + from typing import cast + + from api_utils.server_state import state + + return cast(Lock, state.processing_lock) + + +def get_worker_task(): + from api_utils.server_state import state + + return state.worker_task + + +def get_server_state() -> Dict[str, Any]: + from api_utils.server_state import state + + # Return immutable snapshot to prevent downstream modifications to global references + return dict( + is_initializing=state.is_initializing, + is_playwright_ready=state.is_playwright_ready, + is_browser_connected=state.is_browser_connected, + is_page_ready=state.is_page_ready, + ) + + +def get_page_instance(): + from api_utils.server_state import state + + return state.page_instance + + +def get_model_list_fetch_event() -> Event: + from typing import cast + + from api_utils.server_state import state + + return cast(Event, state.model_list_fetch_event) + + +def get_parsed_model_list() -> List[Dict[str, Any]]: + from api_utils.server_state import state + + return state.parsed_model_list + + +def get_excluded_model_ids() -> Set[str]: + from api_utils.server_state import state + + return state.excluded_model_ids + + +def get_current_ai_studio_model_id() -> str: + from typing import cast + + from api_utils.server_state import state + + return cast(str, state.current_ai_studio_model_id) + + +async def ensure_request_lock(): + """ + Dependency that acts as a 'Parking Lot' for requests. + If Auth Rotation is in progress (Lock is cleared) or Quota is Exceeded (Rotation imminent), + this will pause the request until the system is ready. + """ + import asyncio + import time + + from api_utils.server_state import state as server_state + from config.global_state import GlobalState + + logger = server_state.logger + + # A request is considered "queued" if it has to wait for the lock. + is_waiting = ( + GlobalState.IS_QUOTA_EXCEEDED or not GlobalState.AUTH_ROTATION_LOCK.is_set() + ) + if is_waiting: + GlobalState.queued_request_count += 1 + + start_time = time.time() + max_total_wait = 60.0 # 60 second hard timeout for request parking + + try: + # Wait loop to handle both Lock and Quota states + # We wait if: + # 1. Lock is NOT set (Rotation in progress) + # 2. Quota IS exceeded (Rotation about to start, or we need to wait for it) + while ( + GlobalState.IS_QUOTA_EXCEEDED or not GlobalState.AUTH_ROTATION_LOCK.is_set() + ): + # Check for total timeout + if time.time() - start_time > max_total_wait: + logger.error( + f"🚨 Request parking timeout after {max_total_wait}s. Quota={GlobalState.IS_QUOTA_EXCEEDED}, LockSet={GlobalState.AUTH_ROTATION_LOCK.is_set()}" + ) + from fastapi import HTTPException + + raise HTTPException( + status_code=530, # Custom code for state resolution timeout + detail="System state resolution timeout - please try again later", + ) + + if not GlobalState.AUTH_ROTATION_LOCK.is_set(): + # Rotation in progress. Wait for lock to open with timeout. + try: + await asyncio.wait_for( + GlobalState.AUTH_ROTATION_LOCK.wait(), timeout=30.0 + ) + except asyncio.TimeoutError: + logger.warning( + "🚨 Lock wait timeout after 30s. Service may be unavailable." + ) + from fastapi import HTTPException + + raise HTTPException( + status_code=503, + detail="Service temporarily unavailable - timeout waiting for system lock", + ) + else: + # Lock is Open, but Quota is still marked Exceeded. + # This implies the Watchdog is about to rotate, or we are in a race. + # We wait for the recovery event which signals rotation completion. + try: + if GlobalState.IS_RECOVERING: + # If recovery is active, wait for it to finish + await asyncio.wait_for( + GlobalState.RECOVERY_EVENT.wait(), timeout=30.0 + ) + else: + # Watchdog hasn't started yet, wait briefly + await asyncio.sleep(0.1) + except asyncio.TimeoutError: + await asyncio.sleep(0.1) + finally: + if is_waiting: + GlobalState.queued_request_count -= 1 diff --git a/api_utils/error_utils.py b/api_utils/error_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..d59efc051e46ecad0f69723248aba139a1335f63 --- /dev/null +++ b/api_utils/error_utils.py @@ -0,0 +1,47 @@ +from typing import Dict, Optional + +from fastapi import HTTPException + + +def http_error( + status_code: int, detail: str, headers: Optional[Dict[str, str]] = None +) -> HTTPException: + return HTTPException( + status_code=status_code, detail=detail, headers=headers or None + ) + + +def client_cancelled(req_id: str, message: str = "Request cancelled.") -> HTTPException: + return http_error(499, f"[{req_id}] {message}") + + +def client_disconnected(req_id: str, stage: str = "") -> HTTPException: + suffix = f" during {stage}" if stage else "" + return http_error(499, f"[{req_id}] Client disconnected{suffix}.") + + +def processing_timeout( + req_id: str, message: str = "Processing timed out." +) -> HTTPException: + return http_error(504, f"[{req_id}] {message}") + + +def bad_request(req_id: str, message: str) -> HTTPException: + return http_error(400, f"[{req_id}] {message}") + + +def server_error(req_id: str, message: str) -> HTTPException: + return http_error(500, f"[{req_id}] {message}") + + +def upstream_error(req_id: str, message: str) -> HTTPException: + # 502 Bad Gateway for upstream/playwright failures + return http_error(502, f"[{req_id}] {message}") + + +def service_unavailable(req_id: str, retry_after_seconds: int = 30) -> HTTPException: + return http_error( + 503, + f"[{req_id}] Service currently unavailable. Please try again later.", + headers={"Retry-After": str(retry_after_seconds)}, + ) diff --git a/api_utils/mcp_adapter.py b/api_utils/mcp_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..83d2d95d7ba770b344695e53324f2677e8a993ff --- /dev/null +++ b/api_utils/mcp_adapter.py @@ -0,0 +1,57 @@ +import asyncio +import json +import os +from typing import Any, Dict + +import httpx + + +def _normalize_endpoint(ep: str) -> str: + if not ep: + raise RuntimeError("MCP HTTP endpoint not provided") + return ep.rstrip("/") + + +async def execute_mcp_tool(name: str, params: Dict[str, Any]) -> str: + """ + Minimal MCP-over-HTTP adapter: + - POST {MCP_HTTP_ENDPOINT}/tools/execute with {name, arguments} + - Returns JSON string. + Compatible with servers exposing MCP-like HTTP interface. + """ + ep = os.environ.get("MCP_HTTP_ENDPOINT") + if not ep: + raise RuntimeError("MCP_HTTP_ENDPOINT not configured") + url = f"{_normalize_endpoint(ep)}/tools/execute" + payload = {"name": name, "arguments": params} + headers = {"Content-Type": "application/json"} + timeout = float(os.environ.get("MCP_HTTP_TIMEOUT", "15")) + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post(url, json=payload, headers=headers) + resp.raise_for_status() + try: + data = resp.json() + except asyncio.CancelledError: + raise + except Exception: + data = {"raw": resp.text} + return json.dumps(data, ensure_ascii=False) + + +async def execute_mcp_tool_with_endpoint( + endpoint: str, name: str, params: Dict[str, Any] +) -> str: + url = f"{_normalize_endpoint(endpoint)}/tools/execute" + payload = {"name": name, "arguments": params} + headers = {"Content-Type": "application/json"} + timeout = float(os.environ.get("MCP_HTTP_TIMEOUT", "15")) + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post(url, json=payload, headers=headers) + resp.raise_for_status() + try: + data = resp.json() + except asyncio.CancelledError: + raise + except Exception: + data = {"raw": resp.text} + return json.dumps(data, ensure_ascii=False) diff --git a/api_utils/model_switching.py b/api_utils/model_switching.py new file mode 100644 index 0000000000000000000000000000000000000000..7c057ab0de8f0c3871fa281cd03a7a149385ff00 --- /dev/null +++ b/api_utils/model_switching.py @@ -0,0 +1,121 @@ + +from playwright.async_api import Page as AsyncPage + +from api_utils.server_state import state +from logging_utils import set_request_id + +from .context_types import RequestContext + + +async def analyze_model_requirements( + req_id: str, context: RequestContext, requested_model: str, proxy_model_name: str +) -> RequestContext: + set_request_id(req_id) + logger = context["logger"] + current_ai_studio_model_id = context["current_ai_studio_model_id"] + parsed_model_list = context["parsed_model_list"] + + if requested_model and requested_model != proxy_model_name: + requested_model_id = requested_model.split("/")[-1] + logger.info(f"[{req_id}] Requesting model: {requested_model_id}") + + if parsed_model_list: + valid_model_ids = [ + str(m.get("id")) for m in parsed_model_list if m.get("id") + ] + if requested_model_id not in valid_model_ids: + from .error_utils import bad_request + + raise bad_request( + req_id, + f"Invalid model '{requested_model_id}'. Available models: {', '.join(valid_model_ids)}", + ) + + context["model_id_to_use"] = requested_model_id + if current_ai_studio_model_id != requested_model_id: + context["needs_model_switching"] = True + logger.info( + f"[{req_id}] Model switch needed: Current={current_ai_studio_model_id} -> Target={requested_model_id}" + ) + + return context + + +async def handle_model_switching( + req_id: str, context: RequestContext +) -> RequestContext: + set_request_id(req_id) + if not context["needs_model_switching"]: + return context + + logger = context["logger"] + page = context["page"] + model_switching_lock = context["model_switching_lock"] + model_id_to_use = context["model_id_to_use"] + + # Assert non-None values required for model switching + assert page is not None, "Page must be ready for model switching" + assert model_id_to_use is not None, "Target model ID must be set" + + async with model_switching_lock: + if state.current_ai_studio_model_id != model_id_to_use: + logger.info( + f"[{req_id}] Preparing to switch model: {state.current_ai_studio_model_id} -> {model_id_to_use}" + ) + from browser_utils import switch_ai_studio_model + + switch_success = await switch_ai_studio_model(page, model_id_to_use, req_id) + if switch_success: + state.current_ai_studio_model_id = model_id_to_use + context["model_actually_switched"] = True + context["current_ai_studio_model_id"] = model_id_to_use + logger.info( + f"[{req_id}] ✅ Model switched successfully: {state.current_ai_studio_model_id}" + ) + else: + # Current model ID should exist when switching fails + current_model = state.current_ai_studio_model_id or "unknown" + await _handle_model_switch_failure( + req_id, + page, + model_id_to_use, + current_model, + logger, + ) + + return context + + +async def _handle_model_switch_failure( + req_id: str, page: AsyncPage, model_id_to_use: str, model_before_switch: str, logger +) -> None: + logger.warning(f"[{req_id}] ❌ Failed to switch to model {model_id_to_use}.") + state.current_ai_studio_model_id = model_before_switch + from .error_utils import http_error + + raise http_error( + 422, + f"[{req_id}] Failed to switch to model '{model_id_to_use}'. Ensure model is available.", + ) + + +async def handle_parameter_cache(req_id: str, context: RequestContext) -> None: + set_request_id(req_id) + logger = context["logger"] + params_cache_lock = context["params_cache_lock"] + page_params_cache = context["page_params_cache"] + current_ai_studio_model_id = context["current_ai_studio_model_id"] + model_actually_switched = context["model_actually_switched"] + + async with params_cache_lock: + cached_model_for_params = page_params_cache.get( + "last_known_model_id_for_params" + ) + if model_actually_switched or ( + current_ai_studio_model_id != cached_model_for_params + ): + logger.info(f"[{req_id}] Model changed, parameter cache invalidated.") + page_params_cache.clear() + page_params_cache["last_known_model_id_for_params"] = ( + current_ai_studio_model_id + ) diff --git a/api_utils/page_response.py b/api_utils/page_response.py new file mode 100644 index 0000000000000000000000000000000000000000..d2df17821040c330e0307ddc290ae7a27d8de676 --- /dev/null +++ b/api_utils/page_response.py @@ -0,0 +1,39 @@ +import asyncio +import logging +from typing import Callable + +from playwright.async_api import Error as PlaywrightAsyncError +from playwright.async_api import Page as AsyncPage +from playwright.async_api import expect as expect_async + +from config import RESPONSE_CONTAINER_SELECTOR, RESPONSE_TEXT_SELECTOR + + +async def locate_response_elements( + page: AsyncPage, + req_id: str, + logger: logging.Logger, + check_client_disconnected: Callable[[str], bool], +) -> None: + """Locate response container and text elements, including timeout and error handling.""" + logger.info(f"[{req_id}] Locating response elements...") + response_container = page.locator(RESPONSE_CONTAINER_SELECTOR).last + response_element = response_container.locator(RESPONSE_TEXT_SELECTOR) + + try: + await expect_async(response_container).to_be_attached(timeout=20000) + check_client_disconnected("After Response Container Attached: ") + await expect_async(response_element).to_be_attached(timeout=90000) + logger.info(f"[{req_id}] Response elements located.") + except (PlaywrightAsyncError, asyncio.TimeoutError) as locate_err: + from .error_utils import upstream_error + + raise upstream_error( + req_id, f"Failed to locate AI Studio response elements: {locate_err}" + ) + except Exception as locate_exc: + from .error_utils import server_error + + raise server_error( + req_id, f"Unexpected error while locating response elements: {locate_exc}" + ) diff --git a/api_utils/queue_worker.py b/api_utils/queue_worker.py new file mode 100644 index 0000000000000000000000000000000000000000..677179b40690ecd61abe6bd11b4690a41ec0afe5 --- /dev/null +++ b/api_utils/queue_worker.py @@ -0,0 +1,467 @@ +""" +Queue Worker Module +Handles tasks in the request queue +""" + +import asyncio +import time +from asyncio import Event, Future, Task +from typing import Callable, Optional, cast + +from fastapi import HTTPException, Request +from playwright.async_api import Locator +from playwright.async_api import expect as expect_async + +from api_utils.context_types import QueueItem +from models import QuotaExceededError + +from .client_connection import check_client_connection + + +async def queue_worker() -> None: + """Queue worker, processes tasks in the request queue""" + # Delayed imports to avoid circularity + from api_utils.server_state import state + from config import RESPONSE_COMPLETION_TIMEOUT + + logger = state.logger + request_queue = state.request_queue + processing_lock = state.processing_lock + model_switching_lock = state.model_switching_lock + params_cache_lock = state.params_cache_lock + from browser_utils.auth_rotation import perform_auth_rotation + from browser_utils.page_controller import PageController + from config.global_state import GlobalState + + from .error_utils import ( + client_cancelled, + client_disconnected, + server_error, + ) + + # Internal imports for queue worker logic + from .request_processor import ( + ClientDisconnectedError, + _process_request_refactored, + _test_client_connection, + save_error_snapshot, + ) + from .utils_ext.stream import clear_stream_queue + + logger.info("--- Queue Worker Started ---") + + # Validate that required globals are initialized + if request_queue is None: + logger.critical("FATAL: request_queue is None! Initialization failed.") + raise RuntimeError("request_queue not initialized") + + if processing_lock is None: + logger.critical("FATAL: processing_lock is None! Initialization failed.") + raise RuntimeError("processing_lock not initialized") + + if model_switching_lock is None: + logger.critical("FATAL: model_switching_lock is None! Initialization failed.") + raise RuntimeError("model_switching_lock not initialized") + + if params_cache_lock is None: + logger.critical("FATAL: params_cache_lock is None! Initialization failed.") + raise RuntimeError("params_cache_lock not initialized") + + logger.debug( + f"Queue worker initialized with queue={request_queue}, lock={processing_lock}" + ) + + was_last_request_streaming = False + last_request_completion_time = 0.0 + shutdown_check_interval = 0.1 + + while True: + request_item: Optional[QueueItem] = None + result_future: Optional[Future] = None + http_request: Optional[Request] = None + req_id: str = "UNKNOWN" + completion_event: Optional[Event] = None + submit_btn_loc: Optional[Locator] = None + client_disco_checker: Optional[Callable[[str], bool]] = None + disconnect_monitor_task: Optional[Task] = None + client_disconnected_early: bool = False + + try: + # [SHUTDOWN] Check shutdown signal + if GlobalState.IS_SHUTTING_DOWN.is_set(): + logger.info("🚨 Queue Worker detected shutdown signal, exiting.") + break + + # Clean up disconnected requests in queue + queue_size = request_queue.qsize() + if queue_size > 0: + checked_count = 0 + items_to_requeue = [] + processed_ids = set() + + while checked_count < queue_size and checked_count < 10: + if GlobalState.IS_SHUTTING_DOWN.is_set(): + break + try: + item = request_queue.get_nowait() + item_req_id = item.get("req_id", "unknown") + if item_req_id in processed_ids: + items_to_requeue.append(item) + continue + processed_ids.add(item_req_id) + + if not item.get("cancelled", False): + item_http_req = item.get("http_request") + if item_http_req: + try: + if not await check_client_connection( + item_req_id, item_http_req + ): + logger.info( + f"[{item_req_id}] (Worker Queue Check) Client disconnect detected." + ) + item["cancelled"] = True + item_fut = item.get("result_future") + if item_fut and not item_fut.done(): + item_fut.set_exception( + client_disconnected( + item_req_id, + "Client disconnected while queued.", + ) + ) + except Exception as e: + logger.error( + f"[{item_req_id}] (Worker Queue Check) Error: {e}" + ) + + items_to_requeue.append(item) + checked_count += 1 + except asyncio.QueueEmpty: + break + + for item in items_to_requeue: + await request_queue.put(item) + + # [AUTH-ROTATION] Handle quota or rotation needs + if GlobalState.IS_QUOTA_EXCEEDED or GlobalState.NEEDS_ROTATION: + reason = ( + "Quota Exceeded" + if GlobalState.IS_QUOTA_EXCEEDED + else "Graceful Rotation Pending" + ) + logger.info(f"⏸️ Pausing worker for Auth Rotation ({reason})...") + GlobalState.start_recovery() + try: + current_model_id = state.current_ai_studio_model_id + rotation_success = await perform_auth_rotation( + target_model_id=current_model_id or "" + ) + if rotation_success: + GlobalState.NEEDS_ROTATION = False + logger.info("✅ Auth rotation completed successfully.") + else: + logger.error("❌ Auth rotation failed.") + await asyncio.sleep(1) + finally: + GlobalState.finish_recovery() + if not rotation_success: + continue + + if GlobalState.IS_SHUTTING_DOWN.is_set(): + break + + # Get next request + try: + current_timeout = ( + shutdown_check_interval + if GlobalState.IS_SHUTTING_DOWN.is_set() + else 5.0 + ) + request_item = await asyncio.wait_for( + request_queue.get(), timeout=current_timeout + ) + except asyncio.TimeoutError: + continue + + if request_item is None: + continue + + req_id = request_item["req_id"] + request_data = request_item["request_data"] + http_request = request_item["http_request"] + result_future = request_item["result_future"] + + GlobalState.CURRENT_STREAM_REQ_ID = req_id + logger.info(f"[{req_id}] (Worker) Processing request dequeued.") + + if GlobalState.IS_QUOTA_EXCEEDED: + logger.warning(f"[{req_id}] (Worker) ⛔ Quota exceeded, re-queueing.") + await request_queue.put(request_item) + request_queue.task_done() + continue + + if request_item.get("cancelled", False): + if result_future and not result_future.done(): + result_future.set_exception( + client_cancelled(req_id, "Request cancelled by user") + ) + request_queue.task_done() + continue + + is_streaming_request = request_data.stream + + # Initial connection check + if not await _test_client_connection(req_id, http_request): + if result_future and not result_future.done(): + result_future.set_exception( + HTTPException(status_code=499, detail="Client disconnected") + ) + request_queue.task_done() + continue + + # Streaming delay + current_time = time.time() + if ( + was_last_request_streaming + and is_streaming_request + and (current_time - last_request_completion_time < 1.0) + ): + await asyncio.sleep( + max(0.5, 1.0 - (current_time - last_request_completion_time)) + ) + + # Wait for lock + async with processing_lock: + logger.info(f"[{req_id}] (Worker) Lock acquired.") + + if not await _test_client_connection(req_id, http_request): + if result_future and not result_future.done(): + result_future.set_exception( + HTTPException(status_code=499, detail="Client disconnected") + ) + elif result_future and result_future.done(): + logger.info(f"[{req_id}] (Worker) Future already done.") + else: + try: + returned_value = await _process_request_refactored( + req_id, request_data, http_request, result_future + ) + + if ( + isinstance(returned_value, tuple) + and len(returned_value) == 3 + ): + completion_event, submit_btn_loc, client_disco_checker = ( + returned_value + ) + + if completion_event: + if isinstance(completion_event, dict): + if ( + completion_event.get("done") + and is_streaming_request + ): + if state.STREAM_QUEUE: + await state.STREAM_QUEUE.put(completion_event) + if result_future and not result_future.done(): + result_future.set_result(completion_event) + client_disconnected_early = False + elif hasattr(completion_event, "wait"): + client_disconnected_early = False + comp_ev = cast(Event, completion_event) + + async def enhanced_disconnect_monitor_fn(): + nonlocal client_disconnected_early + disco_count = 0 + while not comp_ev.is_set(): + if GlobalState.IS_SHUTTING_DOWN.is_set(): + comp_ev.set() + break + if ( + GlobalState.IS_QUOTA_EXCEEDED + and not GlobalState.IS_RECOVERING + ): + # Abort if quota exceeded and not recovering + client_disconnected_early = True + comp_ev.set() + break + + if not await _test_client_connection( + req_id, http_request + ): + disco_count += 1 + if disco_count >= 3: + client_disconnected_early = True + comp_ev.set() + break + else: + disco_count = 0 + await asyncio.sleep(0.2) + + disconnect_monitor_task = asyncio.create_task( + enhanced_disconnect_monitor_fn() + ) + await asyncio.wait_for( + comp_ev.wait(), + timeout=RESPONSE_COMPLETION_TIMEOUT / 1000 + 60, + ) + else: + # Non-streaming + client_disconnected_early = False + res_fut = cast(Future, result_future) + + async def non_streaming_monitor_fn(): + nonlocal client_disconnected_early + while not res_fut.done(): + if GlobalState.IS_SHUTTING_DOWN.is_set(): + res_fut.cancel() + break + if not await _test_client_connection( + req_id, http_request + ): + client_disconnected_early = True + res_fut.set_exception( + HTTPException( + status_code=499, + detail="Client disconnected", + ) + ) + break + await asyncio.sleep(0.3) + + disconnect_monitor_task = asyncio.create_task( + non_streaming_monitor_fn() + ) + await asyncio.wait_for( + asyncio.shield(res_fut), + timeout=RESPONSE_COMPLETION_TIMEOUT / 1000 + 60, + ) + + # Post-processing button handling + if client_disconnected_early: + if submit_btn_loc: + try: + if await submit_btn_loc.is_enabled(timeout=2000): + await submit_btn_loc.click( + timeout=5000, force=True + ) + except Exception: + pass + elif ( + submit_btn_loc and client_disco_checker and completion_event + ): + try: + client_disco_checker("Post-stream check") + await asyncio.sleep(0.5) + client_disco_checker("Post-sleep check") + if await submit_btn_loc.is_enabled(timeout=2000): + await submit_btn_loc.click(timeout=5000, force=True) + await expect_async(submit_btn_loc).to_be_disabled( + timeout=10000 + ) + except ClientDisconnectedError: + pass + except Exception: + await save_error_snapshot(f"button_timeout_{req_id}") + + except QuotaExceededError: + raise + except Exception as e: + logger.error(f"[{req_id}] (Worker) Error: {e}") + if result_future and not result_future.done(): + result_future.set_exception( + server_error(req_id, f"Error: {e}") + ) + finally: + if ( + disconnect_monitor_task + and not disconnect_monitor_task.done() + ): + disconnect_monitor_task.cancel() + try: + await disconnect_monitor_task + except asyncio.CancelledError: + pass + + # [ROTATION] Post-request rotation check + just_rotated = False + if GlobalState.NEEDS_ROTATION: + current_model_id_rot = state.current_ai_studio_model_id + if await perform_auth_rotation( + target_model_id=current_model_id_rot or "" + ): + GlobalState.NEEDS_ROTATION = False + just_rotated = True + + # [CLEANUP] + try: + await clear_stream_queue() + + # [COOKIE-REFRESH] Save cookies after successful requests + if not client_disconnected_early and not GlobalState.IS_QUOTA_EXCEEDED: + try: + from browser_utils.cookie_refresh import ( + maybe_refresh_on_request, + ) + + await maybe_refresh_on_request() + except Exception as cookie_err: + logger.debug( + f"[{req_id}] Cookie refresh error (non-critical): {cookie_err}" + ) + + if ( + not GlobalState.IS_QUOTA_EXCEEDED + and not just_rotated + and not GlobalState.IS_SHUTTING_DOWN.is_set() + ): + if submit_btn_loc and client_disco_checker: + s_page = state.page_instance + s_ready = state.is_page_ready + s_browser = state.browser_instance + + if ( + s_page + and s_ready + and s_browser + and s_browser.is_connected() + ): + try: + controller = PageController(s_page, logger, req_id) + await controller.clear_chat_history(lambda stage: False) + except Exception: + try: + await s_page.reload() + except Exception: + pass + except Exception as e: + logger.error(f"[{req_id}] Cleanup error: {e}") + + was_last_request_streaming = is_streaming_request + last_request_completion_time = time.time() + + except asyncio.CancelledError: + if result_future and not result_future.done(): + result_future.cancel() + break + except QuotaExceededError: + try: + if await _test_client_connection(req_id, http_request): + request_queue.put_nowait(request_item) + elif result_future and not result_future.done(): + result_future.set_exception( + HTTPException( + status_code=499, detail="Disconnected during quota error" + ) + ) + except Exception: + pass + except Exception as e: + logger.error(f"[{req_id}] Unexpected error: {e}", exc_info=True) + if result_future and not result_future.done(): + result_future.set_exception(server_error(req_id, f"Error: {e}")) + finally: + if request_item: + request_queue.task_done() + + logger.info("--- Queue Worker Stopped ---") diff --git a/api_utils/request_processor.py b/api_utils/request_processor.py new file mode 100644 index 0000000000000000000000000000000000000000..715486ee0cc933e55d1965009712ee392a469ca1 --- /dev/null +++ b/api_utils/request_processor.py @@ -0,0 +1,975 @@ +""" +Request Processor Module +Contains core request processing logic +""" + +import asyncio +import json +import os +import shutil +from asyncio import Event, Future +from typing import Any, Callable, Dict, List, Optional, Tuple, Union + +from fastapi import HTTPException, Request +from fastapi.responses import JSONResponse, StreamingResponse +from playwright.async_api import ( + Error as PlaywrightAsyncError, +) +from playwright.async_api import ( + Locator, +) +from playwright.async_api import ( + Page as AsyncPage, +) + +# --- browser_utils Module Imports --- +from browser_utils import ( + save_error_snapshot, +) +from browser_utils.page_controller import PageController + +# --- Configuration Module Imports --- +from config import ( + MODEL_NAME, + RESPONSE_COMPLETION_TIMEOUT, + SUBMIT_BUTTON_SELECTOR, + UPLOAD_FILES_DIR, + get_environment_variable, +) +from config.global_state import GlobalState + +# --- logging_utils Module Imports --- +from logging_utils import log_context + +# --- models Module Imports --- +from models import ( + ChatCompletionRequest, + ClientDisconnectedError, + QuotaExceededError, + QuotaExceededRetry, +) + +from .client_connection import ( + check_client_connection as _check_client_connection, +) +from .client_connection import ( + setup_disconnect_monitoring as _setup_disconnect_monitoring, +) +from .common_utils import random_id as _random_id +from .context_init import initialize_request_context as _init_request_context +from .context_types import RequestContext +from .error_utils import ( + bad_request, + client_disconnected, + server_error, + upstream_error, +) +from .model_switching import ( + analyze_model_requirements as ms_analyze, +) +from .model_switching import ( + handle_model_switching as ms_switch, +) +from .model_switching import ( + handle_parameter_cache as ms_param_cache, +) +from .page_response import locate_response_elements +from .response_generators import ( + gen_sse_from_aux_stream, + gen_sse_from_playwright, + resilient_stream_generator, +) +from .response_payloads import build_chat_completion_response_json + +# --- api_utils Module Imports --- +from .utils import ( + maybe_execute_tools, + prepare_combined_prompt, +) +from .utils_ext.files import collect_and_validate_attachments +from .utils_ext.function_calling_orchestrator import ( + FunctionCallingState, + get_function_calling_orchestrator, +) +from .utils_ext.stream import use_stream_response +from .utils_ext.tokens import calculate_usage_stats +from .utils_ext.usage_tracker import increment_profile_usage +from .utils_ext.validation import validate_chat_request + +_initialize_request_context = _init_request_context + + +# Wrapper function for backward compatibility +async def _test_client_connection(req_id: str, http_request) -> bool: + """Test if client is still connected - wrapper for _check_client_connection""" + return await _check_client_connection(req_id, http_request) + + +async def _analyze_model_requirements( + req_id: str, context: RequestContext, request: ChatCompletionRequest +) -> RequestContext: + """Proxy to model_switching.analyze_model_requirements""" + return await ms_analyze(req_id, context, request.model, MODEL_NAME) + + +async def _validate_page_status( + req_id: str, context: RequestContext, check_client_disconnected: Callable +) -> None: + """Validate page status""" + page = context["page"] + is_page_ready = context["is_page_ready"] + + if not page or page.is_closed() or not is_page_ready: + raise HTTPException( + status_code=503, + detail=f"[{req_id}] AI Studio page lost or not ready.", + headers={"Retry-After": "30"}, + ) + + check_client_disconnected("Initial Page Check") + + +async def _handle_model_switching( + req_id: str, context: RequestContext, check_client_disconnected: Callable +) -> RequestContext: + """Proxy to model_switching.handle_model_switching""" + return await ms_switch(req_id, context) + + +async def _handle_model_switch_failure( + req_id: str, page: AsyncPage, model_id_to_use: str, model_before_switch: str, logger +) -> None: + """Handle model switch failure""" + from api_utils.server_state import state + + logger.warning(f"[{req_id}] Failed to switch model to {model_id_to_use}.") + # Attempt to restore global state + state.current_ai_studio_model_id = model_before_switch + + raise HTTPException( + status_code=422, + detail=f"[{req_id}] Failed to switch to model '{model_id_to_use}'. Ensure model is available.", + ) + + +async def _handle_parameter_cache(req_id: str, context: RequestContext) -> None: + """Proxy to model_switching.handle_parameter_cache""" + await ms_param_cache(req_id, context) + + +async def _prepare_and_validate_request( + req_id: str, + request: ChatCompletionRequest, + check_client_disconnected: Callable, + fc_state: Optional[FunctionCallingState] = None, +) -> Tuple[str, List[str], Optional[List[Dict[str, Any]]]]: + """Prepare and validate request, return (combined prompt, attachment path list, tool_exec_results).""" + try: + validate_chat_request(request.messages, req_id) + except ValueError as e: + raise bad_request(req_id, f"Invalid request: {e}") + + prepared_prompt, attachments_list = prepare_combined_prompt( + request.messages, + req_id, + getattr(request, "tools", None), + getattr(request, "tool_choice", None), + fc_state=fc_state, + ) + # Active function execution based on tools/tool_choice (supports per-request MCP endpoints) + try: + # Inject mcp_endpoint into utils.maybe_execute_tools registration logic + if hasattr(request, "mcp_endpoint") and request.mcp_endpoint: + from .tools_registry import register_runtime_tools + + register_runtime_tools( + getattr(request, "tools", None), request.mcp_endpoint + ) + tool_exec_results = await maybe_execute_tools( + request.messages, request.tools, getattr(request, "tool_choice", None) + ) + except asyncio.CancelledError: + raise + except Exception: + tool_exec_results = None + + check_client_disconnected("After Prompt Prep") + # Inline results at the end of the prompt for submission together + if tool_exec_results: + try: + for res in tool_exec_results: + name = res.get("name") + args = res.get("arguments") + result_str = res.get("result") + prepared_prompt += f"\n---\nTool Execution: {name}\nArguments:\n{args}\nResult:\n{result_str}\n" + except Exception: + pass + + # Process and validate attachments + # Acceptance criteria: Only accept data:/file:/absolute paths provided by current request + final_attachments = collect_and_validate_attachments( + request, req_id, attachments_list + ) + + return prepared_prompt, final_attachments, tool_exec_results + + +async def _handle_response_processing( + req_id: str, + request: ChatCompletionRequest, + page: Optional[AsyncPage], + context: RequestContext, + result_future: Future, + submit_button_locator: Locator, + check_client_disconnected: Callable, + prompt_length: int, + timeout: float, + silence_threshold: float = 60.0, +) -> Optional[Tuple[Event, Locator, Callable]]: + """Handle response generation""" + stream_port = get_environment_variable("STREAM_PORT") + use_stream = stream_port != "0" + + if use_stream: + return await _handle_auxiliary_stream_response( + req_id, + request, + context, + result_future, + submit_button_locator, + check_client_disconnected, + timeout=timeout, + silence_threshold=silence_threshold, + ) + else: + return await _handle_playwright_response( + req_id, + request, + page, + context, + result_future, + submit_button_locator, + check_client_disconnected, + prompt_length, + timeout=timeout, + ) + + +async def _handle_auxiliary_stream_response( + req_id: str, + request: ChatCompletionRequest, + context: RequestContext, + result_future: Future[Union[StreamingResponse, JSONResponse]], + submit_button_locator: Locator, + check_client_disconnected: Callable, + timeout: float, + silence_threshold: float = 60.0, +) -> Optional[Tuple[Event, Locator, Callable]]: + """Auxiliary stream response processing path""" + from api_utils.server_state import state + + logger = state.logger + + is_streaming = request.stream + current_ai_studio_model_id = context.get("current_ai_studio_model_id") + + if is_streaming: + try: + completion_event = Event() + page = context["page"] + + # [RESILIENT-WRAPPER] Wrap the stream generator with retry/rotation logic + def aux_stream_factory(event_to_signal: Event): + return gen_sse_from_aux_stream( + req_id, + request, + current_ai_studio_model_id or MODEL_NAME, + check_client_disconnected, + event_to_signal, + timeout=timeout, + silence_threshold=silence_threshold, + page=page, # <--- CRITICAL: This enables the auto-scroll logic in stream.py + ) + + resilient_gen = resilient_stream_generator( + req_id, + current_ai_studio_model_id or MODEL_NAME, + aux_stream_factory, + completion_event, + ) + + if not result_future.done(): + result_future.set_result( + StreamingResponse(resilient_gen, media_type="text/event-stream") + ) + else: + if not completion_event.is_set(): + completion_event.set() + + return ( + completion_event, + submit_button_locator, + check_client_disconnected, + ) + + except asyncio.CancelledError: + if completion_event and not completion_event.is_set(): + completion_event.set() + raise + except Exception as e: + logger.error( + f"[{req_id}] Error getting stream data from queue: {e}", exc_info=True + ) + raise + else: + # Non-streaming logic using auxiliary stream + content = None + reasoning_content = None + functions = None + final_data_from_aux_stream = None + + page = context["page"] + # Disable silence detection for non-streaming requests to prevent premature timeouts + async for raw_data in use_stream_response( + req_id, + page=page, + check_client_disconnected=check_client_disconnected, + timeout=timeout, + silence_threshold=silence_threshold, + enable_silence_detection=False, + ): + check_client_disconnected(f"Non-streaming aux stream - loop ({req_id}): ") + + if isinstance(raw_data, str): + try: + data = json.loads(raw_data) + except json.JSONDecodeError: + logger.warning( + f"[{req_id}] Failed to parse non-stream data JSON: {raw_data}" + ) + continue + elif isinstance(raw_data, dict): + data = raw_data + else: + continue + + if not isinstance(data, dict): + continue + + final_data_from_aux_stream = data + if data.get("done"): + content = data.get("body") + reasoning_content = data.get("reason") + functions = data.get("function") + break + + if ( + final_data_from_aux_stream + and final_data_from_aux_stream.get("reason") == "internal_timeout" + ): + logger.error( + f"[{req_id}] Non-stream request failed via aux stream: Internal Timeout" + ) + raise HTTPException( + status_code=502, + detail=f"[{req_id}] Aux stream processing error (Internal Timeout)", + ) + + if ( + final_data_from_aux_stream + and final_data_from_aux_stream.get("done") is True + and content is None + ): + logger.error( + f"[{req_id}] Non-stream request completed via aux stream but no content provided" + ) + raise HTTPException( + status_code=502, + detail=f"[{req_id}] Aux stream completed but no content provided", + ) + + model_name_for_json = current_ai_studio_model_id or MODEL_NAME + + # Consolidate reasoning content with body content + consolidated_content = "" + if reasoning_content and reasoning_content.strip(): + consolidated_content += reasoning_content.strip() + if content and content.strip(): + if consolidated_content: + consolidated_content += "\n\n" + consolidated_content += content.strip() + + message_payload = {"role": "assistant", "content": consolidated_content} + finish_reason_val = "stop" + + if functions and len(functions) > 0: + tool_calls_list: List[Dict[str, Any]] = [] + for func_idx, function_call_data in enumerate(functions): + tool_calls_list.append( + { + "id": f"call_{_random_id()}", + "index": func_idx, + "type": "function", + "function": { + "name": function_call_data["name"], + "arguments": json.dumps(function_call_data["params"]), + }, + } + ) + message_payload["tool_calls"] = tool_calls_list + finish_reason_val = "tool_calls" + message_payload["content"] = None + + usage_stats = calculate_usage_stats( + [msg.model_dump() for msg in request.messages], + consolidated_content or "", + "", + ) + + total_tokens = usage_stats.get("total_tokens", 0) + GlobalState.increment_token_count(total_tokens) + + from api_utils.server_state import state + + if ( + hasattr(state, "current_auth_profile_path") + and state.current_auth_profile_path + ): + await increment_profile_usage(state.current_auth_profile_path, total_tokens) + + response_payload = build_chat_completion_response_json( + req_id, + model_name_for_json, + message_payload, + finish_reason_val, + usage_stats, + system_fingerprint="camoufox-proxy", + seed=request.seed + if hasattr(request, "seed") and request.seed is not None + else 0, + response_format=( + request.response_format + if hasattr(request, "response_format") + and isinstance(request.response_format, dict) + else {} + ), + ) + + if not result_future.done(): + response_json_str = json.dumps(response_payload, ensure_ascii=False) + if len(response_json_str) > 10000: # 10KB threshold + logger.info( + f"[{req_id}] Large response detected ({len(response_json_str)} chars), using efficient chunking" + ) + + async def generate_json_chunks(): + chunk_size = 8192 # 8KB chunks + for i in range(0, len(response_json_str), chunk_size): + chunk = response_json_str[i : i + chunk_size] + yield chunk + await asyncio.sleep(0.01) + + result_future.set_result( + StreamingResponse( + generate_json_chunks(), media_type="application/json" + ) + ) + else: + result_future.set_result(JSONResponse(content=response_payload)) + return response_payload + + +async def _handle_playwright_response( + req_id: str, + request: ChatCompletionRequest, + page: AsyncPage, + context: dict, + result_future: Future, + submit_button_locator: Locator, + check_client_disconnected: Callable, + prompt_length: int, + timeout: float, +) -> Optional[Tuple[Event, Locator, Callable]]: + """Handle response using Playwright - Enhanced version with integrity verification""" + from api_utils.server_state import state + + logger = state.logger + + is_streaming = request.stream + current_ai_studio_model_id = context.get("current_ai_studio_model_id") + + await locate_response_elements(page, req_id, logger, check_client_disconnected) + check_client_disconnected("After Response Element Located: ") + + if is_streaming: + completion_event = Event() + + def playwright_stream_factory(event_to_signal: Event): + return gen_sse_from_playwright( + page, + logger, + req_id, + current_ai_studio_model_id or MODEL_NAME, + request, + check_client_disconnected, + event_to_signal, + prompt_length=prompt_length, + timeout=timeout, + ) + + resilient_gen = resilient_stream_generator( + req_id, + current_ai_studio_model_id or MODEL_NAME, + playwright_stream_factory, + completion_event, + ) + + if not result_future.done(): + result_future.set_result( + StreamingResponse(resilient_gen, media_type="text/event-stream") + ) + + return completion_event, submit_button_locator, check_client_disconnected + else: + page_controller = PageController(page, logger, req_id) + response_data = await page_controller.get_response_with_integrity_check( + check_client_disconnected, prompt_length, timeout=timeout + ) + + final_content = response_data.get("content", "") + reasoning_content = response_data.get("reasoning_content", "") + recovery_method = response_data.get("recovery_method", "direct") + + if recovery_method == "integrity_verification": + logger.info( + f"[{req_id}] Successfully recovered content via integrity verification ({len(final_content)} chars)" + ) + await save_error_snapshot( + f"integrity_recovery_success_{req_id}", + extra_context={ + "content_length": len(final_content), + "reasoning_length": len(reasoning_content), + "recovery_trigger": response_data.get("trigger_reason", ""), + }, + ) + elif recovery_method == "direct": + logger.info( + f"[{req_id}] Successfully retrieved content directly ({len(final_content)} chars)" + ) + + consolidated_content = "" + if reasoning_content and reasoning_content.strip(): + consolidated_content += reasoning_content.strip() + if final_content and final_content.strip(): + if consolidated_content: + consolidated_content += "\n\n" + consolidated_content += final_content.strip() + + usage_stats = calculate_usage_stats( + [msg.model_dump() for msg in request.messages], + consolidated_content, + "", + ) + logger.info(f"[{req_id}] Token usage stats: {usage_stats}") + + total_tokens = usage_stats.get("total_tokens", 0) + GlobalState.increment_token_count(total_tokens) + + from api_utils.server_state import state + + if ( + hasattr(state, "current_auth_profile_path") + and state.current_auth_profile_path + ): + await increment_profile_usage(state.current_auth_profile_path, total_tokens) + + model_name_for_json = current_ai_studio_model_id or MODEL_NAME + + # Handle function calls if detected + if response_data.get("has_function_calls"): + from api_utils.utils_ext.function_calling_orchestrator import ( + get_function_calling_orchestrator, + ) + + orchestrator = get_function_calling_orchestrator() + message_payload, finish_reason_val = ( + orchestrator.format_function_calls_for_response( + response_data.get("function_calls", []), consolidated_content + ) + ) + else: + message_payload = {"role": "assistant", "content": consolidated_content} + finish_reason_val = "stop" + + response_payload = build_chat_completion_response_json( + req_id, + model_name_for_json, + message_payload, + finish_reason_val, + usage_stats, + system_fingerprint="camoufox-proxy", + seed=request.seed + if hasattr(request, "seed") and request.seed is not None + else 0, + response_format=( + request.response_format + if hasattr(request, "response_format") + and isinstance(request.response_format, dict) + else {} + ), + ) + + if not result_future.done(): + response_json_str = json.dumps(response_payload, ensure_ascii=False) + if len(response_json_str) > 10000: + + async def generate_json_chunks(): + chunk_size = 8192 + for i in range(0, len(response_json_str), chunk_size): + yield response_json_str[i : i + chunk_size] + await asyncio.sleep(0.01) + + result_future.set_result( + StreamingResponse( + generate_json_chunks(), media_type="application/json" + ) + ) + else: + result_future.set_result(JSONResponse(content=response_payload)) + + return response_payload + + +async def _cleanup_request_resources( + req_id: str, + disconnect_check_task: Optional[asyncio.Task], + completion_event: Optional[Event], + result_future: Future, + is_streaming: bool, +) -> None: + """Cleanup request resources""" + from api_utils.server_state import state + + logger = state.logger + + if disconnect_check_task and not disconnect_check_task.done(): + disconnect_check_task.cancel() + try: + await disconnect_check_task + except asyncio.CancelledError: + pass + + # Clean up upload subdirectory + try: + req_dir = os.path.join(UPLOAD_FILES_DIR, req_id) + if os.path.isdir(req_dir): + shutil.rmtree(req_dir, ignore_errors=True) + logger.debug(f"Cleaned up request upload directory: {req_dir}") + except asyncio.CancelledError: + raise + except Exception as clean_err: + logger.warning(f"[{req_id}] Failed to clean up upload directory: {clean_err}") + + if ( + is_streaming + and completion_event + and not completion_event.is_set() + and (result_future.done() and result_future.exception() is not None) + ): + logger.warning( + f"[{req_id}] Stream request exception, ensuring completion event is set." + ) + completion_event.set() + + +async def process_request_with_retry( + req_id: str, + request: ChatCompletionRequest, + http_request: Request, + result_future: Future, +) -> Optional[Tuple[Event, Locator, Callable[[str], bool]]]: + """Wrapper around _process_request_refactored with retry mechanism for quota""" + from api_utils.server_state import state + + logger = state.logger + + max_retries = 3 + attempt = 0 + while attempt < max_retries: + attempt += 1 + try: + return await _process_request_refactored( + req_id, request, http_request, result_future + ) + except QuotaExceededRetry: + logger.warning( + f"[{req_id}] Quota wall hit (attempt {attempt}/{max_retries}). Waiting for rotation..." + ) + await GlobalState.rotation_complete_event.wait() + logger.info(f"[{req_id}] Rotation complete. Retrying request.") + continue + logger.error(f"[{req_id}] Request failed after {max_retries} retries due to quota.") + raise Exception(f"Request failed after {max_retries} retries due to quota issues.") + + +async def process_request( + req_id: str, + request: ChatCompletionRequest, + http_request: Request, + result_future: Future, +) -> Optional[Tuple[Event, Locator, Callable[[str], bool]]]: + """Main entry point for request processing""" + return await process_request_with_retry( + req_id, request, http_request, result_future + ) + + +async def _process_request_refactored( + req_id: str, + request: ChatCompletionRequest, + http_request: Request, + result_future: Future, +) -> Optional[Tuple[Event, Locator, Callable[[str], bool]]]: + """Core Request Processing Function - Refactored Version""" + from api_utils.server_state import state + + logger = state.logger + + # 0. Check Auth Rotation Lock + if not GlobalState.AUTH_ROTATION_LOCK.is_set(): + logger.info(f"[{req_id}] Request held: Waiting for auth rotation...") + await GlobalState.AUTH_ROTATION_LOCK.wait() + logger.info(f"[{req_id}] ▶️ Resuming after Auth Rotation.") + + # [GR-03] Pre-Flight Graceful Rotation Check + if GlobalState.NEEDS_ROTATION: + logger.info(f"[{req_id}] 🔄 Graceful Rotation Pending. Initiating rotation...") + from api_utils.server_state import state + + current_model_id = state.current_ai_studio_model_id + from browser_utils.auth_rotation import perform_auth_rotation + + if await perform_auth_rotation(target_model_id=current_model_id): + GlobalState.NEEDS_ROTATION = False + logger.info(f"[{req_id}] ✅ Pre-flight rotation complete.") + + is_connected = await _test_client_connection(req_id, http_request) + if not is_connected: + logger.info(f"[{req_id}] Client disconnected before processing.") + if not result_future.done(): + result_future.set_exception( + HTTPException(status_code=499, detail="Client disconnected") + ) + return None + + stream_port = get_environment_variable("STREAM_PORT") + use_stream = stream_port != "0" + if use_stream: + try: + from api_utils import clear_stream_queue + + await clear_stream_queue() + except asyncio.CancelledError: + raise + except Exception as clear_err: + logger.warning(f"[Stream] Error clearing queue: {clear_err}") + + context = await _initialize_request_context(req_id, request) + context = await _analyze_model_requirements(req_id, context, request) + + ( + _, + disconnect_check_task, + check_client_disconnected, + ) = await _setup_disconnect_monitoring(req_id, http_request, result_future) + + page = context["page"] + submit_button_locator = page.locator(SUBMIT_BUTTON_SELECTOR) if page else None + completion_event = None + + try: + await _validate_page_status(req_id, context, check_client_disconnected) + if page is None: + raise server_error(req_id, "Page is None") + + page_controller = PageController(page, context["logger"], req_id) + await _handle_model_switching(req_id, context, check_client_disconnected) + await _handle_parameter_cache(req_id, context) + + # --- Native Function Calling Setup (Phase 3) --- + # Configure native function calling if mode is native/auto and tools are present + fc_orchestrator = get_function_calling_orchestrator() + fc_state: Optional[FunctionCallingState] = None + + if getattr(request, "tools", None): + try: + fc_state = await fc_orchestrator.prepare_request( + tools=request.tools, + tool_choice=getattr(request, "tool_choice", None), + page_controller=page_controller, + check_client_disconnected=check_client_disconnected, + req_id=req_id, + ) + except Exception as fc_err: + logger.warning( + f"[{req_id}] Function calling setup failed: {fc_err}, continuing with emulated mode" + ) + # Continue with request - fallback to emulated mode happens in prepare_combined_prompt + + ( + prepared_prompt, + attachments_list, + tool_exec_results, + ) = await _prepare_and_validate_request( + req_id, request, check_client_disconnected, fc_state=fc_state + ) + + # [TOOL-FORCED] If tool was executed locally (forced), return immediately bypassing AI Studio flow + if tool_exec_results: + logger.info( + f"[{req_id}] Active tool execution detected, returning results immediately." + ) + tool_calls_list = [] + for res in tool_exec_results: + tool_calls_list.append( + { + "id": f"call_{_random_id()}", + "type": "function", + "function": { + "name": res["name"], + "arguments": res["arguments"], + }, + } + ) + + message_payload = { + "role": "assistant", + "content": None, + "tool_calls": tool_calls_list, + } + + usage_stats = calculate_usage_stats( + [msg.model_dump() for msg in request.messages], + "", + "", + ) + + response_payload = build_chat_completion_response_json( + req_id, + request.model or MODEL_NAME, + message_payload, + "tool_calls", + usage_stats, + seed=request.seed + if hasattr(request, "seed") and request.seed is not None + else 0, + ) + + if not result_future.done(): + result_future.set_result(JSONResponse(content=response_payload)) + + # Return dummy event for forced tool execution to satisfy type requirement + dummy_event = Event() + dummy_event.set() + return dummy_event, submit_button_locator, check_client_disconnected + + request_params = request.model_dump(exclude_none=True) + if "stop" in request.model_fields_set and request.stop is None: + request_params["stop"] = None + + with log_context("Adjusting Parameters", context["logger"], silent=True): + await page_controller.adjust_parameters( + request_params, + context["page_params_cache"], + context["params_cache_lock"], + context["model_id_to_use"], + context["parsed_model_list"], + check_client_disconnected, + ) + + check_client_disconnected("Final check before submitting prompt") + + with log_context("Execution", context["logger"], silent=True): + await page_controller.submit_prompt( + prepared_prompt, attachments_list, check_client_disconnected + ) + + # Sync page reference if changed + if page_controller.page != page: + logger.info(f"[{req_id}] Page updated, syncing references...") + page = page_controller.page + context["page"] = page + submit_button_locator = page.locator(SUBMIT_BUTTON_SELECTOR) + + calc_timeout = 5.0 + (len(prepared_prompt) / 1000.0) + config_timeout = RESPONSE_COMPLETION_TIMEOUT / 1000.0 + dynamic_timeout = max(calc_timeout, config_timeout) + dynamic_silence_threshold = max(60.0, dynamic_timeout / 2.0) + + logger.info( + f"[{req_id}] Dynamic timeout: {dynamic_timeout:.2f}s, silence threshold: {dynamic_silence_threshold:.2f}s" + ) + + response_result = await _handle_response_processing( + req_id, + request, + page, + context, + result_future, + submit_button_locator, + check_client_disconnected, + len(prepared_prompt), + timeout=dynamic_timeout, + silence_threshold=dynamic_silence_threshold, + ) + + if response_result: + if isinstance(response_result, dict): + return response_result, submit_button_locator, check_client_disconnected + if isinstance(response_result, tuple): + completion_event, _, _ = response_result + return ( + completion_event, + submit_button_locator, + check_client_disconnected, + ) + + return completion_event, submit_button_locator, check_client_disconnected + + except ClientDisconnectedError as disco_err: + logger.info(f"[{req_id}] Client disconnected: {disco_err}") + if not result_future.done(): + result_future.set_exception(client_disconnected(req_id, "Disconnected")) + return completion_event, submit_button_locator, check_client_disconnected + except HTTPException as http_err: + logger.warning(f"[{req_id}] HTTP exception: {http_err.status_code}") + if not result_future.done(): + result_future.set_exception(http_err) + return completion_event, submit_button_locator, check_client_disconnected + except QuotaExceededError as quota_err: + logger.warning(f"[{req_id}] Quota Exceeded: {quota_err}") + if not GlobalState.IS_QUOTA_EXCEEDED: + GlobalState.set_quota_exceeded(message=str(quota_err)) + raise quota_err + except PlaywrightAsyncError as pw_err: + logger.error(f"[{req_id}] Playwright error: {pw_err}") + await save_error_snapshot(f"process_pw_error_{req_id}") + if not result_future.done(): + result_future.set_exception( + upstream_error(req_id, f"Interaction failed: {pw_err}") + ) + return completion_event, submit_button_locator, check_client_disconnected + except Exception as e: + logger.exception(f"[{req_id}] Unexpected error") + await save_error_snapshot(f"process_error_{req_id}") + if not result_future.done(): + result_future.set_exception(server_error(req_id, str(e))) + return completion_event, submit_button_locator, check_client_disconnected + finally: + await _cleanup_request_resources( + req_id, + disconnect_check_task, + completion_event, + result_future, + request.stream or False, + ) diff --git a/api_utils/response_generators.py b/api_utils/response_generators.py new file mode 100644 index 0000000000000000000000000000000000000000..053a2df7356cd8bcc39ef3c826bd2b159594033b --- /dev/null +++ b/api_utils/response_generators.py @@ -0,0 +1,613 @@ +import asyncio +import json +import logging +import random +import re +import time +from asyncio import Event +from typing import Any, AsyncGenerator, Callable, Dict, List, Optional, cast + +from playwright.async_api import Page as AsyncPage + +from api_utils.utils_ext.usage_tracker import increment_profile_usage +from config import CHAT_COMPLETION_ID_PREFIX +from config.global_state import GlobalState +from logging_utils import set_request_id +from models import ( + ChatCompletionRequest, + ClientDisconnectedError, + QuotaExceededError, + QuotaExceededRetry, +) + +from .common_utils import random_id +from .sse import generate_sse_chunk, generate_sse_stop_chunk +from .utils_ext.stream import use_stream_response +from .utils_ext.tokens import calculate_usage_stats + +# Pattern to strip emulated function call text from body content +# This prevents "Request function call: ..." from being sent as text content +_FUNCTION_CALL_TEXT_PATTERN = re.compile( + r"Request\s+function\s+call:\s*[^\n]+(?:\n(?:Parameters:\s*)?\s*\{[\s\S]*?\})?", + re.IGNORECASE, +) + +# Pattern to strip control characters like from body content +# These appear in AI Studio's wire format as string delimiters +# Also captures trailing } or { that may follow control chars (JSON leak artifacts) +_CONTROL_CHAR_PATTERN = re.compile(r"[\}\{]?") + + +def _clean_body_text(body: str) -> str: + """Clean body text by removing control characters and JSON artifacts.""" + if not body: + return body + return _CONTROL_CHAR_PATTERN.sub("", body) + + +async def resilient_stream_generator( + req_id: str, + model_name: str, + generator_factory: Callable[[Event], AsyncGenerator[str, None]], + completion_event: Event, +) -> AsyncGenerator[str, None]: + """ + Wraps a stream generator with resiliency logic. + Handles QuotaExceededError by triggering auth rotation and retrying. + """ + from api_utils.server_state import state + + logger = state.logger + from browser_utils.auth_rotation import perform_auth_rotation + + max_retries = 3 + retry_count = 0 + + inner_event = Event() + + try: + while retry_count <= max_retries: + try: + if inner_event.is_set(): + inner_event.clear() + + async for chunk in generator_factory(inner_event): + yield chunk + + return + + except (QuotaExceededError, QuotaExceededRetry) as e: + retry_count += 1 + if retry_count > max_retries: + logger.error( + f"[{req_id}] Max retries ({max_retries}) exhausted for quota recovery." + ) + yield f"data: {json.dumps({'error': 'Max retries exhausted for quota recovery.'}, ensure_ascii=False)}\n\n" + return + + logger.warning( + f"[{req_id}] Quota limit hit during stream: {str(e)}. Initiating rotation (Attempt {retry_count}/{max_retries})..." + ) + yield f": processing auth rotation (attempt {retry_count})...\n\n" + + rotation_task = asyncio.create_task( + perform_auth_rotation(target_model_id=model_name) + ) + + rotation_start = time.time() + while not rotation_task.done(): + if time.time() - rotation_start > 120: + logger.error(f"[{req_id}] Rotation timed out.") + yield f"data: {json.dumps({'error': 'Auth rotation timed out.'}, ensure_ascii=False)}\n\n" + return + + yield ": processing auth rotation...\n\n" + await asyncio.sleep(2) + + success = await rotation_task + if success: + logger.info( + f"[{req_id}] Auth rotation successful. Retrying stream generation..." + ) + yield ": auth rotation complete, retrying...\n\n" + continue + else: + logger.error(f"[{req_id}] Auth rotation failed.") + yield f"data: {json.dumps({'error': 'Auth rotation failed.'}, ensure_ascii=False)}\n\n" + return + except Exception: + raise + finally: + if not completion_event.is_set(): + completion_event.set() + logger.info(f"[{req_id}] Resilient stream completion event set") + + +async def gen_sse_from_aux_stream( + req_id: str, + request: ChatCompletionRequest, + model_name_for_stream: str, + check_client_disconnected: Callable[[str], bool], + event_to_set: Event, + timeout: float, + silence_threshold: float = 60.0, + page: Optional[AsyncPage] = None, + stream_state: Optional[Dict[str, Any]] = None, +) -> AsyncGenerator[str, None]: + """Auxiliary stream queue -> OpenAI compatible SSE generator.""" + logger = logging.getLogger("AIStudioProxyServer") + set_request_id(req_id) + + last_reason_pos = 0 + last_body_pos = 0 + chat_completion_id = f"{CHAT_COMPLETION_ID_PREFIX}{req_id}-{int(time.time())}-{random.randint(100, 999)}" + created_timestamp = int(time.time()) + + full_reasoning_content = "" + full_body_content = "" + data_receiving = False + is_response_finalized = False + finish_reason = "stop" + + has_started_body = False + + try: + async for raw_data in use_stream_response( + req_id, + timeout=timeout, + silence_threshold=silence_threshold, + page=page, + check_client_disconnected=check_client_disconnected, + enable_silence_detection=True, + ): + data_receiving = True + + if ( + GlobalState.CURRENT_STREAM_REQ_ID + and GlobalState.CURRENT_STREAM_REQ_ID != req_id + ): + logger.warning(f"[{req_id}] 🧟 Zombie Stream Detected! Terminating.") + break + + if GlobalState.QUOTA_EXCEEDED_EVENT.is_set(): + raise QuotaExceededRetry("Quota exceeded detected mid-stream.") + + if is_response_finalized: + logger.warning( + f"[{req_id}] ⚠️ Extraneous message received after response finalization. Ignoring." + ) + continue + + # Holding Pattern for Recovery + if GlobalState.IS_RECOVERING: + logger.info( + f"[{req_id}] ⏸️ System in Recovery Mode. Holding stream open..." + ) + recovery_wait_start = time.time() + while GlobalState.IS_RECOVERING: + if time.time() - recovery_wait_start > 120.0: + logger.error(f"[{req_id}] ❌ Recovery Timed Out. Aborting.") + yield generate_sse_chunk( + "\n\n[SYSTEM: Service Recovery Failed. Please retry.]", + req_id, + model_name_for_stream, + ) + yield generate_sse_stop_chunk(req_id, model_name_for_stream) + break + yield ": heartbeat\n\n" + await asyncio.sleep(1.0) + + if GlobalState.IS_RECOVERING: + break + logger.info(f"[{req_id}] ▶️ Recovery Complete. Resuming stream.") + + if GlobalState.IS_QUOTA_EXCEEDED and not GlobalState.IS_RECOVERING: + logger.warning( + f"[{req_id}] ⚠️ Quota exceeded detected. Waiting for recovery initiation..." + ) + await asyncio.sleep(1) + if GlobalState.IS_RECOVERING: + continue + logger.warning( + f"[{req_id}] ⛔ Quota exceeded, waiting for worker to pick up signal..." + ) + await asyncio.sleep(2) + continue + + try: + check_client_disconnected(f"Stream generator loop ({req_id}): ") + except ClientDisconnectedError: + logger.info( + f"[{req_id}] Client disconnected, terminating stream generation" + ) + if data_receiving and not event_to_set.is_set(): + event_to_set.set() + break + + data: Any + if isinstance(raw_data, str): + try: + data = json.loads(raw_data) + except json.JSONDecodeError: + logger.warning( + f"[{req_id}] Failed to parse stream data JSON: {raw_data}" + ) + continue + elif isinstance(raw_data, dict): + data = cast(Dict[str, Any], raw_data) + else: + continue + + if not isinstance(data, dict): + continue + + typed_data: Dict[str, Any] = cast(Dict[str, Any], data) + reason = str(typed_data.get("reason", "")) + body = _clean_body_text(str(typed_data.get("body", ""))) + done = bool(typed_data.get("done", False)) + function = cast(List[Any], typed_data.get("function", [])) + + if reason: + full_reasoning_content = reason + if body: + full_body_content = body + + # The Latch: Reasoning Handling + if len(reason) > last_reason_pos: + reason_delta = reason[last_reason_pos:] + if not has_started_body: + output = { + "id": chat_completion_id, + "object": "chat.completion.chunk", + "model": model_name_for_stream, + "created": created_timestamp, + "choices": [ + { + "index": 0, + "delta": { + "role": "assistant", + "content": None, + "reasoning_content": reason_delta, + }, + "finish_reason": None, + } + ], + } + yield f"data: {json.dumps(output, ensure_ascii=False, separators=(',', ':'))}\n\n" + last_reason_pos = len(reason) + + # The Latch: Body Handling + # ALWAYS strip "Request function call:..." text from body + # This prevents emulated FC text from appearing as content to clients + # even when function call detection fails (race condition protection) + original_body = body + if body: + body = _FUNCTION_CALL_TEXT_PATTERN.sub("", body).strip() + if body != original_body: + full_body_content = body + # If we stripped FC text but function is empty, try parsing from the original + if not function: + from api_utils.utils_ext.function_call_response_parser import ( + parse_emulated_function_calls_static, + ) + + parsed_fc = parse_emulated_function_calls_static(original_body) + if parsed_fc: + function = parsed_fc + # Demoted from INFO to DEBUG - this is normal fallback behavior + # when model outputs text format instead of native FC + logger.debug( + f"[{req_id}] Recovered function calls from emulated text" + ) + + if len(body) > last_body_pos: + body_delta = body[last_body_pos:] + # Only stream body content if there's actual content after stripping + if body_delta.strip(): + has_started_body = True + output = { + "id": chat_completion_id, + "object": "chat.completion.chunk", + "model": model_name_for_stream, + "created": created_timestamp, + "choices": [ + { + "index": 0, + "delta": { + "role": "assistant", + "content": body_delta, + }, + "finish_reason": None, + } + ], + } + yield f"data: {json.dumps(output, ensure_ascii=False, separators=(',', ':'))}\n\n" + last_body_pos = len(body) + + if done: + is_recovering = GlobalState.IS_RECOVERING + is_quota_exceeded = GlobalState.IS_QUOTA_EXCEEDED + + if ( + done + and not has_started_body + and not is_recovering + and not is_quota_exceeded + ): + try: + from browser_utils.operations import check_quota_limit + + if page: + await check_quota_limit(page, req_id) + except Exception: + pass + await asyncio.sleep(2.0) + is_quota_exceeded = GlobalState.IS_QUOTA_EXCEEDED + is_recovering = GlobalState.IS_RECOVERING + + if ( + not has_started_body + and not is_recovering + and not is_quota_exceeded + and not function + ): + # Only show synthetic message when there's truly no content AND no function calls + # In native FC mode, empty body with function calls is expected + fallback_text = ( + "\n\n*(Model finished thinking but generated no output.)*" + ) + output = { + "id": chat_completion_id, + "object": "chat.completion.chunk", + "model": model_name_for_stream, + "created": created_timestamp, + "choices": [ + { + "index": 0, + "delta": { + "role": "assistant", + "content": fallback_text, + }, + "finish_reason": None, + } + ], + } + yield f"data: {json.dumps(output, ensure_ascii=False, separators=(',', ':'))}\n\n" + full_body_content += fallback_text + has_started_body = True + elif is_recovering or is_quota_exceeded: + while GlobalState.IS_QUOTA_EXCEEDED or GlobalState.IS_RECOVERING: + yield ": heartbeat\n\n" + await asyncio.sleep(1.0) + + if function: + finish_reason = "tool_calls" + tool_calls_list = [] + for func_idx, function_call_data in enumerate(function): + if isinstance(function_call_data, dict): + tool_calls_list.append( + { + "id": f"call_{random_id()}", + "index": func_idx, + "type": "function", + "function": { + "name": function_call_data.get("name", ""), + "arguments": json.dumps( + function_call_data.get("params", {}) + ), + }, + } + ) + choice_item = { + "index": 0, + "delta": { + "tool_calls": tool_calls_list, + }, + "finish_reason": None, + } + else: + finish_reason = "stop" + choice_item = { + "index": 0, + "delta": {}, + "finish_reason": None, + } + + output = { + "id": chat_completion_id, + "object": "chat.completion.chunk", + "model": model_name_for_stream, + "created": created_timestamp, + "choices": [choice_item], + } + yield f"data: {json.dumps(output, ensure_ascii=False, separators=(',', ':'))}\n\n" + is_response_finalized = True + break + + except (QuotaExceededError, QuotaExceededRetry): + raise + except ClientDisconnectedError: + logger.info(f"[{req_id}] Client disconnected in stream generator") + if data_receiving and not event_to_set.is_set(): + event_to_set.set() + except asyncio.CancelledError: + if not event_to_set.is_set(): + event_to_set.set() + raise + except Exception as e: + logger.error(f"[{req_id}] Error in stream generator: {e}", exc_info=True) + try: + error_chunk = { + "id": chat_completion_id, + "object": "chat.completion.chunk", + "model": model_name_for_stream, + "created": created_timestamp, + "choices": [ + { + "index": 0, + "delta": { + "role": "assistant", + "content": f"\n\n[Error: {str(e)}]", + }, + "finish_reason": "stop", + } + ], + } + yield f"data: {json.dumps(error_chunk, ensure_ascii=False, separators=(',', ':'))}\n\n" + except Exception: + pass + finally: + try: + usage_stats = calculate_usage_stats( + [msg.model_dump() for msg in request.messages], + full_body_content, + full_reasoning_content, + ) + total_tokens = usage_stats.get("total_tokens", 0) + GlobalState.increment_token_count(total_tokens) + from api_utils.server_state import state + + if ( + hasattr(state, "current_auth_profile_path") + and state.current_auth_profile_path + ): + await increment_profile_usage( + state.current_auth_profile_path, total_tokens + ) + + final_chunk = { + "id": chat_completion_id, + "object": "chat.completion.chunk", + "model": model_name_for_stream, + "created": created_timestamp, + "choices": [{"index": 0, "delta": {}, "finish_reason": finish_reason}], + "usage": usage_stats, + } + yield f"data: {json.dumps(final_chunk, ensure_ascii=False, separators=(',', ':'))}\n\n" + except Exception as usage_err: + logger.error(f"[{req_id}] Error sending usage stats: {usage_err}") + + yield "data: [DONE]\n\n" + if not event_to_set.is_set(): + event_to_set.set() + + if stream_state is not None: + stream_state["has_content"] = bool( + full_body_content or full_reasoning_content + ) + + +async def gen_sse_from_playwright( + page: AsyncPage, + logger: logging.Logger, + req_id: str, + model_name_for_stream: str, + request: ChatCompletionRequest, + check_client_disconnected: Callable[[str], bool], + completion_event: Event, + prompt_length: int, + timeout: float, +) -> AsyncGenerator[str, None]: + """Playwright response -> OpenAI compatible SSE generator.""" + from browser_utils.page_controller import PageController + from models import ClientDisconnectedError + + set_request_id(req_id) + data_receiving = False + try: + page_controller = PageController(page, logger, req_id) + # Use get_response_with_function_calls which handles both content and functions + response_data = await page_controller.get_response_with_function_calls( + check_client_disconnected, prompt_length=prompt_length, timeout=timeout + ) + final_content = response_data.get("content", "") + function_calls = response_data.get("function_calls", []) + + data_receiving = True + lines = final_content.split("\n") + for line_idx, line in enumerate(lines): + try: + check_client_disconnected( + f"Playwright stream generator loop ({req_id}): " + ) + except ClientDisconnectedError: + if data_receiving and not completion_event.is_set(): + completion_event.set() + break + if line: + chunk_size = 5 + for i in range(0, len(line), chunk_size): + yield generate_sse_chunk( + line[i : i + chunk_size], req_id, model_name_for_stream + ) + await asyncio.sleep(0.03) + if line_idx < len(lines) - 1: + yield generate_sse_chunk("\n", req_id, model_name_for_stream) + await asyncio.sleep(0.01) + + usage_stats = calculate_usage_stats( + [msg.model_dump() for msg in request.messages], final_content, "" + ) + total_tokens = usage_stats.get("total_tokens", 0) + GlobalState.increment_token_count(total_tokens) + from api_utils.server_state import state + + if ( + hasattr(state, "current_auth_profile_path") + and state.current_auth_profile_path + ): + await increment_profile_usage(state.current_auth_profile_path, total_tokens) + + if function_calls: + from api_utils.utils_ext.function_calling_orchestrator import ( + get_function_calling_orchestrator, + ) + + orchestrator = get_function_calling_orchestrator() + tool_calls_deltas = orchestrator.format_streaming_tool_calls(function_calls) + for delta in tool_calls_deltas: + chunk = { + "id": f"chatcmpl-{req_id}", + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": model_name_for_stream, + "choices": [ + { + "index": 0, + "delta": {"tool_calls": [delta]}, + "finish_reason": None, + } + ], + } + yield f"data: {json.dumps(chunk, ensure_ascii=False, separators=(',', ':'))}\n\n" + + yield generate_sse_stop_chunk( + req_id, model_name_for_stream, "tool_calls", usage_stats + ) + else: + yield generate_sse_stop_chunk( + req_id, model_name_for_stream, "stop", usage_stats + ) + except (QuotaExceededError, QuotaExceededRetry): + raise + except ClientDisconnectedError: + if data_receiving and not completion_event.is_set(): + completion_event.set() + except asyncio.CancelledError: + if not completion_event.is_set(): + completion_event.set() + raise + except Exception as e: + logger.error( + f"[{req_id}] Error in Playwright stream generator: {e}", exc_info=True + ) + try: + yield generate_sse_chunk( + f"\n\n[Error: {str(e)}]", req_id, model_name_for_stream + ) + yield generate_sse_stop_chunk(req_id, model_name_for_stream) + except Exception: + pass + finally: + if not completion_event.is_set(): + completion_event.set() diff --git a/api_utils/response_payloads.py b/api_utils/response_payloads.py new file mode 100644 index 0000000000000000000000000000000000000000..acd1bcba3c80ddb4bbdfed186ade1cc2fac29b5c --- /dev/null +++ b/api_utils/response_payloads.py @@ -0,0 +1,39 @@ +import time +from typing import Any, Dict, Optional + +from config import CHAT_COMPLETION_ID_PREFIX + + +def build_chat_completion_response_json( + req_id: str, + model_name: str, + message_payload: Dict[str, Any], + finish_reason: str, + usage_stats: Dict[str, int], + system_fingerprint: str = "camoufox-proxy", + seed: Optional[int] = None, + response_format: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Construct an OpenAI-compatible non-streaming chat.completion JSON response.""" + created_ts = int(time.time()) + resp: Dict[str, Any] = { + "id": f"{CHAT_COMPLETION_ID_PREFIX}{req_id}-{created_ts}", + "object": "chat.completion", + "created": created_ts, + "model": model_name, + "choices": [ + { + "index": 0, + "message": message_payload, + "finish_reason": finish_reason, + "native_finish_reason": finish_reason, + } + ], + "usage": usage_stats, + "system_fingerprint": system_fingerprint, + } + if seed is not None: + resp["seed"] = seed + if response_format is not None: + resp["response_format"] = response_format + return resp diff --git a/api_utils/routers/__init__.py b/api_utils/routers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..54e6f641a83b1c05e4f1f6de9b112f0459594602 --- /dev/null +++ b/api_utils/routers/__init__.py @@ -0,0 +1,42 @@ +""" +Modular FastAPI routers for api_utils. +Each module defines focused endpoint handlers. This package aggregates them. +""" + +# Re-export handlers for convenient imports +from .api_keys import add_api_key, delete_api_key, get_api_keys, test_api_key +from .auth_files import router as auth_files_router +from .chat import chat_completions +from .health import health_check +from .helper import router as helper_router +from .info import get_api_info +from .logs_ws import websocket_log_endpoint +from .model_capabilities import router as model_capabilities_router +from .models import list_models +from .ports import router as ports_router +from .proxy import router as proxy_router +from .queue import cancel_request, get_queue_status +from .server import router as server_router +from .static import read_index, serve_react_assets + +__all__ = [ + "read_index", + "serve_react_assets", + "get_api_info", + "health_check", + "list_models", + "model_capabilities_router", + "chat_completions", + "cancel_request", + "get_queue_status", + "websocket_log_endpoint", + "get_api_keys", + "add_api_key", + "test_api_key", + "delete_api_key", + "proxy_router", + "auth_files_router", + "ports_router", + "server_router", + "helper_router", +] diff --git a/api_utils/routers/api_keys.py b/api_utils/routers/api_keys.py new file mode 100644 index 0000000000000000000000000000000000000000..61ba8a3a57383edda195f265664a8bb2a2d17701 --- /dev/null +++ b/api_utils/routers/api_keys.py @@ -0,0 +1,122 @@ +import logging + +from fastapi import Depends, HTTPException +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from ..dependencies import get_logger + + +class ApiKeyRequest(BaseModel): + key: str + + +class ApiKeyTestRequest(BaseModel): + key: str + + +async def get_api_keys(logger: logging.Logger = Depends(get_logger)): + from .. import auth_utils + + try: + auth_utils.initialize_keys() + keys_info = list(auth_utils.API_KEYS) + return JSONResponse( + content={"success": True, "keys": keys_info, "total_count": len(keys_info)} + ) + except Exception as e: + logger.error(f"Failed to get API key list: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +async def add_api_key( + request: ApiKeyRequest, logger: logging.Logger = Depends(get_logger) +): + from .. import auth_utils + + key_value = request.key.strip() + if not key_value or len(key_value) < 8: + raise HTTPException(status_code=400, detail="Invalid API key format.") + + auth_utils.initialize_keys() + if key_value in auth_utils.API_KEYS: + raise HTTPException(status_code=400, detail="API key already exists.") + + try: + key_file_path = auth_utils.KEY_FILE_PATH + with open(key_file_path, "a+", encoding="utf-8") as f: + f.seek(0) + if f.read(): + f.write("\n") + f.write(key_value) + + auth_utils.initialize_keys() + logger.info(f"API key added: {key_value[:4]}...{key_value[-4:]}") + return JSONResponse( + content={ + "success": True, + "message": "API key added successfully", + "key_count": len(auth_utils.API_KEYS), + } + ) + except Exception as e: + logger.error(f"Failed to add API key: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +async def test_api_key( + request: ApiKeyTestRequest, logger: logging.Logger = Depends(get_logger) +): + from .. import auth_utils + + key_value = request.key.strip() + if not key_value: + raise HTTPException(status_code=400, detail="API key cannot be empty.") + + auth_utils.initialize_keys() + is_valid = auth_utils.verify_api_key(key_value) + logger.info( + f"API key test: {key_value[:4]}...{key_value[-4:]} - {'Valid' if is_valid else 'Invalid'}" + ) + return JSONResponse( + content={ + "success": True, + "valid": is_valid, + "message": "Key valid" if is_valid else "Key invalid or non-existent", + } + ) + + +async def delete_api_key( + request: ApiKeyRequest, logger: logging.Logger = Depends(get_logger) +): + from .. import auth_utils + + key_value = request.key.strip() + if not key_value: + raise HTTPException(status_code=400, detail="API key cannot be empty.") + + auth_utils.initialize_keys() + if key_value not in auth_utils.API_KEYS: + raise HTTPException(status_code=404, detail="API key does not exist.") + + try: + key_file_path = auth_utils.KEY_FILE_PATH + with open(key_file_path, "r", encoding="utf-8") as f: + lines = f.readlines() + + with open(key_file_path, "w", encoding="utf-8") as f: + f.writelines(line for line in lines if line.strip() != key_value) + + auth_utils.initialize_keys() + logger.info(f"API key deleted: {key_value[:4]}...{key_value[-4:]}") + return JSONResponse( + content={ + "success": True, + "message": "API key deleted successfully", + "key_count": len(auth_utils.API_KEYS), + } + ) + except Exception as e: + logger.error(f"Failed to delete API key: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/api_utils/routers/auth_files.py b/api_utils/routers/auth_files.py new file mode 100644 index 0000000000000000000000000000000000000000..a076a0647e65ae2ac43242d17aa989d2bc0e0918 --- /dev/null +++ b/api_utils/routers/auth_files.py @@ -0,0 +1,152 @@ +""" +Authentication Files API Router + +Endpoints for managing authentication profile files. +""" + +import shutil +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, HTTPException +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from config.settings import ACTIVE_AUTH_DIR, SAVED_AUTH_DIR + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +class AuthFileInfo(BaseModel): + """Information about an auth file.""" + + name: str + path: str + size_bytes: int + is_active: bool = False + + +class ActivateRequest(BaseModel): + """Request to activate an auth file.""" + + filename: str = Field(..., description="The name of the auth file to activate") + + +class AuthFilesResponse(BaseModel): + """Response containing list of auth files.""" + + saved_files: list[AuthFileInfo] + active_file: Optional[str] = None + + +def _ensure_dirs() -> None: + """Ensure auth directories exist.""" + Path(ACTIVE_AUTH_DIR).mkdir(parents=True, exist_ok=True) + Path(SAVED_AUTH_DIR).mkdir(parents=True, exist_ok=True) + + +def _get_active_file() -> Optional[str]: + """Get the currently active auth file name.""" + active_dir = Path(ACTIVE_AUTH_DIR) + if not active_dir.exists(): + return None + json_files = list(active_dir.glob("*.json")) + if json_files: + return sorted(json_files)[0].name + return None + + +def _list_saved_files() -> list[AuthFileInfo]: + """List all saved auth files.""" + saved_dir = Path(SAVED_AUTH_DIR) + active_file = _get_active_file() + files: list[AuthFileInfo] = [] + + if saved_dir.exists(): + for f in sorted(saved_dir.glob("*.json")): + files.append( + AuthFileInfo( + name=f.name, + path=str(f), + size_bytes=f.stat().st_size, + is_active=(f.name == active_file), + ) + ) + return files + + +@router.get("/files") +async def list_auth_files() -> JSONResponse: + """List all saved auth files.""" + _ensure_dirs() + files = _list_saved_files() + active = _get_active_file() + return JSONResponse( + content=AuthFilesResponse( + saved_files=files, # type: ignore[arg-type] + active_file=active, + ).model_dump() + ) + + +@router.get("/active") +async def get_active_auth() -> JSONResponse: + """Get the currently active auth file.""" + _ensure_dirs() + active = _get_active_file() + return JSONResponse(content={"active_file": active}) + + +@router.post("/activate") +async def activate_auth_file(request: ActivateRequest) -> JSONResponse: + """Activate the specified auth file.""" + _ensure_dirs() + filename = request.filename + + # Find source file + source_path: Optional[Path] = None + for search_dir in [SAVED_AUTH_DIR, ACTIVE_AUTH_DIR]: + candidate = Path(search_dir) / filename + if candidate.exists() and candidate.is_file(): + source_path = candidate + break + + if not source_path: + raise HTTPException(status_code=404, detail=f"Auth file '{filename}' not found") + + # Clear existing active files + active_dir = Path(ACTIVE_AUTH_DIR) + for existing in active_dir.glob("*.json"): + existing.unlink() + + # Copy to active directory + dest_path = active_dir / filename + shutil.copy2(source_path, dest_path) + + return JSONResponse( + content={ + "success": True, + "message": f"Auth file '{filename}' activated", + "active_file": filename, + } + ) + + +@router.delete("/deactivate") +async def deactivate_auth() -> JSONResponse: + """Remove the currently active auth.""" + _ensure_dirs() + active_dir = Path(ACTIVE_AUTH_DIR) + removed_count = 0 + + for f in active_dir.glob("*.json"): + f.unlink() + removed_count += 1 + + return JSONResponse( + content={ + "success": True, + "message": f"Removed {removed_count} auth file(s)", + "active_file": None, + } + ) diff --git a/api_utils/routers/chat.py b/api_utils/routers/chat.py new file mode 100644 index 0000000000000000000000000000000000000000..445a91180a49d60590e1df45d27bdbcf26aab5d7 --- /dev/null +++ b/api_utils/routers/chat.py @@ -0,0 +1,92 @@ +import asyncio +import logging +import random +import time +from asyncio import Future, Queue + +from fastapi import Depends, HTTPException, Request +from fastapi.responses import JSONResponse + +from config import RESPONSE_COMPLETION_TIMEOUT, get_environment_variable +from logging_utils import set_request_id, set_source +from models import ChatCompletionRequest + +from ..dependencies import ( + ensure_request_lock, + get_logger, + get_request_queue, + get_server_state, + get_worker_task, +) +from ..error_utils import service_unavailable + + +async def chat_completions( + request: ChatCompletionRequest, + http_request: Request, + logger: logging.Logger = Depends(get_logger), + request_queue: Queue = Depends(get_request_queue), + server_state: dict = Depends(get_server_state), + worker_task=Depends(get_worker_task), + _lock: None = Depends(ensure_request_lock), +) -> JSONResponse: + req_id = "".join(random.choices("abcdefghijklmnopqrstuvwxyz0123456789", k=7)) + + # Set log context (Grid Logger) + set_request_id(req_id) + set_source("API") + + logger.info(f"Received /v1/chat/completions request (Stream={request.stream})") + + launch_mode = get_environment_variable("LAUNCH_MODE", "unknown") + browser_page_critical = launch_mode != "direct_debug_no_browser" + + is_service_unavailable = ( + server_state["is_initializing"] + or not server_state["is_playwright_ready"] + or ( + browser_page_critical + and ( + not server_state["is_page_ready"] + or not server_state["is_browser_connected"] + ) + ) + or not worker_task + or worker_task.done() + ) + + if is_service_unavailable: + raise service_unavailable(req_id) + + result_future = Future() + queue_item = { + "req_id": req_id, + "request_data": request, + "http_request": http_request, + "result_future": result_future, + "enqueue_time": time.time(), + "cancelled": False, + } + await request_queue.put(queue_item) + + try: + timeout_seconds = RESPONSE_COMPLETION_TIMEOUT / 1000 + 120 + return await asyncio.wait_for(result_future, timeout=timeout_seconds) + except asyncio.TimeoutError: + raise HTTPException( + status_code=504, detail=f"[{req_id}] Request processing timed out." + ) + except asyncio.CancelledError: + logger.info(f"Request cancelled by client: {req_id}") + raise + except HTTPException as http_exc: + if http_exc.status_code == 499: + logger.info(f"Client disconnected: {http_exc.detail}") + else: + logger.warning(f"HTTP exception: {http_exc.detail}") + raise http_exc + except Exception as e: + logger.exception("Error waiting for Worker response") + raise HTTPException( + status_code=500, detail=f"[{req_id}] Internal server error: {e}" + ) diff --git a/api_utils/routers/health.py b/api_utils/routers/health.py new file mode 100644 index 0000000000000000000000000000000000000000..90ac8b1cc3477d140222b15280c92bb7f9aa8385 --- /dev/null +++ b/api_utils/routers/health.py @@ -0,0 +1,66 @@ +from asyncio import Queue +from typing import Any, Dict + +from fastapi import Depends +from fastapi.responses import JSONResponse + +from config import get_environment_variable + +from ..dependencies import get_request_queue, get_server_state, get_worker_task + + +async def health_check( + server_state: Dict[str, Any] = Depends(get_server_state), + worker_task=Depends(get_worker_task), + request_queue: Queue = Depends(get_request_queue), +) -> JSONResponse: + is_worker_running = bool(worker_task and not worker_task.done()) + launch_mode = get_environment_variable("LAUNCH_MODE", "unknown") + browser_page_critical = launch_mode != "direct_debug_no_browser" + + core_ready_conditions = [ + not server_state["is_initializing"], + server_state["is_playwright_ready"], + ] + if browser_page_critical: + core_ready_conditions.extend( + [server_state["is_browser_connected"], server_state["is_page_ready"]] + ) + + is_core_ready = all(core_ready_conditions) + status_val = "OK" if is_core_ready and is_worker_running else "Error" + q_size = request_queue.qsize() if request_queue else -1 + + status_message_parts = [] + if server_state["is_initializing"]: + status_message_parts.append("Initialization in progress") + if not server_state["is_playwright_ready"]: + status_message_parts.append("Playwright not ready") + if browser_page_critical: + if not server_state["is_browser_connected"]: + status_message_parts.append("Browser not connected") + if not server_state["is_page_ready"]: + status_message_parts.append("Page not ready") + if not is_worker_running: + status_message_parts.append("Worker not running") + + status = { + "status": status_val, + "message": "", + "details": { + **server_state, + "workerRunning": is_worker_running, + "queueLength": q_size, + "launchMode": launch_mode, + "browserAndPageCritical": browser_page_critical, + }, + } + + if status_val == "OK": + status["message"] = f"Service running; Queue length: {q_size}." + return JSONResponse(content=status, status_code=200) + else: + status["message"] = ( + f"Service unavailable; Issue: {(', '.join(status_message_parts) or 'Unknown reason')}. Queue length: {q_size}." + ) + return JSONResponse(content=status, status_code=503) diff --git a/api_utils/routers/helper.py b/api_utils/routers/helper.py new file mode 100644 index 0000000000000000000000000000000000000000..2d7f7a6dfd22e4442e7ebc9fc24d4ece5efa5f72 --- /dev/null +++ b/api_utils/routers/helper.py @@ -0,0 +1,79 @@ +""" +Helper Configuration API Router + +Manages the Helper endpoint configuration. +""" + +import json +import logging +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +logger = logging.getLogger("CamoufoxLauncher") + +router = APIRouter(prefix="/api/helper", tags=["helper"]) + +# Config file path +_CONFIG_DIR = Path(__file__).parent.parent.parent / "config" +_HELPER_CONFIG_FILE = _CONFIG_DIR / "helper_config.json" + + +class HelperConfig(BaseModel): + """Helper configuration.""" + + enabled: bool = False + endpoint: str = Field(default="", description="Helper endpoint URL") + sapisid: Optional[str] = Field( + default=None, description="SAPISID value (auto-extracted)" + ) + + +def _load_config() -> HelperConfig: + """Load helper configuration from file.""" + if _HELPER_CONFIG_FILE.exists(): + try: + data = json.loads(_HELPER_CONFIG_FILE.read_text(encoding="utf-8")) + return HelperConfig(**data) + except Exception as e: + logger.warning(f"[Helper] Failed to load config: {e}") + return HelperConfig() + + +def _save_config(config: HelperConfig) -> None: + """Save helper configuration to file.""" + try: + _CONFIG_DIR.mkdir(parents=True, exist_ok=True) + _HELPER_CONFIG_FILE.write_text( + json.dumps(config.model_dump(), indent=2, ensure_ascii=False), + encoding="utf-8", + ) + except Exception as e: + logger.error(f"[Helper] Failed to save config: {e}") + + +@router.get("/config") +async def get_helper_config() -> JSONResponse: + """Get Helper configuration.""" + config = _load_config() + return JSONResponse(content=config.model_dump()) + + +@router.post("/config") +async def update_helper_config(config: HelperConfig) -> JSONResponse: + """Update Helper configuration.""" + _save_config(config) + logger.info( + f"[Helper] Config updated: enabled={config.enabled}, endpoint={config.endpoint}" + ) + + return JSONResponse( + content={ + "success": True, + "message": "Helper configuration saved", + "config": config.model_dump(), + } + ) diff --git a/api_utils/routers/info.py b/api_utils/routers/info.py new file mode 100644 index 0000000000000000000000000000000000000000..8b52ce9b0cfd7155445b6efdb5a70a8193b58868 --- /dev/null +++ b/api_utils/routers/info.py @@ -0,0 +1,49 @@ +from fastapi import Depends, Request +from fastapi.responses import JSONResponse + +from config import MODEL_NAME, get_environment_variable + +from ..dependencies import get_current_ai_studio_model_id + + +async def get_api_info( + request: Request, + current_ai_studio_model_id: str = Depends(get_current_ai_studio_model_id), +) -> JSONResponse: + from .. import auth_utils + + server_port = request.url.port or get_environment_variable( + "SERVER_PORT_INFO", "8000" + ) + host = request.headers.get("host") or f"127.0.0.1:{server_port}" + scheme = request.headers.get("x-forwarded-proto") or request.url.scheme or "http" + base_url = f"{scheme}://{host}" + api_base = f"{base_url}/v1" + effective_model_name = current_ai_studio_model_id or MODEL_NAME + + api_key_required = bool(auth_utils.API_KEYS) + api_key_count = len(auth_utils.API_KEYS) + + message = ( + f"API Key is required. {api_key_count} valid key(s) configured." + if api_key_required + else "API Key is not required." + ) + + return JSONResponse( + content={ + "model_name": effective_model_name, + "api_base_url": api_base, + "server_base_url": base_url, + "api_key_required": api_key_required, + "api_key_count": api_key_count, + "auth_header": "Authorization: Bearer or X-API-Key: " + if api_key_required + else None, + "openai_compatible": True, + "supported_auth_methods": ["Authorization: Bearer", "X-API-Key"] + if api_key_required + else [], + "message": message, + } + ) diff --git a/api_utils/routers/logs_ws.py b/api_utils/routers/logs_ws.py new file mode 100644 index 0000000000000000000000000000000000000000..3af6fdc3af68d773303f2412cc03441d3a17d8ae --- /dev/null +++ b/api_utils/routers/logs_ws.py @@ -0,0 +1,36 @@ +import asyncio +import logging +import uuid + +from fastapi import Depends, WebSocket, WebSocketDisconnect + +from models import WebSocketConnectionManager + +from ..dependencies import get_log_ws_manager, get_logger + + +async def websocket_log_endpoint( + websocket: WebSocket, + logger: logging.Logger = Depends(get_logger), + log_ws_manager: WebSocketConnectionManager = Depends(get_log_ws_manager), +): + if not log_ws_manager: + await websocket.close(code=1011) + return + + client_id = str(uuid.uuid4()) + try: + await log_ws_manager.connect(client_id, websocket) + while True: + await websocket.receive_text() + except WebSocketDisconnect: + pass + except asyncio.CancelledError: + raise + except Exception as e: + logger.error( + f"Log WebSocket (client {client_id}) encountered an exception: {e}", + exc_info=True, + ) + finally: + log_ws_manager.disconnect(client_id) diff --git a/api_utils/routers/model_capabilities.py b/api_utils/routers/model_capabilities.py new file mode 100644 index 0000000000000000000000000000000000000000..1c2641588b34c647eb7b313115a117a33cc646f8 --- /dev/null +++ b/api_utils/routers/model_capabilities.py @@ -0,0 +1,104 @@ +""" +Model Capabilities API Endpoint + +SINGLE SOURCE OF TRUTH for model thinking capabilities. +Frontend fetches this to determine UI controls dynamically. + +Configuration is loaded from config/model_capabilities.json. +When new models are released, update the JSON file - no code changes needed. +""" + +import json +import re +from functools import lru_cache +from pathlib import Path +from typing import Any + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +router = APIRouter() + +# Config file path +_CONFIG_PATH = ( + Path(__file__).parent.parent.parent / "config" / "model_capabilities.json" +) + + +@lru_cache(maxsize=1) +def _load_config() -> dict[str, Any]: + """ + Load model capabilities configuration from JSON file. + + Uses LRU cache to avoid repeated file reads. + Raises FileNotFoundError if config is missing. + """ + if not _CONFIG_PATH.exists(): + raise FileNotFoundError(f"Model capabilities config not found: {_CONFIG_PATH}") + + with open(_CONFIG_PATH, encoding="utf-8") as f: + return json.load(f) + + +def reload_config() -> None: + """Clear the config cache, forcing a reload on next access.""" + _load_config.cache_clear() + + +def _get_model_capabilities(model_id: str) -> dict[str, Any]: + """ + Determine thinking capabilities for a model. + + Returns dict with: + - thinkingType: "level" | "budget" | "none" + - levels: List of thinking levels (for type="level") + - alwaysOn: Whether thinking is always on (for Gemini 2.5 Pro) + - budgetRange: [min, max] for budget slider + - supportsGoogleSearch: Whether the model supports Google Search + """ + config = _load_config() + categories = config.get("categories", {}) + matchers = config.get("matchers", []) + + model_lower = model_id.lower() + + # Try each matcher in order (order matters: more specific first) + for matcher in matchers: + pattern = matcher.get("pattern", "") + category_name = matcher.get("category", "") + + if pattern and category_name: + try: + if re.search(pattern, model_lower, re.IGNORECASE): + if category_name in categories: + return categories[category_name].copy() + except re.error: + # Invalid regex pattern, skip + continue + + # Default to "other" category + return categories.get( + "other", {"thinkingType": "none", "supportsGoogleSearch": True} + ) + + +@router.get("/api/model-capabilities") +async def get_model_capabilities() -> JSONResponse: + """ + Return thinking capabilities for all known model categories. + + Frontend uses this to dynamically configure thinking controls. + """ + config = _load_config() + return JSONResponse(content=config) + + +@router.get("/api/model-capabilities/{model_id:path}") +async def get_single_model_capabilities(model_id: str) -> JSONResponse: + """ + Return thinking capabilities for a specific model. + + Args: + model_id: Model identifier (e.g., "gemini-2.5-flash-preview") + """ + return JSONResponse(content=_get_model_capabilities(model_id)) diff --git a/api_utils/routers/models.py b/api_utils/routers/models.py new file mode 100644 index 0000000000000000000000000000000000000000..2c6c3a9fd5a8c7a9a9e6352442add01319c22c40 --- /dev/null +++ b/api_utils/routers/models.py @@ -0,0 +1,70 @@ +import asyncio +import logging +import time +from asyncio import Event +from typing import Any, Dict, List, Set + +from fastapi import Depends +from playwright.async_api import Page as AsyncPage + +from config import DEFAULT_FALLBACK_MODEL_ID + +from ..dependencies import ( + ensure_request_lock, + get_excluded_model_ids, + get_logger, + get_model_list_fetch_event, + get_page_instance, + get_parsed_model_list, +) + + +async def list_models( + logger: logging.Logger = Depends(get_logger), + model_list_fetch_event: Event = Depends(get_model_list_fetch_event), + page_instance: AsyncPage = Depends(get_page_instance), + parsed_model_list: List[Dict[str, Any]] = Depends(get_parsed_model_list), + excluded_model_ids: Set[str] = Depends(get_excluded_model_ids), + _lock: None = Depends(ensure_request_lock), +): + logger.debug("[API] Received /v1/models request.") + + if ( + not model_list_fetch_event.is_set() + and page_instance + and not page_instance.is_closed() + ): + logger.info( + "/v1/models: Model list event not set, attempting to refresh page..." + ) + try: + await page_instance.reload(wait_until="domcontentloaded", timeout=20000) + await asyncio.wait_for(model_list_fetch_event.wait(), timeout=10.0) + except asyncio.CancelledError: + raise + except Exception as e: + logger.error(f"/v1/models: Error refreshing or waiting for model list: {e}") + finally: + if not model_list_fetch_event.is_set(): + model_list_fetch_event.set() + + if parsed_model_list: + final_model_list = [ + m + for m in parsed_model_list + if isinstance(m, dict) and m.get("id") not in excluded_model_ids + ] + return {"object": "list", "data": final_model_list} + else: + logger.warning("Model list is empty, returning default fallback model.") + return { + "object": "list", + "data": [ + { + "id": DEFAULT_FALLBACK_MODEL_ID, + "object": "model", + "created": int(time.time()), + "owned_by": "camoufox-proxy-fallback", + } + ], + } diff --git a/api_utils/routers/ports.py b/api_utils/routers/ports.py new file mode 100644 index 0000000000000000000000000000000000000000..e0a0e4d4fe99cfc898a00fac951d214741200957 --- /dev/null +++ b/api_utils/routers/ports.py @@ -0,0 +1,353 @@ +""" +Port Configuration and Status API Router + +Endpoints for port configuration, status querying, and process management. +""" + +import json +import os +import platform +import subprocess +import time +from pathlib import Path + +from fastapi import APIRouter, HTTPException +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field, field_validator + +router = APIRouter(prefix="/api/ports", tags=["ports"]) + +# Config file path +_CONFIG_DIR = Path(__file__).parent.parent.parent +_PORTS_CONFIG_FILE = _CONFIG_DIR / "ports_config.json" + + +class PortConfig(BaseModel): + """Port configuration model.""" + + fastapi_port: int = Field(default=2048, ge=1024, le=65535) + camoufox_debug_port: int = Field(default=9222, ge=1024, le=65535) + stream_proxy_port: int = Field(default=3120, ge=0, le=65535) + stream_proxy_enabled: bool = True + + @field_validator("fastapi_port", "camoufox_debug_port") + @classmethod + def validate_required_port(cls, v: int) -> int: + if v < 1024: + raise ValueError("Port must be >= 1024") + return v + + +class ProcessInfo(BaseModel): + """Information about a process.""" + + pid: int + name: str + + +class PortStatus(BaseModel): + """Status of a port.""" + + port: int + port_type: str + in_use: bool + processes: list[ProcessInfo] = [] + + +class KillRequest(BaseModel): + """Request to kill a process.""" + + pid: int = Field(..., ge=1, description="PID of the process to terminate") + confirm: bool = Field(default=False, description="Confirm termination") + + +def _load_port_config() -> PortConfig: + """Load port config from file or environment.""" + # Environment variables take priority + config = PortConfig( + fastapi_port=int(os.environ.get("DEFAULT_FASTAPI_PORT", "2048")), + camoufox_debug_port=int(os.environ.get("DEFAULT_CAMOUFOX_PORT", "9222")), + stream_proxy_port=int(os.environ.get("STREAM_PORT", "3120")), + stream_proxy_enabled=os.environ.get("STREAM_PORT", "3120") != "0", + ) + + # Override with saved config if exists + if _PORTS_CONFIG_FILE.exists(): + try: + data = json.loads(_PORTS_CONFIG_FILE.read_text(encoding="utf-8")) + config = PortConfig(**data) + except Exception: + pass + + return config + + +def _save_port_config(config: PortConfig) -> None: + """Save port config to file.""" + _PORTS_CONFIG_FILE.write_text( + json.dumps(config.model_dump(), ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + +def _find_processes_on_port(port: int) -> list[ProcessInfo]: + """Find processes listening on a port.""" + processes: list[ProcessInfo] = [] + system = platform.system() + + try: + if system in ("Linux", "Darwin"): + cmd = f"lsof -ti tcp:{port} -sTCP:LISTEN" + result = subprocess.run( + cmd, shell=True, capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0 and result.stdout.strip(): + pids = [ + int(p) for p in result.stdout.strip().splitlines() if p.isdigit() + ] + for pid in pids: + name = _get_process_name(pid) + processes.append(ProcessInfo(pid=pid, name=name)) + + elif system == "Windows": + cmd = "netstat -ano -p TCP" + result = subprocess.run( + cmd, + shell=True, + capture_output=True, + text=True, + timeout=10, + creationflags=subprocess.CREATE_NO_WINDOW, # type: ignore[attr-defined] + ) + if result.returncode == 0: + for line in result.stdout.strip().splitlines(): + parts = line.split() + if ( + len(parts) >= 5 + and parts[0].upper() == "TCP" + and parts[3].upper() == "LISTENING" + ): + local_addr = parts[1] + if local_addr.endswith(f":{port}"): + pid_str = parts[4] + if pid_str.isdigit(): + pid = int(pid_str) + name = _get_process_name(pid) + processes.append(ProcessInfo(pid=pid, name=name)) + + except Exception: + pass + + # Deduplicate by PID + seen_pids: set[int] = set() + unique_processes: list[ProcessInfo] = [] + for p in processes: + if p.pid not in seen_pids: + seen_pids.add(p.pid) + unique_processes.append(p) + + return unique_processes + + +def _get_process_name(pid: int) -> str: + """Get process name by PID.""" + system = platform.system() + + try: + if system == "Linux": + result = subprocess.run( + ["ps", "-p", str(pid), "-o", "comm="], + capture_output=True, + text=True, + timeout=3, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + + elif system == "Darwin": + result = subprocess.run( + ["ps", "-p", str(pid), "-o", "comm="], + capture_output=True, + text=True, + timeout=3, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + + elif system == "Windows": + result = subprocess.run( + ["tasklist", "/NH", "/FO", "CSV", "/FI", f"PID eq {pid}"], + capture_output=True, + text=True, + timeout=3, + creationflags=subprocess.CREATE_NO_WINDOW, # type: ignore[attr-defined] + ) + if result.returncode == 0 and result.stdout.strip(): + parts = result.stdout.strip().split('","') + if parts: + return parts[0].strip('"') + + except Exception: + pass + + return "Unknown" + + +def _kill_process(pid: int) -> tuple[bool, str]: + """Kill a process by PID. Returns (success, message).""" + system = platform.system() + + try: + if system in ("Linux", "Darwin"): + # Try SIGTERM first + subprocess.run(["kill", "-TERM", str(pid)], capture_output=True, timeout=3) + time.sleep(0.5) + + # Check if still alive + check = subprocess.run( + ["kill", "-0", str(pid)], capture_output=True, text=True + ) + if check.returncode != 0: + return True, f"Process {pid} terminated (SIGTERM)" + + # Force kill + subprocess.run(["kill", "-KILL", str(pid)], capture_output=True, timeout=3) + time.sleep(0.2) + + # Verify + check = subprocess.run( + ["kill", "-0", str(pid)], capture_output=True, text=True + ) + if check.returncode != 0: + return True, f"Process {pid} force terminated (SIGKILL)" + else: + return False, f"Unable to terminate process {pid}" + + elif system == "Windows": + result = subprocess.run( + ["taskkill", "/PID", str(pid), "/T", "/F"], + capture_output=True, + text=True, + timeout=5, + creationflags=subprocess.CREATE_NO_WINDOW, # type: ignore[attr-defined] + ) + if result.returncode == 0: + return True, f"Process {pid} terminated" + else: + return False, f"Unable to terminate process {pid}: {result.stderr}" + + except Exception as e: + return False, f"Error terminating process: {e}" + + return False, "Unsupported OS" + + +@router.get("/config") +async def get_port_config() -> JSONResponse: + """Get port configuration.""" + config = _load_port_config() + return JSONResponse(content=config.model_dump()) + + +@router.post("/config") +async def update_port_config(config: PortConfig) -> JSONResponse: + """ + Update port configuration. + + Note: Changes will take effect on next server restart. + """ + _save_port_config(config) + return JSONResponse( + content={ + "success": True, + "config": config.model_dump(), + "message": "Configuration saved. Changes will take effect on next restart.", + } + ) + + +@router.get("/status") +async def get_port_status() -> JSONResponse: + """Get port occupation status.""" + config = _load_port_config() + + statuses: list[PortStatus] = [] + + # Check FastAPI port + fastapi_processes = _find_processes_on_port(config.fastapi_port) + statuses.append( + PortStatus( + port=config.fastapi_port, + port_type="FastAPI", + in_use=len(fastapi_processes) > 0, + processes=fastapi_processes, + ) + ) + + # Check Camoufox debug port + camoufox_processes = _find_processes_on_port(config.camoufox_debug_port) + statuses.append( + PortStatus( + port=config.camoufox_debug_port, + port_type="Camoufox Debug", + in_use=len(camoufox_processes) > 0, + processes=camoufox_processes, + ) + ) + + # Check Stream proxy port (if enabled) + if config.stream_proxy_enabled and config.stream_proxy_port > 0: + stream_processes = _find_processes_on_port(config.stream_proxy_port) + statuses.append( + PortStatus( + port=config.stream_proxy_port, + port_type="Stream Proxy", + in_use=len(stream_processes) > 0, + processes=stream_processes, + ) + ) + + return JSONResponse(content={"ports": [s.model_dump() for s in statuses]}) + + +@router.post("/kill") +async def kill_process(request: KillRequest) -> JSONResponse: + """ + Terminate process with specified PID. + + Security validation: + - Requires confirm=true + - PID must belong to a process on a configured port + """ + if not request.confirm: + raise HTTPException( + status_code=400, + detail="Please set confirm=true to confirm process termination", + ) + + # Security: Validate PID belongs to a tracked port + config = _load_port_config() + tracked_pids: set[int] = set() + + # Collect PIDs from all configured ports + for port in [config.fastapi_port, config.camoufox_debug_port]: + for proc in _find_processes_on_port(port): + tracked_pids.add(proc.pid) + + # Also check stream proxy port if enabled + if config.stream_proxy_enabled and config.stream_proxy_port > 0: + for proc in _find_processes_on_port(config.stream_proxy_port): + tracked_pids.add(proc.pid) + + if request.pid not in tracked_pids: + raise HTTPException( + status_code=403, + detail=f"Security validation failed: PID {request.pid} does not belong to a configured port. Only processes running on FastAPI ({config.fastapi_port}), Camoufox ({config.camoufox_debug_port}) or Stream Proxy ({config.stream_proxy_port}) ports can be terminated.", + ) + + success, message = _kill_process(request.pid) + + return JSONResponse( + content={"success": success, "message": message, "pid": request.pid}, + status_code=200 if success else 500, + ) diff --git a/api_utils/routers/proxy.py b/api_utils/routers/proxy.py new file mode 100644 index 0000000000000000000000000000000000000000..859772e999a4904d8cef95e3a0c90d99f6b29d15 --- /dev/null +++ b/api_utils/routers/proxy.py @@ -0,0 +1,163 @@ +""" +Proxy Configuration API Router + +Endpoints for managing browser proxy settings and testing connectivity. +""" + +import socket +import time +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field, field_validator + +router = APIRouter(prefix="/api/proxy", tags=["proxy"]) + +# Config file path +_CONFIG_DIR = Path(__file__).parent.parent.parent +_PROXY_CONFIG_FILE = _CONFIG_DIR / "proxy_config.json" + + +class ProxyConfig(BaseModel): + """Proxy configuration model.""" + + enabled: bool = False + address: str = "http://127.0.0.1:7890" + + @field_validator("address") + @classmethod + def validate_address(cls, v: str) -> str: + """Validate proxy address format.""" + v = v.strip() + if v and not (v.startswith("http://") or v.startswith("https://")): + raise ValueError("Proxy address must start with http:// or https://") + return v + + +class ProxyTestRequest(BaseModel): + """Request model for proxy test.""" + + address: str = Field(..., description="Proxy address") + test_url: str = Field( + default="http://httpbin.org/get", description="Test target URL" + ) + + +class ProxyTestResult(BaseModel): + """Result of proxy connectivity test.""" + + success: bool + message: str + latency_ms: Optional[float] = None + + +def _load_config() -> ProxyConfig: + """Load proxy config from file.""" + import json + + if _PROXY_CONFIG_FILE.exists(): + try: + data = json.loads(_PROXY_CONFIG_FILE.read_text(encoding="utf-8")) + return ProxyConfig(**data) + except Exception: + pass + return ProxyConfig() + + +def _save_config(config: ProxyConfig) -> None: + """Save proxy config to file.""" + import json + + _PROXY_CONFIG_FILE.write_text( + json.dumps(config.model_dump(), ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + +@router.get("/config") +async def get_proxy_config() -> JSONResponse: + """Get current proxy configuration.""" + config = _load_config() + return JSONResponse(content=config.model_dump()) + + +@router.post("/config") +async def update_proxy_config(config: ProxyConfig) -> JSONResponse: + """Update proxy configuration.""" + _save_config(config) + return JSONResponse(content={"success": True, "config": config.model_dump()}) + + +@router.post("/test") +async def test_proxy_connectivity(request: ProxyTestRequest) -> JSONResponse: + """Test proxy connectivity.""" + import httpx + + proxy_addr = request.address.strip() + test_url = request.test_url + + if not proxy_addr: + return JSONResponse( + content=ProxyTestResult( + success=False, message="Proxy address cannot be empty" + ).model_dump(), + status_code=400, + ) + + try: + start_time = time.monotonic() + async with httpx.AsyncClient( + proxy=proxy_addr, + timeout=15.0, + follow_redirects=True, + ) as client: + response = await client.get(test_url) + latency = (time.monotonic() - start_time) * 1000 + + if 200 <= response.status_code < 300: + return JSONResponse( + content=ProxyTestResult( + success=True, + message=f"Connection successful (HTTP {response.status_code})", + latency_ms=round(latency, 2), + ).model_dump() + ) + else: + return JSONResponse( + content=ProxyTestResult( + success=False, + message=f"HTTP Error: {response.status_code}", + latency_ms=round(latency, 2), + ).model_dump() + ) + + except httpx.ProxyError as e: + return JSONResponse( + content=ProxyTestResult( + success=False, message=f"Proxy error: {e}" + ).model_dump() + ) + except httpx.ConnectTimeout: + return JSONResponse( + content=ProxyTestResult( + success=False, message="Connection timeout" + ).model_dump() + ) + except httpx.ReadTimeout: + return JSONResponse( + content=ProxyTestResult(success=False, message="Read timeout").model_dump() + ) + except socket.gaierror as e: + return JSONResponse( + content=ProxyTestResult( + success=False, message=f"DNS resolution failed: {e}" + ).model_dump() + ) + except Exception as e: + return JSONResponse( + content=ProxyTestResult( + success=False, message=f"Unknown error: {e}" + ).model_dump() + ) diff --git a/api_utils/routers/queue.py b/api_utils/routers/queue.py new file mode 100644 index 0000000000000000000000000000000000000000..f2b534521585d0f654da7797f1faf09a1781f659 --- /dev/null +++ b/api_utils/routers/queue.py @@ -0,0 +1,99 @@ +import logging +import time +from asyncio import Lock, Queue + +from fastapi import Depends +from fastapi.responses import JSONResponse + +from logging_utils import set_request_id + +from ..dependencies import get_logger, get_processing_lock, get_request_queue +from ..error_utils import client_cancelled + + +async def cancel_queued_request( + req_id: str, request_queue: Queue, logger: logging.Logger +) -> bool: + set_request_id(req_id) + items_to_requeue = [] + found = False + try: + while not request_queue.empty(): + item = request_queue.get_nowait() + if item.get("req_id") == req_id: + logger.info("Found request in queue, marking as cancelled.") + item["cancelled"] = True + if (future := item.get("result_future")) and not future.done(): + future.set_exception(client_cancelled(req_id)) + found = True + items_to_requeue.append(item) + finally: + for item in items_to_requeue: + await request_queue.put(item) + return found + + +async def cancel_request( + req_id: str, + logger: logging.Logger = Depends(get_logger), + request_queue: Queue = Depends(get_request_queue), +): + set_request_id(req_id) + logger.info("Received cancellation request.") + if await cancel_queued_request(req_id, request_queue, logger): + return JSONResponse( + content={ + "success": True, + "message": f"Request {req_id} marked as cancelled.", + } + ) + else: + return JSONResponse( + status_code=404, + content={ + "success": False, + "message": f"Request {req_id} not found in queue.", + }, + ) + + +async def get_queue_status( + request_queue: Queue = Depends(get_request_queue), + processing_lock: Lock = Depends(get_processing_lock), +): + # Extract all items temporarily to inspect queue contents + queue_items = [] + try: + while not request_queue.empty(): + item = request_queue.get_nowait() + queue_items.append(item) + except Exception: + pass + finally: + # Put all items back in original order + for item in queue_items: + await request_queue.put(item) + + queue_length = len(queue_items) + + return JSONResponse( + content={ + "queue_length": queue_length, + "is_processing_locked": processing_lock.locked(), + "items": sorted( + [ + { + "req_id": item.get("req_id", "unknown"), + "enqueue_time": item.get("enqueue_time", 0), + "wait_time_seconds": round( + time.time() - item.get("enqueue_time", 0), 2 + ), + "is_streaming": item.get("request_data").stream, + "cancelled": item.get("cancelled", False), + } + for item in queue_items + ], + key=lambda x: x.get("enqueue_time", 0), + ), + } + ) diff --git a/api_utils/routers/server.py b/api_utils/routers/server.py new file mode 100644 index 0000000000000000000000000000000000000000..0ba9fcdd57237b301195732ee541f5e91146665d --- /dev/null +++ b/api_utils/routers/server.py @@ -0,0 +1,140 @@ +""" +Server Control API Router + +Provides endpoints for server status and control operations. +""" + +import logging +import os +import time +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from ..app import VERSION + +logger = logging.getLogger("CamoufoxLauncher") + +router = APIRouter(prefix="/api/server", tags=["server"]) + +# Track server start time +_SERVER_START_TIME: Optional[float] = None + + +def _init_start_time() -> None: + """Initialize server start time (called once at startup).""" + global _SERVER_START_TIME + if _SERVER_START_TIME is None: + _SERVER_START_TIME = time.time() + + +_init_start_time() + + +class ServerStatus(BaseModel): + """Server status information.""" + + status: str + uptime_seconds: float + uptime_formatted: str + launch_mode: str + server_port: int + stream_port: int + version: str + python_version: str + started_at: str + + +class RestartRequest(BaseModel): + """Restart request with mode.""" + + mode: str = "headless" # headless, debug, virtual_display + confirm: bool = False + + +def _format_uptime(seconds: float) -> str: + """Format uptime in human-readable format.""" + days = int(seconds // 86400) + hours = int((seconds % 86400) // 3600) + minutes = int((seconds % 3600) // 60) + secs = int(seconds % 60) + + parts = [] + if days > 0: + parts.append(f"{days}d") + if hours > 0: + parts.append(f"{hours}h") + if minutes > 0: + parts.append(f"{minutes}m") + parts.append(f"{secs}s") + + return " ".join(parts) + + +@router.get("/status") +async def get_server_status() -> JSONResponse: + """Get server status information.""" + import sys + + uptime = time.time() - (_SERVER_START_TIME or time.time()) + started_at = datetime.fromtimestamp(_SERVER_START_TIME or time.time()) + + status = ServerStatus( + status="running", + uptime_seconds=round(uptime, 2), + uptime_formatted=_format_uptime(uptime), + launch_mode=os.environ.get("LAUNCH_MODE", "unknown"), + server_port=int( + os.environ.get("SERVER_PORT_INFO", os.environ.get("PORT", 2048)) + ), + stream_port=int(os.environ.get("STREAM_PORT", 3120)), + version=VERSION, + python_version=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", + started_at=started_at.isoformat(), + ) + + return JSONResponse(content=status.model_dump()) + + +@router.post("/restart") +async def restart_server(request: RestartRequest) -> JSONResponse: + """ + Request server restart. + + Note: This operation terminates the current process and requires an external process manager to restart. + """ + if not request.confirm: + return JSONResponse( + content={ + "success": False, + "message": "Restart operation requires confirmation. Please set confirm=true", + }, + status_code=400, + ) + + valid_modes = ["headless", "debug", "virtual_display"] + if request.mode not in valid_modes: + return JSONResponse( + content={ + "success": False, + "message": f"Invalid launch mode. Valid options: {valid_modes}", + }, + status_code=400, + ) + + logger.info(f"[Server] Received restart request, target mode: {request.mode}") + + # Set environment variable for next launch + os.environ["REQUESTED_RESTART_MODE"] = request.mode + + # Return success - actual restart needs to be handled by process manager + return JSONResponse( + content={ + "success": True, + "message": f"Server will restart in {request.mode} mode. Please refresh the page.", + "mode": request.mode, + } + ) diff --git a/api_utils/routers/static.py b/api_utils/routers/static.py new file mode 100644 index 0000000000000000000000000000000000000000..29835b5e04889e981984d9bd5b960a8070e88d5c --- /dev/null +++ b/api_utils/routers/static.py @@ -0,0 +1,96 @@ +""" +Static files serving routes +Uses FastAPI/Starlette native static files service + +Optimization points: +- Use StaticFiles for high-performance static file serving +- Automatic handling of cache headers, byte-range requests, directory traversal protection +- SPA routing uses catch-all to return only index.html +""" + +import logging +from pathlib import Path + +from fastapi import Depends, HTTPException +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from ..dependencies import get_logger + +_BASE_DIR = Path(__file__).parent.parent.parent + +# React build directory +_REACT_DIST = _BASE_DIR / "static" / "frontend" / "dist" +_REACT_ASSETS = _REACT_DIST / "assets" + + +def get_static_files_app() -> StaticFiles | None: + """ + Create a StaticFiles app for the assets directory. + + Returns None if the directory doesn't exist (frontend not built). + """ + if _REACT_ASSETS.exists(): + return StaticFiles(directory=str(_REACT_ASSETS)) + return None + + +async def read_index(logger: logging.Logger = Depends(get_logger)) -> FileResponse: + """Serve React index.html for SPA routing.""" + react_index = _REACT_DIST / "index.html" + if react_index.exists(): + return FileResponse(react_index, media_type="text/html") + + logger.error("React build not found - run 'npm run build' in static/frontend/") + raise HTTPException( + status_code=503, + detail="Frontend not built. Run 'npm run build' in static/frontend/", + ) + + +async def serve_react_assets( + filename: str, logger: logging.Logger = Depends(get_logger) +) -> FileResponse: + """ + Serve React built assets (JS, CSS, etc.). + + Note: For production deployments, consider mounting StaticFiles directly + in the app configuration for better performance: + + from fastapi.staticfiles import StaticFiles + app.mount("/assets", StaticFiles(directory="static/frontend/dist/assets")) + + This fallback route is provided for flexibility and development convenience. + """ + asset_path = _REACT_ASSETS / filename + + if not asset_path.exists(): + logger.debug(f"Asset not found: {asset_path}") + raise HTTPException(status_code=404, detail=f"Asset {filename} not found") + + # Security: Prevent directory traversal + try: + asset_path.resolve().relative_to(_REACT_ASSETS.resolve()) + except ValueError: + logger.warning(f"Directory traversal attempt blocked: {filename}") + raise HTTPException(status_code=403, detail="Access denied") + + # Determine media type based on suffix + suffix_to_media_type = { + ".js": "application/javascript", + ".css": "text/css", + ".map": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".eot": "application/vnd.ms-fontobject", + } + media_type = suffix_to_media_type.get(asset_path.suffix.lower()) + + return FileResponse(asset_path, media_type=media_type) diff --git a/api_utils/server_state.py b/api_utils/server_state.py new file mode 100644 index 0000000000000000000000000000000000000000..6e1230d4dd739dba2fafb6b7d6509bbce954206d --- /dev/null +++ b/api_utils/server_state.py @@ -0,0 +1,126 @@ +""" +Centralized server state module. + +This module contains all shared state variables that were previously in server.py. +It has NO imports from project modules (only stdlib), making it safe to import +at module level anywhere in the codebase without circular dependency issues. + +Usage: + from api_utils.server_state import state + # Access state attributes + page = state.page_instance + state.current_ai_studio_model_id = "new-model" +""" + +import asyncio +import logging +import multiprocessing +from asyncio import Event, Lock, Queue, Task +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set + +if TYPE_CHECKING: + from playwright.async_api import ( + Browser as AsyncBrowser, + ) + from playwright.async_api import ( + Page as AsyncPage, + ) + from playwright.async_api import ( + Playwright as AsyncPlaywright, + ) + + from api_utils.context_types import QueueItem + from models.logging import WebSocketConnectionManager + + +class ServerState: + """ + Centralized container for all server state. + + This class holds all mutable state that needs to be shared across modules. + Using a class allows for better organization and easier testing (state can be reset). + """ + + def __init__(self) -> None: + """Initialize all state variables with default values.""" + self.reset() + + def reset(self) -> None: + """Reset all state to initial values. Useful for testing.""" + # --- Stream Queue --- + self.STREAM_QUEUE: Optional[multiprocessing.Queue] = None + self.STREAM_PROCESS: Optional[multiprocessing.Process] = None + + # --- Playwright/Browser State --- + self.playwright_manager: Optional["AsyncPlaywright"] = None + self.browser_instance: Optional["AsyncBrowser"] = None + self.page_instance: Optional["AsyncPage"] = None + self.is_playwright_ready: bool = False + self.is_browser_connected: bool = False + self.is_page_ready: bool = False + self.is_initializing: bool = False + + # --- Proxy Configuration --- + self.PLAYWRIGHT_PROXY_SETTINGS: Optional[Dict[str, str]] = None + + # --- Model State --- + self.global_model_list_raw_json: Optional[str] = None + self.parsed_model_list: List[Dict[str, Any]] = [] + self.model_list_fetch_event: Event = asyncio.Event() + self.current_ai_studio_model_id: Optional[str] = None + self.current_auth_profile_path: Optional[str] = None + self.model_switching_lock: Lock = Lock() + self.excluded_model_ids: Set[str] = set() + + # --- Request Processing State --- + self.request_queue: "Optional[Queue[QueueItem]]" = None + self.processing_lock: Optional[Lock] = None + self.worker_task: "Optional[Task[None]]" = None + + # --- Parameter Cache --- + self.page_params_cache: Dict[str, Any] = {} + self.params_cache_lock: Lock = Lock() + + # --- Debug Logging State --- + self.console_logs: List[Dict[str, Any]] = [] + self.network_log: Dict[str, List[Dict[str, Any]]] = { + "requests": [], + "responses": [], + } + + # --- Logging --- + self.logger: logging.Logger = logging.getLogger("AIStudioProxyServer") + self.log_ws_manager: Optional["WebSocketConnectionManager"] = None + + # --- Control Flags --- + self.should_exit: bool = False + self.quota_watchdog: Optional[Callable] = None + + def clear_debug_logs(self) -> None: + """Clear console and network logs (called after each request).""" + self.console_logs = [] + self.network_log = {"requests": [], "responses": []} + + +# Global singleton instance +state = ServerState() + + +# Convenience exports for backward compatibility +# These allow direct attribute access like: from server_state import page_instance +# But the recommended way is: from api_utils.server_state import state; state.page_instance + + +def __getattr__(name: str) -> Any: + """ + Module-level attribute access for backward compatibility. + + Allows: + from server_state import page_instance + Instead of: + from api_utils.server_state import state + page_instance = state.page_instance + """ + if hasattr(state, name): + return getattr(state, name) + raise AttributeError(f"module 'server_state' has no attribute '{name}'") diff --git a/api_utils/sse.py b/api_utils/sse.py new file mode 100644 index 0000000000000000000000000000000000000000..7d8df78ac356bbdbbcca199c317ee3b20674d4cd --- /dev/null +++ b/api_utils/sse.py @@ -0,0 +1,41 @@ +import json +import time +from typing import Any, Dict, Optional + + +def generate_sse_chunk(delta: str, req_id: str, model: str) -> str: + chunk_data: Dict[str, Any] = { + "id": f"chatcmpl-{req_id}", + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": model, + "choices": [{"index": 0, "delta": {"content": delta}, "finish_reason": None}], + } + return f"data: {json.dumps(chunk_data)}\n\n" + + +def generate_sse_stop_chunk( + req_id: str, + model: str, + reason: str = "stop", + usage: Optional[Dict[str, int]] = None, +) -> str: + stop_chunk_data: Dict[str, Any] = { + "id": f"chatcmpl-{req_id}", + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": model, + "choices": [{"index": 0, "delta": {}, "finish_reason": reason}], + } + if usage: + stop_chunk_data["usage"] = usage + return f"data: {json.dumps(stop_chunk_data)}\n\ndata: [DONE]\n\n" + + +def generate_sse_error_chunk( + message: str, req_id: str, error_type: str = "server_error" +) -> str: + error_chunk = { + "error": {"message": message, "type": error_type, "param": None, "code": req_id} + } + return f"data: {json.dumps(error_chunk)}\n\n" diff --git a/api_utils/tools_registry.py b/api_utils/tools_registry.py new file mode 100644 index 0000000000000000000000000000000000000000..bc166a50a8df97c43fd3df795e892d6e212b11e7 --- /dev/null +++ b/api_utils/tools_registry.py @@ -0,0 +1,120 @@ +import asyncio +import json +import os +import time +from typing import Any, Dict, List, Optional, Set + + +def tool_get_current_time(params: Dict[str, Any]) -> Dict[str, Any]: + return {"current_time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())} + + +def tool_echo(params: Dict[str, Any]) -> Dict[str, Any]: + return {"echo": params} + + +def tool_sum(params: Dict[str, Any]) -> Dict[str, Any]: + values = params.get("values") + if isinstance(values, list): + try: + total = sum(float(v) for v in values) + except Exception: + total = None + else: + total = None + return {"sum": total, "count": len(values) if isinstance(values, list) else 0} + + +FUNCTION_REGISTRY = { + "get_current_time": tool_get_current_time, + "echo": tool_echo, + "sum": tool_sum, +} + +# Runtime-allowed tool names from incoming requests (OpenAI tools array) +_ALLOWED_RUNTIME_TOOLS: Set[str] = set() +_runtime_mcp_endpoint: Optional[str] = None + + +def register_runtime_tools( + tools: Optional[List[Dict[str, Any]]], mcp_endpoint: Optional[str] = None +) -> None: + """Register tool names declared in the request as allowed. + The server may delegate unknown tools to MCP if configured. + """ + # Reset per-request registry to avoid leakage across requests + global _runtime_mcp_endpoint + _ALLOWED_RUNTIME_TOOLS.clear() + _runtime_mcp_endpoint = None + if not tools: + return + try: + for t in tools: + name = None + fn = t.get("function") if "function" in t else t + if isinstance(fn, dict): + name = fn.get("name") or t.get("name") + else: + name = t.get("name") + if name: + _ALLOWED_RUNTIME_TOOLS.add(str(name)) + # Detect per-tool endpoint extension + ext_ep = ( + t.get("x-mcp-endpoint") + or t.get("x_mcp_endpoint") + or ( + isinstance(t.get("function"), dict) + and t["function"].get("x-mcp-endpoint") + ) + or None + ) + if ext_ep and not mcp_endpoint: + mcp_endpoint = ext_ep + # Capture per-request MCP endpoint if provided (explicit or via tool extension) + if mcp_endpoint: + _runtime_mcp_endpoint = mcp_endpoint + except Exception: + # be forgiving on malformed tools + pass + + +async def execute_tool_call(name: str, arguments_json: str) -> str: + """Execute registered tools and return stringified result. Unknown tools return descriptive errors. + Fully asynchronous: built-in functions execute directly; MCP path uses async httpx client. + """ + try: + params = json.loads(arguments_json or "{}") + except Exception: + params = {} + + func = FUNCTION_REGISTRY.get(name) + if not func: + # If tool is not built-in but declared, try MCP adapter if configured (env or per-request) + if name in _ALLOWED_RUNTIME_TOOLS: + try: + from api_utils.mcp_adapter import ( + execute_mcp_tool, + execute_mcp_tool_with_endpoint, + ) + + if _runtime_mcp_endpoint: + return await execute_mcp_tool_with_endpoint( + _runtime_mcp_endpoint, name, params + ) + if os.environ.get("MCP_HTTP_ENDPOINT"): + return await execute_mcp_tool(name, params) + except asyncio.CancelledError: + raise + except Exception as e: + return json.dumps( + {"error": f"MCP execution failed: {e}"}, ensure_ascii=False + ) + return json.dumps( + {"error": f"Unknown tool: {name}", "arguments": params}, ensure_ascii=False + ) + + try: + result = func(params) + return json.dumps(result, ensure_ascii=False) + except Exception as e: + return json.dumps({"error": f"Execution failed: {e}"}, ensure_ascii=False) diff --git a/api_utils/utils.py b/api_utils/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..660bc1609190b150c8420810925e9629f51014da --- /dev/null +++ b/api_utils/utils.py @@ -0,0 +1,52 @@ +""" +API utility function module +Contains utility functions for SSE generation, stream processing, token statistics, and request validation +(Refactored: logic moved to api_utils.utils_ext submodules) +""" + + +from .sse import generate_sse_stop_chunk +from .utils_ext import ( + _extension_for_mime, + calculate_usage_stats, + clear_stream_queue, + collect_and_validate_attachments, + estimate_tokens, + extract_data_url_to_local, + extract_json_from_text, + get_latest_user_text, + maybe_execute_tools, + prepare_combined_prompt, + save_blob_to_local, + use_helper_get_response, + use_stream_response, + validate_chat_request, +) + +# For backward compatibility with existing code that might import these private functions +_extract_json_from_text = extract_json_from_text +_get_latest_user_text = get_latest_user_text + + +def generate_sse_stop_chunk_with_usage(req_id: str, model: str, usage_stats: dict, reason: str = "stop") -> str: + """Generate SSE stop chunk with usage statistics""" + return generate_sse_stop_chunk(req_id, model, reason, usage_stats) + + +__all__ = [ + "generate_sse_stop_chunk_with_usage", + "extract_data_url_to_local", + "save_blob_to_local", + "collect_and_validate_attachments", + "prepare_combined_prompt", + "maybe_execute_tools", + "extract_json_from_text", + "get_latest_user_text", + "use_stream_response", + "clear_stream_queue", + "use_helper_get_response", + "validate_chat_request", + "estimate_tokens", + "calculate_usage_stats", + "_extension_for_mime", +] diff --git a/api_utils/utils_ext/__init__.py b/api_utils/utils_ext/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a4f510fa491a2262c77380333838fb9586c4f5ac --- /dev/null +++ b/api_utils/utils_ext/__init__.py @@ -0,0 +1,95 @@ +""" +Extended utility submodules extracted from api_utils.utils. +This package groups stream, helper, validation, files, and tokens utilities. +""" + +from .files import ( + _extension_for_mime, + collect_and_validate_attachments, + extract_data_url_to_local, + save_blob_to_local, +) +from .function_call_response_parser import ( + FunctionCallParseResult, + FunctionCallResponseParser, + format_function_calls_to_openai, +) +from .function_calling import ( + CallIdManager, + FunctionCallingConfig, + FunctionCallingMode, + ParsedFunctionCall, + PendingCall, + ResponseFormatter, + SchemaConversionError, + SchemaConverter, + build_assistant_message_with_tool_calls, + convert_openai_tools_to_gemini, + create_tool_calls_response, + get_finish_reason, +) +from .function_calling_cache import ( + FunctionCallingCache, + FunctionCallingCacheEntry, +) +from .function_calling_orchestrator import ( + FunctionCallingOrchestrator, + FunctionCallingState, + NativeFunctionCallingError, + get_effective_function_calling_mode, + get_function_calling_orchestrator, + reset_orchestrator, + should_skip_tool_injection, +) +from .helper import use_helper_get_response +from .prompts import prepare_combined_prompt +from .stream import clear_stream_queue, use_stream_response +from .string_utils import extract_json_from_text, get_latest_user_text +from .tokens import calculate_usage_stats, estimate_tokens +from .tools_execution import maybe_execute_tools +from .validation import validate_chat_request + +__all__ = [ + "use_stream_response", + "clear_stream_queue", + "use_helper_get_response", + "validate_chat_request", + "_extension_for_mime", + "extract_data_url_to_local", + "save_blob_to_local", + "collect_and_validate_attachments", + "estimate_tokens", + "calculate_usage_stats", + "prepare_combined_prompt", + "maybe_execute_tools", + "extract_json_from_text", + "get_latest_user_text", + # Function Calling utilities + "FunctionCallingMode", + "FunctionCallingConfig", + "SchemaConverter", + "SchemaConversionError", + "CallIdManager", + "PendingCall", + "ParsedFunctionCall", + "ResponseFormatter", + "build_assistant_message_with_tool_calls", + "get_finish_reason", + "convert_openai_tools_to_gemini", + "create_tool_calls_response", + # Function Calling Cache + "FunctionCallingCache", + "FunctionCallingCacheEntry", + # Function Calling Orchestrator + "FunctionCallingOrchestrator", + "FunctionCallingState", + "NativeFunctionCallingError", + "get_function_calling_orchestrator", + "reset_orchestrator", + "should_skip_tool_injection", + "get_effective_function_calling_mode", + # Function Call Response Parser + "FunctionCallResponseParser", + "FunctionCallParseResult", + "format_function_calls_to_openai", +] diff --git a/api_utils/utils_ext/cooldown_manager.py b/api_utils/utils_ext/cooldown_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..7892a6c46f983cff443e10e1a4ac0e47577a17f2 --- /dev/null +++ b/api_utils/utils_ext/cooldown_manager.py @@ -0,0 +1,99 @@ +import json +import logging +import os +import threading +from datetime import datetime + +COOLDOWN_FILE = os.path.join( + os.path.dirname(__file__), "..", "..", "config", "cooldown_status.json" +) +_lock = threading.Lock() + + +def load_cooldown_profiles(): + """ + Loads the cooldown profiles from the persistent JSON file. + + Returns: + dict: A dictionary of cooldown profiles. + """ + with _lock: + if not os.path.exists(COOLDOWN_FILE): + return {} + try: + with open(COOLDOWN_FILE, "r") as f: + data = json.load(f) + + profiles = {} + for profile, val in data.items(): + if isinstance(val, dict): + # Handle nested model-specific cooldowns + model_cooldowns = {} + for model_id, ts in val.items(): + try: + model_cooldowns[model_id] = datetime.fromisoformat(ts) + except (ValueError, TypeError): + continue + if model_cooldowns: + # Clean up redundant "default" entries when specific models exist + has_specific_models = any( + model_id != "default" for model_id in model_cooldowns.keys() + ) + if has_specific_models and "default" in model_cooldowns: + logger = logging.getLogger("CooldownManager") + logger.info( + f"🧹 Cleaning up redundant 'default' entry for profile {os.path.basename(profile)}" + ) + del model_cooldowns["default"] + profiles[profile] = model_cooldowns + else: + # Handle legacy/global cooldowns + try: + profiles[profile] = datetime.fromisoformat(val) + except (ValueError, TypeError): + continue + return profiles + except (json.JSONDecodeError, IOError): + return {} + + +def save_cooldown_profiles(profiles): + """ + Saves the cooldown profiles to the persistent JSON file. + + Args: + profiles (dict): A dictionary of cooldown profiles to save. + """ + with _lock: + try: + serializable_profiles = {} + for profile, data in profiles.items(): + if isinstance(data, dict): + # Handle nested model-specific cooldowns + model_cooldowns = {} + for model_id, ts in data.items(): + if isinstance(ts, datetime): + model_cooldowns[model_id] = ts.isoformat() + elif isinstance(ts, (int, float)): + try: + model_cooldowns[model_id] = datetime.fromtimestamp( + ts + ).isoformat() + except (ValueError, OSError): + pass + serializable_profiles[profile] = model_cooldowns + + elif isinstance(data, datetime): + serializable_profiles[profile] = data.isoformat() + elif isinstance(data, (int, float)): + try: + serializable_profiles[profile] = datetime.fromtimestamp( + data + ).isoformat() + except (ValueError, OSError): + pass + + with open(COOLDOWN_FILE, "w") as f: + json.dump(serializable_profiles, f, indent=4) + except IOError: + pass diff --git a/api_utils/utils_ext/files.py b/api_utils/utils_ext/files.py new file mode 100644 index 0000000000000000000000000000000000000000..04346cc6ffb60cc65621d0a944ec94ab757fed47 --- /dev/null +++ b/api_utils/utils_ext/files.py @@ -0,0 +1,201 @@ +import base64 +import binascii +import hashlib +import logging +import os +import re +from typing import Any, Dict, List, Optional, cast +from urllib.parse import unquote, urlparse + +from logging_utils import set_request_id + + +def _extension_for_mime(mime_type: str) -> str: + mime_type = (mime_type or "").lower() + mapping = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", + "image/svg+xml": ".svg", + "image/bmp": ".bmp", + "video/mp4": ".mp4", + "video/webm": ".webm", + "video/ogg": ".ogv", + "audio/mpeg": ".mp3", + "audio/mp3": ".mp3", + "audio/wav": ".wav", + "audio/ogg": ".ogg", + "audio/webm": ".weba", + "application/pdf": ".pdf", + "application/zip": ".zip", + "application/x-zip-compressed": ".zip", + "application/json": ".json", + "text/plain": ".txt", + "text/markdown": ".md", + "text/html": ".html", + } + return mapping.get( + mime_type, f".{mime_type.split('/')[-1]}" if "/" in mime_type else ".bin" + ) + + +def extract_data_url_to_local( + data_url: str, req_id: Optional[str] = None +) -> Optional[str]: + from config import UPLOAD_FILES_DIR + + logger = logging.getLogger("AIStudioProxyServer") + + output_dir = ( + UPLOAD_FILES_DIR if req_id is None else os.path.join(UPLOAD_FILES_DIR, req_id) + ) + + match = re.match(r"^data:(?P[^;]+);base64,(?P.*)$", data_url) + if not match: + logger.error( + "Error: data:URL format is incorrect or does not contain base64 data." + ) + return None + + mime_type = match.group("mime") + encoded_data = match.group("data") + + try: + decoded_bytes = base64.b64decode(encoded_data) + except binascii.Error as e: + logger.error(f"Error: Base64 decoding failed - {e}") + return None + + md5_hash = hashlib.md5(decoded_bytes).hexdigest() + file_extension = _extension_for_mime(mime_type) + output_filepath = os.path.join(output_dir, f"{md5_hash}{file_extension}") + + os.makedirs(output_dir, exist_ok=True) + + if os.path.exists(output_filepath): + logger.info(f"File already exists, skipping save: {output_filepath}") + return output_filepath + + try: + with open(output_filepath, "wb") as f: + f.write(decoded_bytes) + logger.info(f"Saved data:URL to: {output_filepath}") + return output_filepath + except IOError as e: + logger.error(f"Error: Failed to save file - {e}") + return None + + +def save_blob_to_local( + raw_bytes: bytes, + mime_type: Optional[str] = None, + fmt_ext: Optional[str] = None, + req_id: Optional[str] = None, +) -> Optional[str]: + from config import UPLOAD_FILES_DIR + + logger = logging.getLogger("AIStudioProxyServer") + + output_dir = ( + UPLOAD_FILES_DIR if req_id is None else os.path.join(UPLOAD_FILES_DIR, req_id) + ) + md5_hash = hashlib.md5(raw_bytes).hexdigest() + ext = None + if fmt_ext: + fmt_ext = fmt_ext.strip(". ") + ext = f".{fmt_ext}" if fmt_ext else None + if not ext and mime_type: + ext = _extension_for_mime(mime_type) + if not ext: + ext = ".bin" + os.makedirs(output_dir, exist_ok=True) + output_filepath = os.path.join(output_dir, f"{md5_hash}{ext}") + if os.path.exists(output_filepath): + logger.info(f"File already exists, skipping save: {output_filepath}") + return output_filepath + try: + with open(output_filepath, "wb") as f: + f.write(raw_bytes) + logger.info(f"Saved binary to: {output_filepath}") + return output_filepath + except IOError as e: + logger.error(f"Error: Failed to save binary - {e}") + return None + + +def collect_and_validate_attachments( + request: Any, req_id: str, initial_image_list: List[str] +) -> List[str]: + """ + Collect and validate attachments in the request (including top-level and message-level), merged into image_list. + """ + logger = logging.getLogger("AIStudioProxyServer") + + # 1. Validate initial list + valid_images: List[str] = [] + for p in initial_image_list: + if p and os.path.isabs(p) and os.path.exists(p): + valid_images.append(p) + + set_request_id(req_id) + if len(valid_images) != len(initial_image_list): + logger.warning( + f"Filtered out non-existent attachment paths: {set(initial_image_list) - set(valid_images)}" + ) + + image_list: List[str] = valid_images + + # 2. Collect from request + def _process_attachments_list(items_list: List[Any], container_desc: str): + for it in items_list: + url_value: Optional[str] = None + if isinstance(it, str): + url_value = it + elif isinstance(it, dict): + typed_it: Dict[str, Any] = cast(Dict[str, Any], it) + url_raw: Any = typed_it.get("url") or typed_it.get("path") + if isinstance(url_raw, str): + url_value = url_raw + if not url_value: + continue + url_value = url_value.strip() + if not url_value: + continue + + if url_value.startswith("data:"): + fp = extract_data_url_to_local(url_value, req_id=req_id) + if fp: + image_list.append(fp) + elif url_value.startswith("file:"): + parsed = urlparse(url_value) + lp = unquote(parsed.path) + if os.path.exists(lp): + image_list.append(lp) + else: + logger.warning( + f"{container_desc} attachment file URL does not exist: {lp}" + ) + elif os.path.isabs(url_value) and os.path.exists(url_value): + image_list.append(url_value) + + try: + # Top-level attachments + top_level_atts = getattr(request, "attachments", None) + if isinstance(top_level_atts, list) and len(top_level_atts) > 0: + _process_attachments_list(top_level_atts, "request.attachments") + + # Message-level attachments/images/files/media + messages = getattr(request, "messages", None) + if isinstance(messages, list): + for i, msg in enumerate(messages): + for field in ["attachments", "images", "files", "media"]: + items = getattr(msg, field, None) + if isinstance(items, list) and len(items) > 0: + _process_attachments_list(items, f"message[{i}].{field}") + + except Exception as e: + logger.error(f"Error collecting attachments: {e}") + + return image_list diff --git a/api_utils/utils_ext/function_call_response_parser.py b/api_utils/utils_ext/function_call_response_parser.py new file mode 100644 index 0000000000000000000000000000000000000000..28429be965f4307f4d2d5bb9fbeff06e1f9f9f8e --- /dev/null +++ b/api_utils/utils_ext/function_call_response_parser.py @@ -0,0 +1,1265 @@ +""" +Function Call Response Parser + +Parses function call blocks from AI Studio's DOM response. +Detects when the model wants to call a function and extracts +the function name and arguments. + +This module provides DOM-based parsing as a complement to the +network interceptor-based parsing in stream/interceptors.py. +""" + +from __future__ import annotations + +import json +import logging +import re +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional, Tuple + +from playwright.async_api import Locator +from playwright.async_api import Page as AsyncPage + +from config.selectors import ( + FUNCTION_CALL_ARGS_SELECTOR, + FUNCTION_CALL_CODE_BLOCK_SELECTOR, + FUNCTION_CALL_NAME_SELECTOR, + FUNCTION_CALL_WIDGET_SELECTOR, + NATIVE_FUNCTION_CALL_ARGS_SELECTOR, + NATIVE_FUNCTION_CALL_CHUNK_SELECTOR, + NATIVE_FUNCTION_CALL_NAME_SELECTOR, +) +from config.settings import FUNCTION_CALLING_DEBUG + +logger = logging.getLogger("AIStudioProxyServer") + +# Compiled patterns for static parsing (used outside class context) +# Enhanced pattern: captures function names with underscores, hyphens, and special chars +# Uses greedy match up to newline or open brace, then strips trailing whitespace +_STATIC_EMULATED_FC_PATTERN = re.compile( + r"Request\s+function\s+call:\s*([\w\-_.:]+)(?:\s*\n|\s*\{|\s*$)", + re.IGNORECASE, +) +_STATIC_EMULATED_PARAMS_PATTERN = re.compile( + r"Parameters:\s*\n?\s*(\{[\s\S]*?\})\s*(?:\n\n|\Z|(?=Request\s+function\s+call:))", + re.IGNORECASE, +) +_STATIC_EMULATED_PARAMS_PATTERN = re.compile( + r"Parameters:\s*\n?\s*(\{[\s\S]*?\})\s*(?:\n\n|\Z|(?=Request\s+function\s+call:))", + re.IGNORECASE, +) + + +def parse_emulated_function_calls_static(text: str) -> List[Any]: + """Static function to parse emulated text-based function calls without page instance. + + Handles the format: + Request function call: + Parameters: + { + "key": "value", + ... + } + + This is useful for recovering function calls in response_generators.py + when the DOM-based detection fails due to timing issues. + + Args: + text: The text content to parse. + + Returns: + List of ParsedFunctionCall objects. + """ + calls: List[Any] = [] + + if not text or "Request function call:" not in text: + return calls + + try: + # Split by "Request function call:" to handle multiple calls + parts = re.split(r"(?=Request\s+function\s+call:)", text, flags=re.IGNORECASE) + + for part in parts: + if not part.strip(): + continue + + # Extract function name + name_match = _STATIC_EMULATED_FC_PATTERN.search(part) + if not name_match: + continue + + function_name = name_match.group(1).strip() + + # Strip common prefixes like "default_api_" + if function_name.startswith("default_api_"): + function_name = function_name[len("default_api_") :] + + # Extract parameters + arguments: Dict[str, Any] = {} + params_match = _STATIC_EMULATED_PARAMS_PATTERN.search(part) + if params_match: + try: + json_str = params_match.group(1) + # Clean control characters + json_str = re.sub(r"", "", json_str) + arguments = json.loads(json_str) + except (json.JSONDecodeError, Exception): + pass + + call = _create_parsed_call( + name=function_name, + arguments=arguments, + raw_text=part[:200], + ) + calls.append(call) + + except Exception as e: + if FUNCTION_CALLING_DEBUG: + logger.debug(f"Static emulated FC parsing error: {e}") + + # Validate and potentially correct function names using fuzzy matching + calls = _validate_function_names(calls) + + return calls + + +def _validate_function_names(calls: List[Any]) -> List[Any]: + """Validate parsed function names against registered tools. + + Uses fuzzy matching to correct truncated or slightly malformed function names + that may result from model hallucination in text-format function calls. + + Args: + calls: List of ParsedFunctionCall objects. + + Returns: + List with corrected function names where possible. + """ + if not calls: + return calls + + try: + from api_utils.utils_ext.function_calling_cache import FunctionCallingCache + + cache = FunctionCallingCache.get_instance() + + for call in calls: + if hasattr(call, "name") and call.name: + validated_name, was_corrected, confidence = ( + cache.validate_function_name(call.name) + ) + if was_corrected and confidence >= 0.7: + if FUNCTION_CALLING_DEBUG: + logger.debug( + f"Corrected function name: '{call.name}' -> '{validated_name}' " + f"(confidence={confidence:.2f})" + ) + call.name = validated_name + + except Exception as e: + if FUNCTION_CALLING_DEBUG: + logger.debug(f"Function name validation error: {e}") + + return calls + + +def _create_parsed_call( + name: str, arguments: Dict[str, Any], raw_text: Optional[str] = None +) -> Any: + """Create a ParsedFunctionCall instance using lazy import. + + This avoids circular imports while still returning the proper type. + + Args: + name: Function name. + arguments: Function arguments as dict. + raw_text: Optional raw text for debugging. + + Returns: + ParsedFunctionCall instance. + """ + # Import at call time to avoid circular imports + from api_utils.utils_ext.function_calling import ParsedFunctionCall + + return ParsedFunctionCall(name=name, arguments=arguments, raw_text=raw_text) + + +@dataclass +class FunctionCallParseResult: + """Result of parsing function call responses from DOM. + + Attributes: + has_function_calls: Whether any function calls were detected. + function_calls: List of parsed function calls (ParsedFunctionCall objects). + text_content: Any regular text content (non-function-call). + raw_elements: Raw element text for debugging. + parse_errors: Any errors encountered during parsing. + """ + + has_function_calls: bool = False + function_calls: List[Any] = field(default_factory=list) + text_content: str = "" + raw_elements: List[str] = field(default_factory=list) + parse_errors: List[str] = field(default_factory=list) + + +class FunctionCallResponseParser: + """ + Parses function call widgets and blocks from AI Studio's DOM. + + AI Studio displays function calls in different formats: + 1. Structured widget: ms-function-call element with name and args + 2. JSON code block: ```json with function_call/tool_call structure + 3. Inline format: Function name followed by arguments + + This parser handles all these formats and converts them to + ParsedFunctionCall objects. + """ + + # Common patterns for function call detection in text + FUNCTION_CALL_PATTERNS = [ + # JSON-style function call + re.compile( + r'[\{\[]?\s*"?function_call"?\s*:\s*\{([^}]+)\}', + re.IGNORECASE | re.DOTALL, + ), + # Tool call format + re.compile( + r'[\{\[]?\s*"?tool_call"?\s*:\s*\{([^}]+)\}', + re.IGNORECASE | re.DOTALL, + ), + # Direct name/arguments format + re.compile( + r'"name"\s*:\s*"([^"]+)"[,\s]*"arguments"\s*:\s*(\{[^}]+\})', + re.IGNORECASE | re.DOTALL, + ), + # Gemini-style function call + re.compile( + r'functionCall\s*[\(:]?\s*\{?\s*"?name"?\s*:\s*"([^"]+)"', + re.IGNORECASE | re.DOTALL, + ), + ] + + # Pattern for emulated/text-based function calls: + # "Request function call: \nParameters:\n{...}" + # This handles cases where the model outputs text-formatted tool calls + # instead of using native function calling UI elements + # Supports: with params (newline or {), without params (end of string/line) + # Enhanced: uses \w, hyphen, underscore, and dot to capture full function names + EMULATED_FUNCTION_CALL_PATTERN = re.compile( + r"Request\s+function\s+call:\s*([\w\-_.:]+)(?:\s*\n|\s*\{|\s*$)", + re.IGNORECASE, + ) + # Pattern to extract the JSON parameters block after "Parameters:" + EMULATED_PARAMS_PATTERN = re.compile( + r"Parameters:\s*\n?\s*(\{[\s\S]*?\})\s*(?:\n\n|\Z|(?=Request\s+function\s+call:))", + re.IGNORECASE, + ) + + # Pattern for extracting function name and params from various formats + NAME_PATTERN = re.compile(r'"?name"?\s*:\s*"([^"]+)"', re.IGNORECASE) + ARGS_PATTERN = re.compile( + r'"?(?:arguments|params|parameters)"?\s*:\s*(\{[^}]*\}|\[[^\]]*\]|"[^"]*")', + re.IGNORECASE | re.DOTALL, + ) + + def __init__( + self, + page: AsyncPage, + logger: Optional[logging.Logger] = None, + req_id: str = "", + ): + """Initialize the parser. + + Args: + page: Playwright page instance. + logger: Optional logger instance. + req_id: Request ID for logging. + """ + self.page = page + self.logger = logger or logging.getLogger("AIStudioProxyServer") + self.req_id = req_id + + async def detect_function_calls( + self, + check_client_disconnected: Optional[Callable[..., Any]] = None, + ) -> bool: + """Quickly detect if the current response contains function calls. + + This is a fast check without full parsing. Supports both native + function calling (ms-function-call-chunk) and legacy formats. + + Args: + check_client_disconnected: Optional callback to check connection. + + Returns: + True if function calls are detected, False otherwise. + """ + try: + # Check for native function call chunks first (AI Studio's built-in FC) + native_locator = self.page.locator(NATIVE_FUNCTION_CALL_CHUNK_SELECTOR) + if await native_locator.count() > 0: + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] Detected native function call chunk(s)" + ) + return True + + # Check for legacy function call widgets + widget_locator = self.page.locator(FUNCTION_CALL_WIDGET_SELECTOR) + if await widget_locator.count() > 0: + return True + + # Check for function call code blocks + code_block_locator = self.page.locator(FUNCTION_CALL_CODE_BLOCK_SELECTOR) + if await code_block_locator.count() > 0: + return True + + return False + + except Exception as e: + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] Error detecting function calls: {e}" + ) + return False + + async def parse_function_calls( + self, + check_client_disconnected: Optional[Callable[..., Any]] = None, + ) -> FunctionCallParseResult: + """Parse all function calls from the current response. + + This method supports both native function calling (AI Studio's built-in + ms-function-call-chunk elements) and legacy/emulated formats. + + Args: + check_client_disconnected: Optional callback to check connection. + + Returns: + FunctionCallParseResult with all detected function calls. + """ + result = FunctionCallParseResult() + + try: + # Strategy 1: Try native function call parsing first (ms-function-call-chunk) + # This is the format used by AI Studio's built-in native function calling + native_calls = await self._parse_native_function_calls() + if native_calls: + result.function_calls.extend(native_calls) + result.has_function_calls = True + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] Found {len(native_calls)} native function call(s)" + ) + + # Strategy 2: Try structured widget parsing (legacy/fallback) + if not result.has_function_calls: + widget_calls = await self._parse_widget_function_calls() + if widget_calls: + result.function_calls.extend(widget_calls) + result.has_function_calls = True + + # Strategy 3: Try code block parsing + if not result.has_function_calls: + code_block_calls = await self._parse_code_block_function_calls() + if code_block_calls: + result.function_calls.extend(code_block_calls) + result.has_function_calls = True + + # Strategy 4: Always try to get text content, and use text pattern matching if no calls found yet + text_calls, text_content = await self._parse_text_function_calls() + result.text_content = text_content + + if not result.has_function_calls and text_calls: + result.function_calls.extend(text_calls) + result.has_function_calls = True + + # Deduplicate function calls by name + arguments + result.function_calls = self._deduplicate_calls(result.function_calls) + + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] Parsed {len(result.function_calls)} function call(s) total" + ) + + except Exception as e: + error_msg = f"Error parsing function calls: {e}" + if FUNCTION_CALLING_DEBUG: + self.logger.error(f"[{self.req_id}] {error_msg}") + result.parse_errors.append(error_msg) + + return result + + async def _parse_native_function_calls(self) -> List[Any]: + """Parse function calls from AI Studio's native function call chunks. + + Native function calls use the ms-function-call-chunk element with: + - Function name in mat-panel-title span (after the icon) + - Arguments as JSON in pre > code block + + Returns: + List of parsed function calls from native chunks. + """ + calls: List[Any] = [] + + try: + # Look for native function call chunks + chunks = self.page.locator(NATIVE_FUNCTION_CALL_CHUNK_SELECTOR) + chunk_count = await chunks.count() + + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] Found {chunk_count} native function call chunk(s)" + ) + + for i in range(chunk_count): + try: + chunk = chunks.nth(i) + call = await self._parse_single_native_chunk(chunk) + if call: + calls.append(call) + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] Parsed native function call: {call.name}" + ) + except Exception as e: + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] Error parsing native chunk {i}: {e}" + ) + + except Exception as e: + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] Error accessing native function call chunks: {e}" + ) + + return calls + + async def _parse_single_native_chunk(self, chunk: Locator) -> Optional[Any]: + """Parse a single native function call chunk element. + + Args: + chunk: Locator for the ms-function-call-chunk element. + + Returns: + ParsedFunctionCall if successful, None otherwise. + """ + try: + # Extract function name from mat-panel-title span + # The structure is: mat-panel-title > span.material-symbols-outlined (icon) > span (name) + name_elem = chunk.locator(NATIVE_FUNCTION_CALL_NAME_SELECTOR) + + function_name = "" + if await name_elem.count() > 0: + function_name = await name_elem.first.inner_text(timeout=2000) + function_name = function_name.strip() + + # If name not found via selector, try alternative extraction + if not function_name: + # Try to get from mat-expansion-panel-header-title + header = chunk.locator( + "mat-panel-title, .mat-expansion-panel-header-title" + ) + if await header.count() > 0: + header_text = await header.first.inner_text(timeout=2000) + # Remove icon text and common prefixes + function_name = self._extract_function_name_from_header(header_text) + + if not function_name: + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] Could not extract function name from native chunk" + ) + return None + + # Extract arguments from pre > code block + args_elem = chunk.locator(NATIVE_FUNCTION_CALL_ARGS_SELECTOR) + arguments: Dict[str, Any] = {} + + if await args_elem.count() > 0: + args_text = await args_elem.first.inner_text(timeout=2000) + arguments = self._parse_arguments(args_text) + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] Extracted arguments for {function_name}: {list(arguments.keys())}" + ) + + return _create_parsed_call( + name=function_name, + arguments=arguments, + raw_text=f"{function_name}({json.dumps(arguments)})", + ) + + except Exception as e: + if FUNCTION_CALLING_DEBUG: + self.logger.debug(f"[{self.req_id}] Error parsing native chunk: {e}") + return None + + def _extract_function_name_from_header(self, header_text: str) -> str: + """Extract function name from header text, removing icons and extra content. + + Args: + header_text: Raw text from mat-panel-title or header element. + + Returns: + Cleaned function name. + """ + if not header_text: + return "" + + # Clean up the header text + lines = header_text.strip().split("\n") + for line in lines: + line = line.strip() + # Skip icon names and common prefixes + if line and line not in ( + "function", + "functions", + "chevron_right", + "chevron_left", + ): + # Skip material icon names + if not line.startswith("expand_") and not line.startswith("download"): + return line + + return "" + + async def _parse_widget_function_calls(self) -> List[Any]: + """Parse function calls from structured widget elements. + + Returns: + List of parsed function calls from widgets. + """ + calls: List[Any] = [] + + try: + widgets = self.page.locator(FUNCTION_CALL_WIDGET_SELECTOR) + widget_count = await widgets.count() + + for i in range(widget_count): + try: + widget = widgets.nth(i) + call = await self._parse_single_widget(widget) + if call: + calls.append(call) + except Exception as e: + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] Error parsing widget {i}: {e}" + ) + + except Exception as e: + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] Error accessing function call widgets: {e}" + ) + + return calls + + async def _parse_single_widget(self, widget: Locator) -> Optional[Any]: + """Parse a single function call widget element. + + Args: + widget: Locator for the widget element. + + Returns: + ParsedFunctionCall if successful, None otherwise. + """ + try: + # Extract function name + name_elem = widget.locator(FUNCTION_CALL_NAME_SELECTOR) + if await name_elem.count() == 0: + # Try getting text from the widget itself + widget_text = await widget.inner_text(timeout=1000) + return self._parse_function_call_from_text(widget_text) + + function_name = await name_elem.first.inner_text(timeout=1000) + function_name = function_name.strip() + + if not function_name: + return None + + # Extract arguments + args_elem = widget.locator(FUNCTION_CALL_ARGS_SELECTOR) + arguments: Dict[str, Any] = {} + + if await args_elem.count() > 0: + args_text = await args_elem.first.inner_text(timeout=1000) + arguments = self._parse_arguments(args_text) + + return _create_parsed_call( + name=function_name, + arguments=arguments, + raw_text=f"{function_name}({json.dumps(arguments)})", + ) + + except Exception as e: + if FUNCTION_CALLING_DEBUG: + self.logger.debug(f"[{self.req_id}] Error parsing widget: {e}") + return None + + async def _parse_code_block_function_calls(self) -> List[Any]: + """Parse function calls from JSON/code blocks. + + Returns: + List of parsed function calls from code blocks. + """ + calls: List[Any] = [] + + try: + code_blocks = self.page.locator(FUNCTION_CALL_CODE_BLOCK_SELECTOR) + block_count = await code_blocks.count() + + for i in range(block_count): + try: + block = code_blocks.nth(i) + block_text = await block.inner_text(timeout=2000) + + # Try to parse as JSON + block_calls = self._parse_json_function_calls(block_text) + calls.extend(block_calls) + + except Exception as e: + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] Error parsing code block {i}: {e}" + ) + + except Exception as e: + if FUNCTION_CALLING_DEBUG: + self.logger.debug(f"[{self.req_id}] Error accessing code blocks: {e}") + + return calls + + async def _parse_text_function_calls(self) -> Tuple[List[Any], str]: + """Parse function calls from general text content. + + Returns: + Tuple of (function_calls, remaining_text_content). + """ + calls: List[Any] = [] + text_content = "" + + try: + # Get the last response turn + from config.selectors import FINAL_RESPONSE_SELECTOR + + response_elem = self.page.locator(FINAL_RESPONSE_SELECTOR).last + + if await response_elem.count() > 0: + text_content = await response_elem.inner_text(timeout=2000) + + # Strategy A: Check for emulated text-based function calls first + # Format: "Request function call: \nParameters:\n{...}" + emulated_calls = self._parse_emulated_function_calls(text_content) + if emulated_calls: + calls.extend(emulated_calls) + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] Found {len(emulated_calls)} emulated text-based function call(s)" + ) + + # Strategy B: Check for JSON-style function call patterns + if not calls: + for pattern in self.FUNCTION_CALL_PATTERNS: + matches = pattern.findall(text_content) + if matches: + for match in matches: + call = self._parse_function_call_from_match(match) + if call: + calls.append(call) + + except Exception as e: + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] Error parsing text function calls: {e}" + ) + + return calls, text_content + + def _parse_emulated_function_calls(self, text: str) -> List[Any]: + """Parse emulated text-based function calls. + + Handles the format: + Request function call: + Parameters: + { + "key": "value", + ... + } + + This format is output by the model when it doesn't use the native + function calling UI elements but still intends to call a function. + + Args: + text: The text content to parse. + + Returns: + List of ParsedFunctionCall objects. + """ + calls: List[Any] = [] + + if not text or "Request function call:" not in text: + return calls + + try: + # Find all function call requests in the text + # Split by "Request function call:" to handle multiple calls + parts = re.split( + r"(?=Request\s+function\s+call:)", text, flags=re.IGNORECASE + ) + + for part in parts: + if not part.strip(): + continue + + # Extract function name + name_match = self.EMULATED_FUNCTION_CALL_PATTERN.search(part) + if not name_match: + continue + + function_name = name_match.group(1).strip() + + # Check if original text has inline params like "func_name{params}" + # The regex stops at '{', so check if '{' follows the match + match_end = name_match.end() + has_inline_params = ( + match_end <= len(part) and part[match_end - 1 : match_end] == "{" + ) + + if has_inline_params: + # Try to parse inline params from original text + arguments = self._parse_inline_params(part) + else: + # Extract parameters from "Parameters:" block + arguments = self._extract_emulated_params(part) + + if function_name: + # Clean up function name (remove any trailing colons, etc.) + function_name = function_name.rstrip(":").strip() + + # Strip common prefixes that models add but clients don't expect + # e.g., "default_api:write_to_file" -> "write_to_file" + prefix_patterns = ["default_api:", "functions.", "tools."] + for prefix in prefix_patterns: + if function_name.startswith(prefix): + function_name = function_name[len(prefix) :] + break + + call = _create_parsed_call( + name=function_name, + arguments=arguments, + raw_text=part[:500], # Keep first 500 chars for debugging + ) + calls.append(call) + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] Parsed emulated function call: {function_name} " + f"with {len(arguments)} argument(s)" + ) + + except Exception as e: + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] Error parsing emulated function calls: {e}" + ) + + # Validate and potentially correct function names using fuzzy matching + calls = _validate_function_names(calls) + + return calls + + def _extract_emulated_params(self, text: str) -> Dict[str, Any]: + """Extract parameters from emulated function call text. + + Handles the "Parameters:" block with JSON content. + + Args: + text: Text containing the Parameters block. + + Returns: + Parsed arguments dictionary. + """ + arguments: Dict[str, Any] = {} + + # Try the dedicated params pattern first + params_match = self.EMULATED_PARAMS_PATTERN.search(text) + if params_match: + params_text = params_match.group(1) + try: + arguments = json.loads(params_text) + if isinstance(arguments, dict): + return arguments + except json.JSONDecodeError: + pass + + # Fallback: Find JSON object after "Parameters:" + params_idx = text.lower().find("parameters:") + if params_idx != -1: + after_params = text[params_idx + len("parameters:") :] + + # Find the first { and extract the JSON object + brace_start = after_params.find("{") + if brace_start != -1: + # Count braces to find the matching closing brace + brace_count = 0 + json_end = 0 + for i, char in enumerate(after_params[brace_start:]): + if char == "{": + brace_count += 1 + elif char == "}": + brace_count -= 1 + if brace_count == 0: + json_end = brace_start + i + 1 + break + + if json_end > 0: + json_str = after_params[brace_start:json_end] + try: + arguments = json.loads(json_str) + if isinstance(arguments, dict): + return arguments + except json.JSONDecodeError: + # Try to clean up the JSON string + cleaned = self._clean_json_string(json_str) + try: + arguments = json.loads(cleaned) + if isinstance(arguments, dict): + return arguments + except json.JSONDecodeError: + pass + + return arguments + + def _clean_json_string(self, json_str: str) -> str: + """Clean up a potentially malformed JSON string. + + Args: + json_str: The JSON string to clean. + + Returns: + Cleaned JSON string. + """ + # Remove control characters (like which appears in some outputs) + cleaned = re.sub(r"", "", json_str) + # Remove other non-printable characters except newlines and tabs + cleaned = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]", "", cleaned) + return cleaned + + def _parse_inline_params(self, text: str) -> Dict[str, Any]: + """Parse inline parameters from function call text. + + Handles formats like: + - func_name{key: "value"} (JavaScript-like) + - func_name{key: value} (control char delimiters) + - func_name{"key": "value"} (JSON) + + Args: + text: Text containing the inline params. + + Returns: + Parsed arguments dictionary. + """ + arguments: Dict[str, Any] = {} + + try: + # Find the complete JSON/object block + brace_start = text.find("{") + if brace_start == -1: + return arguments + + brace_count = 0 + json_end = 0 + full_text = text[brace_start:] + + for i, char in enumerate(full_text): + if char == "{": + brace_count += 1 + elif char == "}": + brace_count -= 1 + if brace_count == 0: + json_end = i + 1 + break + + if json_end == 0: + return arguments + + raw_params = full_text[:json_end] + + # Strategy 1: Try direct JSON parse + try: + arguments = json.loads(raw_params) + if isinstance(arguments, dict): + return arguments + except json.JSONDecodeError: + pass + + # Strategy 2: Clean markers and retry + # appears to be used as string delimiters (ASCII 46 = '.') + # Replace with quotes for JSON compatibility + cleaned = re.sub(r"", '"', raw_params) + try: + arguments = json.loads(cleaned) + if isinstance(arguments, dict): + return arguments + except json.JSONDecodeError: + pass + + # Strategy 3: Convert JavaScript-like notation to JSON + # Handle unquoted keys: {key: "value"} -> {"key": "value"} + js_to_json = re.sub(r"(\{|,)\s*(\w+)\s*:", r'\1"\2":', cleaned) + try: + arguments = json.loads(js_to_json) + if isinstance(arguments, dict): + return arguments + except json.JSONDecodeError: + pass + + # Strategy 4: Try parsing as nested structure with arrays + # Handle format like: {files:[{path:"value"}]} + # First, ensure all keys are quoted + step1 = re.sub(r"(\{|\[|,)\s*(\w+)\s*:", r'\1"\2":', raw_params) + # Replace with quotes + step2 = re.sub(r"", '"', step1) + try: + arguments = json.loads(step2) + if isinstance(arguments, dict): + return arguments + except json.JSONDecodeError: + pass + + # Strategy 5: Extract key-value pairs manually + # For simple cases like {path: value} + kv_pattern = re.compile( + r'["\']?(\w+)["\']?\s*:\s*(?:|["\'])([^"\'<}]+)(?:|["\'])?', + re.IGNORECASE, + ) + matches = kv_pattern.findall(raw_params) + if matches: + for key, value in matches: + arguments[key] = value.strip() + + except Exception as e: + if FUNCTION_CALLING_DEBUG: + self.logger.debug(f"[{self.req_id}] Error parsing inline params: {e}") + + return arguments + + def _parse_json_function_calls(self, json_text: str) -> List[Any]: + """Parse function calls from JSON text. + + Args: + json_text: JSON string that may contain function calls. + + Returns: + List of parsed function calls. + """ + calls: List[Any] = [] + + # Clean the text + json_text = json_text.strip() + + # Try direct JSON parse + try: + data = json.loads(json_text) + calls.extend(self._extract_calls_from_json(data)) + return calls + except json.JSONDecodeError: + pass + + # Try to find JSON within the text + json_pattern = re.compile(r"(\{[^{}]*\}|\[[^\[\]]*\])", re.DOTALL) + matches = json_pattern.findall(json_text) + + for match in matches: + try: + data = json.loads(match) + calls.extend(self._extract_calls_from_json(data)) + except json.JSONDecodeError: + continue + + return calls + + def _extract_calls_from_json(self, data: Any) -> List[Any]: + """Extract function calls from parsed JSON data. + + Args: + data: Parsed JSON data (dict or list). + + Returns: + List of parsed function calls. + """ + calls: List[Any] = [] + + if isinstance(data, dict): + # Check for function_call or tool_call + if "function_call" in data: + call = self._parse_function_call_dict(data["function_call"]) + if call: + calls.append(call) + elif "tool_call" in data: + call = self._parse_function_call_dict(data["tool_call"]) + if call: + calls.append(call) + elif "name" in data: + call = self._parse_function_call_dict(data) + if call: + calls.append(call) + + # Check for tool_calls array + if "tool_calls" in data and isinstance(data["tool_calls"], list): + for tc in data["tool_calls"]: + if isinstance(tc, dict) and "function" in tc: + call = self._parse_function_call_dict(tc["function"]) + if call: + calls.append(call) + + elif isinstance(data, list): + for item in data: + calls.extend(self._extract_calls_from_json(item)) + + return calls + + def _parse_function_call_dict(self, fc_dict: Dict[str, Any]) -> Optional[Any]: + """Parse a function call from a dict. + + Args: + fc_dict: Dict with 'name' and 'arguments'/'params'. + + Returns: + ParsedFunctionCall if valid, None otherwise. + """ + if not isinstance(fc_dict, dict): + return None + + name = fc_dict.get("name") + if not name or not isinstance(name, str): + return None + + # Get arguments (may be string or dict) + arguments = fc_dict.get("arguments") or fc_dict.get("params") or {} + + if isinstance(arguments, str): + try: + arguments = json.loads(arguments) + except json.JSONDecodeError: + arguments = {} + + if not isinstance(arguments, dict): + arguments = {} + + return _create_parsed_call( + name=name, + arguments=arguments, + raw_text=json.dumps(fc_dict), + ) + + def _parse_function_call_from_text(self, text: str) -> Optional[Any]: + """Parse a function call from raw text using patterns. + + Args: + text: Raw text that may contain a function call. + + Returns: + ParsedFunctionCall if found, None otherwise. + """ + if not text: + return None + + # Try to extract name + name_match = self.NAME_PATTERN.search(text) + if not name_match: + return None + + name = name_match.group(1) + + # Try to extract arguments + args_match = self.ARGS_PATTERN.search(text) + arguments: Dict[str, Any] = {} + + if args_match: + args_str = args_match.group(1) + arguments = self._parse_arguments(args_str) + + return _create_parsed_call( + name=name, + arguments=arguments, + raw_text=text[:200], # Keep first 200 chars for debugging + ) + + def _parse_function_call_from_match(self, match: Any) -> Optional[Any]: + """Parse function call from regex match. + + Args: + match: Regex match result (string or tuple). + + Returns: + ParsedFunctionCall if valid, None otherwise. + """ + if isinstance(match, tuple): + if len(match) >= 2: + name = match[0] + args_str = match[1] if len(match) > 1 else "{}" + arguments = self._parse_arguments(args_str) + return _create_parsed_call(name=name, arguments=arguments) + elif len(match) == 1: + # Just the inner content, need to extract name + return self._parse_function_call_from_text(match[0]) + elif isinstance(match, str): + return self._parse_function_call_from_text(match) + + return None + + def _parse_arguments(self, args_text: str) -> Dict[str, Any]: + """Parse arguments from text. + + Args: + args_text: Arguments text (JSON or other format). + + Returns: + Parsed arguments dict. + """ + if not args_text: + return {} + + args_text = args_text.strip() + + # Debug log raw args_text + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] Raw args_text (len={len(args_text)}): {args_text[:500]}" + ) + + # Try JSON parse + try: + result = json.loads(args_text) + if isinstance(result, dict): + return result + return {"value": result} + except json.JSONDecodeError as e: + if FUNCTION_CALLING_DEBUG: + self.logger.debug(f"[{self.req_id}] JSON parse failed: {e}") + + # Try to extract key-value pairs + kv_pattern = re.compile(r'"?(\w+)"?\s*[:=]\s*(".*?"|[^,}\]]+)') + matches = kv_pattern.findall(args_text) + + if matches: + result = {} + for key, value in matches: + value = value.strip().strip('"') + # Try to parse value type + if value.lower() == "true": + result[key] = True + elif value.lower() == "false": + result[key] = False + elif value.lower() == "null": + result[key] = None + else: + try: + result[key] = int(value) + except ValueError: + try: + result[key] = float(value) + except ValueError: + result[key] = value + return result + + return {} + + def _deduplicate_calls(self, calls: List[Any]) -> List[Any]: + """Remove duplicate function calls, preferring calls with arguments. + + When the same function name appears multiple times with different argument + sets, this method prefers calls with non-empty arguments over empty ones. + This handles the case where AI Studio's DOM renders multiple chunks for + a single function call, where one chunk may be missing arguments. + + Args: + calls: List of function calls. + + Returns: + Deduplicated list with preference for calls that have arguments. + """ + # First pass: collect all calls, tracking by (name, args) for exact dedup + # and by name alone to detect empty-vs-non-empty conflicts + seen_exact: set = set() + by_name: dict = {} # name -> list of (args_json, call) + + for call in calls: + args_json = json.dumps(call.arguments, sort_keys=True) + exact_key = (call.name, args_json) + + # Skip exact duplicates + if exact_key in seen_exact: + continue + seen_exact.add(exact_key) + + # Track by function name for conflict resolution + if call.name not in by_name: + by_name[call.name] = [] + by_name[call.name].append((args_json, call)) + + # Second pass: resolve conflicts where same function has empty and non-empty args + unique: List[Any] = [] + for name, calls_list in by_name.items(): + if len(calls_list) == 1: + # Only one call with this name, keep it + unique.append(calls_list[0][1]) + else: + # Multiple calls with same name - filter out empty args if non-empty exists + non_empty = [(args, call) for args, call in calls_list if args != "{}"] + empty = [(args, call) for args, call in calls_list if args == "{}"] + + if non_empty: + # Keep all non-empty versions (could be legitimate parallel calls) + for _, call in non_empty: + unique.append(call) + # Log if we're dropping empty duplicates + if empty and FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] Dropped {len(empty)} empty-args duplicate(s) " + f"for function '{name}' (kept {len(non_empty)} with args)" + ) + else: + # All are empty, keep just one + unique.append(calls_list[0][1]) + + return unique + + +def format_function_calls_to_openai( + parsed_calls: List[Any], + content: Optional[str] = None, +) -> Tuple[Dict[str, Any], str]: + """Format parsed function calls to OpenAI response format. + + Args: + parsed_calls: List of parsed function calls (ParsedFunctionCall objects). + content: Optional text content. + + Returns: + Tuple of (message_dict, finish_reason). + """ + from api_utils.utils_ext.function_calling import ( + ResponseFormatter, + build_assistant_message_with_tool_calls, + get_finish_reason, + ) + + formatter = ResponseFormatter() + tool_calls = formatter.format_tool_calls(parsed_calls) + + message = build_assistant_message_with_tool_calls(tool_calls, content) + finish_reason = get_finish_reason(bool(tool_calls)) + + return message, finish_reason + + +__all__ = [ + "FunctionCallResponseParser", + "FunctionCallParseResult", + "format_function_calls_to_openai", +] diff --git a/api_utils/utils_ext/function_calling.py b/api_utils/utils_ext/function_calling.py new file mode 100644 index 0000000000000000000000000000000000000000..39ed8c31a6f0d4e2ce29d47415996e82908c81a0 --- /dev/null +++ b/api_utils/utils_ext/function_calling.py @@ -0,0 +1,1365 @@ +""" +Function Calling Utilities for Native Function Calling Support. + +This module provides: +- Schema conversion from OpenAI tools format to Gemini FunctionDeclaration format +- Call ID generation and management for tracking tool calls +- Response formatting from Gemini responses to OpenAI tool_calls format + +Implements Phase 1 of ADR-001: Native Function Calling Architecture. +""" + +import json +import logging +import uuid +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Dict, List, Optional, Union + +from pydantic import BaseModel + +from config.settings import FUNCTION_CALLING_DEBUG +from logging_utils.fc_debug import FCModule, get_fc_logger + +logger = logging.getLogger("AIStudioProxyServer") + +# FC debug logger for schema conversion and response formatting +fc_logger = get_fc_logger() + + +# ============================================================================= +# Constants for Function Calling Improvements +# ============================================================================= + +# Constant signature value used for Gemini 3 function call validation +# This is a placeholder that satisfies the API validation requirement +# Reference: iBUHub/AIStudioToAPI FormatConverter.js lines 84-112 +DUMMY_THOUGHT_SIGNATURE: str = "context_engineering_is_the_way_to_go" + +# Type mappings for schema conversion - UPPERCASE variant (pending UI verification) +TYPE_MAP_UPPER: Dict[str, str] = { + "string": "STRING", + "integer": "INTEGER", + "number": "NUMBER", + "boolean": "BOOLEAN", + "array": "ARRAY", + "object": "OBJECT", + "null": "NULL", +} + +# Type mappings for schema conversion - lowercase variant (current, verified working) +TYPE_MAP_LOWER: Dict[str, str] = { + "string": "string", + "integer": "integer", + "number": "number", + "boolean": "boolean", + "array": "array", + "object": "object", + "null": "null", +} + +# Type alias for MCP response items +MCPResponseItem = Dict[str, Any] + + +# ============================================================================= +# Configuration Types +# ============================================================================= + + +class FunctionCallingMode(str, Enum): + """Function calling mode selection. + + - EMULATED: Current text-based approach (default, backwards compatible) + - NATIVE: AI Studio UI-driven function calling + - AUTO: Native with automatic fallback to emulated on failure + """ + + EMULATED = "emulated" + NATIVE = "native" + AUTO = "auto" + + +@dataclass +class FunctionCallingConfig: + """Configuration for function calling behavior. + + Attributes: + mode: The function calling mode to use. + native_fallback: Whether to fallback to emulated mode on native failure. + ui_timeout_ms: Timeout for UI operations in milliseconds. + native_retry_count: Number of retries for native mode UI operations. + clear_between_requests: Whether to clear function definitions between requests. + debug: Enable detailed debug logging. + """ + + mode: FunctionCallingMode = FunctionCallingMode.EMULATED + native_fallback: bool = True + ui_timeout_ms: int = 5000 + native_retry_count: int = 2 + clear_between_requests: bool = True + debug: bool = False + + @classmethod + def from_settings(cls) -> "FunctionCallingConfig": + """Create configuration from environment settings.""" + from config.settings import ( + FUNCTION_CALLING_CLEAR_BETWEEN_REQUESTS, + FUNCTION_CALLING_DEBUG, + FUNCTION_CALLING_MODE, + FUNCTION_CALLING_NATIVE_FALLBACK, + FUNCTION_CALLING_NATIVE_RETRY_COUNT, + FUNCTION_CALLING_UI_TIMEOUT, + ) + + mode_str = FUNCTION_CALLING_MODE.lower() + try: + mode = FunctionCallingMode(mode_str) + except ValueError: + mode = FunctionCallingMode.EMULATED + + return cls( + mode=mode, + native_fallback=FUNCTION_CALLING_NATIVE_FALLBACK, + ui_timeout_ms=FUNCTION_CALLING_UI_TIMEOUT, + native_retry_count=FUNCTION_CALLING_NATIVE_RETRY_COUNT, + clear_between_requests=FUNCTION_CALLING_CLEAR_BETWEEN_REQUESTS, + debug=FUNCTION_CALLING_DEBUG, + ) + + +# ============================================================================= +# Tool Choice Conversion: OpenAI -> Gemini (FC-003) +# ============================================================================= + + +@dataclass +class GeminiToolConfig: + """Gemini API toolConfig.functionCallingConfig structure. + + NOTE: This config is for logging/future API mode only. + Current UI automation does not support applying this config. + + Represents the Gemini-native way to control function calling behavior. + + Attributes: + mode: Function calling mode. One of: + - "AUTO": Model decides whether to call functions + - "NONE": Model must not call functions + - "ANY": Model must call at least one function + allowed_function_names: Optional list of function names the model is + allowed to call. Only valid with mode="ANY". + """ + + mode: str + allowed_function_names: Optional[List[str]] = None + + # Valid mode values + VALID_MODES = frozenset({"AUTO", "NONE", "ANY"}) + + def __post_init__(self) -> None: + """Validate configuration.""" + if self.mode not in self.VALID_MODES: + raise ValueError( + f"Invalid mode: {self.mode}. Must be one of {self.VALID_MODES}" + ) + + if self.allowed_function_names is not None and self.mode != "ANY": + raise ValueError( + f"allowed_function_names is only valid with mode='ANY', " + f"got mode='{self.mode}'" + ) + + def to_dict(self) -> Dict[str, Any]: + """Convert to Gemini API request format. + + Returns: + Dict suitable for inclusion in toolConfig field of Gemini request. + + Example: + >>> config = GeminiToolConfig(mode="ANY", allowed_function_names=["fn1"]) + >>> config.to_dict() + {'functionCallingConfig': {'mode': 'ANY', 'allowedFunctionNames': ['fn1']}} + """ + config: Dict[str, Any] = {"mode": self.mode} + + if self.allowed_function_names: + config["allowedFunctionNames"] = self.allowed_function_names + + return {"functionCallingConfig": config} + + def __str__(self) -> str: + """Human-readable representation for logging.""" + if self.allowed_function_names: + funcs = ", ".join(self.allowed_function_names) + return f"GeminiToolConfig(mode={self.mode}, functions=[{funcs}])" + return f"GeminiToolConfig(mode={self.mode})" + + def __repr__(self) -> str: + return ( + f"GeminiToolConfig(mode={self.mode!r}, " + f"allowed_function_names={self.allowed_function_names!r})" + ) + + +def convert_tool_choice( + tool_choice: Union[str, Dict[str, Any], None], +) -> Optional[GeminiToolConfig]: + """Convert OpenAI tool_choice parameter to Gemini toolConfig. + + OpenAI to Gemini mapping: + - "auto" -> mode: AUTO + - "none" -> mode: NONE + - "required" -> mode: ANY + - {"type": "function", "function": {"name": "x"}} -> mode: ANY, allowedFunctionNames: ["x"] + - "function_name" (string) -> mode: ANY, allowedFunctionNames: ["function_name"] + + NOTE: This conversion is for logging/future API use only. + AI Studio UI automation CANNOT set toolConfig. + + Args: + tool_choice: OpenAI tool_choice parameter. Can be: + - None: No preference + - str: "auto", "none", "required", or function name + - dict: Specific function selection + + Returns: + GeminiToolConfig if tool_choice is specified and recognized, + None if tool_choice is None or unrecognized. + + Examples: + >>> convert_tool_choice("auto") + GeminiToolConfig(mode='AUTO', allowed_function_names=None) + + >>> convert_tool_choice("required") + GeminiToolConfig(mode='ANY', allowed_function_names=None) + + >>> convert_tool_choice({"type": "function", "function": {"name": "get_weather"}}) + GeminiToolConfig(mode='ANY', allowed_function_names=['get_weather']) + """ + if tool_choice is None: + return None + + # Handle string values + if isinstance(tool_choice, str): + choice_lower = tool_choice.lower().strip() + + if choice_lower == "auto": + return GeminiToolConfig(mode="AUTO") + elif choice_lower == "none": + return GeminiToolConfig(mode="NONE") + elif choice_lower == "required": + return GeminiToolConfig(mode="ANY") + else: + # Treat as function name (some clients pass function name directly) + return GeminiToolConfig(mode="ANY", allowed_function_names=[tool_choice]) + + # Handle dict format + if isinstance(tool_choice, dict): + # Standard format: {"type": "function", "function": {"name": "x"}} + function_spec = tool_choice.get("function") + if isinstance(function_spec, dict): + func_name = function_spec.get("name") + if func_name and isinstance(func_name, str): + return GeminiToolConfig(mode="ANY", allowed_function_names=[func_name]) + + # Legacy format: {"name": "x"} + func_name = tool_choice.get("name") + if func_name and isinstance(func_name, str): + return GeminiToolConfig(mode="ANY", allowed_function_names=[func_name]) + + # Unrecognized format + return None + + +# ============================================================================= +# MCP Response Handling (FC-002) +# ============================================================================= + + +def normalize_tool_response( + content: Union[str, List[Dict[str, Any]], Dict[str, Any]], + preserve_on_error: bool = True, +) -> Dict[str, Any]: + """Normalize various tool response formats to Gemini-compatible Struct. + + Gemini's functionResponse.response field requires a Struct (object), + but MCP and other tool frameworks may return arrays or strings. + This function normalizes all formats to valid Struct. + + Normalization rules: + 1. Dict input: returned as-is (already valid Struct) + 2. String input: + - If valid JSON object: parsed and returned + - Otherwise: wrapped as {"result": content} + 3. Array input: + - Empty array: {"result": "[]"} + - Single item with parseable JSON text: unwrapped to that object + - Multiple items: wrapped as {"result": JSON.stringify(items)} + + Args: + content: Tool response in any supported format. + preserve_on_error: If True, wrap unparseable content rather than raising. + + Returns: + Gemini-compatible response object (Struct). + + Raises: + ValueError: If preserve_on_error=False and content cannot be normalized. + + Examples: + >>> normalize_tool_response({"temp": 72}) + {'temp': 72} + + >>> normalize_tool_response("plain text") + {'result': 'plain text'} + + >>> normalize_tool_response([{"type": "text", "text": '{"temp": 72}'}]) + {'temp': 72} + """ + # Case 1: Already a dict - return as-is + if isinstance(content, dict): + return content + + # Case 2: String - try to parse as JSON object + if isinstance(content, str): + try: + parsed = json.loads(content) + if isinstance(parsed, dict) and parsed is not None: + return parsed + # Parsed but not a dict (array, primitive) + return {"result": content} + except (json.JSONDecodeError, TypeError): + return {"result": content} + + # Case 3: Array (common in MCP) + if isinstance(content, list): + if len(content) == 0: + return {"result": "[]"} + + processed_items: List[Any] = [] + + for item in content: + if not isinstance(item, dict): + processed_items.append(item) + continue + + # Handle MCP text items with nested JSON + if item.get("type") == "text" and "text" in item: + text_content = item["text"] + try: + parsed = json.loads(text_content) + # Only unwrap if it's a proper dict + if isinstance(parsed, dict) and parsed is not None: + processed_items.append(parsed) + else: + # Keep as wrapped text for primitives/arrays + processed_items.append( + {"content": text_content, "type": "text"} + ) + except (json.JSONDecodeError, TypeError): + processed_items.append({"content": text_content, "type": "text"}) + else: + # Keep other item types (image, etc.) as-is + processed_items.append(item) + + # Unwrap single dict item + if ( + len(processed_items) == 1 + and isinstance(processed_items[0], dict) + and processed_items[0] is not None + ): + return processed_items[0] + + # Multiple items - wrap in result as JSON string + try: + return {"result": json.dumps(processed_items, ensure_ascii=False)} + except (TypeError, ValueError): + if preserve_on_error: + return {"result": str(processed_items)} + raise ValueError(f"Cannot serialize processed items: {processed_items}") + + # Fallback for other types + if preserve_on_error: + return {"result": str(content)} + raise ValueError(f"Unsupported content type: {type(content)}") + + +# ============================================================================= +# thoughtSignature Support for Gemini 3 (FC-001) +# ============================================================================= + + +def ensure_thought_signature( + messages: List[Dict[str, Any]], + apply: bool = True, + signature: str = DUMMY_THOUGHT_SIGNATURE, +) -> List[Dict[str, Any]]: + """Add thoughtSignature to functionCall parts in conversation history. + + Gemini 3 models require thoughtSignature on functionCall parts when + replaying conversation history. This function injects the signature + into assistant messages that contain tool_calls. + + Rules: + - Only assistant messages with tool_calls are modified + - Only the FIRST tool_call in each message gets the signature + - functionResponse (tool role) messages are NOT modified + - Messages are cloned, original list is not mutated + + Args: + messages: List of OpenAI-format message dicts. + apply: If False, returns messages unchanged (for config toggle). + signature: The signature value to inject. + + Returns: + New list with thoughtSignature added where needed. + Original messages list is not modified. + + Example: + >>> messages = [ + ... {"role": "user", "content": "What's the weather?"}, + ... {"role": "assistant", "tool_calls": [ + ... {"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": "{}"}} + ... ]}, + ... {"role": "tool", "tool_call_id": "call_1", "content": "Sunny"} + ... ] + >>> result = ensure_thought_signature(messages) + >>> result[1]["tool_calls"][0]["_thought_signature"] + 'context_engineering_is_the_way_to_go' + """ + if not apply: + return messages + + if not messages: + return messages + + result: List[Dict[str, Any]] = [] + + for msg in messages: + # Only process assistant messages with tool_calls + if msg.get("role") != "assistant": + result.append(msg) + continue + + tool_calls = msg.get("tool_calls") + if not tool_calls or not isinstance(tool_calls, list): + result.append(msg) + continue + + # Clone message to avoid mutation + modified_msg = msg.copy() + modified_calls: List[Dict[str, Any]] = [] + signature_added = False + + for call in tool_calls: + if not isinstance(call, dict): + modified_calls.append(call) + continue + + call_copy = call.copy() + + # Add signature to first function-type call only + if not signature_added and call.get("type") == "function": + call_copy["_thought_signature"] = signature + signature_added = True + + modified_calls.append(call_copy) + + modified_msg["tool_calls"] = modified_calls + result.append(modified_msg) + + return result + + +# ============================================================================= +# Schema Conversion: OpenAI -> Gemini +# ============================================================================= + + +class SchemaConversionError(Exception): + """Raised when schema conversion fails.""" + + pass + + +class SchemaConverter: + """Converts OpenAI tool definitions to Gemini FunctionDeclaration format. + + OpenAI Format: + ```json + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get weather for a location", + "parameters": { + "type": "object", + "properties": {"location": {"type": "string"}}, + "required": ["location"] + }, + "strict": true # <-- Stripped (not supported) + } + } + ``` + + Gemini Format: + ```json + { + "name": "get_weather", + "description": "Get weather for a location", + "parameters": { + "type": "object", + "properties": {"location": {"type": "string"}}, + "required": ["location"] + } + } + ``` + """ + + # ========================================================================= + # AI STUDIO SCHEMA WHITELIST (Empirically Tested) + # ========================================================================= + # Only these fields are accepted by AI Studio's function calling UI. + # This was tested against AI Studio web interface on 2025-12-24. + # + # IMPORTANT: AI Studio Web UI has STRICTER validation than the direct + # Gemini API. The Gemini API docs list many fields that AI Studio rejects. + # + # ------------------------------------------------------------------------- + # SUPPORTED FIELDS (Tested & Working): + # ------------------------------------------------------------------------- + ALLOWED_SCHEMA_FIELDS = { + "type", # Data type (REQUIRED on every property) + "format", # Format hint (e.g., "date-time", "email") + "description", # Human-readable description + "nullable", # Whether null is allowed + "enum", # Allowed values + "maxItems", # Maximum array items + "minItems", # Minimum array items + "properties", # Object properties + "required", # Required property names + "items", # Array item schema + "minProperties", # Minimum object properties + "maxProperties", # Maximum object properties + "minimum", # Minimum numeric value + "maximum", # Maximum numeric value + "minLength", # Minimum string length + "maxLength", # Maximum string length + "pattern", # Regex pattern for strings + "propertyOrdering", # Order of properties for display + } + + # ------------------------------------------------------------------------- + # UNSUPPORTED FIELDS (AI Studio rejects these with "Unknown key" error): + # ------------------------------------------------------------------------- + # DO NOT ADD THESE TO ALLOWED_SCHEMA_FIELDS - They have been tested and + # confirmed to cause errors in AI Studio: + # + # - "title" : Unknown key error + # - "default" : Unknown key error + # - "additionalProperties" : Unknown key error + # - "const" : Unknown key error (convert to enum instead) + # - "anyOf" : Unknown key error + "type must be specified" + # - "oneOf" : Unknown key error (convert to first type) + # - "allOf" : Unknown key error (convert to first type) + # - "$schema" : Unknown key error + # - "$id" : Unknown key error + # - "$ref" : Unknown key error + # - "$defs" : Unknown key error + # - "definitions" : Unknown key error + # - "examples" : Unknown key error + # - "exclusiveMinimum" : Unknown key error + # - "exclusiveMaximum" : Unknown key error + # - "multipleOf" : Unknown key error + # - "uniqueItems" : Unknown key error + # - "strict" : OpenAI-specific, not supported + # + # ========================================================================= + + # Fields that require special handling (recursion, conversion) + # anyOf/oneOf/allOf are converted to first non-null type since AI Studio + # doesn't support union types + SPECIAL_FIELDS = {"type", "properties", "items", "anyOf", "const", "oneOf", "allOf"} + + # Legacy TYPE_MAP - kept for backwards compatibility + # Use type_map property for configurable case + TYPE_MAP = { + "string": "string", + "integer": "integer", + "number": "number", + "boolean": "boolean", + "array": "array", + "object": "object", + } + + @property + def type_map(self) -> Dict[str, str]: + """Get the type map based on configuration. + + Returns: + TYPE_MAP_UPPER if FUNCTION_CALLING_UPPERCASE_TYPES is True, + TYPE_MAP_LOWER otherwise. + + This allows configurable type case for Gemini compatibility. + Default is lowercase (TYPE_MAP_LOWER) for backwards compatibility. + """ + from config.settings import FUNCTION_CALLING_UPPERCASE_TYPES + + return TYPE_MAP_UPPER if FUNCTION_CALLING_UPPERCASE_TYPES else TYPE_MAP_LOWER + + def _normalize_type(self, type_value: str) -> str: + """Normalize a type value using the configured type map. + + Args: + type_value: JSON Schema type string (e.g., "string", "object"). + + Returns: + Normalized type string based on configuration. + """ + lower_type = type_value.lower().strip() + current_type_map = self.type_map + # If not in map, use the configured case convention + from config.settings import FUNCTION_CALLING_UPPERCASE_TYPES + + default = lower_type.upper() if FUNCTION_CALLING_UPPERCASE_TYPES else lower_type + return current_type_map.get(lower_type, default) + + def convert_tool(self, openai_tool: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Convert a single OpenAI tool definition to Gemini FunctionDeclaration. + + Supports both standard OpenAI format and flat format (e.g. from opencode). + Safely ignores non-function tools. + + Args: + openai_tool: OpenAI tool definition. + + Returns: + Gemini FunctionDeclaration dict, or None if the tool should be ignored. + + Raises: + SchemaConversionError: If the tool is a function but the format is invalid. + """ + if not isinstance(openai_tool, dict): + return None + + tool_type = openai_tool.get("type") + if tool_type != "function": + if FUNCTION_CALLING_DEBUG: + logger.debug(f"Ignoring non-function tool type: {tool_type}") + return None + + # Try to find function definition (nested or flat) + function_def = openai_tool.get("function") + if isinstance(function_def, dict): + # Standard format: {"type": "function", "function": {"name": "...", ...}} + source = function_def + else: + # Maybe flat format: {"type": "function", "name": "...", "parameters": { ... }} + source = openai_tool + + name = source.get("name") + if not name or not isinstance(name, str): + raise SchemaConversionError( + "Function 'name' is required and must be a string" + ) + + if FUNCTION_CALLING_DEBUG: + logger.debug(f"Converting OpenAI tool to Gemini: {name}") + fc_logger.debug(FCModule.SCHEMA, f"Converting tool: {name}") + + # Build Gemini FunctionDeclaration + gemini_declaration: Dict[str, Any] = {"name": name} + + # Description is optional but recommended + description = source.get("description") + if description and isinstance(description, str): + gemini_declaration["description"] = description + + # Parameters are optional (some functions have no params) + parameters = source.get("parameters") + if parameters and isinstance(parameters, dict): + # Strip unsupported fields but keep the rest + clean_params = self._clean_parameters(parameters) + gemini_declaration["parameters"] = clean_params + + if FUNCTION_CALLING_DEBUG: + logger.debug( + f"Converted tool '{name}' to Gemini format: {json.dumps(gemini_declaration, ensure_ascii=False)}" + ) + + return gemini_declaration + + def convert_tools(self, openai_tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Convert an array of OpenAI tool definitions to Gemini FunctionDeclarations. + + Args: + openai_tools: List of OpenAI tool definitions. + + Returns: + List of Gemini FunctionDeclaration dicts. + + Raises: + SchemaConversionError: If any tool conversion fails or tools is not a list. + """ + if not isinstance(openai_tools, list): + raise SchemaConversionError( + f"Tools must be a list, got {type(openai_tools).__name__}" + ) + + declarations: List[Dict[str, Any]] = [] + for i, tool in enumerate(openai_tools): + try: + declaration = self.convert_tool(tool) + if declaration: + declarations.append(declaration) + except SchemaConversionError as e: + raise SchemaConversionError(f"Error converting tool at index {i}: {e}") + + if FUNCTION_CALLING_DEBUG: + fc_logger.info( + FCModule.SCHEMA, + f"Converted {len(declarations)} tools to Gemini format", + ) + return declarations + + def to_json_string( + self, declarations: List[Dict[str, Any]], indent: Optional[int] = 2 + ) -> str: + """Serialize Gemini FunctionDeclarations to JSON string for UI paste. + + Args: + declarations: List of Gemini FunctionDeclaration dicts. + indent: JSON indentation (None for compact, int for pretty). + + Returns: + JSON string suitable for pasting into AI Studio function declarations textarea. + """ + return json.dumps(declarations, indent=indent, ensure_ascii=False) + + def _clean_parameters(self, schema: Dict[str, Any]) -> Dict[str, Any]: + """Convert OpenAI/JSON Schema to Gemini-compatible format. + + Uses WHITELIST approach - only copies fields that Gemini/AI Studio accepts. + AI Studio rejects unknown fields with errors like "Unknown key 'additionalProperties'". + + Handles: + - Type normalization (list to single type + nullable) + - Conversion: const -> enum, oneOf/allOf -> anyOf (simplified) + - Whitelist-based field filtering + - Recursive cleaning of nested schemas + """ + if not isinstance(schema, dict): + return schema + + cleaned: Dict[str, Any] = {} + + # 1. Handle anyOf/oneOf/allOf: AI Studio doesn't support these, extract first non-null type + for logic_field in ["anyOf", "oneOf", "allOf"]: + if logic_field in schema: + val = schema[logic_field] + if isinstance(val, list) and len(val) > 0: + # Find the first non-null option and use it + for option in val: + if isinstance(option, dict): + option_type = option.get("type") + if option_type != "null": + # Merge the first valid option into cleaned + merged = self._clean_parameters(option) + cleaned.update(merged) + break + # Check if null was an option for nullable + for option in val: + if isinstance(option, dict) and option.get("type") == "null": + cleaned["nullable"] = True + break + # Return early since logic fields define the whole schema + if cleaned: + return cleaned + + # 2. Handle Const Conversion: const -> enum + if "const" in schema: + cleaned["enum"] = [schema["const"]] + + # 3. Handle Type Normalization: ["string", "null"] -> "string" + nullable + if "type" in schema: + raw_type = schema["type"] + nullable = schema.get("nullable", False) + + if isinstance(raw_type, list): + if "null" in raw_type: + nullable = True + # Get the first non-null type + types = [t for t in raw_type if t != "null"] + raw_type = types[0] if types else "string" + + if nullable: + cleaned["nullable"] = True + + # Map type using configurable type map + if isinstance(raw_type, str): + cleaned["type"] = self._normalize_type(raw_type) + + # 4. Handle properties recursively (must do before the loop) + if "properties" in schema and isinstance(schema["properties"], dict): + cleaned["properties"] = { + prop_name: self._clean_parameters(prop_schema) + for prop_name, prop_schema in schema["properties"].items() + } + + # 5. Handle items recursively (for arrays) + if "items" in schema and isinstance(schema["items"], dict): + cleaned["items"] = self._clean_parameters(schema["items"]) + + # 6. Copy ONLY allowed fields (whitelist approach) + for key, value in schema.items(): + # Skip fields we already handled + if key in self.SPECIAL_FIELDS: + continue + + # Skip nullable if already set from type array + if key == "nullable" and "nullable" in cleaned: + continue + + # Only copy fields that Gemini accepts + if key not in self.ALLOWED_SCHEMA_FIELDS: + continue + + # Copy allowed fields as-is (properties/items already handled above) + if key not in cleaned: + cleaned[key] = value + + return cleaned + + +# ============================================================================= +# Call ID Manager +# ============================================================================= + + +@dataclass +class PendingCall: + """Represents a pending function call awaiting result. + + Attributes: + call_id: Unique identifier for this call (call_). + function_name: Name of the function being called. + arguments: Arguments passed to the function. + timestamp: Unix timestamp when the call was registered. + """ + + call_id: str + function_name: str + arguments: Dict[str, Any] + timestamp: float = field(default_factory=lambda: __import__("time").time()) + + +class CallIdManager: + """Generates and tracks function call IDs. + + Gemini does not return call IDs, so the proxy must generate and track them + to maintain OpenAI API compatibility. + + ID Format: call_<24-character-hex> + Example: call_a1b2c3d4e5f6789012345678 + """ + + # Prefix for all generated call IDs + CALL_ID_PREFIX = "call_" + # Length of the hex portion of the ID + HEX_LENGTH = 24 + + def __init__(self) -> None: + """Initialize the call ID manager.""" + self._pending_calls: Dict[str, PendingCall] = {} + + def generate_id(self) -> str: + """Generate a unique call ID. + + Returns: + A unique call ID in format: call_<24-character-hex> + """ + hex_part = uuid.uuid4().hex[: self.HEX_LENGTH] + return f"{self.CALL_ID_PREFIX}{hex_part}" + + def register_call( + self, + call_id: str, + function_name: str, + arguments: Dict[str, Any], + ) -> PendingCall: + """Register a function call for tracking. + + Args: + call_id: The unique call ID. + function_name: Name of the function being called. + arguments: Arguments for the function call. + + Returns: + The registered PendingCall object. + """ + pending = PendingCall( + call_id=call_id, + function_name=function_name, + arguments=arguments, + ) + self._pending_calls[call_id] = pending + if FUNCTION_CALLING_DEBUG: + logger.debug(f"Registered pending call: {call_id} -> {function_name}") + return pending + + def get_pending_call(self, call_id: str) -> Optional[PendingCall]: + """Get a pending call by ID. + + Args: + call_id: The call ID to look up. + + Returns: + The PendingCall if found, None otherwise. + """ + return self._pending_calls.get(call_id) + + def get_pending_calls(self) -> List[PendingCall]: + """Get all pending calls. + + Returns: + List of all pending calls. + """ + return list(self._pending_calls.values()) + + def remove_call(self, call_id: str) -> Optional[PendingCall]: + """Remove a pending call (when result is received). + + Args: + call_id: The call ID to remove. + + Returns: + The removed PendingCall if found, None otherwise. + """ + return self._pending_calls.pop(call_id, None) + + def clear(self) -> None: + """Clear all pending calls.""" + self._pending_calls.clear() + + +# ============================================================================= +# Parsed Function Call Types +# ============================================================================= + + +@dataclass +class ParsedFunctionCall: + """Represents a parsed function call from Gemini's response. + + Attributes: + name: The function name. + arguments: Parsed arguments as a dict (not string). + raw_text: Original raw text if parsed from text (for debugging). + """ + + name: str + arguments: Dict[str, Any] + raw_text: Optional[str] = None + + +# ============================================================================= +# Response Formatter: Gemini -> OpenAI +# ============================================================================= + + +class OpenAIFunctionCall(BaseModel): + """OpenAI function call structure within a tool call.""" + + name: str + arguments: str # JSON string, NOT dict + + +class OpenAIToolCall(BaseModel): + """OpenAI tool_calls array item structure.""" + + id: str + type: str = "function" + function: OpenAIFunctionCall + + +class OpenAIToolCallDelta(BaseModel): + """OpenAI streaming delta for tool calls.""" + + index: int + id: Optional[str] = None # Only on first chunk + type: Optional[str] = None # Only on first chunk + function: Optional[Dict[str, Any]] = None # Contains name and/or arguments + + +class ResponseFormatter: + """Formats parsed function calls to OpenAI's tool_calls structure. + + Handles both non-streaming and streaming response formats. + """ + + def __init__(self, id_manager: Optional[CallIdManager] = None) -> None: + """Initialize the response formatter. + + Args: + id_manager: Optional CallIdManager for ID generation. + If None, a new one will be created. + """ + self._id_manager = id_manager or CallIdManager() + + @property + def id_manager(self) -> CallIdManager: + """Get the call ID manager.""" + return self._id_manager + + def format_non_streaming_response( + self, + parsed_calls: List[ParsedFunctionCall], + content: Optional[str] = None, + ) -> Dict[str, Any]: + """Format a non-streaming response with tool calls. + + Ensures structure: {"role": "assistant", "content": null, "tool_calls": [...]} + """ + tool_calls = self.format_tool_calls(parsed_calls) + return { + "role": "assistant", + "content": content, + "tool_calls": tool_calls, + } + + def format_tool_call( + self, + parsed_call: ParsedFunctionCall, + call_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Format a single parsed function call to OpenAI tool_call format. + + Args: + parsed_call: The parsed function call from Gemini. + call_id: Optional pre-generated call ID. If None, one will be generated. + + Returns: + OpenAI tool_call dict: + { + "id": "call_abc123...", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"location\": \"Boston\"}" # STRING + } + } + """ + if call_id is None: + call_id = self._id_manager.generate_id() + + if FUNCTION_CALLING_DEBUG: + logger.debug(f"Formatting tool call: {call_id} ({parsed_call.name})") + + # Register the call for tracking + + self._id_manager.register_call( + call_id=call_id, + function_name=parsed_call.name, + arguments=parsed_call.arguments, + ) + + # Arguments must be a JSON string per OpenAI spec + arguments_str = json.dumps(parsed_call.arguments, ensure_ascii=False) + + tool_call = OpenAIToolCall( + id=call_id, + type="function", + function=OpenAIFunctionCall( + name=parsed_call.name, + arguments=arguments_str, + ), + ) + + return tool_call.model_dump() + + def format_tool_calls( + self, + parsed_calls: List[ParsedFunctionCall], + ) -> List[Dict[str, Any]]: + """Format multiple parsed function calls to OpenAI tool_calls array. + + Args: + parsed_calls: List of parsed function calls. + + Returns: + List of OpenAI tool_call dicts. + """ + if FUNCTION_CALLING_DEBUG: + logger.debug(f"Formatting {len(parsed_calls)} tool call(s)") + fc_logger.debug( + FCModule.RESPONSE, + f"Formatting {len(parsed_calls)} tool call(s) for OpenAI response", + ) + return [self.format_tool_call(call) for call in parsed_calls] + + def format_tool_call_delta( + self, + index: int, + call_id: Optional[str] = None, + function_name: Optional[str] = None, + arguments_fragment: str = "", + ) -> Dict[str, Any]: + """Format a streaming delta chunk for tool calls. + + For the first chunk of a tool call, provide call_id and function_name. + For subsequent chunks, provide only arguments_fragment. + + Args: + index: The index of this tool call in the array. + call_id: The call ID (only on first chunk). + function_name: The function name (only on first chunk). + arguments_fragment: Fragment of the arguments JSON string. + + Returns: + OpenAI streaming delta dict: + { + "index": 0, + "id": "call_abc123", # Only first chunk + "type": "function", # Only first chunk + "function": { + "name": "get_weather", # Only first chunk + "arguments": "{\"loc" # Streamed fragment + } + } + """ + delta: Dict[str, Any] = {"index": index} + + # First chunk includes id and type + if call_id is not None: + delta["id"] = call_id + delta["type"] = "function" + + # Build function object + function_delta: Dict[str, Any] = {} + if function_name is not None: + function_delta["name"] = function_name + if arguments_fragment: + function_delta["arguments"] = arguments_fragment + + if function_delta: + delta["function"] = function_delta + + return delta + + def format_streaming_first_chunk( + self, + index: int, + parsed_call: ParsedFunctionCall, + ) -> Dict[str, Any]: + """Format the first streaming chunk for a function call. + + This chunk includes the call ID, type, function name, and empty arguments. + + Args: + index: The index of this tool call. + parsed_call: The parsed function call. + + Returns: + First delta chunk dict. + """ + call_id = self._id_manager.generate_id() + + # Register for tracking + self._id_manager.register_call( + call_id=call_id, + function_name=parsed_call.name, + arguments=parsed_call.arguments, + ) + + return self.format_tool_call_delta( + index=index, + call_id=call_id, + function_name=parsed_call.name, + arguments_fragment="", + ) + + def format_streaming_chunks( + self, + index: int, + parsed_call: ParsedFunctionCall, + chunk_size: int = 50, + ) -> List[Dict[str, Any]]: + """Format all streaming chunks for a complete function call. + + Generates the first chunk with metadata, then chunks of the arguments. + + Args: + index: The index of this tool call. + parsed_call: The parsed function call. + chunk_size: Size of each arguments chunk. + + Returns: + List of delta chunks for streaming. + """ + call_id = self._id_manager.generate_id() + + # Register for tracking + self._id_manager.register_call( + call_id=call_id, + function_name=parsed_call.name, + arguments=parsed_call.arguments, + ) + + chunks: List[Dict[str, Any]] = [] + + # First chunk with metadata + chunks.append( + self.format_tool_call_delta( + index=index, + call_id=call_id, + function_name=parsed_call.name, + arguments_fragment="", + ) + ) + + # Arguments chunks + arguments_str = json.dumps(parsed_call.arguments, ensure_ascii=False) + for i in range(0, len(arguments_str), chunk_size): + fragment = arguments_str[i : i + chunk_size] + chunks.append( + self.format_tool_call_delta( + index=index, + call_id=call_id, # Include ID in all chunks for consistency + arguments_fragment=fragment, + ) + ) + + return chunks + + +# ============================================================================= +# Message Builder Helper +# ============================================================================= + + +def build_assistant_message_with_tool_calls( + tool_calls: List[Dict[str, Any]], + content: Optional[str] = None, +) -> Dict[str, Any]: + """Build an OpenAI-compatible assistant message with tool_calls. + + Args: + tool_calls: List of formatted tool call dicts. + content: Optional text content (usually None for pure function calls). + + Returns: + OpenAI message dict: + { + "role": "assistant", + "content": null, # or text + "tool_calls": [...] + } + """ + message: Dict[str, Any] = { + "role": "assistant", + "content": content, + } + + if tool_calls: + message["tool_calls"] = tool_calls + + return message + + +def get_finish_reason(has_tool_calls: bool) -> str: + """Determine the appropriate finish_reason. + + Args: + has_tool_calls: Whether the response contains tool calls. + + Returns: + "tool_calls" if function calls present, "stop" otherwise. + """ + return "tool_calls" if has_tool_calls else "stop" + + +# ============================================================================= +# Convenience Functions +# ============================================================================= + + +def convert_openai_tools_to_gemini( + openai_tools: List[Dict[str, Any]], +) -> str: + """Convenience function to convert OpenAI tools to Gemini JSON string. + + Args: + openai_tools: List of OpenAI tool definitions. + + Returns: + JSON string of Gemini FunctionDeclarations for UI paste. + + Raises: + SchemaConversionError: If conversion fails. + """ + converter = SchemaConverter() + declarations = converter.convert_tools(openai_tools) + return converter.to_json_string(declarations) + + +def create_tool_calls_response( + parsed_calls: List[ParsedFunctionCall], + content: Optional[str] = None, +) -> tuple[Dict[str, Any], str]: + """Create a complete tool_calls response tuple. + + Args: + parsed_calls: List of parsed function calls. + content: Optional text content. + + Returns: + Tuple of (message_dict, finish_reason). + """ + formatter = ResponseFormatter() + tool_calls = formatter.format_tool_calls(parsed_calls) + message = build_assistant_message_with_tool_calls(tool_calls, content) + finish_reason = get_finish_reason(bool(tool_calls)) + return message, finish_reason + + +# ============================================================================= +# Module Exports +# ============================================================================= + +__all__ = [ + # Configuration + "FunctionCallingMode", + "FunctionCallingConfig", + # Schema Conversion + "SchemaConverter", + "SchemaConversionError", + # Call ID Management + "CallIdManager", + "PendingCall", + # Response Parsing Types + "ParsedFunctionCall", + # Response Formatting + "ResponseFormatter", + "OpenAIFunctionCall", + "OpenAIToolCall", + "OpenAIToolCallDelta", + # Helpers + "build_assistant_message_with_tool_calls", + "get_finish_reason", + # Convenience Functions + "convert_openai_tools_to_gemini", + "create_tool_calls_response", + # FC-001: thoughtSignature Support + "DUMMY_THOUGHT_SIGNATURE", + "ensure_thought_signature", + # FC-002: MCP Response Handling + "normalize_tool_response", + "MCPResponseItem", + # FC-003: Tool Choice Conversion + "GeminiToolConfig", + "convert_tool_choice", + # FC-004: Type Case Normalization + "TYPE_MAP_UPPER", + "TYPE_MAP_LOWER", +] diff --git a/api_utils/utils_ext/function_calling_cache.py b/api_utils/utils_ext/function_calling_cache.py new file mode 100644 index 0000000000000000000000000000000000000000..4feb689235ffd2831aa463838060510da6c2afce --- /dev/null +++ b/api_utils/utils_ext/function_calling_cache.py @@ -0,0 +1,446 @@ +""" +Function Calling Cache Module + +Manages caching of function calling state to avoid redundant UI operations. +Implements digest-based caching for tool definitions and toggle state caching. +""" + +import hashlib +import json +import logging +import time +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Set, Tuple + +from config import settings +from config.settings import FUNCTION_CALLING_DEBUG +from logging_utils.fc_debug import FCModule, get_fc_logger + +# FC debug logger for cache-specific events +fc_logger = get_fc_logger() + + +@dataclass +class FunctionCallingCacheEntry: + """Represents a cached function calling state. + + Attributes: + tools_digest: SHA256 hash of tool definitions (first 16 chars). + toggle_enabled: Whether FC toggle is on. + declarations_set: Whether declarations were successfully set. + timestamp: When cached (epoch seconds). + model_name: Associated model name (optional). + tool_names: Set of registered tool names for validation. + """ + + tools_digest: str # SHA256 hash of tool definitions + toggle_enabled: bool # Whether FC toggle is on + declarations_set: bool # Whether declarations were successfully set + timestamp: float # When cached + model_name: Optional[str] = None # Associated model + tool_names: Set[str] = field( + default_factory=set + ) # Registered tool names for validation + + +class FunctionCallingCache: + """ + Manages caching of function calling state to avoid redundant UI operations. + + Caching Strategy: + 1. Toggle State: Cache whether FC toggle is enabled + 2. Tool Digest: Hash tool definitions to detect changes + 3. Invalidation: On model switch, new chat, or explicit clear + + Usage: + cache = FunctionCallingCache.get_instance() + + # Check if cache is valid before UI operations + digest = cache.compute_tools_digest(tools) + if cache.is_cache_valid(digest, model_name): + # Skip UI operations + pass + else: + # Perform UI operations, then update cache + cache.update_cache(digest, toggle_enabled=True, declarations_set=True) + """ + + _instance: Optional["FunctionCallingCache"] = None + + def __init__(self, logger: Optional[logging.Logger] = None): + """Initialize the cache. + + Args: + logger: Optional logger instance. If None, uses default logger. + """ + self.logger = logger or logging.getLogger("AIStudioProxyServer") + self._cache: Optional[FunctionCallingCacheEntry] = None + self._enabled = getattr(settings, "FUNCTION_CALLING_CACHE_ENABLED", True) + self._debug = getattr(settings, "FUNCTION_CALLING_DEBUG", False) + self._ttl = getattr(settings, "FUNCTION_CALLING_CACHE_TTL", 0) + self._hit_count = 0 + self._miss_count = 0 + + @classmethod + def get_instance( + cls, logger: Optional[logging.Logger] = None + ) -> "FunctionCallingCache": + """Get or create the singleton instance. + + Args: + logger: Optional logger instance. + + Returns: + The singleton FunctionCallingCache instance. + """ + if cls._instance is None: + cls._instance = cls(logger) + return cls._instance + + @classmethod + def reset_instance(cls) -> None: + """Reset the singleton instance (useful for testing).""" + cls._instance = None + + def compute_tools_digest(self, tools: List[Dict[str, Any]]) -> str: + """Compute SHA256 digest of tool definitions for change detection. + + Args: + tools: List of tool definitions (OpenAI format). + + Returns: + First 16 characters of SHA256 hex digest. + """ + if not tools: + return "empty" + + # Normalize and serialize tools for consistent hashing + # Sort keys to ensure deterministic output + try: + normalized = json.dumps(tools, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(normalized.encode()).hexdigest()[:16] + except (TypeError, ValueError) as e: + if self._debug: + self.logger.warning(f"[FC:Cache] Failed to compute digest: {e}") + return "invalid" + + def _extract_tool_names(self, tools: List[Dict[str, Any]]) -> Set[str]: + """Extract tool names from a list of tool definitions. + + Handles both OpenAI format (nested function.name) and flat format (name at top level). + + Args: + tools: List of tool definitions. + + Returns: + Set of tool names. + """ + names: Set[str] = set() + for tool in tools: + if not isinstance(tool, dict): + continue + + # Try nested format: {"function": {"name": "..."}} + func = tool.get("function", {}) + if isinstance(func, dict) and "name" in func: + names.add(func["name"]) + # Try flat format: {"name": "..."} + elif "name" in tool: + names.add(tool["name"]) + + return names + + def is_cache_valid( + self, + tools_digest: str, + model_name: Optional[str] = None, + req_id: str = "", + ) -> bool: + """Check if cached state matches current request. + + Args: + tools_digest: Digest of current tool definitions. + model_name: Current model name (optional). + req_id: Request ID for logging. + + Returns: + True if cache is valid and can be used, False otherwise. + """ + prefix = f"[{req_id}] " if req_id else "" + + if not self._enabled: + if self._debug: + self.logger.debug(f"{prefix}[FC:Cache] Caching disabled") + if FUNCTION_CALLING_DEBUG: + fc_logger.debug(FCModule.CACHE, "Caching disabled", req_id=req_id) + return False + + if self._cache is None: + if self._debug: + self.logger.debug(f"{prefix}[FC:Cache] No cached state") + if FUNCTION_CALLING_DEBUG: + fc_logger.log_cache_miss(req_id, "no_cached_state") + self._miss_count += 1 + return False + + # Check TTL if configured + if self._ttl > 0: + age = time.time() - self._cache.timestamp + if age > self._ttl: + if self._debug: + self.logger.debug( + f"{prefix}[FC:Cache] Cache expired (age={age:.1f}s > TTL={self._ttl}s)" + ) + if FUNCTION_CALLING_DEBUG: + fc_logger.log_cache_miss(req_id, f"expired_ttl_{age:.1f}s") + self._miss_count += 1 + return False + + # Check digest match + if self._cache.tools_digest != tools_digest: + if self._debug: + self.logger.debug( + f"{prefix}[FC:Cache] Digest mismatch " + f"(cached={self._cache.tools_digest[:8]}... vs current={tools_digest[:8]}...)" + ) + if FUNCTION_CALLING_DEBUG: + fc_logger.log_cache_miss(req_id, "digest_mismatch") + self._miss_count += 1 + return False + + # Check model match if provided + if ( + model_name + and self._cache.model_name + and self._cache.model_name != model_name + ): + if self._debug: + self.logger.debug( + f"{prefix}[FC:Cache] Model changed " + f"({self._cache.model_name} -> {model_name})" + ) + if FUNCTION_CALLING_DEBUG: + fc_logger.log_cache_miss(req_id, "model_changed") + self._miss_count += 1 + return False + + # Cache is valid + self._hit_count += 1 + age = time.time() - self._cache.timestamp + if self._debug: + self.logger.debug( + f"{prefix}[FC:Cache] Valid cache found " + f"(digest={tools_digest[:8]}..., toggle={self._cache.toggle_enabled})" + ) + if FUNCTION_CALLING_DEBUG: + fc_logger.log_cache_hit(req_id, tools_digest, age) + return True + + def get_cached_state(self) -> Optional[FunctionCallingCacheEntry]: + """Get current cached state if available. + + Returns: + The cached entry or None if no cache exists. + """ + return self._cache + + def update_cache( + self, + tools_digest: str, + toggle_enabled: bool, + declarations_set: bool, + model_name: Optional[str] = None, + req_id: str = "", + tools: Optional[List[Dict[str, Any]]] = None, + ) -> None: + """Update cache with new state. + + Args: + tools_digest: Digest of tool definitions. + toggle_enabled: Whether FC toggle is enabled. + declarations_set: Whether declarations were set successfully. + model_name: Model name (optional). + req_id: Request ID for logging. + tools: Optional list of tool definitions to extract names from. + """ + if not self._enabled: + return + + prefix = f"[{req_id}] " if req_id else "" + + # Extract tool names for validation + tool_names: Set[str] = set() + if tools: + tool_names = self._extract_tool_names(tools) + + self._cache = FunctionCallingCacheEntry( + tools_digest=tools_digest, + toggle_enabled=toggle_enabled, + declarations_set=declarations_set, + timestamp=time.time(), + model_name=model_name, + tool_names=tool_names, + ) + if self._debug: + self.logger.debug( + f"{prefix}[FC:Cache] Updated: digest={tools_digest[:8]}..., " + f"toggle={toggle_enabled}, declarations_set={declarations_set}" + ) + if FUNCTION_CALLING_DEBUG: + fc_logger.debug( + FCModule.CACHE, + f"Cache updated: digest={tools_digest[:8]}..., " + f"toggle={toggle_enabled}, declarations={declarations_set}", + req_id=req_id, + ) + + def update_toggle_state(self, enabled: bool, req_id: str = "") -> None: + """Update just the toggle state without changing other cache data. + + Args: + enabled: Whether the toggle is enabled. + req_id: Request ID for logging. + """ + if self._cache: + prefix = f"[{req_id}] " if req_id else "" + self._cache.toggle_enabled = enabled + self._cache.timestamp = time.time() + if self._debug: + self.logger.debug( + f"{prefix}[FC:Cache] Toggle updated: enabled={enabled}" + ) + + def invalidate(self, reason: str = "manual", req_id: str = "") -> None: + """Clear the cache. + + Args: + reason: Reason for invalidation (for logging). + req_id: Request ID for logging. + """ + prefix = f"[{req_id}] " if req_id else "" + if self._cache: + if self._debug: + self.logger.debug(f"{prefix}[FC:Cache] Invalidated: {reason}") + if FUNCTION_CALLING_DEBUG: + fc_logger.debug( + FCModule.CACHE, f"Cache invalidated: {reason}", req_id=req_id + ) + self._cache = None + + def is_toggle_cached_enabled(self) -> Optional[bool]: + """Quick check if toggle is cached as enabled. + + Returns: + True if toggle is cached as enabled, + False if cached as disabled, + None if no cache exists. + """ + if not self._enabled or self._cache is None: + return None + return self._cache.toggle_enabled + + @property + def is_enabled(self) -> bool: + """Check if caching is enabled.""" + return self._enabled + + @property + def cache_stats(self) -> Dict[str, Any]: + """Return cache statistics for debugging. + + Returns: + Dictionary with cache statistics. + """ + if self._cache is None: + return { + "cached": False, + "enabled": self._enabled, + "hits": self._hit_count, + "misses": self._miss_count, + } + + return { + "cached": True, + "enabled": self._enabled, + "tools_digest": self._cache.tools_digest, + "toggle_enabled": self._cache.toggle_enabled, + "declarations_set": self._cache.declarations_set, + "model": self._cache.model_name, + "age_seconds": round(time.time() - self._cache.timestamp, 2), + "hits": self._hit_count, + "misses": self._miss_count, + } + + def get_registered_tool_names(self) -> Set[str]: + """Get the set of registered tool names from cache. + + Returns: + Set of tool names, or empty set if no cache exists. + """ + if self._cache is None: + return set() + return self._cache.tool_names + + def validate_function_name( + self, parsed_name: str, req_id: str = "" + ) -> Tuple[str, bool, float]: + """Validate a parsed function name against registered tools. + + If the exact name isn't found, attempts fuzzy matching (prefix match). + + Args: + parsed_name: The function name parsed from model output. + req_id: Request ID for logging. + + Returns: + Tuple of (validated_name, was_corrected, confidence). + - validated_name: The matched tool name or original if no match. + - was_corrected: True if the name was corrected via fuzzy match. + - confidence: Match confidence (1.0 = exact, 0.0-0.99 = fuzzy). + """ + registered_names = self.get_registered_tool_names() + + if not registered_names: + # No registered tools to validate against + return parsed_name, False, 0.0 + + # Exact match + if parsed_name in registered_names: + return parsed_name, False, 1.0 + + # Fuzzy match: check if any registered name starts with parsed_name + # (handles truncation case like "gh_grep_searchGitH" -> "gh_grep_searchGitHub") + prefix = f"[{req_id}] " if req_id else "" + for registered in registered_names: + if registered.startswith(parsed_name): + confidence = len(parsed_name) / len(registered) + if self._debug: + self.logger.debug( + f"{prefix}[FC:Cache] Fuzzy match: '{parsed_name}' -> '{registered}' " + f"(confidence={confidence:.2f})" + ) + return registered, True, confidence + + # Also check if parsed_name starts with registered (reversed truncation) + for registered in registered_names: + if parsed_name.startswith(registered): + confidence = len(registered) / len(parsed_name) + if self._debug: + self.logger.debug( + f"{prefix}[FC:Cache] Fuzzy match (reverse): '{parsed_name}' -> '{registered}' " + f"(confidence={confidence:.2f})" + ) + return registered, True, confidence + + # No match found + if self._debug: + self.logger.warning( + f"{prefix}[FC:Cache] Function '{parsed_name}' not found in registered tools" + ) + return parsed_name, False, 0.0 + + +__all__ = [ + "FunctionCallingCache", + "FunctionCallingCacheEntry", +] diff --git a/api_utils/utils_ext/function_calling_orchestrator.py b/api_utils/utils_ext/function_calling_orchestrator.py new file mode 100644 index 0000000000000000000000000000000000000000..3e99acf63f08d8621d030063c92970c33bdc0b29 --- /dev/null +++ b/api_utils/utils_ext/function_calling_orchestrator.py @@ -0,0 +1,788 @@ +""" +Function Calling Orchestrator + +Central coordinator that routes function calling between native and emulated modes. +Integrates SchemaConverter, ResponseFormatter, and browser automation into the request flow. + +Implements Phase 3 of ADR-001: Native Function Calling Architecture. +Includes caching to skip redundant UI operations for subsequent requests with same tools. +""" + +import asyncio +import logging +import time +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Optional, Tuple, Union + +from browser_utils.page_controller import PageController +from logging_utils.fc_debug import FCModule, get_fc_logger + +# FC debug logger for orchestrator-level events +fc_logger = get_fc_logger() +# Import directly from the module file to avoid circular imports through __init__.py +# fmt: off +from api_utils.utils_ext.function_calling import ( # noqa: E501 + FunctionCallingConfig, + FunctionCallingMode, + ParsedFunctionCall, + ResponseFormatter, + SchemaConversionError, + SchemaConverter, + get_finish_reason, +) +from api_utils.utils_ext.function_calling_cache import FunctionCallingCache +from config.settings import ( + FUNCTION_CALLING_CLEAR_BETWEEN_REQUESTS, + FUNCTION_CALLING_DEBUG, + FUNCTION_CALLING_MODE, +) +from models import ClientDisconnectedError + +# fmt: on + + +class NativeFunctionCallingError(Exception): + """Raised when native function calling fails and fallback may be needed.""" + + pass + + +@dataclass +class FunctionCallingState: + """Tracks the state of function calling for a request. + + Attributes: + mode: The effective mode being used for this request. + native_enabled: Whether native mode was successfully enabled. + tools_configured: Whether tools were configured in native mode. + fallback_used: Whether fallback to emulated mode was used. + error_message: Any error message from native mode attempts. + tools_digest: Digest of tool definitions for caching. + cache_hit: Whether cache was used to skip UI operations. + """ + + mode: FunctionCallingMode + native_enabled: bool = False + tools_configured: bool = False + fallback_used: bool = False + error_message: Optional[str] = None + tools_digest: Optional[str] = None + cache_hit: bool = False + + +class FunctionCallingOrchestrator: + """ + Central orchestrator for function calling that handles mode selection, + tool configuration, and response processing. + + Usage: + orchestrator = FunctionCallingOrchestrator() + + # Before sending prompt + state = await orchestrator.prepare_request( + tools=request.tools, + tool_choice=request.tool_choice, + page_controller=page_controller, + check_client_disconnected=check_fn, + req_id=req_id, + ) + + # After receiving response (for non-streaming) + processed = await orchestrator.process_response( + raw_content=response_content, + functions=function_data, + state=state, + ) + """ + + def __init__(self, logger: Optional[logging.Logger] = None): + """Initialize the orchestrator. + + Args: + logger: Optional logger instance. If None, uses default logger. + """ + self.logger = logger or logging.getLogger("AIStudioProxyServer") + self._config = FunctionCallingConfig.from_settings() + self._schema_converter = SchemaConverter() + self._response_formatter = ResponseFormatter() + self._cache = FunctionCallingCache.get_instance(self.logger) + + @property + def config(self) -> FunctionCallingConfig: + """Get the current function calling configuration.""" + return self._config + + @property + def response_formatter(self) -> ResponseFormatter: + """Get the response formatter instance.""" + return self._response_formatter + + @property + def cache(self) -> FunctionCallingCache: + """Get the function calling cache instance.""" + return self._cache + + async def _ensure_fc_disabled_when_no_tools( + self, + page_controller: PageController, + check_client_disconnected: Callable, + req_id: str, + ) -> None: + """Ensure function calling toggle is disabled when no tools are provided. + + This handles the edge case where a previous request enabled FC, + but the current request (e.g., XML-based tools) doesn't use OpenAI-format tools. + We need to disable the toggle to prevent interference. + + Args: + page_controller: PageController instance for browser automation. + check_client_disconnected: Callback to check client connection. + req_id: Request ID for logging. + """ + # Only check/disable if we're in native or auto mode + # In emulated mode, the toggle should never have been enabled by us + if self._config.mode == FunctionCallingMode.EMULATED: + return + + try: + # Check if FC toggle is currently enabled + is_enabled = await page_controller.is_function_calling_enabled( + check_client_disconnected, use_cache=True + ) + + if is_enabled: + if FUNCTION_CALLING_DEBUG: + self.logger.info( + f"[{req_id}] [FC] No tools in request but FC toggle is enabled - disabling for clean state" + ) + start_time = time.perf_counter() + + success = await page_controller.disable_function_calling( + check_client_disconnected + ) + + elapsed = time.perf_counter() - start_time + + if success: + # Invalidate cache since state changed + self._cache.invalidate(reason="no_tools_cleanup", req_id=req_id) + if FUNCTION_CALLING_DEBUG: + self.logger.info( + f"[{req_id}] [FC:Perf] FC toggle disabled in {elapsed:.2f}s" + ) + else: + if FUNCTION_CALLING_DEBUG: + self.logger.warning( + f"[{req_id}] [FC] Failed to disable FC toggle - may affect response" + ) + + except ClientDisconnectedError: + # Client gone, nothing to do + pass + except asyncio.CancelledError: + # Cancelled, skip cleanup + pass + except Exception as e: + # Non-fatal error - log and continue + # The request can still proceed, just with FC potentially enabled + if FUNCTION_CALLING_DEBUG: + self.logger.warning( + f"[{req_id}] [FC] Error checking/disabling FC toggle: {e}" + ) + + def should_use_native_mode( + self, + tools: Optional[List[Dict[str, Any]]], + tool_choice: Optional[Union[str, Dict[str, Any]]], + ) -> bool: + """Determine if native mode should be attempted for this request. + + Args: + tools: List of tool definitions from the request. + tool_choice: Tool choice parameter from the request. + + Returns: + True if native mode should be attempted, False otherwise. + """ + # No tools = no need for native mode + if not tools or len(tools) == 0: + return False + + # Check mode setting + mode = self._config.mode + + # Emulated mode always uses text injection + if mode == FunctionCallingMode.EMULATED: + if self._config.debug: + self.logger.debug("FC: Mode is EMULATED, using prompt injection") + return False + + # Native and auto modes should try native + if mode in (FunctionCallingMode.NATIVE, FunctionCallingMode.AUTO): + if self._config.debug: + self.logger.debug( + f"FC: Mode is {mode.value}, attempting native UI automation" + ) + return True + + if self._config.debug: + self.logger.debug(f"FC: Mode {mode} unknown, defaulting to False") + return False + + def get_effective_mode( + self, + tools: Optional[List[Dict[str, Any]]], + ) -> FunctionCallingMode: + """Get the effective function calling mode for a request. + + Args: + tools: List of tool definitions from the request. + + Returns: + The effective FunctionCallingMode to use. + """ + if not tools or len(tools) == 0: + return FunctionCallingMode.EMULATED + + return self._config.mode + + async def prepare_request( + self, + tools: Optional[List[Dict[str, Any]]], + tool_choice: Optional[Union[str, Dict[str, Any]]], + page_controller: PageController, + check_client_disconnected: Callable, + req_id: str, + model_name: Optional[str] = None, + ) -> FunctionCallingState: + """Prepare a request for function calling based on the configured mode. + + This method: + 1. Computes tool digest and checks cache for existing state + 2. If cache valid: skips UI automation (cache hit) + 3. If cache miss: converts tools and configures browser UI + 4. Updates cache on success + 5. Handles fallback if native mode fails and fallback is enabled + 6. If no tools provided but FC was previously enabled, disables it + + Args: + tools: List of OpenAI-format tool definitions. + tool_choice: Tool choice parameter (auto, none, required, or specific). + page_controller: PageController instance for browser automation. + check_client_disconnected: Callback to check client connection. + req_id: Request ID for logging. + model_name: Optional model name for cache validation. + + Returns: + FunctionCallingState with the configuration result. + """ + total_start = time.perf_counter() + state = FunctionCallingState(mode=self.get_effective_mode(tools)) + + # No tools provided - ensure FC toggle is disabled if it was previously enabled + if not tools or len(tools) == 0: + await self._ensure_fc_disabled_when_no_tools( + page_controller=page_controller, + check_client_disconnected=check_client_disconnected, + req_id=req_id, + ) + if self._config.debug: + self.logger.debug( + f"[{req_id}] [FC] No tools in request, FC setup skipped/disabled" + ) + return state + + if state.mode == FunctionCallingMode.EMULATED: + if self._config.debug: + self.logger.debug( + f"[{req_id}] [FC] Using emulated mode, skipping native FC setup" + ) + if FUNCTION_CALLING_DEBUG: + fc_logger.log_mode_selection(req_id, "emulated", "configured mode") + return state + + # Native or Auto mode - compute digest and check cache + tools_digest = self._cache.compute_tools_digest(tools) + state.tools_digest = tools_digest + + # Check cache first + if self._cache.is_cache_valid(tools_digest, model_name, req_id=req_id): + cached_state = self._cache.get_cached_state() + if ( + cached_state + and cached_state.declarations_set + and cached_state.toggle_enabled + ): + # Cache HIT - but UI toggle may have been reset by new_chat + # Verify and re-enable toggle if needed before trusting cache + try: + # Check actual UI toggle state (bypass instance cache) + toggle_enabled = await page_controller.is_function_calling_enabled( + check_client_disconnected, use_cache=False + ) + if not toggle_enabled: + if FUNCTION_CALLING_DEBUG: + self.logger.warning( + f"[{req_id}] [FC:Cache] HIT but UI toggle disabled - re-enabling" + ) + enable_success = await page_controller.enable_function_calling( + check_client_disconnected + ) + if not enable_success: + if FUNCTION_CALLING_DEBUG: + self.logger.warning( + f"[{req_id}] [FC:Cache] Failed to re-enable toggle, " + "falling through to full setup" + ) + # Fall through to full native configuration + else: + elapsed = time.perf_counter() - total_start + if FUNCTION_CALLING_DEBUG: + self.logger.info( + f"[{req_id}] [FC:Cache] HIT - toggle re-enabled " + f"(digest={tools_digest[:8]}..., checked in {elapsed:.3f}s)" + ) + state.native_enabled = True + state.tools_configured = True + state.cache_hit = True + return state + else: + elapsed = time.perf_counter() - total_start + if FUNCTION_CALLING_DEBUG: + self.logger.info( + f"[{req_id}] [FC:Cache] HIT - skipping native FC setup " + f"(digest={tools_digest[:8]}..., checked in {elapsed:.3f}s)" + ) + state.native_enabled = True + state.tools_configured = True + state.cache_hit = True + return state + except ClientDisconnectedError: + raise + except Exception as e: + if FUNCTION_CALLING_DEBUG: + self.logger.warning( + f"[{req_id}] [FC:Cache] Toggle verification failed: {e}, " + "falling through to full setup" + ) + # Fall through to full native configuration + + # Cache miss - proceed with native configuration + if FUNCTION_CALLING_DEBUG: + self.logger.info( + f"[{req_id}] [FC] Configuring native function calling with {len(tools)} tool(s) " + f"(digest={tools_digest[:8]}...)" + ) + if FUNCTION_CALLING_DEBUG: + fc_logger.log_mode_selection( + req_id, "native", f"cache_miss, {len(tools)} tools" + ) + + # Log tool choice if specific + if tool_choice: + if isinstance(tool_choice, dict): + forced_fn = tool_choice.get("function", {}).get( + "name" + ) or tool_choice.get("name") + if forced_fn: + if FUNCTION_CALLING_DEBUG: + self.logger.info( + f"[{req_id}] [FC] Tool choice: FORCING specific tool '{forced_fn}'" + ) + elif isinstance(tool_choice, str) and tool_choice.lower() not in ( + "auto", + "none", + "required", + ): + if FUNCTION_CALLING_DEBUG: + self.logger.info( + f"[{req_id}] [FC] Tool choice: FORCING specific tool '{tool_choice}'" + ) + + try: + # Convert OpenAI tools to Gemini format + convert_start = time.perf_counter() + gemini_declarations = self._schema_converter.convert_tools(tools) + convert_elapsed = time.perf_counter() - convert_start + + if self._config.debug: + self.logger.debug( + f"[{req_id}] [FC:Perf] Converted {len(tools)} tools to Gemini format " + f"in {convert_elapsed:.3f}s" + ) + if FUNCTION_CALLING_DEBUG: + fc_logger.log_schema_conversion( + req_id, tool_count=len(tools), elapsed_ms=convert_elapsed * 1000 + ) + + # Retry loop for UI automation + last_error: Optional[Exception] = None + for attempt in range(1, self._config.native_retry_count + 1): + try: + if attempt > 1: + if FUNCTION_CALLING_DEBUG: + self.logger.warning( + f"[{req_id}] [FC:UI] Retry attempt {attempt}/{self._config.native_retry_count}" + ) + + check_client_disconnected(f"FC prepare attempt {attempt}") + + # Check if function calling is available + fc_available = await page_controller.is_function_calling_available( + check_client_disconnected + ) + + if not fc_available: + raise NativeFunctionCallingError( + "Function calling UI not available for this model" + ) + + # Set function declarations via UI (with caching support) + success = await page_controller.set_function_declarations( + gemini_declarations, + check_client_disconnected, + tools_digest=tools_digest, + model_name=model_name, + tools=tools, + ) + + if success: + state.native_enabled = True + state.tools_configured = True + total_elapsed = time.perf_counter() - total_start + if FUNCTION_CALLING_DEBUG: + self.logger.info( + f"[{req_id}] [FC:Perf] Native function calling configured " + f"in {total_elapsed:.2f}s" + ) + if FUNCTION_CALLING_DEBUG: + fc_logger.info( + FCModule.ORCHESTRATOR, + f"Native FC configured successfully in {total_elapsed:.2f}s", + req_id=req_id, + ) + return state + else: + raise NativeFunctionCallingError( + "Failed to set function declarations in UI" + ) + + except ClientDisconnectedError: + raise + except asyncio.CancelledError: + raise + except Exception as e: + last_error = e + if FUNCTION_CALLING_DEBUG: + self.logger.warning( + f"[{req_id}] [FC] Native FC attempt {attempt}/{self._config.native_retry_count} failed: {e}" + ) + if attempt < self._config.native_retry_count: + await asyncio.sleep(0.5) + + # All retries failed + raise NativeFunctionCallingError( + f"Native function calling failed after {self._config.native_retry_count} attempts: {last_error}" + ) + + except SchemaConversionError as e: + state.error_message = f"Schema conversion error: {e}" + if FUNCTION_CALLING_DEBUG: + self.logger.error(f"[{req_id}] [FC] {state.error_message}") + + # Schema errors are not recoverable - don't fallback + if state.mode == FunctionCallingMode.NATIVE: + raise + + # Auto mode with schema error - fall through to emulated + state.fallback_used = True + state.mode = FunctionCallingMode.EMULATED + if FUNCTION_CALLING_DEBUG: + self.logger.warning( + f"[{req_id}] [FC] Falling back to emulated mode due to schema error" + ) + if FUNCTION_CALLING_DEBUG: + fc_logger.log_mode_selection( + req_id, "emulated", "fallback_schema_error" + ) + return state + + except NativeFunctionCallingError as e: + state.error_message = str(e) + if FUNCTION_CALLING_DEBUG: + self.logger.warning( + f"[{req_id}] [FC] Native function calling failed: {e}" + ) + + # Check if fallback is allowed + if state.mode == FunctionCallingMode.AUTO and self._config.native_fallback: + state.fallback_used = True + state.mode = FunctionCallingMode.EMULATED + if FUNCTION_CALLING_DEBUG: + self.logger.info( + f"[{req_id}] [FC] Falling back to emulated mode for function calling" + ) + if FUNCTION_CALLING_DEBUG: + fc_logger.log_mode_selection( + req_id, "emulated", "fallback_native_error" + ) + return state + elif state.mode == FunctionCallingMode.NATIVE: + # Native mode with no fallback - raise error + raise + + except ClientDisconnectedError: + raise + except asyncio.CancelledError: + raise + except Exception as e: + state.error_message = f"Unexpected error: {e}" + if FUNCTION_CALLING_DEBUG: + self.logger.error( + f"[{req_id}] [FC] Unexpected error in FC prepare: {e}" + ) + + if state.mode == FunctionCallingMode.AUTO and self._config.native_fallback: + state.fallback_used = True + state.mode = FunctionCallingMode.EMULATED + return state + elif state.mode == FunctionCallingMode.NATIVE: + raise + + return state + + async def cleanup_after_request( + self, + state: FunctionCallingState, + page_controller: PageController, + check_client_disconnected: Callable, + req_id: str, + preserve_cache: bool = False, + ) -> None: + """Clean up function calling state after a request completes. + + If configured to clear between requests and native mode was used, + this will clear the function declarations from the UI. + + Args: + state: The function calling state from prepare_request. + page_controller: PageController instance for browser automation. + check_client_disconnected: Callback to check client connection. + req_id: Request ID for logging. + preserve_cache: If True, don't invalidate cache (for same-tool sequences). + """ + if not FUNCTION_CALLING_CLEAR_BETWEEN_REQUESTS: + if self._config.debug: + self.logger.debug( + f"[{req_id}] [FC] Skipping cleanup (CLEAR_BETWEEN_REQUESTS=False)" + ) + return + + if not state.tools_configured: + return + + # If this was a cache hit, we didn't actually change anything + if state.cache_hit: + if self._config.debug: + self.logger.debug( + f"[{req_id}] [FC] Skipping cleanup (cache hit, no UI changes made)" + ) + return + + try: + start_time = time.perf_counter() + # Pass invalidate_cache=not preserve_cache to control cache behavior + await page_controller.clear_function_declarations( + check_client_disconnected, + invalidate_cache=not preserve_cache, + ) + elapsed = time.perf_counter() - start_time + + if self._config.debug: + self.logger.debug( + f"[{req_id}] [FC:Perf] Cleared function declarations in {elapsed:.2f}s" + ) + except ClientDisconnectedError: + pass # Client gone, nothing to clean up + except asyncio.CancelledError: + pass # Cancelled, skip cleanup + except Exception as e: + if FUNCTION_CALLING_DEBUG: + self.logger.warning( + f"[{req_id}] [FC] Failed to clear function declarations: {e}" + ) + + def format_function_calls_for_response( + self, + functions: List[Dict[str, Any]], + content: Optional[str] = None, + ) -> Tuple[Dict[str, Any], str]: + """Format function call data from AI Studio into OpenAI response format. + + Args: + functions: List of function call data from AI Studio. + Each item should have 'name' and 'params' keys. + content: Optional text content to include in the response. + + Returns: + Tuple of (message_dict, finish_reason). + """ + if not functions: + return {"role": "assistant", "content": content or ""}, "stop" + + parsed_calls: List[ParsedFunctionCall] = [] + for func_data in functions: + if isinstance(func_data, dict): + name = func_data.get("name", "") + params = func_data.get("params", {}) + if name: + parsed_calls.append(ParsedFunctionCall(name=name, arguments=params)) + + if not parsed_calls: + return {"role": "assistant", "content": content or ""}, "stop" + + message = self._response_formatter.format_non_streaming_response( + parsed_calls, content=None + ) + finish_reason = get_finish_reason(True) + + return message, finish_reason + + def format_streaming_tool_calls( + self, + functions: List[Dict[str, Any]], + chunk_size: int = 50, + ) -> List[Dict[str, Any]]: + """Format function calls for streaming response. + + Generates all the delta chunks needed to stream function calls. + + Args: + functions: List of function call data. + chunk_size: Size of each arguments chunk. + + Returns: + List of delta objects for streaming. + """ + if not functions: + return [] + + all_chunks: List[Dict[str, Any]] = [] + for idx, func_data in enumerate(functions): + if not isinstance(func_data, dict): + continue + + name = func_data.get("name", "") + params = func_data.get("params", {}) + + if not name: + continue + + parsed = ParsedFunctionCall(name=name, arguments=params) + chunks = self._response_formatter.format_streaming_chunks( + index=idx, parsed_call=parsed, chunk_size=chunk_size + ) + all_chunks.extend(chunks) + + return all_chunks + + +# Module-level singleton for convenience +_orchestrator: Optional[FunctionCallingOrchestrator] = None + + +def get_function_calling_orchestrator() -> FunctionCallingOrchestrator: + """Get or create the global function calling orchestrator instance.""" + global _orchestrator + if _orchestrator is None: + _orchestrator = FunctionCallingOrchestrator() + return _orchestrator + + +def reset_orchestrator() -> None: + """Reset the global orchestrator (useful for testing).""" + global _orchestrator + _orchestrator = None + + +# ============================================================================= +# Convenience Functions for Request Processing Integration +# ============================================================================= + + +def should_skip_tool_injection( + tools: Optional[List[Dict[str, Any]]], + fc_state: Optional[FunctionCallingState] = None, +) -> bool: + """Determine if tool catalog injection should be skipped. + + In native mode, the tool catalog is configured via UI automation, + so we should skip injecting it into the prompt text. + + When fc_state is provided (from prepare_request), it takes precedence + over static config to handle AUTO mode fallback correctly. + + Args: + tools: List of tool definitions from the request. + fc_state: Optional dynamic state from FunctionCallingOrchestrator. + If provided, uses the actual resolved mode (handles fallback). + + Returns: + True if tool injection should be skipped (native mode successfully configured), + False if tool catalog should be injected (emulated mode or fallback). + """ + if not tools or len(tools) == 0: + return True # No tools, nothing to inject anyway + + # If we have dynamic state from the orchestrator, use it + # This correctly handles AUTO mode fallback scenarios + if fc_state is not None: + # Only skip injection if native mode was successfully configured + if fc_state.native_enabled and fc_state.tools_configured: + return True + # If fallback was used, we need to inject tools + if fc_state.fallback_used: + return False + # If mode is explicitly EMULATED (either configured or after fallback) + if fc_state.mode == FunctionCallingMode.EMULATED: + return False + # Native/Auto mode attempted but tools not configured - inject as fallback + if not fc_state.tools_configured: + return False + return True + + # Fall back to static config check (backwards compatibility) + mode_str = FUNCTION_CALLING_MODE.lower() + + # In emulated mode, always inject tools into prompt + if mode_str == "emulated": + return False + + # In native or auto mode, ONLY skip if we're sure native mode is being used. + # If fc_state is None, we don't know the dynamic state, so we default to + # injecting tools into the prompt for safety (backwards compatibility). + return False + + +def get_effective_function_calling_mode() -> FunctionCallingMode: + """Get the currently configured function calling mode. + + Returns: + The FunctionCallingMode enum value. + """ + mode_str = FUNCTION_CALLING_MODE.lower() + try: + return FunctionCallingMode(mode_str) + except ValueError: + return FunctionCallingMode.EMULATED + + +__all__ = [ + "FunctionCallingOrchestrator", + "FunctionCallingState", + "NativeFunctionCallingError", + "get_function_calling_orchestrator", + "reset_orchestrator", + "should_skip_tool_injection", + "get_effective_function_calling_mode", +] diff --git a/api_utils/utils_ext/helper.py b/api_utils/utils_ext/helper.py new file mode 100644 index 0000000000000000000000000000000000000000..1125452638f4ca7283fdf1dfefe64d5767424a80 --- /dev/null +++ b/api_utils/utils_ext/helper.py @@ -0,0 +1,34 @@ +import asyncio +from typing import AsyncGenerator + + +async def use_helper_get_response( + helper_endpoint: str, helper_sapisid: str +) -> AsyncGenerator[str, None]: + import aiohttp + + from api_utils.server_state import state + + logger = state.logger + + logger.info(f"Attempting to use Helper endpoint: {helper_endpoint}") + + try: + async with aiohttp.ClientSession() as session: + headers = { + "Content-Type": "application/json", + "Cookie": f"SAPISID={helper_sapisid}" if helper_sapisid else "", + } + async with session.get(helper_endpoint, headers=headers) as response: + if response.status == 200: + async for chunk in response.content.iter_chunked(1024): + if chunk: + yield chunk.decode("utf-8", errors="ignore") + else: + logger.error( + f"Helper endpoint returned error status: {response.status}" + ) + except asyncio.CancelledError: + raise + except Exception as e: + logger.error(f"Error using Helper endpoint: {e}") diff --git a/api_utils/utils_ext/prompts.py b/api_utils/utils_ext/prompts.py new file mode 100644 index 0000000000000000000000000000000000000000..40527dffd8368eebc9758a71a82b9047806d9ede --- /dev/null +++ b/api_utils/utils_ext/prompts.py @@ -0,0 +1,630 @@ +import base64 +import json +import logging +import os +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple, Union, cast +from urllib.parse import unquote, urlparse + +from api_utils.utils_ext.files import extract_data_url_to_local, save_blob_to_local +from api_utils.utils_ext.function_calling_orchestrator import should_skip_tool_injection +from logging_utils import set_request_id +from models import Message + +if TYPE_CHECKING: + from api_utils.utils_ext.function_calling_orchestrator import FunctionCallingState + + +def prepare_combined_prompt( + messages: List[Message], + req_id: str, + tools: Optional[List[Dict[str, Any]]] = None, + tool_choice: Optional[Union[str, Dict[str, Any]]] = None, + fc_state: Optional["FunctionCallingState"] = None, +) -> Tuple[str, List[str]]: + """Prepare combined prompt""" + logger = logging.getLogger("AIStudioProxyServer") + set_request_id(req_id) + + # Track summary stats for consolidated logging + _has_system_prompt = False + _msg_count = len(messages) + # Do not clear upload_files here; it is cleared by the upper layer at the start of each request as needed + # to avoid "file not found" errors caused by loss of historical attachments. + + combined_parts: List[str] = [] + system_prompt_content: Optional[str] = None + processed_system_message_indices: Set[int] = set() + files_list: List[ + str + ] = [] # Collect local file paths to be uploaded (images, videos, PDFs, etc.) + + # If available tools are declared, inject the tool catalog before the prompt to help the model know available functions + # Skip injection when using native function calling mode (tools configured via UI) + # Pass fc_state to handle AUTO mode fallback correctly + if isinstance(tools, list) and len(tools) > 0: + if should_skip_tool_injection(tools, fc_state=fc_state): + logger.debug( + f"[{req_id}] Skipping tool catalog injection - native mode active and configured" + ) + else: + try: + tool_lines: List[str] = ["Available Tools Catalog:"] + for t in tools: + name: Optional[str] = None + params_schema: Optional[Dict[str, Any]] = None + # t is Dict[str, Any] from List[Dict[str, Any]] + fn_val: Any = t.get("function") if "function" in t else t + if isinstance(fn_val, dict): + # Type narrowed: fn_val is dict + typed_fn: Dict[str, Any] = cast(Dict[str, Any], fn_val) + name_raw: Any = typed_fn.get("name") or t.get("name") + if isinstance(name_raw, str): + name = name_raw + params_raw: Any = typed_fn.get("parameters") + if isinstance(params_raw, dict): + params_schema = cast(Dict[str, Any], params_raw) + else: + # fn_val is not dict, get name directly from t + name_raw: Any = t.get("name") + if isinstance(name_raw, str): + name = name_raw + if name: + tool_lines.append(f"- Function: {name}") + if params_schema: + try: + tool_lines.append( + f" Parameter Schema: {json.dumps(params_schema, ensure_ascii=False)}" + ) + except Exception: + pass + if tool_choice: + # Explicitly request or suggest callable function name + chosen_name: Optional[str] = None + if isinstance(tool_choice, dict): + # Type narrowed to dict by isinstance + typed_tool_choice: Dict[str, Any] = tool_choice + fn_val: Any = typed_tool_choice.get("function") + if isinstance(fn_val, dict): + # Standard format: {"type": "function", "function": {"name": "..."}} + typed_fn: Dict[str, Any] = cast(Dict[str, Any], fn_val) + name_raw: Any = typed_fn.get("name") + if isinstance(name_raw, str): + chosen_name = name_raw + elif "name" in typed_tool_choice: + # Flat format: {"type": "function", "name": "..."} + name_raw = typed_tool_choice.get("name") + if isinstance(name_raw, str): + chosen_name = name_raw + elif tool_choice.lower() not in ( + "auto", + "none", + "no", + "off", + "required", + "any", + ): + chosen_name = tool_choice + if chosen_name: + tool_lines.append(f"Recommended function to use: {chosen_name}") + combined_parts.append("\n".join(tool_lines) + "\n---\n") + except Exception: + pass + + # Process system messages + for i, msg in enumerate(messages): + if msg.role == "system": + content = msg.content + if isinstance(content, str) and content.strip(): + system_prompt_content = content.strip() + processed_system_message_indices.add(i) + _has_system_prompt = True + logger.debug( + f"Found system prompt at index {i}: {system_prompt_content[:80]}..." + ) + system_instr_prefix = "System Instructions:\n" + combined_parts.append(f"{system_instr_prefix}{system_prompt_content}") + else: + logger.debug(f"Ignoring empty system message at index {i}") + processed_system_message_indices.add(i) + break + + role_map_ui = { + "user": "User", + "assistant": "Assistant", + "system": "System", + "tool": "Tool", + } + turn_separator = "\n---\n" + + # Process other messages + for i, msg in enumerate(messages): + if i in processed_system_message_indices: + continue + + if msg.role == "system": + logger.debug(f"Skipping subsequent system message at index {i}") + continue + + if combined_parts: + combined_parts.append(turn_separator) + + role = msg.role or "unknown" + role_prefix_ui = f"{role_map_ui.get(role, role.capitalize())}:\n" + current_turn_parts: List[str] = [role_prefix_ui] + + content = msg.content or "" + content_str: str = "" + + if isinstance(content, str): + content_str = content.strip() + elif isinstance(content, list): + # Process multimodal content + text_parts: List[str] = [] + for item in content: + # Get item type + item_type: Optional[str] = None + try: + # Guard against property exceptions when using hasattr/getattr + if hasattr(item, "type"): + item_type = item.type + except Exception: + item_type = None + + if item_type is None and isinstance(item, dict): + typed_item: Dict[str, Any] = cast(Dict[str, Any], item) + item_type_raw: Any = typed_item.get("type") + if isinstance(item_type_raw, str): + item_type = item_type_raw + + if item_type == "text": + # Text item + if hasattr(item, "text"): + text_parts.append(getattr(item, "text", "") or "") + elif isinstance(item, dict): + typed_item: Dict[str, Any] = cast(Dict[str, Any], item) + text_raw: Any = typed_item.get("text", "") + text_parts.append(str(text_raw)) + continue + + # Image/File/Media URL item + if item_type in ( + "image_url", + "file_url", + "media_url", + "input_image", + ) or ( + isinstance(item, dict) + and ( + "image_url" in item + or "input_image" in item + or "file_url" in item + or "media_url" in item + or "url" in item + ) + ): + try: + url_value: Optional[str] = None + # Pydantic object attributes + if hasattr(item, "image_url") and item.image_url: + url_value = item.image_url.url + try: + detail_val: Optional[str] = getattr( + item.image_url, "detail", None + ) + if detail_val: + text_parts.append( + f"[Image Details: detail={detail_val}]" + ) + except Exception: + pass + elif hasattr(item, "input_image") and item.input_image: + url_value = item.input_image.url + try: + detail_val: Optional[str] = getattr( + item.input_image, "detail", None + ) + if detail_val: + text_parts.append( + f"[Image Details: detail={detail_val}]" + ) + except Exception: + pass + elif hasattr(item, "file_url") and item.file_url: + url_value = item.file_url.url + elif hasattr(item, "media_url") and item.media_url: + url_value = item.media_url.url + elif hasattr(item, "url") and item.url: + url_value = item.url + # Dictionary structure (backwards compatibility) + if url_value is None and isinstance(item, dict): + typed_item: Dict[str, Any] = cast(Dict[str, Any], item) + image_url_raw: Any = typed_item.get("image_url") + input_image_raw: Any = typed_item.get("input_image") + + if isinstance(image_url_raw, dict): + typed_img_url: Dict[str, Any] = cast( + Dict[str, Any], image_url_raw + ) + url_raw: Any = typed_img_url.get("url") + if isinstance(url_raw, str): + url_value = url_raw + detail_raw: Any = typed_img_url.get("detail") + if isinstance(detail_raw, str): + text_parts.append( + f"[Image Details: detail={detail_raw}]" + ) + elif isinstance(image_url_raw, str): + url_value = image_url_raw + elif isinstance(input_image_raw, dict): + typed_input_img: Dict[str, Any] = cast( + Dict[str, Any], input_image_raw + ) + url_raw: Any = typed_input_img.get("url") + if isinstance(url_raw, str): + url_value = url_raw + detail_raw: Any = typed_input_img.get("detail") + if isinstance(detail_raw, str): + text_parts.append( + f"[Image Details: detail={detail_raw}]" + ) + elif isinstance(input_image_raw, str): + url_value = input_image_raw + else: + # Check other URL fields + file_url_raw: Any = typed_item.get("file_url") + media_url_raw: Any = typed_item.get("media_url") + file_raw: Any = typed_item.get("file") + + if isinstance(file_url_raw, dict): + typed_file_url: Dict[str, Any] = cast( + Dict[str, Any], file_url_raw + ) + url_raw: Any = typed_file_url.get("url") + if isinstance(url_raw, str): + url_value = url_raw + elif isinstance(file_url_raw, str): + url_value = file_url_raw + elif isinstance(media_url_raw, dict): + typed_media_url: Dict[str, Any] = cast( + Dict[str, Any], media_url_raw + ) + url_raw: Any = typed_media_url.get("url") + if isinstance(url_raw, str): + url_value = url_raw + elif isinstance(media_url_raw, str): + url_value = media_url_raw + elif "url" in typed_item: + url_raw: Any = typed_item.get("url") + if isinstance(url_raw, str): + url_value = url_raw + elif isinstance(file_raw, dict): + # Compatible with general file field + typed_file: Dict[str, Any] = cast( + Dict[str, Any], file_raw + ) + url_raw: Any = typed_file.get( + "url" + ) or typed_file.get("path") + if isinstance(url_raw, str): + url_value = url_raw + + url_value = (url_value or "").strip() + if not url_value: + continue + + # Normalize to local file list and log + if url_value.startswith("data:"): + file_path = extract_data_url_to_local( + url_value, req_id=req_id + ) + if file_path: + files_list.append(file_path) + logger.debug( + f"(Prepare Prompt) Identified and added data:URL attachment: {file_path}" + ) + elif url_value.startswith("file:"): + parsed = urlparse(url_value) + local_path = unquote(parsed.path) + if os.path.exists(local_path): + files_list.append(local_path) + logger.debug( + f"(Prepare Prompt) Identified and added local attachment (file://): {local_path}" + ) + else: + logger.warning( + f"(Prepare Prompt) Local file pointed to by file URL does not exist: {local_path}" + ) + elif os.path.isabs(url_value) and os.path.exists(url_value): + files_list.append(url_value) + logger.debug( + f"(Prepare Prompt) Identified and added local attachment (absolute path): {url_value}" + ) + else: + logger.debug( + f"(Prepare Prompt) Ignoring non-local attachment URL: {url_value}" + ) + except Exception as e: + logger.warning( + f"(Prepare Prompt) Error processing attachment URL: {e}" + ) + continue + + # Audio/Video input + if item_type in ("input_audio", "input_video"): + try: + inp: Any = None + if hasattr(item, "input_audio") and item.input_audio: + inp = item.input_audio + elif hasattr(item, "input_video") and item.input_video: + inp = item.input_video + elif isinstance(item, dict): + typed_item: Dict[str, Any] = cast(Dict[str, Any], item) + inp = typed_item.get("input_audio") or typed_item.get( + "input_video" + ) + + if inp: + url_value: Optional[str] = None + data_val: Optional[str] = None + mime_val: Optional[str] = None + fmt_val: Optional[str] = None + if isinstance(inp, dict): + typed_inp: Dict[str, Any] = cast(Dict[str, Any], inp) + url_raw: Any = typed_inp.get("url") + if isinstance(url_raw, str): + url_value = url_raw + data_raw: Any = typed_inp.get("data") + if isinstance(data_raw, str): + data_val = data_raw + mime_raw: Any = typed_inp.get("mime_type") + if isinstance(mime_raw, str): + mime_val = mime_raw + fmt_raw: Any = typed_inp.get("format") + if isinstance(fmt_raw, str): + fmt_val = fmt_raw + else: + # Pydantic model or object with attributes + url_attr: Any = getattr(inp, "url", None) + if isinstance(url_attr, str): + url_value = url_attr + data_attr: Any = getattr(inp, "data", None) + if isinstance(data_attr, str): + data_val = data_attr + mime_attr: Any = getattr(inp, "mime_type", None) + if isinstance(mime_attr, str): + mime_val = mime_attr + fmt_attr: Any = getattr(inp, "format", None) + if isinstance(fmt_attr, str): + fmt_val = fmt_attr + + if url_value: + if url_value.startswith("data:"): + saved = extract_data_url_to_local( + url_value, req_id=req_id + ) + if saved: + files_list.append(saved) + logger.debug( + f"(Prepare Prompt) Identified and added audio/video data:URL attachment: {saved}" + ) + elif url_value.startswith("file:"): + parsed = urlparse(url_value) + local_path = unquote(parsed.path) + if os.path.exists(local_path): + files_list.append(local_path) + logger.debug( + f"(Prepare Prompt) Identified and added local audio/video attachment (file://): {local_path}" + ) + elif os.path.isabs(url_value) and os.path.exists( + url_value + ): + files_list.append(url_value) + logger.debug( + f"(Prepare Prompt) Identified and added local audio/video attachment (absolute path): {url_value}" + ) + elif data_val: + if isinstance(data_val, str) and data_val.startswith( + "data:" + ): + saved = extract_data_url_to_local( + data_val, req_id=req_id + ) + if saved: + files_list.append(saved) + logger.debug( + f"(Prepare Prompt) Identified and added audio/video data:URL attachment: {saved}" + ) + else: + # Treat as pure base64 data + try: + raw = base64.b64decode(data_val) + saved = save_blob_to_local( + raw, mime_val, fmt_val, req_id=req_id + ) + if saved: + files_list.append(saved) + logger.debug( + f"(Prepare Prompt) Identified and added audio/video base64 attachment: {saved}" + ) + except Exception: + pass + except Exception as e: + logger.warning( + f"(Prepare Prompt) Error processing audio/video input: {e}" + ) + continue + + # Other unknown items: log without affecting + logger.warning( + f"(Prepare Prompt) Warning: Ignoring non-text or unknown type content item in message at index {i}" + ) + content_str = "\n".join(text_parts).strip() + elif isinstance(content, dict): + # Compatible with dictionary format content, may contain 'attachments'/'images'/'media'/'files' + typed_content: Dict[str, Any] = cast(Dict[str, Any], content) + text_parts = [] + attachments_keys = ["attachments", "images", "media", "files"] + for key in attachments_keys: + items: Any = typed_content.get(key) + if isinstance(items, list): + for it in items: + url_value: Optional[str] = None + if isinstance(it, str): + url_value = it + elif isinstance(it, dict): + typed_it: Dict[str, Any] = cast(Dict[str, Any], it) + url_raw: Any = typed_it.get("url") or typed_it.get("path") + if isinstance(url_raw, str): + url_value = url_raw + if not url_value: + image_url_raw: Any = typed_it.get("image_url") + input_image_raw: Any = typed_it.get("input_image") + if isinstance(image_url_raw, dict): + typed_img_url: Dict[str, Any] = cast( + Dict[str, Any], image_url_raw + ) + url_from_image: Any = typed_img_url.get("url") + if isinstance(url_from_image, str): + url_value = url_from_image + elif isinstance(input_image_raw, dict): + typed_input_img: Dict[str, Any] = cast( + Dict[str, Any], input_image_raw + ) + url_from_input: Any = typed_input_img.get("url") + if isinstance(url_from_input, str): + url_value = url_from_input + if not url_value: + continue + url_value = url_value.strip() + if not url_value: + continue + if url_value.startswith("data:"): + fp = extract_data_url_to_local(url_value) + if fp: + files_list.append(fp) + logger.debug( + f"(Prepare Prompt) Identified and added dict attachment data:URL: {fp}" + ) + elif url_value.startswith("file:"): + parsed = urlparse(url_value) + lp = unquote(parsed.path) + if os.path.exists(lp): + files_list.append(lp) + logger.debug( + f"(Prepare Prompt) Identified and added dict attachment file://: {lp}" + ) + elif os.path.isabs(url_value) and os.path.exists(url_value): + files_list.append(url_value) + logger.debug( + f"(Prepare Prompt) Identified and added dict attachment absolute path: {url_value}" + ) + else: + logger.debug( + f"(Prepare Prompt) Ignoring non-local URL for dict attachment: {url_value}" + ) + # Also append potential plain text description in dictionary + text_field: Any = typed_content.get("text") + if isinstance(text_field, str): + text_parts.append(text_field) + content_str = "\n".join(text_parts).strip() + else: + logger.warning( + f"(Prepare Prompt) Warning: Unexpected content type for role {role} at index {i} ({type(content)}) or is None." + ) + content_str = str(content or "").strip() + + if content_str: + current_turn_parts.append(content_str) + + # Handle tool calls (visualize only, do not execute actively here to avoid conflict with client execution in conversational loop) + tool_calls = msg.tool_calls + if role == "assistant" and tool_calls: + if content_str: + current_turn_parts.append("\n") + + tool_call_visualizations = [] + for tool_call in tool_calls: + if hasattr(tool_call, "type") and tool_call.type == "function": + function_call = tool_call.function + func_name = function_call.name if function_call else None + func_args_str = function_call.arguments if function_call else None + + try: + parsed_args = json.loads( + func_args_str if func_args_str else "{}" + ) + formatted_args = json.dumps( + parsed_args, indent=2, ensure_ascii=False + ) + except (json.JSONDecodeError, TypeError): + formatted_args = ( + func_args_str if func_args_str is not None else "{}" + ) + + tool_call_visualizations.append( + f"Request function call: {func_name}\nParameters:\n{formatted_args}" + ) + + if tool_call_visualizations: + current_turn_parts.append("\n".join(tool_call_visualizations)) + + # Handle tool result messages (role = 'tool'): include in prompt so model sees tool output + if role == "tool": + tool_result_lines: List[str] = [] + # Standard OpenAI style: content is string, tool_call_id associates with previous call + tool_call_id = getattr(msg, "tool_call_id", None) + if tool_call_id: + tool_result_lines.append(f"Tool result (tool_call_id={tool_call_id}):") + if isinstance(msg.content, str): + tool_result_lines.append(msg.content) + elif isinstance(msg.content, list): + # Compatible with few clients putting results in a list + try: + merged_parts: List[str] = [] + for it in msg.content: + if isinstance(it, dict): + if it.get("type") == "text": + text_raw = it.get("text", "") + if isinstance(text_raw, str): + merged_parts.append(text_raw) + else: + merged_parts.append(str(text_raw)) + else: + merged_parts.append(str(it)) + else: + merged_parts.append(str(it)) + merged = "\n".join(merged_parts) + tool_result_lines.append(merged) + except Exception: + tool_result_lines.append(str(msg.content)) + else: + tool_result_lines.append(str(msg.content)) + if tool_result_lines: + if content_str: + current_turn_parts.append("\n") + current_turn_parts.append("\n".join(tool_result_lines)) + + if len(current_turn_parts) > 1 or (role == "assistant" and tool_calls): + combined_parts.append("".join(current_turn_parts)) + elif not combined_parts and not current_turn_parts: + logger.debug( + f"(Prepare Prompt) Skipping empty message for role {role} at index {i} (and no tool calls)." + ) + elif len(current_turn_parts) == 1 and not combined_parts: + logger.debug( + f"(Prepare Prompt) Skipping empty message for role {role} at index {i} (prefix only)." + ) + + final_prompt = "".join(combined_parts) + if final_prompt: + final_prompt += "\n" + + # Consolidated English summary (replaces verbose Chinese logs) + sys_indicator = "Yes" if _has_system_prompt else "No" + attach_info = f", {len(files_list)} attachments" if files_list else "" + logger.debug( + f"[Prompt] Built messages: {_msg_count} (System: {sys_indicator}), " + f"Total {len(final_prompt):,} characters{attach_info}" + ) + + return final_prompt, files_list diff --git a/api_utils/utils_ext/stream.py b/api_utils/utils_ext/stream.py new file mode 100644 index 0000000000000000000000000000000000000000..41972cdaf15d7d5428b83ebe33a83a85969afb61 --- /dev/null +++ b/api_utils/utils_ext/stream.py @@ -0,0 +1,536 @@ +import asyncio +import json +import queue +import re +import time +from typing import Any, AsyncGenerator, Callable, List, Optional, Tuple + +from config.settings import FUNCTION_CALLING_DEBUG +from logging_utils import set_request_id + +# [REFAC-01] Structural Boundary Pattern +TOOL_STRUCTURE_PATTERN = re.compile( + r"(?:^|\n)\s*(?:```[a-zA-Z0-9]*\s*)?<[a-zA-Z0-9_\-]+(?:\s|>)" +) + + +async def use_stream_response( + req_id: str, + timeout: float = 5.0, + silence_threshold: float = 60.0, + page: Any = None, + check_client_disconnected: Optional[Callable] = None, + stream_start_time: float = 0.0, + enable_silence_detection: bool = True, +) -> AsyncGenerator[Any, None]: + """Enhanced stream response handler with UI-based generation active checks.""" + from api_utils.server_state import state + + STREAM_QUEUE = state.STREAM_QUEUE + logger = state.logger + from browser_utils.page_controller import PageController + from config import ( + CHAT_SESSION_CONTENT_SELECTOR, + LAST_CHAT_TURN_SELECTOR, + SCROLL_CONTAINER_SELECTOR, + UI_GENERATION_WAIT_TIMEOUT_MS, + ) + from config.global_state import GlobalState + from models import ( + ClientDisconnectedError, + QuotaExceededError, + UpstreamError, + ) + + set_request_id(req_id) + if STREAM_QUEUE is None: + logger.warning(f"[{req_id}] STREAM_QUEUE is None, cannot use stream response") + return + + if stream_start_time == 0.0: + stream_start_time = time.time() - 10.0 + + accumulated_body = "" + accumulated_reason_len = 0 + total_reason_processed = 0 + total_body_processed = 0 + boundary_transitions = 0 + boundary_buffer = "" + + acc_reason_state = "" + acc_body_state = "" + force_body_mode = False + split_index = -1 + empty_count = 0 + initial_wait_limit = int(timeout * 10) + silence_wait_limit = int(silence_threshold * 10) + max_empty_retries = max(silence_wait_limit, initial_wait_limit) + hard_timeout_limit = int(timeout * 10 * 3) + + _data_received = False + has_content = False + has_seen_functions = False + received_items_count = 0 + stale_done_ignored = False + last_ui_check_time = 0 + ui_check_interval = int(UI_GENERATION_WAIT_TIMEOUT_MS / 100) + if ui_check_interval <= 0: + ui_check_interval = 1 + + last_packet_time = time.time() + min_items_before_silence_check = 10 + + async def check_ui_generation_active(): + if not page: + return False + try: + stop_button = page.locator('button[aria-label="Stop generating"]') + if await stop_button.is_visible(timeout=1000): + return True + from config.selectors import SUBMIT_BUTTON_SELECTOR + + submit_button = page.locator(SUBMIT_BUTTON_SELECTOR) + if await submit_button.count() > 0: + try: + if await submit_button.first.is_disabled(timeout=2000): + return True + except Exception: + return False + return False + except Exception: + return False + + try: + while True: + if ( + GlobalState.CURRENT_STREAM_REQ_ID + and GlobalState.CURRENT_STREAM_REQ_ID != req_id + ): + logger.warning(f"[{req_id}] Zombie Stream detected. Terminating.") + yield { + "done": True, + "reason": "zombie_stream_aborted", + "body": "", + "function": [], + } + return + + if page: + try: + await page.evaluate( + """([scrollSel, contentSel, lastTurnSel]) => { + const scrollContainer = document.querySelector(scrollSel); + if (scrollContainer) scrollContainer.scrollTop = scrollContainer.scrollHeight; + const sessionContent = document.querySelector(contentSel); + if (sessionContent) sessionContent.scrollTop = sessionContent.scrollHeight; + const lastTurn = document.querySelector(lastTurnSel); + if (lastTurn) lastTurn.scrollIntoView({behavior: "instant", block: "end"}); + window.scrollTo(0, document.body.scrollHeight); + }""", + [ + SCROLL_CONTAINER_SELECTOR, + CHAT_SESSION_CONTENT_SELECTOR, + LAST_CHAT_TURN_SELECTOR, + ], + ) + except Exception: + pass + + if GlobalState.IS_QUOTA_EXCEEDED and not GlobalState.IS_RECOVERING: + logger.warning(f"[{req_id}] Quota detected. Pausing...") + try: + start_wait = time.time() + while time.time() - start_wait < 2.0: + if GlobalState.IS_RECOVERING: + break + await asyncio.sleep(0.2) + except Exception: + pass + + if GlobalState.IS_RECOVERING: + logger.info(f"[{req_id}] 🔄 Recovery mode detected. Holding...") + elif not GlobalState.IS_QUOTA_EXCEEDED: + logger.info(f"[{req_id}] ✅ Recovery completed. Resuming...") + else: + logger.warning(f"[{req_id}] ⛔ Quota exceeded, waiting...") + await asyncio.sleep(1.0) + continue + + if GlobalState.IS_SHUTTING_DOWN.is_set(): + logger.warning(f"[{req_id}] 🛑 Global Shutdown. Aborting.") + yield { + "done": True, + "reason": "global_shutdown", + "body": "", + "function": [], + } + return + + try: + data = STREAM_QUEUE.get_nowait() + if data is None: + logger.info(f"[{req_id}] 🔴 Received termination signal.") + break + + if isinstance(data, dict) and data.get("done") is True: + logger.info(f"[{req_id}] ✅ Explicit DONE received.") + yield data + break + + empty_count = 0 + _data_received = True + received_items_count += 1 + last_packet_time = time.time() + + actual_data = data + if isinstance(data, str): + try: + parsed_wrapper = json.loads(data) + if ( + isinstance(parsed_wrapper, dict) + and "ts" in parsed_wrapper + and "data" in parsed_wrapper + ): + if parsed_wrapper["ts"] < stream_start_time: + logger.warning(f"[{req_id}] 🗑️ Stale data ignored.") + continue + actual_data = parsed_wrapper["data"] + else: + actual_data = parsed_wrapper + except json.JSONDecodeError: + pass + + if isinstance(actual_data, dict): + if actual_data.get("error"): + status = actual_data.get("status", 500) + message = actual_data.get("message", "Unknown error") + if status == 429 or "quota" in message.lower(): + raise QuotaExceededError( + f"AI Studio quota exceeded: {message}", req_id=req_id + ) + else: + raise UpstreamError( + f"AI Studio error: {message}", + status_code=status, + ) + + parsed_data = actual_data + p_reason = str(parsed_data.get("reason", "")) + p_body = str(parsed_data.get("body", "")) + + if ( + p_reason + and acc_reason_state + and p_reason.startswith(acc_reason_state) + ): + new_reason_delta = p_reason[len(acc_reason_state) :] + acc_reason_state = p_reason + else: + acc_reason_state += p_reason + new_reason_delta = p_reason + + if p_body and acc_body_state and p_body.startswith(acc_body_state): + acc_body_state = p_body + else: + acc_body_state += p_body + + if force_body_mode: + parsed_data["reason"] = acc_reason_state[:split_index] + parsed_data["body"] = ( + acc_body_state + acc_reason_state[split_index:] + ) + else: + text_to_check = boundary_buffer + new_reason_delta + match = TOOL_STRUCTURE_PATTERN.search(text_to_check) + if match: + offset = len(acc_reason_state) - len(text_to_check) + split_index = offset + match.start() + force_body_mode = True + boundary_transitions += 1 + parsed_data["reason"] = acc_reason_state[:split_index] + parsed_data["body"] = ( + acc_body_state + acc_reason_state[split_index:] + ) + logger.info(f"[{req_id}] ✂️ Boundary Split Applied.") + else: + parsed_data["reason"] = acc_reason_state + parsed_data["body"] = acc_body_state + boundary_buffer = (boundary_buffer + new_reason_delta)[ + -100: + ] + + accumulated_body += str(parsed_data.get("body", "")) + accumulated_reason_len += len(str(parsed_data.get("reason", ""))) + total_body_processed += len(str(parsed_data.get("body", ""))) + total_reason_processed += len(str(parsed_data.get("reason", ""))) + + if parsed_data.get("body") or parsed_data.get("reason"): + has_content = True + + if parsed_data.get("function"): + has_seen_functions = True + # Track if any function call has empty arguments (potential parse failure) + for fc in parsed_data.get("function", []): + fc_params = fc.get("params") or fc.get("arguments") or {} + if not fc_params: + if FUNCTION_CALLING_DEBUG: + logger.warning( + f"[{req_id}] ⚠️ Wire format returned '{fc.get('name')}' with empty args - will try DOM fallback" + ) + has_seen_functions = False # Force DOM fallback + break + + if parsed_data.get("done") is True: + if GlobalState.IS_QUOTA_EXCEEDED or GlobalState.IS_RECOVERING: + logger.info( + f"[{req_id}] 🛡️ Quota/Recovery active: Holding stream open." + ) + continue + just_rotated = ( + time.time() - GlobalState.LAST_ROTATION_TIMESTAMP < 15.0 + ) + recently_recovered = ( + time.time() - GlobalState.LAST_ROTATION_TIMESTAMP < 30.0 + ) + if ( + not has_content + and received_items_count == 1 + and not stale_done_ignored + and not GlobalState.IS_QUOTA_EXCEEDED + and (just_rotated or recently_recovered) + ): + logger.info( + f"[{req_id}] 🔄 Post-rotation empty DONE detected. Ignoring." + ) + stale_done_ignored = True + continue + if ( + parsed_data.get("done") is True + and not has_seen_functions + and page + ): + # Retry loop for DOM function call detection - UI elements may not render immediately + # Similar to body text retry loop below, but shorter timeout for function calls + dom_functions = [] + dom_text = "" + max_fc_retries = 10 # 10 retries * 0.3s = 3 seconds max wait + for fc_retry in range(max_fc_retries): + ( + dom_functions, + dom_text, + ) = await detect_function_calls_from_dom( + page, req_id, logger + ) + if dom_functions: + if FUNCTION_CALLING_DEBUG: + logger.info( + f"[{req_id}] ✅ DOM captured function calls after {fc_retry + 1} attempts" + ) + break + # Only retry if we haven't found functions and body is also empty + # (indicates potential race condition with UI rendering) + if accumulated_body: + break # We have body text, no need to wait for functions + await asyncio.sleep(0.3) + + if dom_functions: + parsed_data["function"] = dom_functions + has_seen_functions = True + + # If we have DOM text and accumulated body is empty, inject it to final chunk + if dom_text and not accumulated_body: + parsed_data["body"] = dom_text + accumulated_body = dom_text + + yield parsed_data + if parsed_data.get("done") is True: + if ( + accumulated_reason_len > 0 + and len(accumulated_body) == 0 + and not has_seen_functions + ): + logger.info( + f"[{req_id}] ⚠️ Thinking-Only response detected. Waiting for DOM..." + ) + try: + if page: + pc = PageController(page, logger, req_id) + for _ in range(20): + await asyncio.sleep(0.5) + dom_text = ( + await pc.get_body_text_only_from_dom() + ) + if dom_text and len(dom_text.strip()) > 0: + logger.info( + f"[{req_id}] ✅ DOM captured body: {len(dom_text)} chars" + ) + yield { + "body": dom_text, + "reason": "", + "done": False, + } + break + except Exception as e: + logger.error(f"[{req_id}] DOM Wait Error: {e}") + break + else: + stale_done_ignored = False + continue + except (queue.Empty, asyncio.QueueEmpty): + empty_count += 1 + if ( + enable_silence_detection + and received_items_count >= min_items_before_silence_check + and time.time() - last_packet_time > silence_threshold + ): + logger.info(f"[{req_id}] 🔇 Stream silence detected.") + yield { + "done": True, + "reason": "silence_detected", + "body": "", + "function": [], + } + return + if empty_count % 50 == 0: + logger.info( + f"[{req_id}] Waiting for data... ({empty_count}/{max_empty_retries})" + ) + if empty_count >= max_empty_retries: + if GlobalState.IS_RECOVERING: + empty_count = 0 + continue + if ( + await check_ui_generation_active() + and empty_count < hard_timeout_limit + ): + logger.warning(f"[{req_id}] Timeout but UI active. Snoozing...") + empty_count = max(0, empty_count - int(max_empty_retries * 0.5)) + continue + elif empty_count >= hard_timeout_limit: + logger.error(f"[{req_id}] HARD TIMEOUT REACHED!") + yield { + "done": True, + "reason": "hard_timeout", + "body": "", + "function": [], + } + return + yield { + "done": True, + "reason": "internal_timeout", + "body": "", + "function": [], + } + return + if check_client_disconnected: + try: + check_client_disconnected(f"Stream Queue Wait ({req_id})") + except ClientDisconnectedError: + raise + if received_items_count == 0 and empty_count >= initial_wait_limit: + logger.error(f"[{req_id}] Stream has no data (TTFB Timeout).") + try: + from api_utils.server_state import state + + page_instance = state.page_instance + if page_instance: + await page_instance.reload() + except Exception: + pass + yield { + "done": True, + "reason": "ttfb_timeout", + "body": "", + "function": [], + } + return + if empty_count - last_ui_check_time >= ui_check_interval: + if await check_ui_generation_active(): + logger.info(f"[{req_id}] UI detected still generating...") + last_ui_check_time = empty_count + await asyncio.sleep(0.1) + continue + except asyncio.CancelledError: + raise + except Exception as e: + if isinstance(e, ClientDisconnectedError): + raise e + logger.error(f"[{req_id}] Error in stream generator: {e}", exc_info=True) + raise + finally: + logger.info( + f"[{req_id}] Stream response completed. Items: {received_items_count}" + ) + await clear_stream_queue() + + +async def clear_stream_queue(): + import queue + + from api_utils.server_state import state + + STREAM_QUEUE = state.STREAM_QUEUE + logger = state.logger + + if STREAM_QUEUE is None: + return + cleared_count = 0 + while True: + try: + await asyncio.to_thread(STREAM_QUEUE.get_nowait) + cleared_count += 1 + except queue.Empty: + break + except Exception: + break + if cleared_count > 0: + logger.info(f"Stream queue cleared. Items: {cleared_count}") + + +async def detect_function_calls_from_dom( + page: Any, + req_id: str, + logger: Any, +) -> Tuple[List[dict], str]: + """Fallback function call detection using DOM parsing. + + This is used when the network interceptor doesn't capture function calls + (e.g., due to timing issues or format changes). + + Args: + page: Playwright page instance. + req_id: Request ID for logging. + logger: Logger instance. + + Returns: + Tuple of (List of function call dicts, text content). + """ + if not page: + return [], "" + + try: + from api_utils.utils_ext.function_call_response_parser import ( + FunctionCallResponseParser, + ) + + parser = FunctionCallResponseParser(page, logger, req_id) + result = await parser.parse_function_calls() + + function_calls: List[dict] = [] + if result.has_function_calls and result.function_calls: + # Convert ParsedFunctionCall objects to dict format expected by stream + for fc in result.function_calls: + function_calls.append({"name": fc.name, "params": fc.arguments}) + + if FUNCTION_CALLING_DEBUG: + logger.info( + f"[{req_id}] DOM fallback detected {len(function_calls)} function call(s)" + ) + + return function_calls, result.text_content + + except Exception as e: + if FUNCTION_CALLING_DEBUG: + logger.debug(f"[{req_id}] DOM function call detection failed: {e}") + + return [], "" diff --git a/api_utils/utils_ext/string_utils.py b/api_utils/utils_ext/string_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..d8d45758bb29cbf202ec1fd380836416035746f7 --- /dev/null +++ b/api_utils/utils_ext/string_utils.py @@ -0,0 +1,50 @@ +import json +from typing import List, Optional + +from models import Message + + +def extract_json_from_text(text: str) -> Optional[str]: + """Attempt to extract the first JSON object string from plain text.""" + if not text: + return None + # Simple heuristic: find the first '{' and the last matching '}' + try: + start = text.find("{") + end = text.rfind("}") + if start != -1 and end != -1 and end > start: + candidate = text[start : end + 1].strip() + json.loads(candidate) + return candidate + except Exception: + return None + return None + + +def get_latest_user_text(messages: List[Message]) -> str: + """Extract the text content of the most recent user message (concatenating multiple text segments).""" + for msg in reversed(messages): + if msg.role == "user": + content = msg.content + if isinstance(content, str): + return content + elif isinstance(content, list): + parts: List[str] = [] + for it in content: + if isinstance(it, dict): + if it.get("type") == "text": + text_raw = it.get("text", "") + if isinstance(text_raw, str): + parts.append(text_raw) + else: + parts.append(str(text_raw)) + elif hasattr(it, "type") and it.type == "text": + text_attr = getattr(it, "text", "") + if isinstance(text_attr, str): + parts.append(text_attr) + else: + parts.append(str(text_attr)) + return "\n".join(p for p in parts if p) + else: + return "" + return "" diff --git a/api_utils/utils_ext/tokens.py b/api_utils/utils_ext/tokens.py new file mode 100644 index 0000000000000000000000000000000000000000..d0b233d0466dfbf8b8bdc26b68e48d637325bf00 --- /dev/null +++ b/api_utils/utils_ext/tokens.py @@ -0,0 +1,41 @@ +from typing import Any, Dict, List, Optional + + +def estimate_tokens(text: str) -> int: + if not text: + return 0 + chinese_chars = sum( + 1 + for char in text + if "\u4e00" <= char <= "\u9fff" + or "\u3000" <= char <= "\u303f" + or "\uff00" <= char <= "\uffef" + ) + non_chinese_chars = len(text) - chinese_chars + chinese_tokens = chinese_chars / 1.5 + english_tokens = non_chinese_chars / 4.0 + return max(1, int(chinese_tokens + english_tokens)) + + +def calculate_usage_stats( + messages: List[Dict[str, Any]], + response_content: str, + reasoning_content: Optional[str] = None, +) -> Dict[str, int]: + prompt_text = "" + for message in messages: + role = message.get("role", "") + content = message.get("content", "") + prompt_text += f"{role}: {content}\n" + prompt_tokens = estimate_tokens(prompt_text) + + completion_text = response_content or "" + if reasoning_content: + completion_text += reasoning_content + completion_tokens = estimate_tokens(completion_text) + total_tokens = prompt_tokens + completion_tokens + return { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": total_tokens, + } diff --git a/api_utils/utils_ext/tools_execution.py b/api_utils/utils_ext/tools_execution.py new file mode 100644 index 0000000000000000000000000000000000000000..2f20698149c67c7d1d07dd93cc6a1d99af05c734 --- /dev/null +++ b/api_utils/utils_ext/tools_execution.py @@ -0,0 +1,74 @@ +import asyncio +from typing import Any, Dict, List, Optional, Union + +from api_utils.tools_registry import execute_tool_call, register_runtime_tools +from api_utils.utils_ext.string_utils import ( + extract_json_from_text, + get_latest_user_text, +) +from models import Message + + +async def maybe_execute_tools( + messages: List[Message], + tools: Optional[List[Dict[str, Any]]], + tool_choice: Optional[Union[str, Dict[str, Any]]], +) -> Optional[List[Dict[str, Any]]]: + """ + Active function execution based on tools/tool_choice: + - If tool_choice specifies a function name (string or {type:'function', function:{name}}), attempt to execute that function. + - If tool_choice is 'auto' and only one tool is provided, execute that tool. + - Argument source: Attempt to extract JSON from the text of the most recent user message; if fails, use empty parameters. + - Returns [{name, arguments, result}]; returns None if no executable is found. + """ + try: + # Track runtime-declared tools and optional MCP endpoints + mcp_ep: Optional[str] = None + # support per-request MCP endpoint via request-level message or tool spec extension (if present later) + # current: read from env only in registry when not provided + register_runtime_tools(tools, mcp_ep) + # If tool result messages already exist (role='tool'), follow the conversational calling loop, + # driven by the client, the server does not actively execute again. + for m in messages: + if getattr(m, "role", None) == "tool": + return None + chosen_name: Optional[str] = None + if isinstance(tool_choice, dict): + fn_raw = tool_choice.get("function") + if isinstance(fn_raw, dict): + name_raw = fn_raw.get("name") + if isinstance(name_raw, str): + chosen_name = name_raw + elif isinstance(tool_choice, str): + lc = tool_choice.lower() + if lc in ("none", "no", "off"): + return None + if lc in ("auto", "required", "any"): + if isinstance(tools, list) and len(tools) == 1: + first_tool = tools[0] + func_raw = first_tool.get("function", {}) + if isinstance(func_raw, dict): + name_from_func = func_raw.get("name") + if isinstance(name_from_func, str): + chosen_name = name_from_func + if not chosen_name: + name_from_tool = first_tool.get("name") + if isinstance(name_from_tool, str): + chosen_name = name_from_tool + else: + chosen_name = tool_choice + elif tool_choice is None: + # Do not execute actively + return None + + if not chosen_name: + return None + + user_text = get_latest_user_text(messages) + args_json = extract_json_from_text(user_text) or "{}" + result_str = await execute_tool_call(chosen_name, args_json) + return [{"name": chosen_name, "arguments": args_json, "result": result_str}] + except asyncio.CancelledError: + raise + except Exception: + return None diff --git a/api_utils/utils_ext/usage_tracker.py b/api_utils/utils_ext/usage_tracker.py new file mode 100644 index 0000000000000000000000000000000000000000..6fc6681b06f27c7f0ab6d91aba7027f097b0734b --- /dev/null +++ b/api_utils/utils_ext/usage_tracker.py @@ -0,0 +1,93 @@ +import asyncio +import json +import logging +import os +from typing import Dict + +logger = logging.getLogger("UsageTracker") + +USAGE_FILE = os.path.join("config", "profile_usage.json") +_USAGE_LOCK = asyncio.Lock() + + +def _load_usage_data() -> Dict[str, int]: + """Internal function to load usage data from disk.""" + if not os.path.exists(USAGE_FILE): + return {} + try: + with open(USAGE_FILE, "r", encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, IOError) as e: + logger.error(f"Failed to load profile usage data: {e}") + return {} + + +def _save_usage_data(data: Dict[str, int]) -> None: + """Internal function to save usage data to disk.""" + try: + # Ensure directory exists + os.makedirs(os.path.dirname(USAGE_FILE), exist_ok=True) + with open(USAGE_FILE, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + except IOError as e: + logger.error(f"Failed to save profile usage data: {e}") + + +async def increment_profile_usage(profile_path: str, tokens: int) -> None: + """ + Increments the usage count for a specific profile. + This function is async and thread-safe via asyncio.Lock. + """ + if not profile_path or not os.path.exists(profile_path): + return + + # Normalize path to ensure consistency as key + profile_path = os.path.abspath(profile_path) + + async with _USAGE_LOCK: + usage_data = _load_usage_data() + + # Smart path reconciliation: Check if we have data for this file under a different path + target_key = profile_path + if profile_path not in usage_data: + profile_basename = os.path.basename(profile_path) + for key in list(usage_data.keys()): + if os.path.basename(key) == profile_basename: + # Found a match by filename! Migrate the data to the new path + logger.info( + f"Migrating usage data for '{profile_basename}' from '{key}' to '{profile_path}'" + ) + usage_data[profile_path] = usage_data.pop(key) + target_key = profile_path + break + + current_usage = usage_data.get(target_key, 0) + usage_data[target_key] = current_usage + tokens + _save_usage_data(usage_data) + logger.debug( + f"Updated usage for {os.path.basename(profile_path)}: +{tokens} tokens (Total: {usage_data[target_key]})" + ) + + +def get_profile_usage(profile_path: str) -> int: + """ + Returns the total usage for a profile. + Reads directly from file (blocking), suitable for auth rotation logic which might run in sync context or low frequency. + For high frequency, consider caching. + Includes fallback logic to find usage by filename if absolute path doesn't match (handles moved files). + """ + profile_path = os.path.abspath(profile_path) + usage_data = _load_usage_data() + + # Direct match + if profile_path in usage_data: + return usage_data[profile_path] + + # Fallback: Match by filename + # This handles cases where files are moved (e.g. saved -> emergency) but usage data has old path + profile_basename = os.path.basename(profile_path) + for key, usage in usage_data.items(): + if os.path.basename(key) == profile_basename: + return usage + + return 0 diff --git a/api_utils/utils_ext/validation.py b/api_utils/utils_ext/validation.py new file mode 100644 index 0000000000000000000000000000000000000000..a1a144b55f95f1abfc358e0dce7a7bbe35ce085a --- /dev/null +++ b/api_utils/utils_ext/validation.py @@ -0,0 +1,19 @@ +from typing import Dict, List, Optional + +from models import Message + + +def validate_chat_request( + messages: List[Message], req_id: str +) -> Dict[str, Optional[str]]: + if not messages: + raise ValueError( + f"[{req_id}] Invalid request: 'messages' array is missing or empty." + ) + + if not any(msg.role != "system" for msg in messages): + raise ValueError( + f"[{req_id}] Invalid request: All messages are system messages. At least one user or assistant message is required." + ) + + return {"error": None, "warning": None} diff --git a/auth_profiles/active/.gitkeep b/auth_profiles/active/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/auth_profiles/emergency/.gitkeep b/auth_profiles/emergency/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/auth_profiles/saved/.gitkeep b/auth_profiles/saved/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/browser_utils/__init__.py b/browser_utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..468f111c539c2f55b48f74e702498c8cdaf184cb --- /dev/null +++ b/browser_utils/__init__.py @@ -0,0 +1,72 @@ +# --- browser_utils/__init__.py --- +# Browser operation utility module +from .debug_utils import ( + capture_dom_structure, + capture_playwright_state, + get_texas_timestamp, + save_comprehensive_snapshot, + save_error_snapshot_enhanced, +) +from .initialization import ( + _close_page_logic, + _initialize_page_logic, + enable_temporary_chat_mode, + signal_camoufox_shutdown, +) +from .model_management import ( + _force_ui_state_settings, + _force_ui_state_with_retry, + _handle_initial_model_state_and_storage, + _set_model_from_page_display, + _verify_and_apply_ui_state, + _verify_ui_state_settings, + load_excluded_models, + switch_ai_studio_model, +) +from .operations import ( + _get_final_response_content, + _handle_model_list_response, + _wait_for_response_completion, + check_quota_limit, + detect_and_extract_page_error, + get_raw_text_content, + get_response_via_copy_button, + get_response_via_edit_button, + save_error_snapshot, +) +from .page_controller import PageController + +__all__ = [ + # Initialization + "_initialize_page_logic", + "_close_page_logic", + "signal_camoufox_shutdown", + "enable_temporary_chat_mode", + # Page operations + "_handle_model_list_response", + "detect_and_extract_page_error", + "save_error_snapshot", + "get_response_via_edit_button", + "get_response_via_copy_button", + "_wait_for_response_completion", + "_get_final_response_content", + "get_raw_text_content", + "check_quota_limit", + # Model management + "switch_ai_studio_model", + "load_excluded_models", + "_handle_initial_model_state_and_storage", + "_set_model_from_page_display", + "_verify_ui_state_settings", + "_force_ui_state_settings", + "_force_ui_state_with_retry", + "_verify_and_apply_ui_state", + # Page Controller + "PageController", + # Debug utilities (comprehensive error snapshots) + "save_comprehensive_snapshot", + "save_error_snapshot_enhanced", + "get_texas_timestamp", + "capture_dom_structure", + "capture_playwright_state", +] diff --git a/browser_utils/auth_rotation.py b/browser_utils/auth_rotation.py new file mode 100644 index 0000000000000000000000000000000000000000..da854e5b11ddaee804c7e453ac71d7f7fe113237 --- /dev/null +++ b/browser_utils/auth_rotation.py @@ -0,0 +1,763 @@ +import asyncio +import glob +import json +import logging +import os +import random +import time +from datetime import datetime, timedelta +from typing import Optional + +from playwright.async_api import Page, TimeoutError + +from api_utils.server_state import state +from api_utils.utils_ext.cooldown_manager import ( + load_cooldown_profiles, + save_cooldown_profiles, +) +from api_utils.utils_ext.usage_tracker import get_profile_usage +from config import AI_STUDIO_URL_PATTERN +from config.global_state import GlobalState +from config.selectors import PROMPT_TEXTAREA_SELECTOR +from config.settings import ( + AUTO_ROTATE_AUTH_PROFILE, + HIGH_TRAFFIC_QUEUE_THRESHOLD, + ROTATION_DEPLETION_GUARD_HIGH_TRAFFIC, +) +from config.timeouts import QUOTA_EXCEEDED_COOLDOWN_SECONDS, RATE_LIMIT_COOLDOWN_SECONDS + +logger = logging.getLogger("AuthRotation") + +# Track recently used profiles to avoid rapid cycling/reuse +# Maps filename -> timestamp of last use +_USED_PROFILES_HISTORY = {} +_HISTORY_RETENTION_SECONDS = 3600 * 2 # 2 hours retention for history + +# Profiles currently in cooldown (e.g. due to quota limit) +# Maps filename -> Dict[model_id, expiry_timestamp] OR filename -> expiry_timestamp (legacy/global) +_COOLDOWN_PROFILES = load_cooldown_profiles() + +# [FINAL-02] Depletion Guard: Track rotation attempts +_ROTATION_TIMESTAMPS = [] +_ROTATION_LIMIT_WINDOW = 60 # seconds +_ROTATION_LIMIT_COUNT = 3 # max attempts per window + + +def _normalize_model_id(model_id: str) -> str: + """ + Normalize model ID to match cooldown key format. + Converts "gemini 3 pro preview" to "gemini-3-pro-preview" + """ + if not model_id: + return "default" + + # Convert to lowercase and replace spaces/dots with hyphens + normalized = model_id.lower() + normalized = normalized.replace(" ", "-") + normalized = normalized.replace(".", "-") + + # Handle specific model patterns + if "gemini" in normalized: + # Ensure consistent gemini model naming + if "gemini-1-5-pro" in normalized: + return "gemini-1.5-pro" + elif "gemini-2-5-pro" in normalized: + return "gemini-2.5-pro" + elif "gemini-3-1-pro" in normalized: + return "gemini-3.1-pro" + elif "gemini-3-pro-preview" in normalized: + return "gemini-3-pro-preview" + elif "gemini-pro" in normalized: + return "gemini-pro" + + return normalized + + +def _calculate_smart_priority( + profile_path: str, target_model_id: str, cooldown_dict: dict +) -> tuple: + """ + Calculates a sorting priority for a profile based on 'Efficiency' logic. + + Priority Tuple: (neg_efficiency_score, usage_count, random_factor) + 1. efficiency_score (Higher is better): Count of ACTIVE cooldowns for OTHER models. + Rationale: Prefer profiles that are already "damaged" (in cooldown) for other models + but valid for the current target, over "fresh" profiles that can serve everything. + 2. usage_count (Lower is better): Standard wear leveling. + 3. random_factor: Tie-breaker. + """ + efficiency_score = 0 + now = time.time() + + # Check cooldown data for this profile + if profile_path in cooldown_dict: + data = cooldown_dict[profile_path] + if isinstance(data, dict): + # Iterate through model cooldowns + for model, ts in data.items(): + # Skip global cooldowns (already filtered out) and target model (already filtered out) + if model == "global": + continue + + # Check if this model is different from target + if target_model_id and model == target_model_id: + continue + + # If cooldown is active for this OTHER model, increase efficiency score + ts_val = ts.timestamp() if hasattr(ts, "timestamp") else ts + if ts_val > now: + efficiency_score += 1 + + usage = get_profile_usage(profile_path) + + # Return tuple for sorting: + # 1. Negative efficiency_score (Ascending sort -> Higher score comes first) + # 2. Positive usage (Ascending sort -> Lower usage comes first) + # 3. Random (Ascending sort -> Random tie breaker) + return (-efficiency_score, usage, random.random()) + + +def check_profile_cookie_health(profile_path: str) -> dict: + """ + Check the health of cookies in an auth profile. + + Returns a dict with: + - total: total number of cookies + - expired: number of expired cookies + - valid: number of valid cookies + - critical_expired: list of critical expired cookie names (auth-related) + - health_status: 'healthy', 'warning', or 'critical' + """ + result = { + "total": 0, + "expired": 0, + "valid": 0, + "session": 0, + "critical_expired": [], + "health_status": "healthy", + } + + # Critical cookies that affect authentication + CRITICAL_COOKIES = { + "SID", + "HSID", + "SSID", + "APISID", + "SAPISID", + "SIDCC", + "__Secure-1PSID", + "__Secure-3PSID", + } + + try: + with open(profile_path, "r", encoding="utf-8") as f: + data = json.load(f) + + cookies = data.get("cookies", []) + result["total"] = len(cookies) + now = time.time() + + for cookie in cookies: + name = cookie.get("name", "") + expires = cookie.get("expires", -1) + + if expires == -1: + # Session cookie (no expiry) + result["session"] += 1 + result["valid"] += 1 + elif expires < now: + # Expired + result["expired"] += 1 + if name in CRITICAL_COOKIES: + result["critical_expired"].append(name) + else: + # Valid + result["valid"] += 1 + + # Determine health status + if result["critical_expired"]: + result["health_status"] = "critical" + logger.warning( + f"🔴 Auth profile '{os.path.basename(profile_path)}' has expired critical cookies: {result['critical_expired']}" + ) + elif result["expired"] > result["total"] * 0.3: # More than 30% expired + result["health_status"] = "warning" + logger.warning( + f"🟡 Auth profile '{os.path.basename(profile_path)}' has {result['expired']}/{result['total']} expired cookies" + ) + else: + logger.debug( + f"🟢 Auth profile '{os.path.basename(profile_path)}' cookie health: {result['valid']}/{result['total']} valid" + ) + + except (json.JSONDecodeError, OSError) as e: + logger.error(f"Failed to check cookie health for '{profile_path}': {e}") + result["health_status"] = "error" + + return result + + +def _find_best_profile_in_dirs( + directories: list[str], target_model_id: str = None +) -> Optional[str]: + """ + Finds the best available profile within a given list of directories. + - Scans for .json files. + - Excludes profiles in cooldown (Global or Model-Specific). + - Sorts by usage count (ascending) and then randomly. + """ + if not directories or not isinstance(directories, list): + return None + + logger.info(f"[DEBUG] Scanning directories: {directories}") + all_profiles = [] + for d in directories: + if d and isinstance(d, str) and os.path.exists(d): + files = glob.glob(os.path.join(d, "*.json")) + logger.info(f"[DEBUG] Found {len(files)} profiles in {d}") + all_profiles.extend([os.path.abspath(f) for f in files]) + else: + logger.warning( + f"[DEBUG] Directory missing or invalid: {d} (Abs: {os.path.abspath(d) if d else 'None'})" + ) + + if not all_profiles: + logger.warning(f"[DEBUG] No profiles found in {directories}") + return None + + # Normalize target model ID for cooldown checking + normalized_target_model = ( + _normalize_model_id(target_model_id) if target_model_id else None + ) + logger.info( + f"[DEBUG] Target model: {target_model_id} -> Normalized: {normalized_target_model}" + ) + + # Filter out profiles that don't exist or are in cooldown + valid_profiles = [] + now = time.time() + + for p in all_profiles: + if not os.path.exists(p): + continue + + if p in _COOLDOWN_PROFILES: + cooldown_data = _COOLDOWN_PROFILES[p] + + # Check if cooldown is active + is_cooldown_active = False + + if isinstance(cooldown_data, dict): + # New Format: Dict[model_id, timestamp] + # Check Global Cooldown + if "global" in cooldown_data: + ts = cooldown_data["global"] + ts_val = ts.timestamp() if hasattr(ts, "timestamp") else ts + if ts_val > now: + is_cooldown_active = True + + # Check Specific Model Cooldown + if not is_cooldown_active and normalized_target_model: + # Try both the normalized model ID and the original + for model_key in [ + normalized_target_model, + target_model_id.lower() if target_model_id else None, + ]: + if model_key and model_key in cooldown_data: + ts = cooldown_data[model_key] + ts_val = ts.timestamp() if hasattr(ts, "timestamp") else ts + if ts_val > now: + is_cooldown_active = True + logger.info( + f"[DEBUG] Profile {os.path.basename(p)} is in cooldown for model '{model_key}'" + ) + break + else: + # Legacy Format: timestamp direct + if cooldown_data: + ts = cooldown_data + ts_val = ts.timestamp() if hasattr(ts, "timestamp") else ts + if isinstance(ts_val, (int, float)) and ts_val > now: + is_cooldown_active = True + + if is_cooldown_active: + continue + + valid_profiles.append(p) + + if not valid_profiles: + return None + + # Smart Efficiency Selection Logic: + # Sort candidates using the smart priority tuple + # Key: (-efficiency_score, usage, random) + valid_profiles.sort( + key=lambda p: _calculate_smart_priority( + p, normalized_target_model, _COOLDOWN_PROFILES + ) + ) + + logger.info(f"[DEBUG] Best profile selected: {os.path.basename(valid_profiles[0])}") + return valid_profiles[0] + + +def _get_next_profile(target_model_id: str = None) -> Optional[str]: + """ + Implements a two-tiered profile selection system: Standard and Emergency. + + Tier 1 (Standard): + - Scans `auth_profiles/saved` and `auth_profiles/active`. + - Selects the best profile based on usage and cooldown status. + + Tier 2 (Emergency): + - If no standard profiles are available, falls back to `auth_profiles/emergency`. + - Selects the best emergency profile using the same logic. + """ + # Ensure the emergency directory exists + emergency_dir = "auth_profiles/emergency" + abs_emergency = os.path.abspath(emergency_dir) + logger.info(f"[DEBUG] Emergency Dir: {emergency_dir} (Absolute: {abs_emergency})") + os.makedirs(emergency_dir, exist_ok=True) + + # Note: Cooldown cleanup is complex with nested structure. + # We rely on check-time filtering in _find_best_profile_in_dirs to handle expired entries effectively. + + # --- Tier 1: Standard Profiles --- + logger.info( + f"Tier 1: Searching for standard profiles... (Target Model: {target_model_id or 'Any'})" + ) + # [FIX] Explicitly include emergency profiles in standard rotation scan if they are healthy + # This ensures we don't artificially ignore valid profiles just because they are in the 'emergency' folder + # The 'emergency' fallback logic (Tier 2) below is still useful for specific logging or aggressive fallback if needed, + # but primarily we want to treat all available profiles as candidates. + standard_dirs = [ + "auth_profiles/saved", + "auth_profiles/active", + "auth_profiles/emergency", + ] + best_profile = _find_best_profile_in_dirs(standard_dirs, target_model_id) + + if best_profile: + usage_val = get_profile_usage(best_profile) + logger.info( + f"🎯 Selected standard profile '{os.path.basename(best_profile)}' with usage: {usage_val}" + ) + return best_profile + + # --- Tier 2: Emergency Profiles --- + logger.warning( + "Tier 1 yielded no profiles. Falling back to Tier 2: Emergency Pool." + ) + emergency_dirs = [emergency_dir] + best_emergency_profile = _find_best_profile_in_dirs(emergency_dirs, target_model_id) + + if best_emergency_profile: + usage_val = get_profile_usage(best_emergency_profile) + logger.info( + f"🚨 Selected emergency profile '{os.path.basename(best_emergency_profile)}' with usage: {usage_val}" + ) + return best_emergency_profile + + logger.error("No available profiles in standard or emergency pools.") + return None + + +async def _perform_canary_test(page: Page) -> bool: + """ + Performs a simple check to ensure the new profile is healthy. + Navigates to the chat page and verifies a key element is present. + """ + if not page or page.is_closed(): + logger.warning("⚠️ Canary Test: Page is not available.") + return False + + # Early exit during shutdown to avoid proxy connection issues + if GlobalState.IS_SHUTTING_DOWN.is_set(): + logger.info("🔬 Canary Test Skipped: System is shutting down.") + return True # Return True to allow rotation to complete during shutdown + + try: + logger.info("🔬 Performing Canary Test on new profile...") + target_url = f"https://{AI_STUDIO_URL_PATTERN}prompts/new_chat" + await page.goto(target_url, wait_until="domcontentloaded", timeout=30000) + + # Check for a reliable element that indicates a logged-in state + await page.wait_for_selector(PROMPT_TEXTAREA_SELECTOR, timeout=15000) + + logger.info("✅ Canary Test Passed: Profile is healthy.") + return True + except TimeoutError: + logger.warning( + "❌ Canary Test Failed: Timed out waiting for key element. Profile is likely bad." + ) + return False + except Exception as e: + # Special handling for proxy connection errors during shutdown + if ( + "NS_ERROR_PROXY_CONNECTION_REFUSED" in str(e) + and GlobalState.IS_SHUTTING_DOWN.is_set() + ): + logger.info( + "🔬 Canary Test Skipped: Proxy connection refused during shutdown." + ) + return True # Allow rotation to complete during shutdown + + logger.error(f"❌ Canary Test Failed: Unexpected error - {e}", exc_info=True) + return False + + +async def perform_auth_rotation(target_model_id: str = None) -> bool: + """ + Performs the authentication profile rotation with a soft-swap and canary test. + + Checks AUTO_ROTATE_AUTH_PROFILE environment variable to determine if rotation should proceed. + + 1. Acquires Hard Lock (stops requests). + 2. Enters a loop to find a healthy profile. + 3. Selects next profile, puts the old one in cooldown. + 4. Performs a soft-swap of cookies. + 5. Runs a canary test to validate the new profile. + 6. If healthy, breaks the loop and releases the lock. + 7. If unhealthy, adds the profile to cooldown and repeats. + """ + + # Check if auto-rotation is enabled via environment variable + if not AUTO_ROTATE_AUTH_PROFILE: + logger.info( + "🔒 Auth rotation is disabled via AUTO_ROTATE_AUTH_PROFILE environment variable" + ) + logger.info("♻️ ROTATION SKIPPED - Auto-rotation disabled") + logger.info("♻️ =========================================") + return False + + # [OBS-04] Explicit Rotation Logging with Visual Separators + logger.info("♻️ =========================================") + logger.info("♻️ INITIATING AUTH ROTATION") + logger.info("♻️ =========================================") + + # Avoid re-entry if already rotating (Atomic Check & Wait) + if not GlobalState.AUTH_ROTATION_LOCK.is_set(): + logger.info( + "⚠️ Rotation already in progress (Lock is cleared). Waiting for completion..." + ) + await GlobalState.AUTH_ROTATION_LOCK.wait() + logger.info("♻️ Rotation skipped - already in progress (Waited for completion)") + logger.info("♻️ =========================================") + return True + + # Atomically acquire the lock + GlobalState.AUTH_ROTATION_LOCK.clear() + logger.info("🔒 Request processing locked.") + + should_release_lock = True + + try: + # [FINAL-02] Depletion Guard Check + global _ROTATION_TIMESTAMPS + current_time = time.time() + + # Dynamic "Rotation Window" Adjustment + if GlobalState.queued_request_count > HIGH_TRAFFIC_QUEUE_THRESHOLD: + effective_rotation_limit = ROTATION_DEPLETION_GUARD_HIGH_TRAFFIC + logger.info( + f"High traffic detected ({GlobalState.queued_request_count} queued). Using lenient rotation guard: {effective_rotation_limit}" + ) + else: + effective_rotation_limit = _ROTATION_LIMIT_COUNT + + # Filter timestamps within the window, ensuring we only process numeric values + _ROTATION_TIMESTAMPS = [ + t + for t in _ROTATION_TIMESTAMPS + if isinstance(t, (int, float)) and current_time - t < _ROTATION_LIMIT_WINDOW + ] + + if len(_ROTATION_TIMESTAMPS) >= effective_rotation_limit: + logger.critical( + f"🚨 CRITICAL: TOO MANY ROTATIONS! (limit: {effective_rotation_limit}) All accounts may be exhausted. Stopping Browser & Locking API." + ) + logger.critical("♻️ ROTATION ABORTED - System Exhausted") + logger.critical("♻️ =========================================") + + # SOFT DEPLETION STRATEGY: Avoid hard shutdown to maintain "No Downtime" goal + logger.critical( + "🚨 DEPLETION DETECTED: Switching to emergency operation mode" + ) + logger.critical("🚨 All profiles exhausted, but avoiding hard shutdown") + + # Set emergency mode flag + GlobalState.DEPLOYMENT_EMERGENCY_MODE = True + + # Try to perform soft profile rotation even during depletion + # This maintains the "No Downtime" requirement + try: + # Attempt one final soft rotation with emergency profiles + emergency_profile = _find_best_profile_in_dirs( + ["auth_profiles/emergency"] + ) + if emergency_profile: + logger.critical("🚨 Attempting emergency profile activation...") + # Perform minimal soft swap for emergency operation + if state.page_instance and not state.page_instance.is_closed(): + with open(emergency_profile, "r", encoding="utf-8") as f: + storage_state = json.load(f) + context = state.page_instance.context + await context.clear_cookies() + await context.add_cookies(storage_state.get("cookies", [])) + logger.critical( + "🚨 Emergency profile activated - continuing operation" + ) + return True + except Exception as e: + logger.critical(f"🚨 Emergency activation failed: {e}") + + # Only if soft emergency operation fails, then consider partial shutdown + # But still try to maintain some level of service + logger.critical( + "🚨 Entering minimal operation mode - limited service available" + ) + + # PERMANENT LOCK (Do not release GlobalState.AUTH_ROTATION_LOCK) + # We leave the lock cleared so no new requests can proceed. + should_release_lock = False + return False + + # Record this attempt + _ROTATION_TIMESTAMPS.append(current_time) + logger.info( + f"🔄 Rotation attempt #{len(_ROTATION_TIMESTAMPS)} in current window" + ) + + # (Lock is already acquired above) + + max_retries = 5 + failed_attempts = 0 + + # Note: Nested try block removed, logic flattened into main try/finally + while failed_attempts < max_retries: + # 2. Select next profile + logger.info("🔍 Selecting next auth profile...") + next_profile_path = _get_next_profile(target_model_id) + + if not next_profile_path: + logger.warning("All profiles are on cooldown. Calculating wait time...") + + now = time.time() + min_expiry = float("inf") + + # Find the soonest expiry time among all cooldown profiles + for _, cooldown_data in _COOLDOWN_PROFILES.items(): + if isinstance(cooldown_data, dict): + for ts in cooldown_data.values(): + ts_val = ts.timestamp() if hasattr(ts, "timestamp") else ts + if ts_val > now and ts_val < min_expiry: + min_expiry = ts_val + else: # Legacy timestamp format + ts_val = ( + cooldown_data.timestamp() + if hasattr(cooldown_data, "timestamp") + else cooldown_data + ) + if ( + isinstance(ts_val, (int, float)) + and ts_val > now + and ts_val < min_expiry + ): + min_expiry = ts_val + + if min_expiry != float("inf"): + # Add a small buffer to avoid timing issues + wait_time = (min_expiry - now) + 1 + if wait_time > 0: + logger.info( + f"🕒 Waiting for {wait_time:.2f} seconds for the next profile to become available." + ) + await asyncio.sleep(wait_time) + + # Retry getting the profile + logger.info("Retrying to get next profile after waiting.") + next_profile_path = _get_next_profile(target_model_id) + + # Final check after waiting + if not next_profile_path: + logger.critical( + "❌ Rotation Failed: No available auth profiles found even after waiting!" + ) + logger.critical("♻️ ROTATION FAILED - No profiles available") + logger.critical("♻️ =========================================") + return False + + # Always place the *previous* profile on cooldown on the first attempt + if failed_attempts == 0: + old_profile = getattr(state, "current_auth_profile_path", "unknown") + if old_profile != "unknown" and os.path.exists(old_profile): + # Calculate cooldown based on error type + error_type = GlobalState.last_error_type + + # Ensure existing entry is a dict if it exists + if old_profile not in _COOLDOWN_PROFILES or not isinstance( + _COOLDOWN_PROFILES[old_profile], dict + ): + _COOLDOWN_PROFILES[old_profile] = {} + + expiry_ts = ( + datetime.now() + + timedelta(seconds=QUOTA_EXCEEDED_COOLDOWN_SECONDS) + ).timestamp() + rate_limit_ts = ( + datetime.now() + timedelta(seconds=RATE_LIMIT_COOLDOWN_SECONDS) + ).timestamp() + + if error_type == "RATE_LIMIT": + # Rate Limit -> Global Cooldown + _COOLDOWN_PROFILES[old_profile]["global"] = rate_limit_ts + logger.info( + f"❄️ Placing profile in GLOBAL cooldown for {RATE_LIMIT_COOLDOWN_SECONDS}s (Rate Limit)." + ) + else: + # Quota Exceeded -> Model Specific Cooldowns + + # 1. Identify models to cooldown + models_to_cooldown = set( + GlobalState.current_profile_exhausted_models + ) + logger.info( + f"🔍 Model cooldown analysis: exhausted_models={GlobalState.current_profile_exhausted_models}, target_model={target_model_id}" + ) + + # 2. Ensure target/current model is included if appropriate + # If a specific target was requested, it's likely the one failing + if target_model_id: + models_to_cooldown.add(target_model_id.lower()) + + # 3. Fallback: Only use "default" as absolute last resort + # Prioritize target_model_id and avoid unnecessary "default" entries + if not models_to_cooldown: + if target_model_id: + models_to_cooldown.add(target_model_id.lower()) + logger.info( + f"🔍 Using target_model_id as fallback: {target_model_id}" + ) + elif state.current_ai_studio_model_id: + models_to_cooldown.add( + state.current_ai_studio_model_id.lower() + ) + logger.info( + f"🔍 Using state.current_ai_studio_model_id as fallback: {state.current_ai_studio_model_id}" + ) + else: + # Only use "default" if we truly cannot identify any model + logger.warning( + "⚠️ Unable to identify specific model, falling back to 'default'. This should be rare." + ) + models_to_cooldown.add("default") + + # 4. Apply cooldowns + logger.info( + f"🎯 Applying cooldown to models: {list(models_to_cooldown)}" + ) + for m_id in models_to_cooldown: + _COOLDOWN_PROFILES[old_profile][m_id] = expiry_ts + logger.info( + f"❄️ Placing profile in cooldown for model '{m_id}' for {QUOTA_EXCEEDED_COOLDOWN_SECONDS}s." + ) + + save_cooldown_profiles(_COOLDOWN_PROFILES) + + new_profile_name = os.path.basename(next_profile_path) + logger.info(f"👉 Attempting to rotate to profile: {new_profile_name}") + + # Update global state for the new profile path + state.current_auth_profile_path = next_profile_path + os.environ["ACTIVE_AUTH_JSON_PATH"] = next_profile_path + + # 3. Soft Context Swap + logger.info("🚀 Performing Soft Context Swap...") + if not state.page_instance or state.page_instance.is_closed(): + logger.error( + "❌ Page instance not found or closed, cannot perform soft swap." + ) + return False + + try: + try: + with open(next_profile_path, "r", encoding="utf-8") as f: + storage_state = json.load(f) + except (json.JSONDecodeError, OSError) as json_err: + logger.error( + f"❌ Corrupt or inaccessible profile file '{new_profile_name}': {json_err}" + ) + # Treat as a failed attempt, will trigger cooldown logic in outer except/continue + raise + + if not isinstance(storage_state, dict): + raise ValueError(f"Invalid profile format in '{new_profile_name}'") + + context = state.page_instance.context + await context.clear_cookies() + await context.add_cookies(storage_state.get("cookies", [])) + logger.info("✅ Injected new cookies.") + + # 4. Perform Canary Test + if await _perform_canary_test(state.page_instance): + # Healthy profile found, break the loop + GlobalState.reset_quota_status() + # GlobalState.current_profile_token_count = 0 # Removed: handled by reset_quota_status + logger.info( + f"♻️ ROTATION SUCCESSFUL with profile: {new_profile_name}" + ) + logger.info("♻️ =========================================") + return True + else: + # Canary test failed, profile is bad + logger.warning( + f" Canary test failed for {new_profile_name}. Adding to cooldown and retrying." + ) + failed_attempts += 1 + + # Place failed profile in cooldown immediately + expiry_time = datetime.now() + timedelta( + seconds=QUOTA_EXCEEDED_COOLDOWN_SECONDS + ) + _COOLDOWN_PROFILES[next_profile_path] = expiry_time + save_cooldown_profiles(_COOLDOWN_PROFILES) + logger.info( + f"❄️ Placing unhealthy profile '{new_profile_name}' in cooldown for {QUOTA_EXCEEDED_COOLDOWN_SECONDS}s." + ) + continue # Try next profile + + except Exception as swap_err: + logger.error( + f"❌ Failed to perform soft swap for {new_profile_name}: {swap_err}" + ) + failed_attempts += 1 + # Also place this profile on cooldown + expiry_time = datetime.now() + timedelta( + seconds=QUOTA_EXCEEDED_COOLDOWN_SECONDS + ) + _COOLDOWN_PROFILES[next_profile_path] = expiry_time + save_cooldown_profiles(_COOLDOWN_PROFILES) + logger.info( + f"❄️ Placing swap-failed profile '{new_profile_name}' in cooldown." + ) + continue + + # If loop finishes without success + logger.critical( + f"🚨 ROTATION FAILED: All {max_retries} attempts to find a healthy profile failed." + ) + return False + except Exception as e: + logger.error( + f"❌ Unexpected error during auth rotation loop: {e}", exc_info=True + ) + logger.error("♻️ ROTATION FAILED - Unexpected error") + logger.error("♻️ =========================================") + return False + finally: + # 5. Release lock (if not permanently locked) + if should_release_lock: + GlobalState.AUTH_ROTATION_LOCK.set() + logger.info("🔓 Request processing unlocked.") + logger.info("♻️ Rotation flow completed") + logger.info("♻️ =========================================") diff --git a/browser_utils/cookie_refresh.py b/browser_utils/cookie_refresh.py new file mode 100644 index 0000000000000000000000000000000000000000..0215fd26970122c1b3f16170f4d1534a63518fb9 --- /dev/null +++ b/browser_utils/cookie_refresh.py @@ -0,0 +1,231 @@ +# --- browser_utils/cookie_refresh.py --- +""" +Cookie Refresh Module + +Provides functionality to automatically refresh and persist browser cookies +back to the auth profile files, keeping them up-to-date during runtime. + +Features: +- Periodic background refresh +- On-demand refresh after successful API requests +- Graceful shutdown save +""" + +import asyncio +import json +import logging +import os +import time +from typing import Optional + +from config.settings import ( + COOKIE_REFRESH_ENABLED, + COOKIE_REFRESH_INTERVAL_SECONDS, + COOKIE_REFRESH_ON_REQUEST_ENABLED, + COOKIE_REFRESH_ON_SHUTDOWN, + COOKIE_REFRESH_REQUEST_INTERVAL, +) + +logger = logging.getLogger("CookieRefresh") + +# Module-level state +_last_refresh_time: float = 0 +_request_count_since_refresh: int = 0 +_refresh_lock = asyncio.Lock() +_periodic_task: Optional[asyncio.Task] = None + + +async def save_current_cookies_to_profile() -> bool: + """ + Save the current browser cookies back to the active auth profile file. + + Returns: + True if cookies were saved successfully, False otherwise. + """ + global _last_refresh_time + + if not COOKIE_REFRESH_ENABLED: + logger.debug("Cookie refresh is disabled, skipping save") + return False + + try: + from api_utils.server_state import state + + # Check if we have a valid page instance + if not state.page_instance or state.page_instance.is_closed(): + logger.debug("No active page instance, skipping cookie save") + return False + + # Get the current auth profile path + profile_path = state.current_auth_profile_path + if not profile_path: + profile_path = os.environ.get("ACTIVE_AUTH_JSON_PATH") + + if not profile_path or not os.path.exists(profile_path): + logger.debug(f"No valid auth profile path found: {profile_path}") + return False + + # Use lock to prevent concurrent saves + async with _refresh_lock: + context = state.page_instance.context + + # Get current storage state (cookies + origins/localStorage) + storage_state = await context.storage_state() + + # Read existing profile to preserve any custom data + try: + with open(profile_path, "r", encoding="utf-8") as f: + existing_data = json.load(f) + except (json.JSONDecodeError, OSError): + existing_data = {} + + # Update cookies and origins + existing_data["cookies"] = storage_state.get("cookies", []) + existing_data["origins"] = storage_state.get("origins", []) + + # Write back to file + with open(profile_path, "w", encoding="utf-8") as f: + json.dump(existing_data, f, indent=2, ensure_ascii=False) + + _last_refresh_time = time.time() + cookie_count = len(storage_state.get("cookies", [])) + logger.info( + f"🍪 Cookies saved to '{os.path.basename(profile_path)}' " + f"({cookie_count} cookies)" + ) + return True + + except asyncio.CancelledError: + raise + except Exception as e: + logger.error(f"Failed to save cookies: {e}") + return False + + +async def maybe_refresh_on_request() -> bool: + """ + Called after successful API requests. Saves cookies if enough requests + have been processed since the last save. + + Returns: + True if cookies were saved, False otherwise. + """ + global _request_count_since_refresh + + if not COOKIE_REFRESH_ON_REQUEST_ENABLED: + return False + + _request_count_since_refresh += 1 + + if _request_count_since_refresh >= COOKIE_REFRESH_REQUEST_INTERVAL: + _request_count_since_refresh = 0 + logger.debug( + f"Request-based cookie refresh triggered " + f"(every {COOKIE_REFRESH_REQUEST_INTERVAL} requests)" + ) + return await save_current_cookies_to_profile() + + return False + + +async def save_cookies_on_shutdown() -> bool: + """ + Save cookies during graceful shutdown. + + Returns: + True if cookies were saved successfully, False otherwise. + """ + if not COOKIE_REFRESH_ON_SHUTDOWN: + logger.debug("Cookie save on shutdown is disabled") + return False + + logger.info("💾 Saving cookies before shutdown...") + return await save_current_cookies_to_profile() + + +async def _periodic_refresh_loop(): + """ + Background task that periodically saves cookies. + """ + global _last_refresh_time + + logger.info( + f"🔄 Periodic cookie refresh started " + f"(interval: {COOKIE_REFRESH_INTERVAL_SECONDS}s)" + ) + + # Initial delay before first save + await asyncio.sleep(COOKIE_REFRESH_INTERVAL_SECONDS) + + while True: + try: + elapsed = time.time() - _last_refresh_time + if elapsed >= COOKIE_REFRESH_INTERVAL_SECONDS: + logger.debug("Periodic cookie refresh triggered") + await save_current_cookies_to_profile() + + # Sleep until next check + await asyncio.sleep(min(60, COOKIE_REFRESH_INTERVAL_SECONDS // 2)) + + except asyncio.CancelledError: + logger.info("🔄 Periodic cookie refresh task cancelled") + raise + except Exception as e: + logger.error(f"Error in periodic cookie refresh: {e}") + # Continue running despite errors + await asyncio.sleep(60) + + +def start_periodic_refresh() -> Optional[asyncio.Task]: + """ + Start the periodic cookie refresh background task. + + Returns: + The created asyncio task, or None if refresh is disabled. + """ + global _periodic_task + + if not COOKIE_REFRESH_ENABLED: + logger.info("Cookie refresh is disabled, not starting periodic task") + return None + + if _periodic_task is not None and not _periodic_task.done(): + logger.warning("Periodic refresh task already running") + return _periodic_task + + _periodic_task = asyncio.create_task(_periodic_refresh_loop()) + return _periodic_task + + +async def stop_periodic_refresh(): + """ + Stop the periodic cookie refresh background task. + """ + global _periodic_task + + if _periodic_task is not None and not _periodic_task.done(): + _periodic_task.cancel() + try: + await _periodic_task + except asyncio.CancelledError: + pass + _periodic_task = None + logger.info("🔄 Periodic cookie refresh stopped") + + +def get_refresh_stats() -> dict: + """ + Get statistics about cookie refresh operations. + + Returns: + Dict with refresh statistics. + """ + return { + "enabled": COOKIE_REFRESH_ENABLED, + "last_refresh_time": _last_refresh_time, + "requests_since_refresh": _request_count_since_refresh, + "refresh_interval_seconds": COOKIE_REFRESH_INTERVAL_SECONDS, + "request_interval": COOKIE_REFRESH_REQUEST_INTERVAL, + "periodic_task_running": _periodic_task is not None + and not _periodic_task.done(), + } diff --git a/browser_utils/debug_utils.py b/browser_utils/debug_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..5c5ce0c4bc72bf1f26caaf349c8a54f2a9cb099f --- /dev/null +++ b/browser_utils/debug_utils.py @@ -0,0 +1,778 @@ +""" +Debug utilities for comprehensive error snapshots and logging. + +This module provides enhanced debugging capabilities with: +- Date-based directory structure (errors_py/YYYY-MM-DD/) +- Multiple artifact types (screenshot, DOM dump, console logs, network state) +- Human-readable Texas timestamps +- Complete metadata capture + +Purpose: Fix headless mode debugging and client disconnect issues +""" + +import asyncio +import json +import logging +import os +import traceback +from asyncio import Lock, Queue +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Optional, Protocol, Tuple, Union + +from playwright.async_api import Locator +from playwright.async_api import Page as AsyncPage + + +class SupportsSizeQuery(Protocol): + """Protocol for queue-like objects that support qsize().""" + + def qsize(self) -> int: + """Return the approximate size of the queue.""" + ... + + +class SupportsLockQuery(Protocol): + """Protocol for lock-like objects that support locked().""" + + def locked(self) -> bool: + """Return True if the lock is currently held.""" + ... + + +logger = logging.getLogger("AIStudioProxyServer") + + +def get_local_timestamp() -> Tuple[str, str]: + """ + Get current timestamp in both ISO format and human-readable local time. + + Uses the system's local timezone automatically. + + Returns: + Tuple[str, str]: (iso_format, human_readable_format) + Example: ("2025-12-20T00:53:35.440", "2025-12-20 00:53:35.440 JST") + """ + # Get current local time + local_now = datetime.now().astimezone() + + # ISO format (without timezone suffix for directory naming) + iso_format = local_now.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + + # Human-readable format with timezone abbreviation + human_format = ( + local_now.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + " " + local_now.strftime("%Z") + ) + + return iso_format, human_format + + +# Keep alias for backwards compatibility +get_texas_timestamp = get_local_timestamp + + +async def capture_dom_structure(page: AsyncPage) -> str: + """ + Capture human-readable DOM tree structure. + + Unlike raw HTML, this provides a clean, indented tree view showing: + - Element hierarchy + - IDs and classes + - Important attributes (disabled, value, etc.) + + Args: + page: Playwright page instance + + Returns: + str: Human-readable DOM tree structure + """ + try: + dom_tree = await page.evaluate("""() => { + function getTreeStructure(element, indent = '', depth = 0, maxDepth = 15) { + // Prevent infinite recursion + if (depth > maxDepth) { + return indent + '... (max depth reached)\\n'; + } + + let result = indent + element.tagName; + + // Add ID + if (element.id) { + result += `#${element.id}`; + } + + // Add classes + if (element.className && typeof element.className === 'string') { + const classes = element.className.trim().split(/\\s+/).filter(c => c); + if (classes.length > 0) { + result += '.' + classes.join('.'); + } + } + + // Add important attributes + const importantAttrs = ['aria-label', 'type', 'role', 'data-test-id']; + for (const attr of importantAttrs) { + const val = element.getAttribute(attr); + if (val) { + result += ` [${attr}="${val}"]`; + } + } + + // Add state attributes + if (element.disabled !== undefined) { + result += ` [disabled=${element.disabled}]`; + } + if (element.hasAttribute('aria-disabled')) { + result += ` [aria-disabled=${element.getAttribute('aria-disabled')}]`; + } + + // Add value for input elements (truncated) + if (element.value && typeof element.value === 'string') { + const truncated = element.value.substring(0, 50); + const suffix = element.value.length > 50 ? '...' : ''; + result += ` value="${truncated}${suffix}"`; + } + + result += '\\n'; + + // Recurse for children + for (let child of element.children) { + result += getTreeStructure(child, indent + ' ', depth + 1, maxDepth); + } + + return result; + } + + return getTreeStructure(document.body); + }""") + + return dom_tree + except asyncio.CancelledError: + raise + except Exception as e: + logger.error(f"Failed to capture DOM structure: {e}") + return f"Error capturing DOM structure: {str(e)}\n" + + +async def capture_system_context( + req_id: str = "unknown", error_name: str = "unknown" +) -> Dict[str, Any]: + """ + Captures the current system global state for debugging context. + + This function gathers critical system metrics, application state (locks, queues), + and configuration details to help diagnose issues like client disconnects + or headless mode failures. + + Args: + req_id: Request ID associated with the context + error_name: Name of the error triggering the capture + + Returns: + Dict containing comprehensive system context + """ + # Import server locally to avoid circular dependency + import platform + import sys + + from api_utils.server_state import state + + iso_time, texas_time = get_texas_timestamp() + + # Helper to safely get queue size + def get_qsize(q: Optional[Union[Queue[Any], SupportsSizeQuery]]) -> int: + try: + return q.qsize() if q else -1 + except (NotImplementedError, AttributeError): + return -1 # Some queue types (like multiprocessing.Queue on macOS) might not support qsize + + # Helper to safely check lock state + def is_locked(lock: Optional[Union[Lock, SupportsLockQuery]]) -> bool: + try: + return lock.locked() if lock else False + except AttributeError: + return False + + # Helper to sanitize proxy settings + def _sanitize_proxy(settings: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + if not settings: + return None + safe_settings = settings.copy() + server_val = safe_settings.get("server") + if isinstance(server_val, str) and "@" in server_val: + # Redact credentials in http://user:pass@host format + try: + parts = server_val.split("@") + scheme_creds = parts[0].split("://") + if len(scheme_creds) == 2: + safe_settings["server"] = f"{scheme_creds[0]}://***:***@{parts[1]}" + except Exception: + safe_settings["server"] = "REDACTED" + return safe_settings + + context: Dict[str, Any] = { + "meta": { + "timestamp_iso": iso_time, + "timestamp_texas": texas_time, + "req_id": req_id, + "error_name": error_name, + }, + "system": { + "platform": platform.platform(), + "python_version": sys.version.split()[0], + "pid": os.getpid(), + }, + "application_state": { + "flags": { + "is_playwright_ready": state.is_playwright_ready, + "is_browser_connected": state.is_browser_connected, + "is_page_ready": state.is_page_ready, + "is_initializing": state.is_initializing, + }, + "queues": { + "request_queue_size": get_qsize(state.request_queue), + "stream_queue_active": state.STREAM_QUEUE is not None, + }, + "locks": { + "processing_lock_locked": is_locked(state.processing_lock), + "model_switching_lock_locked": is_locked(state.model_switching_lock), + }, + "active_model": { + "current_id": state.current_ai_studio_model_id, + "excluded_count": len(state.excluded_model_ids), + }, + }, + "browser_state": { + "connected": ( + state.browser_instance.is_connected() + if state.browser_instance + else False + ), + "page_available": ( + not state.page_instance.is_closed() if state.page_instance else False + ), + }, + "configuration": { + "launch_args": { + "headless": os.environ.get("HEADLESS", "unknown"), + "debug_logs": os.environ.get("DEBUG_LOGS_ENABLED", "unknown"), + }, + "proxy_settings": _sanitize_proxy(state.PLAYWRIGHT_PROXY_SETTINGS), + }, + "recent_activity": { + "console_logs_count": len(state.console_logs), + "network_requests_count": len(state.network_log.get("requests", [])), + }, + } + + # Add snippets of recent logs (last 5) + if state.console_logs: + context["recent_activity"]["last_console_logs"] = state.console_logs[-5:] + + # Filter for errors/warnings + console_errors = [ + log + for log in state.console_logs + if str(log.get("type", "")).lower() in ("error", "warning") + ] + if console_errors: + context["recent_activity"]["recent_console_errors"] = console_errors[-5:] + + # Add failed network requests summary + if state.network_log and "responses" in state.network_log: + failed_responses = [ + resp + for resp in state.network_log["responses"] + if isinstance(resp.get("status"), int) and resp.get("status") >= 400 + ] + if failed_responses: + context["recent_activity"]["failed_network_responses"] = failed_responses[ + -5: + ] + + # Add current page details if available + if state.page_instance and not state.page_instance.is_closed(): + try: + context["browser_state"]["current_url"] = state.page_instance.url + except Exception: + context["browser_state"]["current_url"] = "error_getting_url" + + return context + + +async def capture_playwright_state( + page: AsyncPage, locators: Optional[Dict[str, Locator]] = None +) -> Dict[str, Any]: + """ + Capture current Playwright page and element states. + + Args: + page: Playwright page instance + locators: Optional dict of named locators to inspect + Example: {"submit_button": loc, "input_field": loc} + + Returns: + Dict containing page state and locator states + """ + state: Dict[str, Any] = { + "page": { + "url": page.url, + "title": "", + "viewport": page.viewport_size, + }, + "locators": {}, + "storage": { + "cookies_count": 0, + "localStorage_keys": [], + }, + } + + try: + state["page"]["title"] = await page.title() + except asyncio.CancelledError: + raise + except Exception as e: + logger.warning(f"Failed to get page title: {e}") + state["page"]["title"] = f"Error: {e}" + + # Capture locator states + if locators: + for name, locator in locators.items(): + loc_state: Dict[str, Any] = { + "exists": False, + "count": 0, + "visible": False, + "enabled": False, + "value": None, + } + + try: + loc_state["count"] = await locator.count() + loc_state["exists"] = loc_state["count"] > 0 + + if loc_state["exists"]: + # Check visibility with short timeout + try: + loc_state["visible"] = await locator.is_visible(timeout=1000) + except asyncio.CancelledError: + raise + except Exception: + loc_state["visible"] = False + + # Check enabled state + try: + loc_state["enabled"] = await locator.is_enabled(timeout=1000) + except asyncio.CancelledError: + raise + except Exception: + loc_state["enabled"] = False + + # Try to get value (for input elements) + try: + value = await locator.input_value(timeout=1000) + if value: + # Truncate long values + loc_state["value"] = ( + value[:100] + "..." if len(value) > 100 else value + ) + except asyncio.CancelledError: + raise + except Exception: + pass # Not an input element or error + + except asyncio.CancelledError: + raise + except Exception as e: + logger.warning(f"Failed to capture state for locator '{name}': {e}") + loc_state["error"] = str(e) + + state["locators"][name] = loc_state + + # Capture storage info + try: + cookies = await page.context.cookies() + state["storage"]["cookies_count"] = len(cookies) + except asyncio.CancelledError: + raise + except Exception as e: + logger.warning(f"Failed to get cookies: {e}") + + try: + localStorage_keys = await page.evaluate("() => Object.keys(localStorage)") + state["storage"]["localStorage_keys"] = localStorage_keys + except asyncio.CancelledError: + raise + except Exception as e: + logger.warning(f"Failed to get localStorage keys: {e}") + + return state + + +async def save_comprehensive_snapshot( + page: AsyncPage, + error_name: str, + req_id: str, + error_stage: str = "", + additional_context: Optional[Dict[str, Any]] = None, + locators: Optional[Dict[str, Locator]] = None, + error_exception: Optional[Exception] = None, +) -> str: + """ + Save comprehensive error snapshot with all debugging artifacts. + + Directory structure: + errors_py/YYYY-MM-DD/HH-MM-SS_reqid_errorname/ + ├── screenshot.png + ├── dom_dump.html + ├── dom_structure.txt + ├── console_logs.txt + ├── network_requests.json + ├── playwright_state.json + └── metadata.json + + Args: + page: Playwright page instance + error_name: Base error name (e.g., "stream_post_button_check_disconnect") + req_id: Request ID + error_stage: Description of error stage (e.g., "Post-stream response button state check") + additional_context: Extra context to include in metadata + locators: Dict of named locators to capture states for + error_exception: Exception object (if available) + + Returns: + str: Path to snapshot directory + """ + # Set request context for grid logging + from logging_utils import set_request_id + + set_request_id(req_id if req_id else "DEBUG") + + # Check page availability + if not page or page.is_closed(): + logger.warning(f"Cannot save snapshot ({error_name}), page is unavailable.") + return "" + + logger.info(f"Saving comprehensive error snapshot ({error_name})...") + + # Get timestamps + iso_timestamp, human_timestamp = get_texas_timestamp() + time_component = iso_timestamp.split("T")[1].replace(":", "-").replace(".", "-") + + # Create date-based directory structure + date_str = iso_timestamp.split("T")[0] # YYYY-MM-DD + base_error_dir = Path(__file__).parent.parent / "errors_py" + date_dir = base_error_dir / date_str + snapshot_dir_name = f"{time_component}_{req_id}_{error_name}" + snapshot_dir = date_dir / snapshot_dir_name + + try: + # Create directory structure + snapshot_dir.mkdir(parents=True, exist_ok=True) + logger.info(f"Created snapshot directory: {snapshot_dir}") + + # === 1. Screenshot === + screenshot_path = snapshot_dir / "screenshot.png" + try: + await page.screenshot( + path=str(screenshot_path), full_page=True, timeout=15000 + ) + logger.info("Screenshot saved") + except asyncio.CancelledError: + raise + except Exception as ss_err: + logger.error(f"Screenshot failed: {ss_err}") + + # === 2. HTML Dump === + html_path = snapshot_dir / "dom_dump.html" + try: + content = await page.content() + with open(html_path, "w", encoding="utf-8") as f: + f.write(content) + logger.info("HTML dump saved") + except asyncio.CancelledError: + raise + except Exception as html_err: + logger.error(f"HTML dump failed: {html_err}") + + # === 3. DOM Structure (Human-Readable) === + dom_structure_path = snapshot_dir / "dom_structure.txt" + try: + dom_tree = await capture_dom_structure(page) + with open(dom_structure_path, "w", encoding="utf-8") as f: + f.write(dom_tree) + logger.info("DOM structure saved") + except asyncio.CancelledError: + raise + except Exception as dom_err: + logger.error(f"DOM structure failed: {dom_err}") + + # === 4. Console Logs === + console_logs_path = snapshot_dir / "console_logs.txt" + try: + # Get console logs from global state + from api_utils.server_state import state + + console_logs = state.console_logs + + if console_logs: + with open(console_logs_path, "w", encoding="utf-8") as f: + f.write("=== Browser Console Logs ===\n\n") + for log_entry in console_logs: + timestamp = log_entry.get("timestamp", "N/A") + log_type = log_entry.get("type", "log") + text = log_entry.get("text", "") + location = log_entry.get("location", "") + + f.write(f"[{timestamp}] [{log_type.upper()}] {text}\n") + if location: + f.write(f" Location: {location}\n") + f.write("\n") + + logger.info(f"Console logs saved ({len(console_logs)} entries)") + else: + with open(console_logs_path, "w", encoding="utf-8") as f: + f.write("No console logs captured.\n") + logger.info("No console logs available") + except Exception as console_err: + logger.error(f"Console logs failed: {console_err}") + + # === 5. Network Requests === + network_path = snapshot_dir / "network_requests.json" + try: + from api_utils.server_state import state + + network_log = state.network_log + + with open(network_path, "w", encoding="utf-8") as f: + json.dump(network_log, f, indent=2, ensure_ascii=False) + + req_count = len(network_log.get("requests", [])) + resp_count = len(network_log.get("responses", [])) + logger.info(f"Network log saved ({req_count} reqs, {resp_count} resps)") + except Exception as net_err: + logger.error(f"Network log failed: {net_err}") + + # === 6. Playwright State === + playwright_state_path = snapshot_dir / "playwright_state.json" + try: + pw_state = await capture_playwright_state(page, locators) + with open(playwright_state_path, "w", encoding="utf-8") as f: + json.dump(pw_state, f, indent=2, ensure_ascii=False) + logger.info("Playwright state saved") + except asyncio.CancelledError: + raise + except Exception as pw_err: + logger.error(f"Playwright state failed: {pw_err}") + + # === 7. System Context (LLM Context) === + context_path = snapshot_dir / "llm.json" + try: + system_context = await capture_system_context(req_id, error_name) + with open(context_path, "w", encoding="utf-8") as f: + json.dump(system_context, f, indent=2, ensure_ascii=False) + logger.info("LLM context saved") + except asyncio.CancelledError: + raise + except Exception as ctx_err: + logger.error(f"LLM context failed: {ctx_err}") + + # === 8. Metadata === + metadata_path = snapshot_dir / "metadata.json" + try: + # Build metadata + metadata = { + "req_id": req_id, + "error_name": error_name, + "error_stage": error_stage, + "timestamp": { + "iso": iso_timestamp, + "human": human_timestamp, + }, + "headless_mode": os.environ.get("HEADLESS", "true").lower() == "true", + "launch_mode": os.environ.get("LAUNCH_MODE", "unknown"), + "environment": { + "RESPONSE_COMPLETION_TIMEOUT": os.environ.get( + "RESPONSE_COMPLETION_TIMEOUT", "300000" + ), + "DEBUG_LOGS_ENABLED": os.environ.get( + "DEBUG_LOGS_ENABLED", "false" + ).lower() + == "true", + "DEFAULT_MODEL": os.environ.get("DEFAULT_MODEL", "unknown"), + }, + } + + # Add exception info if available + if error_exception: + metadata["exception"] = { + "type": type(error_exception).__name__, + "message": str(error_exception), + "traceback": traceback.format_exc(), + } + + # Add additional context + if additional_context: + metadata["additional_context"] = additional_context + + with open(metadata_path, "w", encoding="utf-8") as f: + json.dump(metadata, f, indent=2, ensure_ascii=False) + logger.info("Metadata saved") + except Exception as meta_err: + logger.error(f"Metadata failed: {meta_err}") + + # === 9. Human-Readable SUMMARY.txt === + summary_path = snapshot_dir / "SUMMARY.txt" + try: + summary_lines = [ + "=" * 70, + "ERROR SNAPSHOT SUMMARY", + "=" * 70, + "", + f"Timestamp: {human_timestamp}", + f"Request ID: {req_id}", + f"Error Name: {error_name}", + f"Error Stage: {error_stage or 'N/A'}", + "Snapshot Type: COMPREHENSIVE", + "", + ] + + # Exception info + if error_exception: + summary_lines.extend( + [ + "-" * 70, + "EXCEPTION", + "-" * 70, + f"Type: {type(error_exception).__name__}", + f"Message: {error_exception}", + "", + ] + ) + + # Application state summary + summary_lines.extend( + [ + "-" * 70, + "QUICK REFERENCE", + "-" * 70, + f"Headless Mode: {os.environ.get('HEADLESS', 'true')}", + f"Default Model: {os.environ.get('DEFAULT_MODEL', 'unknown')}", + "", + "-" * 70, + "FILES IN THIS SNAPSHOT", + "-" * 70, + " SUMMARY.txt - This file (start here!)", + " screenshot.png - Full page screenshot", + " dom_dump.html - Complete HTML source", + " dom_structure.txt - Human-readable DOM tree", + " console_logs.txt - Browser console output", + " network_requests.json - Network activity", + " playwright_state.json - Page/locator states", + " llm.json - System context for AI analysis", + " metadata.json - Full error details", + "", + "-" * 70, + "DEBUGGING TIPS", + "-" * 70, + "1. Check screenshot.png for visual state", + "2. Search dom_structure.txt for element issues", + "3. Review console_logs.txt for JS errors", + "4. Check network_requests.json for API failures", + "", + "=" * 70, + ] + ) + + with open(summary_path, "w", encoding="utf-8") as f: + f.write("\n".join(summary_lines)) + logger.info("SUMMARY.txt saved") + except Exception as summary_err: + logger.error(f"SUMMARY.txt failed: {summary_err}") + + logger.info(f"Comprehensive snapshot complete: {snapshot_dir.name}") + return str(snapshot_dir) + + except asyncio.CancelledError: + raise + except Exception as e: + logger.error(f"Failed to create snapshot directory: {e}", exc_info=True) + return "" + + +async def save_error_snapshot_enhanced( + error_name: str = "error", + error_exception: Optional[Exception] = None, + error_stage: str = "", + additional_context: Optional[Dict[str, Any]] = None, + locators: Optional[Dict[str, Locator]] = None, +) -> None: + """ + Enhanced error snapshot function supporting rich context capture. + + This function bridges the simple interface with the comprehensive snapshot system, + allowing callers to pass exception objects and locator states for debugging. + + Args: + error_name: Error name with optional req_id suffix (e.g., "clear_chat_error_hbfu521") + error_exception: The exception that triggered the snapshot (optional) + error_stage: Description of the error stage (optional) + additional_context: Extra context dict to include in metadata (optional) + locators: Dict of named locators to capture states for (optional) + """ + from api_utils.server_state import state + + # Parse req_id from error_name if present (format: "error_name_req_id") + name_parts = error_name.split("_") + req_id = ( + name_parts[-1] + if len(name_parts) > 1 and len(name_parts[-1]) == 7 + else "unknown" + ) + base_error_name = error_name if req_id == "unknown" else "_".join(name_parts[:-1]) + + page_to_snapshot = state.page_instance + + if ( + not hasattr(state, "browser_instance") + or not state.browser_instance + or not state.browser_instance.is_connected() + or not page_to_snapshot + or page_to_snapshot.is_closed() + ): + logger.warning( + f"[{req_id}] Browser/page unavailable ({base_error_name}), saving minimal snapshot..." + ) + # Fallback to minimal snapshot + from browser_utils.operations_modules.errors import save_minimal_snapshot + + await save_minimal_snapshot( + error_name=base_error_name, + req_id=req_id, + error_exception=error_exception, + additional_context=additional_context, + ) + return + + # Merge additional context with exception info + merged_context = additional_context.copy() if additional_context else {} + + # Add exception details to context if provided + if error_exception: + merged_context["exception_type"] = type(error_exception).__name__ + merged_context["exception_message"] = str(error_exception) + # Include args if available (some exceptions store useful info here) + if hasattr(error_exception, "args") and error_exception.args: + merged_context["exception_args"] = [ + str(a) for a in error_exception.args[:3] + ] + + # Call comprehensive snapshot with all context + await save_comprehensive_snapshot( + page=page_to_snapshot, + error_name=base_error_name, + req_id=req_id, + error_stage=error_stage or "Enhanced snapshot call", + additional_context=merged_context if merged_context else None, + locators=locators, + error_exception=error_exception, + ) diff --git a/browser_utils/initialization/__init__.py b/browser_utils/initialization/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..919ad015627a4d84378e54868ddfa2b47f291f0a --- /dev/null +++ b/browser_utils/initialization/__init__.py @@ -0,0 +1,18 @@ +# --- browser_utils/initialization/__init__.py --- +from .core import ( + close_page_logic as _close_page_logic, +) +from .core import ( + enable_temporary_chat_mode, + signal_camoufox_shutdown, +) +from .core import ( + initialize_page_logic as _initialize_page_logic, +) + +__all__ = [ + "_initialize_page_logic", + "_close_page_logic", + "signal_camoufox_shutdown", + "enable_temporary_chat_mode", +] diff --git a/browser_utils/initialization/auth.py b/browser_utils/initialization/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..3d748e47b7cfd0e1015ed5c2bbd4835ed7f8ae8a --- /dev/null +++ b/browser_utils/initialization/auth.py @@ -0,0 +1,61 @@ +# --- browser_utils/initialization/auth.py --- +""" +Authentication Saving Module - Simplified Version + +Handles saving authentication state after login. Automatically saves to SAVED_AUTH_DIR. +""" + +import asyncio +import logging +import os +import time + +from config import SAVED_AUTH_DIR + +logger = logging.getLogger("AIStudioProxyServer") + + +async def wait_for_model_list_and_handle_auth_save(temp_context, launch_mode, loop): + """Wait for model list response and handle authentication saving""" + from api_utils.server_state import state + + # Wait for model list response to confirm login success + logger.info("Waiting for model list response to confirm login success...") + try: + await asyncio.wait_for(state.model_list_fetch_event.wait(), timeout=30.0) + logger.info("Model list response detected, login confirmed!") + except asyncio.TimeoutError: + logger.warning( + "Timeout waiting for model list response, but continuing with auth save..." + ) + + # Determine filename: env var > auto-generate + filename = os.environ.get("SAVE_AUTH_FILENAME", "").strip() + if not filename: + filename = f"auth_auto_{int(time.time())}" + + await _save_auth_state(temp_context, filename) + + +async def _save_auth_state(temp_context, filename: str): + """Unified authentication saving function""" + os.makedirs(SAVED_AUTH_DIR, exist_ok=True) + + if not filename.endswith(".json"): + filename += ".json" + auth_save_path = os.path.join(SAVED_AUTH_DIR, filename) + + print("\n" + "=" * 50, flush=True) + print("Login successful! Saving authentication state...", flush=True) + + try: + await temp_context.storage_state(path=auth_save_path) + logger.info(f"Authentication state saved to: {auth_save_path}") + print(f"Authentication state saved to: {auth_save_path}", flush=True) + except asyncio.CancelledError: + raise + except Exception as e: + logger.error(f"Failed to save authentication state: {e}", exc_info=True) + print(f"Failed to save authentication state: {e}", flush=True) + + print("=" * 50 + "\n", flush=True) diff --git a/browser_utils/initialization/core.py b/browser_utils/initialization/core.py new file mode 100644 index 0000000000000000000000000000000000000000..ef4e977e3dd9cc27387be951a2dc45ce56ade177 --- /dev/null +++ b/browser_utils/initialization/core.py @@ -0,0 +1,692 @@ +# --- browser_utils/initialization/core.py --- +import asyncio +import logging +import os +from typing import Any, Dict, Optional, Tuple + +from playwright.async_api import ( + Browser as AsyncBrowser, +) +from playwright.async_api import ( + BrowserContext as AsyncBrowserContext, +) +from playwright.async_api import ( + Error as PlaywrightAsyncError, +) +from playwright.async_api import ( + Page as AsyncPage, +) +from playwright.async_api import ( + expect as expect_async, +) + +from config import ( + AI_STUDIO_URL_PATTERN, + INPUT_SELECTOR, + MODEL_NAME_SELECTOR, + USER_INPUT_END_MARKER_SERVER, + USER_INPUT_START_MARKER_SERVER, + GlobalState, +) +from config.selector_utils import ( + INPUT_WRAPPER_SELECTORS, +) + +from .auth import wait_for_model_list_and_handle_auth_save +from .debug import setup_debug_listeners +from .network import setup_network_interception_and_scripts + +logger = logging.getLogger("AIStudioProxyServer") + + +async def _wait_for_shutdown(): + """Helper to wait for GlobalState.IS_SHUTTING_DOWN event.""" + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, GlobalState.IS_SHUTTING_DOWN.wait) + + +async def initialize_page_logic( # pragma: no cover + browser: AsyncBrowser, storage_state_path: Optional[str] = None +) -> Tuple[AsyncPage, bool]: + """ + Initialize page logic, connecting to existing browser + + Args: + browser: Playwright browser instance + storage_state_path: Optional authentication file path. If provided, it will be prioritized. + """ + logger.debug("[Init] Initializing page logic") + temp_context: Optional[AsyncBrowserContext] = None + storage_state_path_to_use: Optional[str] = None + launch_mode = os.environ.get("LAUNCH_MODE", "debug") + loop = asyncio.get_running_loop() + + # Prioritize the passed storage_state_path + if storage_state_path: + if os.path.exists(storage_state_path): + storage_state_path_to_use = storage_state_path + logger.debug(f"Using specified auth file: {storage_state_path_to_use}") + else: + logger.error(f"Specified auth file does not exist: {storage_state_path}") + # If it's a clearly specified path but does not exist, it should be an error + raise RuntimeError( + f"Specified auth file does not exist: {storage_state_path}" + ) + else: + # Fall back to existing environment variable logic + if launch_mode == "headless" or launch_mode == "virtual_headless": + # Check for Auto-Auth Rotation on Startup + if ( + os.environ.get("AUTO_AUTH_ROTATION_ON_STARTUP", "false").lower() + == "true" + ): + logger.info( + " 🤖 Auto-Auth Rotation on Startup is ENABLED. Selecting profile..." + ) + try: + # Local import to avoid circular dependencies + from browser_utils.auth_rotation import ( + _get_next_profile, + check_profile_cookie_health, + ) + + next_profile = _get_next_profile() + if next_profile: + os.environ["ACTIVE_AUTH_JSON_PATH"] = next_profile + logger.info(f" ✅ Auto-selected profile: {next_profile}") + + # Check cookie health of selected profile + health = check_profile_cookie_health(next_profile) + if health["health_status"] == "critical": + logger.warning( + " ⚠️ Selected profile has expired authentication cookies. " + "Consider refreshing by logging in again in debug mode." + ) + else: + logger.warning( + " ⚠️ Auto-Auth Rotation: No available profiles found. Continuing with environment defaults." + ) + except ImportError: + logger.error( + " ❌ Auto-Auth Rotation failed: Could not import auth_rotation module." + ) + except Exception as e: + logger.error( + f" ❌ Error during Auto-Auth Rotation on Startup: {e}", + exc_info=True, + ) + + auth_filename = os.environ.get("ACTIVE_AUTH_JSON_PATH") + logger.info( + f"[DEBUG] Headless Init: ACTIVE_AUTH_JSON_PATH='{auth_filename}'" + ) + + if auth_filename: + constructed_path = auth_filename + if os.path.exists(constructed_path): + storage_state_path_to_use = constructed_path + else: + logger.error( + f"{launch_mode} mode auth file invalid or does not exist: '{constructed_path}'" + ) + # DIAGNOSTIC: Check if we should have rotated + logger.info( + f"[DEBUG] Auth file missing. Auto-Rotation Flag: {os.environ.get('AUTO_AUTH_ROTATION_ON_STARTUP')}" + ) + raise RuntimeError( + f"{launch_mode} mode auth file invalid: '{constructed_path}'" + ) + else: + logger.error( + f"{launch_mode} mode requires ACTIVE_AUTH_JSON_PATH environment variable, but it's not set or is empty." + ) + raise RuntimeError( + f"{launch_mode} mode requires ACTIVE_AUTH_JSON_PATH." + ) + elif launch_mode == "debug": + logger.info( + "Debug mode: Attempting to load auth file from environment variable ACTIVE_AUTH_JSON_PATH..." + ) + auth_filepath_from_env = os.environ.get("ACTIVE_AUTH_JSON_PATH") + if auth_filepath_from_env and os.path.exists(auth_filepath_from_env): + storage_state_path_to_use = auth_filepath_from_env + logger.debug( + f"Auth file to be used in debug mode (from environment variable): {storage_state_path_to_use}" + ) + elif auth_filepath_from_env: + logger.warning( + f"The file pointed to by ACTIVE_AUTH_JSON_PATH in debug mode does not exist: '{auth_filepath_from_env}'. Auth file not loaded." + ) + else: + logger.info( + "Auth file not provided via environment variable in debug mode. Current browser state will be used." + ) + elif launch_mode == "direct_debug_no_browser": + logger.info( + "direct_debug_no_browser mode: storage_state not loaded, no browser operations performed." + ) + else: + logger.warning( + f"Warning: Unknown launch mode '{launch_mode}'. storage_state not loaded." + ) + + try: + # Consolidate into one log message + auth_file = ( + os.path.basename(storage_state_path_to_use) + if storage_state_path_to_use + else None + ) + context_options: Dict[str, Any] = {"viewport": {"width": 460, "height": 800}} + if storage_state_path_to_use: + context_options["storage_state"] = storage_state_path_to_use + from api_utils.server_state import state + + state.current_auth_profile_path = storage_state_path_to_use + logger.info( + f" (Using storage_state='{os.path.basename(storage_state_path_to_use)}')" + ) + else: + from api_utils.server_state import state + + state.current_auth_profile_path = None + logger.info(" (Not using storage_state)") + + # Proxy settings need to be retrieved from the server module + from api_utils.server_state import state + + if state.PLAYWRIGHT_PROXY_SETTINGS: + context_options["proxy"] = state.PLAYWRIGHT_PROXY_SETTINGS + logger.debug( + f"[Browser] Context configured with proxy: {state.PLAYWRIGHT_PROXY_SETTINGS['server']}" + ) + + context_options["ignore_https_errors"] = True + + # Single consolidated log + if auth_file: + logger.info(f"[Browser] Context created (Auth: {auth_file})") + else: + logger.debug("[Browser] Context created (No Auth)") + + temp_context = await browser.new_context(**context_options) + + # Set up network interception and script injection + await setup_network_interception_and_scripts(temp_context) + + found_page: Optional[AsyncPage] = None + pages = temp_context.pages + target_url_base = f"https://{AI_STUDIO_URL_PATTERN}" + target_full_url = f"{target_url_base}prompts/new_chat" + login_url_pattern = "accounts.google.com" + current_url = "" + + # Import _handle_model_list_response - delayed import to avoid circular dependency + from browser_utils.operations import _handle_model_list_response + + for p_iter in pages: + try: + page_url_to_check = p_iter.url + if ( + not p_iter.is_closed() + and target_url_base in page_url_to_check + and "/prompts/" in page_url_to_check + ): + found_page = p_iter + current_url = page_url_to_check + logger.debug(f"Found opened AI Studio page: {current_url}") + if found_page: + logger.debug( + f"Adding model list response listener to existing page {found_page.url}." + ) + found_page.on("response", _handle_model_list_response) + # Setup debug listeners for error snapshots + setup_debug_listeners(found_page) + break + except PlaywrightAsyncError as pw_err_url: + logger.warning(f"Playwright error checking page URL: {pw_err_url}") + except AttributeError as attr_err_url: + logger.warning(f"Attribute error checking page URL: {attr_err_url}") + except asyncio.CancelledError: + raise + except Exception as e_url_check: + logger.warning( + f" Other unexpected error checking page URL: {e_url_check} (Type: {type(e_url_check).__name__})" + ) + + if not found_page: + logger.info(f"[Navigation] Opening new page: {target_full_url}") + found_page = await temp_context.new_page() + if found_page: + logger.debug( + "Adding model list response listener to new page (before navigation)." + ) + found_page.on("response", _handle_model_list_response) + # Setup debug listeners for error snapshots + setup_debug_listeners(found_page) + try: + await found_page.goto( + target_full_url, wait_until="domcontentloaded", timeout=90000 + ) + current_url = found_page.url + logger.debug( + f"New page navigation attempt complete. Current URL: {current_url}" + ) + except asyncio.CancelledError: + raise + except Exception as new_page_nav_err: + # Import save_error_snapshot function + from browser_utils.operations import save_error_snapshot + + await save_error_snapshot("init_new_page_nav_fail") + error_str = str(new_page_nav_err) + if "NS_ERROR_NET_INTERRUPT" in error_str: + logger.error( + "\n" + "=" * 30 + " Network Navigation Error Tips " + "=" * 30 + ) + logger.error( + f"Navigation to '{target_full_url}' failed with network interrupt error (NS_ERROR_NET_INTERRUPT)." + ) + logger.error( + "This usually means the connection was unexpectedly disconnected while the browser was trying to load the page." + ) + logger.error("Possible causes and troubleshooting suggestions:") + logger.error( + " 1. Network connection: Please check if your local network connection is stable and try to access the target URL in a normal browser." + ) + logger.error( + " 2. AI Studio service: Confirm if the aistudio.google.com service itself is available." + ) + logger.error( + " 3. Firewall/Proxy/VPN: Check local firewall, antivirus, proxy, or VPN settings." + ) + logger.error( + " 4. Camoufox service: Confirm if the launch_camoufox.py script is running normally." + ) + logger.error( + " 5. System resource issues: Ensure the system has enough memory and CPU resources." + ) + logger.error("=" * 74 + "\n") + raise RuntimeError( + f"Failed to navigate to new page: {new_page_nav_err}" + ) from new_page_nav_err + + if login_url_pattern in current_url: + if launch_mode == "headless": + logger.error( + "Detected redirect to login page in headless mode, authentication may have expired. Please update the auth file." + ) + raise RuntimeError( + "Auth failed in headless mode, auth file update required." + ) + else: + print(f"\n{'=' * 20} Action Required {'=' * 20}", flush=True) + login_prompt = " Detected login may be required. If the browser shows a login page, please complete the Google login in the browser window, then press Enter here to continue..." + # NEW: If SUPPRESS_LOGIN_WAIT is set, skip waiting for user input. + if os.environ.get("SUPPRESS_LOGIN_WAIT", "").lower() in ( + "1", + "true", + "yes", + ): + logger.info( + "SUPPRESS_LOGIN_WAIT flag detected, skipping wait for user input." + ) + else: + print(USER_INPUT_START_MARKER_SERVER, flush=True) + await loop.run_in_executor(None, input, login_prompt) + print(USER_INPUT_END_MARKER_SERVER, flush=True) + logger.info("Checking login status...") + try: + await found_page.wait_for_url( + f"**/{AI_STUDIO_URL_PATTERN}**", timeout=180000 + ) + current_url = found_page.url + if login_url_pattern in current_url: + logger.error( + "Page still seems to be on the login page after manual login attempt." + ) + raise RuntimeError( + "Still on login page after manual login attempt." + ) + logger.info( + "Login successful! Please do not operate the browser window and wait for further instructions." + ) + + # Call auth save logic after successful login + if os.environ.get("AUTO_SAVE_AUTH", "false").lower() == "true": + await wait_for_model_list_and_handle_auth_save( + temp_context, launch_mode, loop + ) + + except asyncio.CancelledError: + raise + except Exception as wait_login_err: + from browser_utils.operations import save_error_snapshot + + await save_error_snapshot("init_login_wait_fail") + logger.error( + f"Failed to detect AI Studio URL after login prompt or error saving status: {wait_login_err}", + exc_info=True, + ) + raise RuntimeError( + f"Failed to detect AI Studio URL after login prompt: {wait_login_err}" + ) from wait_login_err + + elif target_url_base not in current_url or "/prompts/" not in current_url: + from browser_utils.operations import save_error_snapshot + + await save_error_snapshot("init_unexpected_page") + logger.error( + f"Unexpected page URL after initial navigation: {current_url}. Expected it to contain '{target_url_base}' and '/prompts/'." + ) + raise RuntimeError( + f"Unexpected page after initial navigation: {current_url}." + ) + + await found_page.bring_to_front() + + try: + # Use centralized selector fallback logic to find input container + # Supports current and old UI structures (ms-prompt-input-wrapper / ms-chunk-editor / ms-prompt-box) + # Use find_first_visible_locator to wait for element visibility, solving timing issues in headless mode + from config.selector_utils import find_first_visible_locator + + # Wrap in a way that respects the shutdown signal + async def find_locator_task(): + return await find_first_visible_locator( + found_page, + INPUT_WRAPPER_SELECTORS, + description="Input Container", + timeout_per_selector=30000, + ) + + find_task = asyncio.create_task(find_locator_task()) + shutdown_task = asyncio.create_task(_wait_for_shutdown()) + + done, pending = await asyncio.wait( + [find_task, shutdown_task], return_when=asyncio.FIRST_COMPLETED + ) + + if shutdown_task in done: + logger.info( + "🛑 Shutdown signal received during initialization. Aborting." + ) + find_task.cancel() + raise RuntimeError("Initialization aborted due to shutdown signal.") + + shutdown_task.cancel() + input_wrapper_locator, matched_selector = await find_task + + if not input_wrapper_locator: + raise RuntimeError( + "Could not find input container element. Tried selectors: " + + ", ".join(INPUT_WRAPPER_SELECTORS) + ) + + # Container confirmed visible by find_first_visible_locator, check input box directly + await expect_async(found_page.locator(INPUT_SELECTOR)).to_be_visible( + timeout=10000 + ) + logger.debug( + f"[Selector] Input area located and visible ({matched_selector})" + ) + + model_name_locator = found_page.locator(MODEL_NAME_SELECTOR) + try: + model_name_on_page = await model_name_locator.first.inner_text( + timeout=5000 + ) + except PlaywrightAsyncError as e: + logger.error(f"Error getting model name (model_name_locator): {e}") + raise + + result_page_instance = found_page + result_page_ready = True + + logger.info( + f"[Page] Logic initialization successful | Current Model: {model_name_on_page}" + ) + return result_page_instance, result_page_ready + except asyncio.CancelledError: + raise + except Exception as input_visible_err: + from browser_utils.operations import save_error_snapshot + + await save_error_snapshot("init_fail_input_timeout") + logger.error( + f"Page initialization failed: core input area did not become visible within expected time. Last URL was {found_page.url}", + exc_info=True, + ) + raise RuntimeError( + f"Page initialization failed: core input area did not become visible within expected time. Last URL was {found_page.url}" + ) from input_visible_err + except asyncio.CancelledError: + logger.warning("Page initialization cancelled.") + if temp_context: + try: + await temp_context.close() + except asyncio.CancelledError: + raise + except Exception: + pass + raise + except Exception as e_init_page: + logger.critical( + f"Serious unexpected error during page logic initialization: {e_init_page}", + exc_info=True, + ) + if temp_context: + try: + logger.info( + " Attempting to close temporary browser context due to initialization error." + ) + # [ID-04] Optimize Browser Lifecycle Management: Add timeout to context close + await asyncio.wait_for(temp_context.close(), timeout=2.0) + logger.info(" Temporary browser context closed.") + except asyncio.TimeoutError: + logger.warning( + " 🚨 [ID-04] Browser context close timeout (2s), skipping forced close to speed up shutdown." + ) + except asyncio.CancelledError: + raise + except Exception as close_err: + logger.warning(f"Error closing temporary browser context: {close_err}") + from browser_utils.operations import save_error_snapshot + + await save_error_snapshot("init_unexpected_error") + raise RuntimeError( + f"Unexpected page initialization error: {e_init_page}" + ) from e_init_page + + +async def close_page_logic() -> Tuple[None, bool]: # pragma: no cover + """Close page logic""" + # Need to access global variables + from api_utils.server_state import state + + logger.info("--- Running page logic shutdown --- ") + if state.page_instance and not state.page_instance.is_closed(): + try: + # [ID-04] Optimize Browser Lifecycle Management: 2-second timeout for graceful close + await asyncio.wait_for(state.page_instance.close(), timeout=2.0) + logger.info(" Page closed") + except asyncio.TimeoutError: + logger.warning( + " 🚨 [ID-04] Browser page close timeout (2s), skipping forced close to speed up shutdown." + ) + except PlaywrightAsyncError as pw_err: + logger.warning(f"Playwright error closing page: {pw_err}") + except asyncio.CancelledError: + raise + except Exception as other_err: + logger.error( + f" Unexpected error closing page: {other_err} (Type: {type(other_err).__name__})", + exc_info=True, + ) + state.page_instance = None + state.is_page_ready = False + logger.info("Page logic state reset.") + return None, False + + +async def signal_camoufox_shutdown() -> None: # pragma: no cover + """Send shutdown signal to Camoufox server""" + logger.info( + "Attempting to send shutdown signal to Camoufox server (this feature may have been handled by parent process)..." + ) + ws_endpoint = os.environ.get("CAMOUFOX_WS_ENDPOINT") + if not ws_endpoint: + logger.warning( + "Could not send shutdown signal: CAMOUFOX_WS_ENDPOINT environment variable not found." + ) + return + + # Need to access global browser instance + from api_utils.server_state import state + + if not state.browser_instance or not state.browser_instance.is_connected(): + logger.warning( + "Browser instance disconnected or not initialized, skipping shutdown signal send." + ) + return + try: + await asyncio.sleep(0.2) + logger.info("(Simulated) Shutdown signal handled.") + except asyncio.CancelledError: + raise + except Exception as e: + logger.error( + f"Captured exception while sending shutdown signal: {e}", exc_info=True + ) + + +async def _is_temporary_chat_active(page: AsyncPage) -> bool: + """Best-effort check for whether Temporary chat is currently enabled. + + Supports both the legacy toggle-class signal and the newer UI variants: + - menu item checkmark (`data-test-incognito-checkmark`) + - header indicator (`ms-incognito-mode-indicator`) + """ + + try: + # Newer UI: header indicator appears only when Temporary chat is active. + indicator_locator = page.locator( + "ms-incognito-mode-indicator [data-test-id='main-text'], " + "ms-incognito-mode-indicator .main-text" + ) + indicator_count = await indicator_locator.count() + for i in range(indicator_count): + try: + indicator_item = indicator_locator.nth(i) + if not await indicator_item.is_visible(timeout=500): + continue + indicator_text = (await indicator_item.inner_text()).strip().lower() + if indicator_text == "temporary chat": + return True + except Exception: + continue + + # New menu UI: active state is represented by a checkmark inside the menu item. + try: + checkmark_locator = page.locator("[data-test-incognito-checkmark]") + if await checkmark_locator.count() > 0: + return True + except Exception: + pass + + # Menu button variants (legacy and gray-release UI). + toggle_locator = page.locator( + "button[data-test-incognito-toggle], " + 'button[aria-label="Temporary chat toggle"], ' + 'button[aria-label="Toggle temporary chat"]' + ) + if await toggle_locator.count() > 0: + # Legacy/alternative signals. + for attr_name in ("aria-pressed", "aria-checked"): + try: + attr_value = await toggle_locator.get_attribute(attr_name) + if attr_value and attr_value.lower() == "true": + return True + except Exception: + pass + + try: + button_classes = await toggle_locator.get_attribute("class") or "" + if "ms-button-active" in button_classes: + return True + except Exception: + pass + + return False + except Exception: + return False + + +async def enable_temporary_chat_mode(page: AsyncPage) -> bool: # pragma: no cover + """ + Check and enable "Temporary chat" mode in the AI Studio interface. + Supports both direct UI visibility and collapsed menu visibility. + """ + incognito_selector = ( + "button[data-test-incognito-toggle], " + 'button[aria-label="Temporary chat toggle"], ' + 'button[aria-label="Toggle temporary chat"]' + ) + menu_trigger_selector = 'button[aria-label="View more actions"]' + + incognito_locator = page.locator(incognito_selector) + menu_trigger = page.locator(menu_trigger_selector) + + async def _close_menu_if_needed() -> None: + """Best-effort menu cleanup that tolerates UI variants without a menu trigger.""" + try: + if await menu_trigger.count() == 0: + return + if await menu_trigger.is_visible(): + if await menu_trigger.get_attribute("aria-expanded") == "true": + logger.debug("[UI] Closing menu to restore UI state") + await page.keyboard.press("Escape") + except Exception: + pass + + try: + if await _is_temporary_chat_active(page): + logger.debug("[UI] Temporary chat mode already active") + await _close_menu_if_needed() + return True + + # Fast path + logger.debug("[UI] Searching for temporary chat button (Fast path)") + try: + await incognito_locator.wait_for(state="visible", timeout=3000) + except Exception: + # Fallback + logger.debug("[UI] Button not visible, attempting to open menu") + if await menu_trigger.is_visible(): + await menu_trigger.click() + # Wait for the menu item to appear + await incognito_locator.wait_for(state="visible", timeout=5000) + else: + logger.warning("[UI] Neither button nor menu trigger found") + return False + + if await _is_temporary_chat_active(page): + logger.debug("[UI] Temporary chat mode already active") + else: + logger.debug("[UI] Enabling temporary chat mode") + await incognito_locator.click(timeout=5000, force=True) + await asyncio.sleep(1) + + enabled = await _is_temporary_chat_active(page) + + # Recovery: Close menu if still expanded + await _close_menu_if_needed() + return enabled + + except asyncio.CancelledError: + raise + except Exception as e: + logger.warning(f"[UI] Error in temporary chat mode: {e}") + # Final safety attempt to clear any stuck UI + await _close_menu_if_needed() + return await _is_temporary_chat_active(page) diff --git a/browser_utils/initialization/debug.py b/browser_utils/initialization/debug.py new file mode 100644 index 0000000000000000000000000000000000000000..6798b60720e62a4364d602cee6e7db25fc14bf9c --- /dev/null +++ b/browser_utils/initialization/debug.py @@ -0,0 +1,109 @@ +# --- browser_utils/initialization/debug.py --- +import logging + +from playwright.async_api import Page as AsyncPage + +logger = logging.getLogger("AIStudioProxyServer") + + +def setup_debug_listeners(page: AsyncPage) -> None: + """ + Setup console and network logging listeners for comprehensive error snapshots. + + This function attaches event listeners to capture: + - Browser console messages (log, warning, error, etc.) + - Network requests and responses + + Args: + page: Playwright page instance to attach listeners to + """ + from datetime import datetime, timezone + + from api_utils.server_state import state + + def handle_console(msg): + """Handle console messages from the browser.""" + try: + # Extract location info if available + location_str = "" + if msg.location: + url = msg.location.get("url", "") + line = msg.location.get("lineNumber", 0) + if url or line: + location_str = f"{url}:{line}" + + state.console_logs.append( + { + "timestamp": datetime.now(timezone.utc).isoformat(), + "type": msg.type, + "text": msg.text, + "location": location_str, + } + ) + + # Log errors to our logger as well + if msg.type == "error": + # Filter out known benign browser warnings - log at DEBUG level + text_lower = msg.text.lower() + if "cookie" in text_lower and "rejected" in text_lower: + # Known Google cookie warning (SIDCC, etc.) - benign but may indicate stale auth profile + # Log at DEBUG to reduce noise but preserve for troubleshooting + logger.debug( + f"[Browser Cookie Warning] {msg.text} - This may indicate the auth profile needs refresh" + ) + return + logger.warning(f"[Browser Console Error] {msg.text}") + + except Exception as e: + logger.error(f"Failed to capture console message: {e}") + + def handle_request(request): + """Handle network requests.""" + try: + # Only log relevant requests (skip static assets, images, etc.) + url_lower = request.url.lower() + if any( + ext in url_lower + for ext in [".png", ".jpg", ".jpeg", ".gif", ".css", ".woff", ".woff2"] + ): + return # Skip static assets + + state.network_log["requests"].append( + { + "timestamp": datetime.now(timezone.utc).isoformat(), + "url": request.url, + "method": request.method, + "resource_type": request.resource_type, + } + ) + except Exception as e: + logger.error(f"Failed to capture network request: {e}") + + def handle_response(response): + """Handle network responses.""" + try: + # Only log relevant responses + url_lower = response.url.lower() + if any( + ext in url_lower + for ext in [".png", ".jpg", ".jpeg", ".gif", ".css", ".woff", ".woff2"] + ): + return # Skip static assets + + state.network_log["responses"].append( + { + "timestamp": datetime.now(timezone.utc).isoformat(), + "url": response.url, + "status": response.status, + "status_text": response.status_text, + } + ) + except Exception as e: + logger.error(f"Failed to capture network response: {e}") + + # Attach listeners + page.on("console", handle_console) + page.on("request", handle_request) + page.on("response", handle_response) + + logger.debug("Debug listeners (console + network) attached to page") diff --git a/browser_utils/initialization/network.py b/browser_utils/initialization/network.py new file mode 100644 index 0000000000000000000000000000000000000000..931eff247833885c3cb165ee79d8847755083b35 --- /dev/null +++ b/browser_utils/initialization/network.py @@ -0,0 +1,110 @@ +# --- browser_utils/initialization/network.py --- +import asyncio +import json +import logging + +from playwright.async_api import BrowserContext as AsyncBrowserContext + +from config import settings + +from .scripts import add_init_scripts_to_context + +logger = logging.getLogger("AIStudioProxyServer") + + +async def setup_network_interception_and_scripts(context: AsyncBrowserContext): + """Setup network interception and script injection""" + try: + # Check for network interception toggle + if settings.NETWORK_INTERCEPTION_ENABLED: + # Setup network interception + await _setup_model_list_interception(context) + else: + logger.debug("[Network] Network interception disabled") + + # Check for script injection toggle + if settings.ENABLE_SCRIPT_INJECTION: + # Optional: still inject scripts as fallback + await add_init_scripts_to_context(context) + else: + logger.debug("[Network] Script injection disabled") + + except asyncio.CancelledError: + raise + except Exception as e: + logger.error(f"Error setting up network interception and scripts: {e}") + + +async def _setup_model_list_interception(context: AsyncBrowserContext): + """Setup model list network interception""" + try: + + async def handle_model_list_route(route): + """Handle model list request route""" + request = route.request + + # Check if it's a model list request + if "alkalimakersuite" in request.url and "ListModels" in request.url: + logger.info(f"Intercepted model list request: {request.url}") + + # Continue original request + response = await route.fetch() + + # Get original response body + original_body = await response.body() + + # Process response + modified_body = await _modify_model_list_response( + original_body, request.url + ) + + # Return modified response + await route.fulfill(response=response, body=modified_body) + else: + # For other requests, continue normally + await route.continue_() + + # Register route interceptor + await context.route("**/*", handle_model_list_route) + logger.info("Model list network interception setup") + + except asyncio.CancelledError: + raise + except Exception as e: + logger.error(f"Error setting up model list network interception: {e}") + + +async def _modify_model_list_response(original_body: bytes, url: str) -> bytes: + """Modify model list response (Cleanup/Pass-through)""" + try: + # Decode response body + original_text = original_body.decode("utf-8") + + # Handle anti-hijack prefix + ANTI_HIJACK_PREFIX = ")]}'\n" + has_prefix = False + if original_text.startswith(ANTI_HIJACK_PREFIX): + original_text = original_text[len(ANTI_HIJACK_PREFIX) :] + has_prefix = True + + # Parse JSON to ensure it's valid, but we don't inject models anymore + try: + json_data = json.loads(original_text) + except json.JSONDecodeError as json_err: + logger.error(f"Failed to parse model list response JSON: {json_err}") + return original_body + + # Serialize back to JSON + modified_text = json.dumps(json_data, separators=(",", ":")) + + # Add prefix back + if has_prefix: + modified_text = ANTI_HIJACK_PREFIX + modified_text + + return modified_text.encode("utf-8") + + except asyncio.CancelledError: + raise + except Exception as e: + logger.error(f"Error processing model list response: {e}") + return original_body diff --git a/browser_utils/initialization/scripts.py b/browser_utils/initialization/scripts.py new file mode 100644 index 0000000000000000000000000000000000000000..a040984c1446d161d4411c7edca9612f05015a57 --- /dev/null +++ b/browser_utils/initialization/scripts.py @@ -0,0 +1,60 @@ +# --- browser_utils/initialization/scripts.py --- +import asyncio +import logging +import os + +from playwright.async_api import BrowserContext as AsyncBrowserContext + +logger = logging.getLogger("AIStudioProxyServer") + + +async def add_init_scripts_to_context(context: AsyncBrowserContext): + """Add initialization scripts to browser context (fallback option)""" + try: + from config.settings import USERSCRIPT_PATH + + # Check if script file exists + if not os.path.exists(USERSCRIPT_PATH): + logger.info( + f"Script file does not exist, skipping script injection: {USERSCRIPT_PATH}" + ) + return + + # Read script content + with open(USERSCRIPT_PATH, "r", encoding="utf-8") as f: + script_content = f.read() + + # Clean UserScript headers + cleaned_script = _clean_userscript_headers(script_content) + + # Add to context initialization scripts + await context.add_init_script(cleaned_script) + logger.info( + f"Added script to browser context initialization scripts: {os.path.basename(USERSCRIPT_PATH)}" + ) + + except asyncio.CancelledError: + raise + except Exception as e: + logger.error(f"Error adding initialization script to context: {e}") + + +def _clean_userscript_headers(script_content: str) -> str: + """Clean UserScript header information""" + lines = script_content.split("\n") + cleaned_lines = [] + in_userscript_block = False + + for line in lines: + if line.strip().startswith("// ==UserScript=="): + in_userscript_block = True + continue + elif line.strip().startswith("// ==/UserScript=="): + in_userscript_block = False + continue + elif in_userscript_block: + continue + else: + cleaned_lines.append(line) + + return "\n".join(cleaned_lines) diff --git a/browser_utils/model_management.py b/browser_utils/model_management.py new file mode 100644 index 0000000000000000000000000000000000000000..251d376236c6b813fbc9fa133fd49241b785bfbe --- /dev/null +++ b/browser_utils/model_management.py @@ -0,0 +1,32 @@ +""" +browser_utils/model_management.py +--------------------------------- +Shim module for backward compatibility. +Refactored logic is now located in browser_utils.models.* +""" + +from browser_utils.models.startup import ( + _handle_initial_model_state_and_storage, + _set_model_from_page_display, +) +from browser_utils.models.switcher import ( + load_excluded_models, + switch_ai_studio_model, +) +from browser_utils.models.ui_state import ( + _force_ui_state_settings, + _force_ui_state_with_retry, + _verify_and_apply_ui_state, + _verify_ui_state_settings, +) + +__all__ = [ + "_verify_ui_state_settings", + "_force_ui_state_settings", + "_force_ui_state_with_retry", + "_verify_and_apply_ui_state", + "switch_ai_studio_model", + "load_excluded_models", + "_handle_initial_model_state_and_storage", + "_set_model_from_page_display", +] diff --git a/browser_utils/models/startup.py b/browser_utils/models/startup.py new file mode 100644 index 0000000000000000000000000000000000000000..16de2537b55dfd5a51cf628ac222948a19e765a7 --- /dev/null +++ b/browser_utils/models/startup.py @@ -0,0 +1,247 @@ +""" +Model State Initialization and Synchronization +""" + +import asyncio +import json +import logging + +from playwright.async_api import Page as AsyncPage +from playwright.async_api import expect as expect_async + +from config import INPUT_SELECTOR, MODEL_NAME_SELECTOR + +from .ui_state import _verify_and_apply_ui_state, _verify_ui_state_settings + +logger = logging.getLogger("AIStudioProxyServer") + + +async def _handle_initial_model_state_and_storage(page: AsyncPage): + """Handle initial model state and storage""" + from api_utils.server_state import state + + getattr(state, "current_ai_studio_model_id", None) + getattr(state, "parsed_model_list", []) + getattr(state, "model_list_fetch_event", None) + + logger.debug("[Init] Processing initial model state and localStorage...") + needs_reload_and_storage_update = False + reason_for_reload = "" + + try: + initial_prefs_str = await page.evaluate( + "() => localStorage.getItem('aiStudioUserPreference')" + ) + if not initial_prefs_str: + needs_reload_and_storage_update = True + reason_for_reload = "localStorage not found" + else: + try: + pref_obj = json.loads(initial_prefs_str) + prompt_model_path = pref_obj.get("promptModel") + pref_obj.get("isAdvancedOpen") + is_prompt_model_valid = ( + isinstance(prompt_model_path, str) and prompt_model_path.strip() + ) + + if not is_prompt_model_valid: + needs_reload_and_storage_update = True + reason_for_reload = "promptModel invalid" + else: + # Use new UI state verification + ui_state = await _verify_ui_state_settings(page, "initial") + if ui_state["needsUpdate"]: + needs_reload_and_storage_update = True + reason_for_reload = "UI state mismatch" + else: + state.current_ai_studio_model_id = prompt_model_path.split("/")[ + -1 + ] + logger.debug( + f"localStorage valid and UI state correct. Initial model ID set from localStorage: {state.current_ai_studio_model_id}" + ) + except json.JSONDecodeError: + needs_reload_and_storage_update = True + reason_for_reload = ( + "Failed to parse localStorage.aiStudioUserPreference JSON." + ) + logger.error( + f"Determined refresh and storage update needed: {reason_for_reload}" + ) + + if needs_reload_and_storage_update: + logger.debug(f"[State] Refresh needed: {reason_for_reload}") + await _set_model_from_page_display(page, set_storage=True) + + current_page_url = page.url + logger.info("[UI Operation] Reloading page to apply settings...") + max_retries = 3 + for attempt in range(max_retries): + try: + logger.debug( + f"Attempting page reload (attempt {attempt + 1}/{max_retries}): {current_page_url}" + ) + await page.goto( + current_page_url, wait_until="domcontentloaded", timeout=40000 + ) + await expect_async(page.locator(INPUT_SELECTOR)).to_be_visible( + timeout=30000 + ) + logger.debug(f"Page successfully reloaded to: {page.url}") + + # Verify UI state after page reload + logger.debug("[State] Verifying UI state...") + reload_ui_state_success = await _verify_and_apply_ui_state( + page, "reload" + ) + if reload_ui_state_success: + logger.info("[UI Check] Verification passed after page reload") + else: + logger.warning("UI state verification failed after reload") + + break # Exit loop on success + except asyncio.CancelledError: + raise + except Exception as reload_err: + logger.warning( + f"Page reload attempt {attempt + 1}/{max_retries} failed: {reload_err}" + ) + if attempt < max_retries - 1: + logger.debug("[Init] Retrying in 5 seconds...") + await asyncio.sleep(5) + else: + logger.error( + f"Page reload ultimately failed after {max_retries} attempts: {reload_err}. Subsequent model state may be inaccurate.", + exc_info=True, + ) + from browser_utils.operations import save_error_snapshot + + await save_error_snapshot( + f"initial_storage_reload_fail_attempt_{attempt + 1}" + ) + + logger.debug("[State] Syncing model ID after reload") + await _set_model_from_page_display(page, set_storage=False) + logger.debug( + f"[State] Complete, current model: {state.current_ai_studio_model_id}" + ) + else: + logger.debug("[State] localStorage state OK, no refresh needed") + except asyncio.CancelledError: + raise + except Exception as e: + logger.error( + f"(New) Critical error processing initial model state and localStorage: {e}", + exc_info=True, + ) + try: + logger.warning( + "Due to error, attempting fallback to set global model ID from page display only (not writing to localStorage)..." + ) + await _set_model_from_page_display(page, set_storage=False) + except asyncio.CancelledError: + raise + except Exception as fallback_err: + logger.error(f"Fallback model ID setting also failed: {fallback_err}") + + +async def _set_model_from_page_display(page: AsyncPage, set_storage: bool = False): + """Set model from page display""" + from api_utils.server_state import state + + getattr(state, "current_ai_studio_model_id", None) + getattr(state, "parsed_model_list", []) + model_list_fetch_event = getattr(state, "model_list_fetch_event", None) + + try: + logger.debug("[Model] Reading current model from page display...") + model_name_locator = page.locator(MODEL_NAME_SELECTOR) + displayed_model_name_from_page_raw = await model_name_locator.first.inner_text( + timeout=7000 + ) + displayed_model_name = displayed_model_name_from_page_raw.strip() + logger.debug(f"[Model] Page display: '{displayed_model_name}'") + + found_model_id_from_display = None + if model_list_fetch_event and not model_list_fetch_event.is_set(): + logger.debug("[Model] Waiting for model list data (up to 5 seconds)...") + try: + await asyncio.wait_for(model_list_fetch_event.wait(), timeout=5.0) + except asyncio.TimeoutError: + logger.warning( + "Timeout waiting for model list, may not be able to accurately convert display name to ID." + ) + + found_model_id_from_display = displayed_model_name + + new_model_value = found_model_id_from_display + if state.current_ai_studio_model_id != new_model_value: + state.current_ai_studio_model_id = new_model_value + logger.debug(f"[Model] Global ID updated: {new_model_value}") + # No log needed if unchanged + + if set_storage: + logger.debug("[State] Preparing to update localStorage") + existing_prefs_for_update_str = await page.evaluate( + "() => localStorage.getItem('aiStudioUserPreference')" + ) + prefs_to_set = {} + if existing_prefs_for_update_str: + try: + prefs_to_set = json.loads(existing_prefs_for_update_str) + except json.JSONDecodeError: + logger.warning( + "Failed to parse existing localStorage.aiStudioUserPreference, will create new preferences." + ) + + # Use new force settings feature + logger.debug("[State] Applying forced UI state settings...") + ui_state_success = await _verify_and_apply_ui_state(page, "set_model") + if not ui_state_success: + logger.warning("UI state setting failed, using legacy method") + prefs_to_set["isAdvancedOpen"] = True + prefs_to_set["areToolsOpen"] = True + else: + # Ensure prefs_to_set also contains correct settings + prefs_to_set["isAdvancedOpen"] = True + prefs_to_set["areToolsOpen"] = True + logger.debug("[State] Set: isAdvancedOpen=true, areToolsOpen=true") + + if found_model_id_from_display: + new_prompt_model_path = f"models/{found_model_id_from_display}" + prefs_to_set["promptModel"] = new_prompt_model_path + elif "promptModel" not in prefs_to_set: + logger.warning( + f"Could not find model ID from page display '{displayed_model_name}', and no existing promptModel in localStorage. promptModel will not be actively set to avoid potential issues." + ) + + default_keys_if_missing = { + "bidiModel": "models/gemini-1.0-pro-001", + "isSafetySettingsOpen": False, + "hasShownSearchGroundingTos": False, + "autosaveEnabled": True, + "theme": "system", + "bidiOutputFormat": 3, + "isSystemInstructionsOpen": False, + "warmWelcomeDisplayed": True, + "getCodeLanguage": "Node.js", + "getCodeHistoryToggle": False, + "fileCopyrightAcknowledged": True, + } + for key, val_default in default_keys_if_missing.items(): + if key not in prefs_to_set: + prefs_to_set[key] = val_default + + await page.evaluate( + "(prefsStr) => localStorage.setItem('aiStudioUserPreference', prefsStr)", + json.dumps(prefs_to_set), + ) + logger.debug( + f"[State] localStorage updated (model: {prefs_to_set.get('promptModel', 'N/A')})" + ) + except asyncio.CancelledError: + raise + except Exception as e_set_disp: + logger.error( + f"Error setting model from page display: {e_set_disp}", exc_info=True + ) diff --git a/browser_utils/models/switcher.py b/browser_utils/models/switcher.py new file mode 100644 index 0000000000000000000000000000000000000000..50631a35b554ad6ae11bed51a9378ed58f51faee --- /dev/null +++ b/browser_utils/models/switcher.py @@ -0,0 +1,240 @@ +""" +Model Switching Logic +""" + +import asyncio +import json +import logging +import os +from typing import Optional + +from playwright.async_api import Page as AsyncPage +from playwright.async_api import expect as expect_async + +from config import AI_STUDIO_URL_PATTERN, INPUT_SELECTOR, MODEL_NAME_SELECTOR + +from .ui_state import _verify_and_apply_ui_state + +logger = logging.getLogger("AIStudioProxyServer") + + +async def switch_ai_studio_model(page: AsyncPage, model_id: str, req_id: str) -> bool: + """Switch AI Studio model""" + logger.info(f"[Model] Switching to -> {model_id}") + original_prefs_str: Optional[str] = None + _original_prompt_model: Optional[str] = None + new_chat_url = f"https://{AI_STUDIO_URL_PATTERN}prompts/new_chat" + + try: + original_prefs_str = await page.evaluate( + "() => localStorage.getItem('aiStudioUserPreference')" + ) + if original_prefs_str: + try: + original_prefs_obj = json.loads(original_prefs_str) + _original_prompt_model = original_prefs_obj.get("promptModel") + except json.JSONDecodeError: + logger.warning( + "Failed to parse original aiStudioUserPreference JSON string." + ) + original_prefs_str = None + + current_prefs_for_modification = ( + json.loads(original_prefs_str) if original_prefs_str else {} + ) + full_model_path = f"models/{model_id}" + + if current_prefs_for_modification.get("promptModel") == full_model_path: + logger.debug(f"[Model] Already at target model {model_id}") + if page.url != new_chat_url: + logger.debug( + f"[Model] URL is not new_chat, navigating to {new_chat_url}" + ) + await page.goto( + new_chat_url, wait_until="domcontentloaded", timeout=30000 + ) + await expect_async(page.locator(INPUT_SELECTOR)).to_be_visible( + timeout=30000 + ) + return True + + logger.debug( + f"[Model] Updating localStorage.promptModel: {current_prefs_for_modification.get('promptModel', 'unknown')} -> {full_model_path}" + ) + current_prefs_for_modification["promptModel"] = full_model_path + await page.evaluate( + "(prefsStr) => localStorage.setItem('aiStudioUserPreference', prefsStr)", + json.dumps(current_prefs_for_modification), + ) + + # Use new forced setting feature + logger.debug("[State] Applying forced UI state settings...") + ui_state_success = await _verify_and_apply_ui_state(page, req_id) + if not ui_state_success: + logger.warning( + "UI state setting failed, but continuing model switching flow" + ) + + # To maintain compatibility, also update current prefs object + current_prefs_for_modification["isAdvancedOpen"] = True + current_prefs_for_modification["areToolsOpen"] = True + await page.evaluate( + "(prefsStr) => localStorage.setItem('aiStudioUserPreference', prefsStr)", + json.dumps(current_prefs_for_modification), + ) + + logger.debug(f"[Model] Navigating to {new_chat_url}...") + await page.goto(new_chat_url, wait_until="domcontentloaded", timeout=30000) + + input_field = page.locator(INPUT_SELECTOR) + await expect_async(input_field).to_be_visible(timeout=30000) + logger.debug("[Model] Page navigation complete, input box visible") + + # Verify UI state settings again after page load + logger.debug("[State] Verifying UI state...") + final_ui_state_success = await _verify_and_apply_ui_state(page, req_id) + if final_ui_state_success: + logger.debug("[State] UI state verification successful") + else: + logger.warning( + "Final UI state verification failed, but continuing model switching flow" + ) + + final_prefs_str = await page.evaluate( + "() => localStorage.getItem('aiStudioUserPreference')" + ) + final_prompt_model_in_storage: Optional[str] = None + if final_prefs_str: + try: + final_prefs_obj = json.loads(final_prefs_str) + final_prompt_model_in_storage = final_prefs_obj.get("promptModel") + except json.JSONDecodeError: + logger.warning( + "Failed to parse refreshed aiStudioUserPreference JSON string." + ) + + if final_prompt_model_in_storage == full_model_path: + logger.debug(f"[Model] localStorage set correctly: {full_model_path}") + + page_display_match = False + + # Get parsed_model_list + from api_utils.server_state import state + + parsed_model_list = getattr(state, "parsed_model_list", []) + + if parsed_model_list: + for m_obj in parsed_model_list: + if m_obj.get("id") == model_id: + m_obj.get("display_name") + break + + try: + model_name_locator = page.locator(MODEL_NAME_SELECTOR) + actual_displayed_model_id_on_page_raw = ( + await model_name_locator.first.inner_text(timeout=5000) + ) + actual_displayed_model_id_on_page = ( + actual_displayed_model_id_on_page_raw.strip() + ) + + target_model_id = model_id + + if actual_displayed_model_id_on_page == target_model_id: + page_display_match = True + logger.info("[Model] Switching successful") + else: + page_display_match = False + logger.error( + f"Page displayed model ID ('{actual_displayed_model_id_on_page}') inconsistent with expected ID ('{target_model_id}')." + ) + + except asyncio.CancelledError: + raise + except Exception as e_disp: + page_display_match = False # Reading failed, assume mismatch + logger.warning( + f"Error reading displayed model ID: {e_disp}. Cannot verify page display." + ) + + if page_display_match: + try: + logger.debug("[Model] Re-enabling temporary chat mode...") + from browser_utils.initialization import enable_temporary_chat_mode + + enabled = await enable_temporary_chat_mode(page) + if enabled: + logger.debug("[Model] Temporary chat mode enabled or already active") + else: + logger.warning( + "[Model] Temporary chat mode state verification failed" + ) + + except asyncio.CancelledError: + raise + except Exception as e: + logger.warning(f"Failed to re-enable temporary chat mode after model switching: {e}") + + # Invalidate function calling cache on model switch + try: + from api_utils.utils_ext.function_calling_cache import ( + FunctionCallingCache, + ) + + FunctionCallingCache.get_instance().invalidate( + reason=f"model_switch:{model_id}", req_id=req_id + ) + except ImportError: + pass # Cache module not available + except Exception as e_cache: + logger.debug(f"[Model] Failed to invalidate FC cache: {e_cache}") + + return True + else: + logger.error( + "Model switching failed because page displayed model does not match expectation (even if localStorage may have changed)." + ) + else: + logger.error( + f"AI Studio did not accept model change (localStorage). Expected='{full_model_path}', Actual='{final_prompt_model_in_storage or 'not set or invalid'}'." + ) + + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Serious error occurred during model switching") + from browser_utils.operations import save_error_snapshot + + await save_error_snapshot(f"model_switch_error_{req_id}") + return False + return False + + +def load_excluded_models(filename: str): + """Load excluded model list""" + from api_utils.server_state import state + + excluded_model_ids = getattr(state, "excluded_model_ids", set()) + excluded_file_path = os.path.join(os.path.dirname(__file__), "..", "..", filename) + try: + if os.path.exists(excluded_file_path): + with open(excluded_file_path, "r", encoding="utf-8") as f: + loaded_ids = {line.strip() for line in f if line.strip()} + if loaded_ids: + excluded_model_ids.update(loaded_ids) + state.excluded_model_ids = excluded_model_ids + logger.debug( + f"Loaded {len(loaded_ids)} models from '{filename}' into exclusion list" + ) + else: + logger.debug( + f"'{filename}' file is empty or contains no valid model IDs, exclusion list unchanged." + ) + else: + logger.debug( + f"Model exclusion list file '{filename}' not found, list is empty." + ) + except Exception as e: + logger.error( + f"Error loading excluded models from '{filename}': {e}", exc_info=True + ) diff --git a/browser_utils/models/ui_state.py b/browser_utils/models/ui_state.py new file mode 100644 index 0000000000000000000000000000000000000000..7440f4d7779094e8c60ca11409e4b6df6e5722ae --- /dev/null +++ b/browser_utils/models/ui_state.py @@ -0,0 +1,209 @@ +""" +UI State Management +""" + +import asyncio +import json +import logging + +from playwright.async_api import Page as AsyncPage + +from logging_utils import set_request_id + +logger = logging.getLogger("AIStudioProxyServer") + + +async def _verify_ui_state_settings(page: AsyncPage, req_id: str = "unknown") -> dict: + """ + Verify if the UI state settings are correct. + + Args: + page: Playwright page object. + req_id: Request ID for logging. + + Returns: + dict: A dictionary containing the validation result. + """ + # Don't set lifecycle phase names as request IDs - they appear as ghost prefixes + # Only set actual request IDs (7-char alphanumerics) + if req_id not in ("initial", "set_mod", "set_model", "reload", "unknown", ""): + set_request_id(req_id) + try: + logger.debug("[State] Verifying UI state...") + + # Get current localStorage settings + prefs_str = await page.evaluate( + "() => localStorage.getItem('aiStudioUserPreference')" + ) + + if not prefs_str: + logger.warning("localStorage.aiStudioUserPreference does not exist") + return { + "exists": False, + "isAdvancedOpen": None, + "areToolsOpen": None, + "needsUpdate": True, + "error": "localStorage not found", + } + + try: + prefs = json.loads(prefs_str) + is_advanced_open = prefs.get("isAdvancedOpen") + are_tools_open = prefs.get("areToolsOpen") + + # Check if update is needed + needs_update = (is_advanced_open is not True) or ( + are_tools_open is not True + ) + + result = { + "exists": True, + "isAdvancedOpen": is_advanced_open, + "areToolsOpen": are_tools_open, + "needsUpdate": needs_update, + "prefs": prefs, + } + + if needs_update: + logger.debug( + f"[State] State mismatch: adv={is_advanced_open}, tools={are_tools_open} (update needed)" + ) + # No log needed when state is correct + return result + + except json.JSONDecodeError as e: + logger.error(f"Failed to parse localStorage JSON: {e}") + return { + "exists": False, + "isAdvancedOpen": None, + "areToolsOpen": None, + "needsUpdate": True, + "error": f"JSON parse failed: {e}", + } + + except asyncio.CancelledError: + raise + except Exception as e: + logger.error(f"Error verifying UI state settings: {e}") + return { + "exists": False, + "isAdvancedOpen": None, + "areToolsOpen": None, + "needsUpdate": True, + "error": f"Verification failed: {e}", + } + + +async def _force_ui_state_settings(page: AsyncPage, req_id: str = "unknown") -> bool: + """ + Forcefully set the UI state. + + Args: + page: Playwright page object. + req_id: Request ID for logging. + + Returns: + bool: Whether the setting was successful. + """ + try: + logger.debug("[State] Forcefully setting UI state...") + + # First verify current state + current_state = await _verify_ui_state_settings(page, req_id) + + if not current_state["needsUpdate"]: + logger.debug("[State] State is already correct, no update needed") + return True + + # Get existing preferences or create new ones + prefs = current_state.get("prefs", {}) + + # Force key configurations + prefs["isAdvancedOpen"] = True + prefs["areToolsOpen"] = True + + # Save to localStorage + prefs_str = json.dumps(prefs) + await page.evaluate( + "(prefsStr) => localStorage.setItem('aiStudioUserPreference', prefsStr)", + prefs_str, + ) + + logger.debug("[State] Set: isAdvancedOpen=true, areToolsOpen=true") + + # Verify if setting was successful + verify_state = await _verify_ui_state_settings(page, req_id) + if not verify_state["needsUpdate"]: + logger.debug("[State] Setting verification successful") + return True + else: + logger.warning("UI state setting verification failed, may need to retry") + return False + + except asyncio.CancelledError: + raise + except Exception as e: + logger.error(f"Error forcefully setting UI state: {e}") + return False + + +async def _force_ui_state_with_retry( + page: AsyncPage, + req_id: str = "unknown", + max_retries: int = 3, + retry_delay: float = 1.0, +) -> bool: + """ + Forcefully set UI state with retry mechanism. + + Args: + page: Playwright page object. + req_id: Request ID for logging. + max_retries: Maximum number of retries. + retry_delay: Retry delay (seconds). + + Returns: + bool: Whether the setting was ultimately successful. + """ + for attempt in range(1, max_retries + 1): + success = await _force_ui_state_settings(page, req_id) + if success: + return True + + if attempt < max_retries: + logger.debug(f"[State] Retrying {attempt}/{max_retries}...") + await asyncio.sleep(retry_delay) + else: + logger.warning(f"[State] Still failed after {max_retries} attempts") + + return False + + +async def _verify_and_apply_ui_state(page: AsyncPage, req_id: str = "unknown") -> bool: + """ + Full process of verifying and applying UI state settings. + + Args: + page: Playwright page object. + req_id: Request ID for logging. + + Returns: + bool: Whether the operation was successful. + """ + try: + logger.debug("[State] Starting to verify and apply UI state...") + + # First verify current state + state = await _verify_ui_state_settings(page, req_id) + + if state["needsUpdate"]: + logger.debug("[State] Update needed, applying forced settings...") + return await _force_ui_state_with_retry(page, req_id) + else: + return True + + except asyncio.CancelledError: + raise + except Exception as e: + logger.error(f"Error during verifying and applying UI state: {e}") + return False diff --git a/browser_utils/operations.py b/browser_utils/operations.py new file mode 100644 index 0000000000000000000000000000000000000000..b710b546efaabe672dad9315dfe00ca4d919dfe0 --- /dev/null +++ b/browser_utils/operations.py @@ -0,0 +1,1308 @@ +# --- browser_utils/operations.py --- +# Browser page operation functional module + +import asyncio +import json +import logging +import os +import time +from typing import Any, Callable, Dict, Optional + +from playwright.async_api import ( + Error as PlaywrightAsyncError, +) +from playwright.async_api import ( + Locator, +) +from playwright.async_api import ( + Page as AsyncPage, +) + +# Import config and models +from config import ( + CHAT_SESSION_CONTENT_SELECTOR, + CLICK_TIMEOUT_MS, + DEBUG_LOGS_ENABLED, + ERROR_TOAST_SELECTOR, + INITIAL_WAIT_MS_BEFORE_POLLING, + LAST_CHAT_TURN_SELECTOR, + MODELS_ENDPOINT_URL_CONTAINS, + QUOTA_EXCEEDED_SELECTOR, + SCROLL_CONTAINER_SELECTOR, +) +from config.global_state import GlobalState +from models import ClientDisconnectedError, QuotaExceededError + +logger = logging.getLogger("AIStudioProxyServer") + + +async def check_quota_limit(page: AsyncPage, req_id: str) -> None: + """Check for blocking quota errors immediately.""" + # 1. Check Global State first + if GlobalState.IS_QUOTA_EXCEEDED: + raise QuotaExceededError("Global Quota Exceeded Flag is Active.") + + try: + # 2. Check UI for Quota Error (New Selector) + if await page.locator(QUOTA_EXCEEDED_SELECTOR).count() > 0: + element = page.locator(QUOTA_EXCEEDED_SELECTOR).first + if await element.is_visible(timeout=500): + text = await element.text_content() + if text and "user has exceeded quota" in text.lower(): + logger.critical( + f"[{req_id}] ❌ Quota Limit Detected via UI! Text: {text}" + ) + GlobalState.set_quota_exceeded(message=text) + raise QuotaExceededError(f"Quota exceeded detected via UI: {text}") + + # 3. Check UI for Quota Error (Old Selector - Legacy Fallback) + quota_selector = ( + 'ms-callout.warning-callout:has-text("You are out of free generations")' + ) + if await page.locator(quota_selector).count() > 0: + if await page.locator(quota_selector).first.is_visible(timeout=500): + logger.critical( + f"[{req_id}] ❌ Quota Limit Detected (Legacy)! Account is out of free generations." + ) + GlobalState.set_quota_exceeded( + message="AI Studio Account is out of free generations" + ) + raise QuotaExceededError( + "AI Studio Account is out of free generations." + ) + + except QuotaExceededError: + raise + except Exception as e: + # Don't let check errors block the main flow, unless it's the quota error itself + logger.warning(f"[{req_id}] Error checking for quota limit: {e}") + + +async def get_raw_text_content( + response_element: Locator, previous_text: str, req_id: str +) -> str: + """Get raw text content from response element""" + raw_text = previous_text + try: + await response_element.wait_for(state="attached", timeout=1000) + + # [FIX-SELECTOR] Ensure element is in viewport for DOM virtualization + try: + await response_element.scroll_into_view_if_needed(timeout=1000) + except Exception: + pass + + pre_element = response_element.locator("pre").last + pre_found_and_visible = False + try: + await pre_element.wait_for(state="visible", timeout=250) + pre_found_and_visible = True + except PlaywrightAsyncError: + pass + + if pre_found_and_visible: + try: + # [FIX-SELECTOR] Ensure pre element is in viewport + await pre_element.scroll_into_view_if_needed(timeout=500) + raw_text = await pre_element.inner_text(timeout=500) + except PlaywrightAsyncError as pre_err: + if DEBUG_LOGS_ENABLED: + logger.debug( + f"[{req_id}] (GetRawText) Failed to get inner text of pre element: {pre_err}" + ) + else: + try: + # [FIX-SELECTOR] Ensure response element is in viewport + await response_element.scroll_into_view_if_needed(timeout=500) + raw_text = await response_element.inner_text(timeout=500) + except PlaywrightAsyncError as e_parent: + if DEBUG_LOGS_ENABLED: + logger.debug( + f"[{req_id}] (GetRawText) Failed to get inner text of response element: {e_parent}" + ) + except PlaywrightAsyncError as e_parent: + if DEBUG_LOGS_ENABLED: + logger.debug( + f"[{req_id}] (GetRawText) Response element not ready: {e_parent}" + ) + except Exception as e_unexpected: + logger.warning(f"[{req_id}] (GetRawText) Unexpected error: {e_unexpected}") + + if raw_text != previous_text: + if DEBUG_LOGS_ENABLED: + preview = raw_text[:100].replace("\n", "\\n") + logger.debug( + f"[{req_id}] (GetRawText) Text updated, length: {len(raw_text)}, Preview: '{preview}...'" + ) + return raw_text + + +async def _handle_model_list_response(response: Any): + """Handle model list response""" + # Need access to global variables + from api_utils.server_state import state + + global_model_list_raw_json = state.global_model_list_raw_json # noqa: F841 + parsed_model_list = state.parsed_model_list # noqa: F841 + model_list_fetch_event = state.model_list_fetch_event + excluded_model_ids = state.excluded_model_ids + + if MODELS_ENDPOINT_URL_CONTAINS in response.url and response.ok: + # Check if in login flow + launch_mode = os.environ.get("LAUNCH_MODE", "debug") + is_in_login_flow = launch_mode in ["debug"] and not state.is_page_ready + + if is_in_login_flow: + # Silent during login flow + pass + else: + logger.info( + f"Captured potential model list response from: {response.url} (Status: {response.status})" + ) + try: + # Fix: Handle Network.getResponseBody protocol error by using fallback methods + try: + data = await response.json() + except Exception as protocol_err: + if "Network.getResponseBody" in str( + protocol_err + ) or "Protocol error" in str(protocol_err): + logger.warning( + f"Playwright Protocol Error detected in model list response: {protocol_err}" + ) + # Fallback: Try to get response body text and parse manually + try: + response_text = await response.text() + data = json.loads(response_text) + logger.info( + "Successfully parsed model list response using fallback method" + ) + except Exception as fallback_err: + logger.error( + f"Fallback parsing also failed for model list response: {fallback_err}" + ) + if ( + model_list_fetch_event + and not model_list_fetch_event.is_set() + ): + model_list_fetch_event.set() + return + else: + raise # Re-raise if it's not the specific protocol error we're handling + models_array_container = None + if isinstance(data, list) and data: + if ( + isinstance(data[0], list) + and data[0] + and isinstance(data[0][0], list) + ): + if not is_in_login_flow: + logger.info( + "Detected three-level list structure data[0][0] is list. Setting models_array_container to data[0]." + ) + models_array_container = data[0] + elif ( + isinstance(data[0], list) + and data[0] + and isinstance(data[0][0], str) + ): + if not is_in_login_flow: + logger.info( + "Detected two-level list structure data[0][0] is str. Setting models_array_container to data." + ) + models_array_container = data + elif isinstance(data[0], dict): + if not is_in_login_flow: + logger.info( + "Detected root list with dictionaries. Using data directly as models_array_container." + ) + models_array_container = data + else: + logger.warning( + f"Unknown list nested structure. data[0] type: {type(data[0]) if data else 'N/A'}. data[0] Preview: {str(data[0])[:200] if data else 'N/A'}" + ) + elif isinstance(data, dict): + if "data" in data and isinstance(data["data"], list): + models_array_container = data["data"] + elif "models" in data and isinstance(data["models"], list): + models_array_container = data["models"] + else: + for key, value in data.items(): + if ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], (dict, list)) + ): + models_array_container = value + logger.info( + f"Model list data found under '{key}' key via heuristic search." + ) + break + if models_array_container is None: + logger.warning( + "Could not automatically locate model list array in dictionary response." + ) + if ( + model_list_fetch_event + and not model_list_fetch_event.is_set() + ): + model_list_fetch_event.set() + return + else: + logger.warning( + f"Received model list data is neither list nor dictionary: {type(data)}" + ) + if model_list_fetch_event and not model_list_fetch_event.is_set(): + model_list_fetch_event.set() + return + + if models_array_container is not None: + new_parsed_list = [] + for entry_in_container in models_array_container: + model_fields_list = None + if isinstance(entry_in_container, dict): + potential_id = entry_in_container.get( + "id", + entry_in_container.get( + "model_id", entry_in_container.get("modelId") + ), + ) + if potential_id: + model_fields_list = entry_in_container + else: + model_fields_list = list(entry_in_container.values()) + elif isinstance(entry_in_container, list): + model_fields_list = entry_in_container + else: + logger.debug( + f"Skipping entry of unknown type: {type(entry_in_container)}" + ) + continue + + if not model_fields_list: + logger.debug( + "Skipping entry because model_fields_list is empty or None." + ) + continue + + model_id_path_str = None + display_name_candidate = "" + description_candidate = "N/A" + default_max_output_tokens_val = None + default_top_p_val = None + default_temperature_val = 1.0 + supported_max_output_tokens_val = None + current_model_id_for_log = "UnknownModelYet" + + try: + if isinstance(model_fields_list, list): + if not ( + len(model_fields_list) > 0 + and isinstance(model_fields_list[0], (str, int, float)) + ): + logger.debug( + f"Skipping list-based model_fields due to invalid first element: {str(model_fields_list)[:100]}" + ) + continue + model_id_path_str = str(model_fields_list[0]) + current_model_id_for_log = ( + model_id_path_str.split("/")[-1] + if model_id_path_str and "/" in model_id_path_str + else model_id_path_str + ) + display_name_candidate = ( + str(model_fields_list[3]) + if len(model_fields_list) > 3 + else "" + ) + description_candidate = ( + str(model_fields_list[4]) + if len(model_fields_list) > 4 + else "N/A" + ) + + if ( + len(model_fields_list) > 6 + and model_fields_list[6] is not None + ): + try: + val_int = int(model_fields_list[6]) + default_max_output_tokens_val = val_int + supported_max_output_tokens_val = val_int + except (ValueError, TypeError): + logger.warning( + f"Model {current_model_id_for_log}: Could not parse value at index 6 '{model_fields_list[6]}' as max_output_tokens." + ) + + if ( + len(model_fields_list) > 9 + and model_fields_list[9] is not None + ): + try: + raw_top_p = float(model_fields_list[9]) + if not (0.0 <= raw_top_p <= 1.0): + logger.warning( + f"Model {current_model_id_for_log}: raw top_p value {raw_top_p} (from index 9) out of [0,1] range, will clip." + ) + default_top_p_val = max( + 0.0, min(1.0, raw_top_p) + ) + else: + default_top_p_val = raw_top_p + except (ValueError, TypeError): + logger.warning( + f"Model {current_model_id_for_log}: Could not parse value at index 9 '{model_fields_list[9]}' as top_p." + ) + + elif isinstance(model_fields_list, dict): + model_id_path_str = str( + model_fields_list.get( + "id", + model_fields_list.get( + "model_id", model_fields_list.get("modelId") + ), + ) + ) + current_model_id_for_log = ( + model_id_path_str.split("/")[-1] + if model_id_path_str and "/" in model_id_path_str + else model_id_path_str + ) + display_name_candidate = str( + model_fields_list.get( + "displayName", + model_fields_list.get( + "display_name", + model_fields_list.get("name", ""), + ), + ) + ) + description_candidate = str( + model_fields_list.get("description", "N/A") + ) + + mot_parsed = model_fields_list.get( + "maxOutputTokens", + model_fields_list.get( + "defaultMaxOutputTokens", + model_fields_list.get("outputTokenLimit"), + ), + ) + if mot_parsed is not None: + try: + val_int = int(mot_parsed) + default_max_output_tokens_val = val_int + supported_max_output_tokens_val = val_int + except (ValueError, TypeError): + logger.warning( + f"Model {current_model_id_for_log}: Could not parse dict value '{mot_parsed}' as max_output_tokens." + ) + + top_p_parsed = model_fields_list.get( + "topP", model_fields_list.get("defaultTopP") + ) + if top_p_parsed is not None: + try: + raw_top_p = float(top_p_parsed) + if not (0.0 <= raw_top_p <= 1.0): + logger.warning( + f"Model {current_model_id_for_log}: raw top_p value {raw_top_p} (from dict) out of [0,1] range, will clip." + ) + default_top_p_val = max( + 0.0, min(1.0, raw_top_p) + ) + else: + default_top_p_val = raw_top_p + except (ValueError, TypeError): + logger.warning( + f"Model {current_model_id_for_log}: Could not parse dict value '{top_p_parsed}' as top_p." + ) + + temp_parsed = model_fields_list.get( + "temperature", + model_fields_list.get("defaultTemperature"), + ) + if temp_parsed is not None: + try: + default_temperature_val = float(temp_parsed) + except (ValueError, TypeError): + logger.warning( + f"Model {current_model_id_for_log}: Could not parse dict value '{temp_parsed}' as temperature." + ) + else: + logger.debug( + f"Skipping entry because model_fields_list is not list or dict: {type(model_fields_list)}" + ) + continue + except Exception as e_parse_fields: + logger.error( + f"Error parsing model fields for entry {str(entry_in_container)[:100]}: {e_parse_fields}" + ) + continue + + if model_id_path_str and model_id_path_str.lower() != "none": + simple_model_id_str = ( + model_id_path_str.split("/")[-1] + if "/" in model_id_path_str + else model_id_path_str + ) + if simple_model_id_str in excluded_model_ids: + if not is_in_login_flow: + logger.info( + f"Model '{simple_model_id_str}' is in excluded_model_ids list, skipped." + ) + continue + + final_display_name_str = ( + display_name_candidate + if display_name_candidate + else simple_model_id_str.replace("-", " ").title() + ) + model_entry_dict = { + "id": simple_model_id_str, + "object": "model", + "created": int(time.time()), + "owned_by": "ai_studio", + "display_name": final_display_name_str, + "description": description_candidate, + "raw_model_path": model_id_path_str, + "default_temperature": default_temperature_val, + "default_max_output_tokens": default_max_output_tokens_val, + "supported_max_output_tokens": supported_max_output_tokens_val, + "default_top_p": default_top_p_val, + } + new_parsed_list.append(model_entry_dict) + else: + logger.debug( + f"Skipping entry due to invalid model_id_path: {model_id_path_str} from entry {str(entry_in_container)[:100]}" + ) + + if new_parsed_list: + # Check if already has network intercepted injected models + has_network_injected_models = False + if models_array_container: + for entry_in_container in models_array_container: + if ( + isinstance(entry_in_container, list) + and len(entry_in_container) > 10 + ): + # Check for network injection marker + if "__NETWORK_INJECTED__" in entry_in_container: + has_network_injected_models = True + break + + if has_network_injected_models and not is_in_login_flow: + logger.info("Detected network interception injected models") + + # Note: No longer add injected models in backend + # Only rely on network interception injection + + state.parsed_model_list = sorted( + new_parsed_list, key=lambda m: m.get("display_name", "").lower() + ) + state.global_model_list_raw_json = json.dumps( + {"data": state.parsed_model_list, "object": "list"} + ) + if DEBUG_LOGS_ENABLED: + log_output = f"Successfully parsed and updated model list. Total parsed models: {len(state.parsed_model_list)}.\n" + for i, item in enumerate( + state.parsed_model_list[ + : min(3, len(state.parsed_model_list)) + ] + ): + log_output += f" Model {i + 1}: ID={item.get('id')}, Name={item.get('display_name')}, Temp={item.get('default_temperature')}, MaxTokDef={item.get('default_max_output_tokens')}, MaxTokSup={item.get('supported_max_output_tokens')}, TopP={item.get('default_top_p')}\n" + logger.info(log_output) + if model_list_fetch_event and not model_list_fetch_event.is_set(): + model_list_fetch_event.set() + elif not state.parsed_model_list: + logger.warning("Model list remains empty after parsing.") + if model_list_fetch_event and not model_list_fetch_event.is_set(): + model_list_fetch_event.set() + else: + logger.warning( + "models_array_container is None, cannot parse model list." + ) + if model_list_fetch_event and not model_list_fetch_event.is_set(): + model_list_fetch_event.set() + except json.JSONDecodeError as json_err: + logger.error( + f"Failed to parse model list JSON: {json_err}. Response (first 500 chars): {await response.text()[:500]}" + ) + except Exception as e_handle_list_resp: + logger.exception( + f"Unexpected error handling model list response: {e_handle_list_resp}" + ) + finally: + if model_list_fetch_event and not model_list_fetch_event.is_set(): + logger.info( + "Model list response handling finished, forcing model_list_fetch_event set." + ) + model_list_fetch_event.set() + + +async def detect_and_extract_page_error(page: AsyncPage, req_id: str) -> Optional[str]: + """Detect and extract page error""" + error_toast_locator = page.locator(ERROR_TOAST_SELECTOR).last + try: + await error_toast_locator.wait_for(state="visible", timeout=500) + message_locator = error_toast_locator.locator("span.content-text") + error_message = await message_locator.text_content(timeout=500) + if error_message: + logger.error( + f"[{req_id}] Detected and extracted error message: {error_message}" + ) + return error_message.strip() + else: + logger.warning( + f"[{req_id}] Detected error toast but failed to extract message." + ) + return "Detected error toast but failed to extract specific message." + except PlaywrightAsyncError: + return None + except Exception as e: + logger.warning(f"[{req_id}] Error checking for page error: {e}") + return None + + +async def save_error_snapshot( + error_name: str = "error", extra_context: Optional[Dict[str, Any]] = None +): + """Save error snapshot - enhanced version with extra context support + + Args: + error_name: Error name used for filename generation + extra_context: Extra context info to be saved as JSON file + """ + from api_utils.server_state import state + + name_parts = error_name.split("_") + req_id = ( + name_parts[-1] if len(name_parts) > 1 and len(name_parts[-1]) == 7 else None + ) + base_error_name = error_name if not req_id else "_".join(name_parts[:-1]) + log_prefix = f"[{req_id}]" if req_id else "[No ReqID]" + page_to_snapshot = state.page_instance + + if ( + not state.browser_instance + or not state.browser_instance.is_connected() + or not page_to_snapshot + or page_to_snapshot.is_closed() + ): + logger.warning( + f"{log_prefix} Cannot save snapshot ({base_error_name}), browser/page unavailable." + ) + return + + logger.info( + f"{log_prefix} Attempting to save error snapshot ({base_error_name})..." + ) + timestamp = int(time.time() * 1000) + error_dir = os.path.join(os.path.dirname(__file__), "..", "errors_py") + + try: + os.makedirs(error_dir, exist_ok=True) + filename_suffix = f"{req_id}_{timestamp}" if req_id else f"{timestamp}" + filename_base = f"{base_error_name}_{filename_suffix}" + screenshot_path = os.path.join(error_dir, f"{filename_base}.png") + html_path = os.path.join(error_dir, f"{filename_base}.html") + context_path = os.path.join(error_dir, f"{filename_base}_context.json") + + # Save screenshot + try: + await page_to_snapshot.screenshot( + path=screenshot_path, full_page=True, timeout=15000 + ) + logger.info(f"{log_prefix} Snapshot saved to: {screenshot_path}") + except Exception as ss_err: + logger.error( + f"{log_prefix} Failed to save screenshot ({base_error_name}): {ss_err}" + ) + + # Save HTML content + try: + content = await page_to_snapshot.content() + f = None + try: + f = open(html_path, "w", encoding="utf-8") + f.write(content) + logger.info(f"{log_prefix} HTML saved to: {html_path}") + except Exception as write_err: + logger.error( + f"{log_prefix} Failed to save HTML ({base_error_name}): {write_err}" + ) + finally: + if f: + try: + f.close() + logger.debug(f"{log_prefix} HTML file closed correctly") + except Exception as close_err: + logger.error( + f"{log_prefix} Error closing HTML file: {close_err}" + ) + except Exception as html_err: + logger.error( + f"{log_prefix} Failed to get page content ({base_error_name}): {html_err}" + ) + + # Save extra context info + if extra_context: + try: + context_data = { + "timestamp": timestamp, + "error_name": base_error_name, + "req_id": req_id, + "context": extra_context, + "page_url": page_to_snapshot.url if page_to_snapshot else "N/A", + "user_agent": await page_to_snapshot.evaluate("navigator.userAgent") + if page_to_snapshot + else "N/A", + } + with open(context_path, "w", encoding="utf-8") as f: + json.dump(context_data, f, indent=2, ensure_ascii=False) + logger.info(f"{log_prefix} Context info saved to: {context_path}") + except Exception as context_err: + logger.error( + f"{log_prefix} Failed to save context info ({base_error_name}): {context_err}" + ) + + except Exception as dir_err: + logger.error( + f"{log_prefix} Error creating error directory or saving snapshot ({base_error_name}): {dir_err}" + ) + + +async def capture_response_state_for_debug( + req_id: str, captured_content: str = "", detection_method: str = "" +) -> Dict[str, Any]: + """Capture response state for debug - dedicated for analysis of response integrity issues""" + from api_utils.server_state import state + + page = state.page_instance + + if not page or page.is_closed(): + return {"error": "Page not available"} + + debug_info = { + "req_id": req_id, + "timestamp": int(time.time() * 1000), + "detection_method": detection_method, + "captured_content_length": len(captured_content) if captured_content else 0, + "captured_content_preview": captured_content[:200] + "..." + if len(captured_content) > 200 + else captured_content, + "page_url": page.url, + "thinking_blocks_found": [], + "response_blocks_found": [], + "generation_status": {}, + "ui_elements": {}, + } + + try: + # Check Thinking blocks + from config.selectors import ( + FINAL_RESPONSE_SELECTOR, + GENERATION_STATUS_SELECTOR, + THINKING_CONTAINER_SELECTOR, + ) + + # Find Thinking containers + thinking_containers = await page.locator(THINKING_CONTAINER_SELECTOR).all() + for i, container in enumerate(thinking_containers): + try: + is_visible = await container.is_visible(timeout=1000) + text_content = ( + await container.inner_text(timeout=1000) if is_visible else "" + ) + debug_info["thinking_blocks_found"].append( + { + "index": i, + "visible": is_visible, + "text_length": len(text_content), + "text_preview": text_content[:100] + "..." + if len(text_content) > 100 + else text_content, + } + ) + except Exception as e: + debug_info["thinking_blocks_found"].append( + {"index": i, "error": str(e)} + ) + + # Find final response blocks + response_elements = await page.locator(FINAL_RESPONSE_SELECTOR).all() + for i, elem in enumerate(response_elements): + try: + is_visible = await elem.is_visible(timeout=1000) + text_content = await elem.inner_text(timeout=1000) if is_visible else "" + debug_info["response_blocks_found"].append( + { + "index": i, + "visible": is_visible, + "text_length": len(text_content), + "text_preview": text_content[:100] + "..." + if len(text_content) > 100 + else text_content, + } + ) + except Exception as e: + debug_info["response_blocks_found"].append( + {"index": i, "error": str(e)} + ) + + # Check generation status + generation_elements = await page.locator(GENERATION_STATUS_SELECTOR).all() + for i, elem in enumerate(generation_elements): + try: + is_visible = await elem.is_visible(timeout=1000) + aria_label = ( + await elem.get_attribute("aria-label") if is_visible else "" + ) + debug_info["generation_status"][f"status_{i}"] = { + "visible": is_visible, + "aria_label": aria_label, + } + except Exception as e: + debug_info["generation_status"][f"status_{i}"] = {"error": str(e)} + + # Check key UI elements + from config.selectors import ( + INPUT_SELECTOR, + REGENERATE_BUTTON_SELECTOR, + SUBMIT_BUTTON_SELECTOR, + ) + + key_elements = { + "input_field": INPUT_SELECTOR, + "submit_button": SUBMIT_BUTTON_SELECTOR, + "regenerate_button": REGENERATE_BUTTON_SELECTOR, + } + + for name, selector in key_elements.items(): + try: + elem = page.locator(selector) + is_visible = await elem.is_visible(timeout=1000) + is_disabled = ( + await elem.is_disabled(timeout=1000) if is_visible else False + ) + debug_info["ui_elements"][name] = { + "visible": is_visible, + "disabled": is_disabled, + } + except Exception as e: + debug_info["ui_elements"][name] = {"error": str(e)} + + except Exception as e: + debug_info["capture_error"] = str(e) + + return debug_info + + +async def get_response_via_edit_button( + page: AsyncPage, req_id: str, check_client_disconnected: Callable +) -> Optional[str]: + """Get response via edit button""" + logger.info(f"[{req_id}] (Helper) Attempting to get response via edit button...") + last_message_container = page.locator("ms-chat-turn").last + edit_button = last_message_container.get_by_label("Edit") + finish_edit_button = last_message_container.get_by_label("Stop editing") + autosize_textarea_locator = last_message_container.locator("ms-autosize-textarea") + actual_textarea_locator = autosize_textarea_locator.locator("textarea") + + try: + logger.info( + f"[{req_id}] - Attempting to hover last message to show 'Edit' button..." + ) + try: + # Perform hover on message container + await last_message_container.hover(timeout=CLICK_TIMEOUT_MS / 2) + await asyncio.sleep(0.3) # Wait for hover effect + check_client_disconnected("Edit Response - after hover: ") + except (ClientDisconnectedError, asyncio.CancelledError): + raise + except Exception as hover_err: + logger.warning( + f"[{req_id}] - (get_response_via_edit_button) Hover last message failed (ignoring): {type(hover_err).__name__}" + ) + + logger.info(f"[{req_id}] - Locating and clicking 'Edit' button...") + try: + from playwright.async_api import expect as expect_async + + await expect_async(edit_button).to_be_visible(timeout=CLICK_TIMEOUT_MS) + check_client_disconnected("Edit Response - 'Edit' button visible after: ") + await edit_button.click(timeout=CLICK_TIMEOUT_MS) + logger.info(f"[{req_id}] - 'Edit' button clicked.") + except (ClientDisconnectedError, asyncio.CancelledError): + raise + except Exception as edit_btn_err: + logger.error( + f"[{req_id}] - 'Edit' button not visible or click failed: {edit_btn_err}" + ) + await save_error_snapshot(f"edit_response_edit_button_failed_{req_id}") + return None + + check_client_disconnected("Edit Response - after clicking 'Edit' button: ") + await asyncio.sleep(0.3) + check_client_disconnected( + "Edit Response - after delay following 'Edit' button click: " + ) + + logger.info(f"[{req_id}] - Getting content from textarea...") + response_content = None + textarea_failed = False + + try: + await expect_async(autosize_textarea_locator).to_be_visible( + timeout=CLICK_TIMEOUT_MS + ) + check_client_disconnected( + "Edit Response - after autosize-textarea visible: " + ) + + try: + data_value_content = await autosize_textarea_locator.get_attribute( + "data-value" + ) + check_client_disconnected( + "Edit Response - after get_attribute data-value: " + ) + if data_value_content is not None: + response_content = str(data_value_content) + logger.info( + f"[{req_id}] - Successfully obtained content from data-value." + ) + except Exception as data_val_err: + logger.warning( + f"[{req_id}] - Failed to get data-value: {data_val_err}" + ) + check_client_disconnected( + "Edit Response - after get_attribute data-value error: " + ) + + if response_content is None: + logger.info( + f"[{req_id}] - data-value failed or None, attempting to get input_value from internal textarea..." + ) + try: + await expect_async(actual_textarea_locator).to_be_visible( + timeout=CLICK_TIMEOUT_MS / 2 + ) + input_val_content = await actual_textarea_locator.input_value( + timeout=CLICK_TIMEOUT_MS / 2 + ) + check_client_disconnected("Edit Response - after input_value: ") + if input_val_content is not None: + response_content = str(input_val_content) + logger.info( + f"[{req_id}] - Successfully obtained content from input_value." + ) + except Exception as input_val_err: + logger.warning( + f"[{req_id}] - Failed to get input_value as well: {input_val_err}" + ) + check_client_disconnected( + "Edit Response - after input_value error: " + ) + + if response_content is not None: + response_content = response_content.strip() + content_preview = response_content[:100].replace("\\n", "\\\\n") + logger.info( + f"[{req_id}] - ✅ Final content obtained (length={len(response_content)}): '{content_preview}...'" + ) + else: + logger.warning( + f"[{req_id}] - All content retrieval methods (data-value, input_value) failed or returned None." + ) + textarea_failed = True + + except Exception as textarea_err: + logger.error( + f"[{req_id}] - Failed to locate or process textarea: {textarea_err}" + ) + textarea_failed = True + response_content = None + check_client_disconnected("Edit Response - after textarea error: ") + + if not textarea_failed: + logger.info( + f"[{req_id}] - Locating and clicking 'Stop editing' button..." + ) + try: + await expect_async(finish_edit_button).to_be_visible( + timeout=CLICK_TIMEOUT_MS + ) + check_client_disconnected( + "Edit Response - 'Stop editing' button visible after: " + ) + await finish_edit_button.click(timeout=CLICK_TIMEOUT_MS) + logger.info(f"[{req_id}] - 'Stop editing' button clicked.") + except Exception as finish_btn_err: + logger.warning( + f"[{req_id}] - 'Stop editing' button not visible or click failed: {finish_btn_err}" + ) + await save_error_snapshot( + f"edit_response_finish_button_failed_{req_id}" + ) + check_client_disconnected("Edit Response - after clicking 'Stop editing': ") + await asyncio.sleep(0.2) + check_client_disconnected( + "Edit Response - after delay following 'Stop editing' click: " + ) + else: + logger.info( + f"[{req_id}] - Skipping 'Stop editing' button click due to textarea read failure." + ) + + return response_content + + except ClientDisconnectedError: + logger.info(f"[{req_id}] (Helper Edit) Client disconnected.") + raise + except Exception: + logger.exception( + f"[{req_id}] Unexpected error during getting response via edit button" + ) + await save_error_snapshot(f"edit_response_unexpected_error_{req_id}") + return None + + +async def get_response_via_copy_button( + page: AsyncPage, req_id: str, check_client_disconnected: Callable +) -> Optional[str]: + """Get response via copy button""" + logger.info(f"[{req_id}] (Helper) Attempting to get response via copy button...") + last_message_container = page.locator("ms-chat-turn").last + more_options_button = last_message_container.get_by_label("Open options") + copy_markdown_button = page.get_by_role("menuitem", name="Copy markdown") + + try: + logger.info( + f"[{req_id}] - Attempting to hover last message to show options..." + ) + await last_message_container.hover(timeout=CLICK_TIMEOUT_MS) + check_client_disconnected("Copy Response - after hover: ") + await asyncio.sleep(0.5) + check_client_disconnected("Copy Response - after hover delay: ") + logger.info(f"[{req_id}] - Hovered.") + + logger.info(f"[{req_id}] - Locating and clicking 'More options' button...") + try: + from playwright.async_api import expect as expect_async + + await expect_async(more_options_button).to_be_visible( + timeout=CLICK_TIMEOUT_MS + ) + check_client_disconnected( + "Copy Response - after more options button visible: " + ) + await more_options_button.click(timeout=CLICK_TIMEOUT_MS) + logger.info(f"[{req_id}] - 'More options' clicked (via get_by_label).") + except (ClientDisconnectedError, asyncio.CancelledError): + raise + except Exception as more_opts_err: + logger.error( + f"[{req_id}] - 'More options' button (via get_by_label) not visible or click failed: {more_opts_err}" + ) + await save_error_snapshot(f"copy_response_more_options_failed_{req_id}") + return None + + check_client_disconnected("Copy Response - after clicking more options: ") + await asyncio.sleep(0.5) + check_client_disconnected( + "Copy Response - after delay following more options click: " + ) + + logger.info(f"[{req_id}] - Locating and clicking 'Copy markdown' button...") + copy_success = False + try: + await expect_async(copy_markdown_button).to_be_visible( + timeout=CLICK_TIMEOUT_MS + ) + check_client_disconnected("Copy Response - after copy button visible: ") + await copy_markdown_button.click(timeout=CLICK_TIMEOUT_MS, force=True) + copy_success = True + logger.info(f"[{req_id}] - 'Copy markdown' clicked (via get_by_role).") + except (ClientDisconnectedError, asyncio.CancelledError): + raise + except Exception as copy_err: + logger.error( + f"[{req_id}] - 'Copy markdown' button (via get_by_role) click failed: {copy_err}" + ) + await save_error_snapshot(f"copy_response_copy_button_failed_{req_id}") + return None + + if not copy_success: + logger.error(f"[{req_id}] - Failed to click 'Copy markdown' button.") + return None + + check_client_disconnected("Copy Response - after clicking copy button: ") + await asyncio.sleep(0.5) + check_client_disconnected( + "Copy Response - after delay following copy button click: " + ) + + logger.info(f"[{req_id}] - Reading clipboard content...") + try: + clipboard_content = await page.evaluate("navigator.clipboard.readText()") + check_client_disconnected("Copy Response - after reading clipboard: ") + if clipboard_content: + content_preview = clipboard_content[:100].replace("\n", "\\\\n") + logger.info( + f"[{req_id}] - ✅ Successfully obtained clipboard content (length={len(clipboard_content)}): '{content_preview}...'" + ) + return clipboard_content + else: + logger.error(f"[{req_id}] - Clipboard content is empty.") + return None + except Exception as clipboard_err: + if "clipboard-read" in str(clipboard_err): + logger.error( + f"[{req_id}] - Clipboard read failed: possible permissions issue. Error: {clipboard_err}" + ) + else: + logger.error(f"[{req_id}] - Clipboard read failed: {clipboard_err}") + await save_error_snapshot(f"copy_response_clipboard_read_failed_{req_id}") + return None + + except ClientDisconnectedError: + logger.info(f"[{req_id}] (Helper Copy) Client disconnected.") + raise + except Exception: + logger.exception(f"[{req_id}] Unexpected error during copying response") + await save_error_snapshot(f"copy_response_unexpected_error_{req_id}") + return None + + +async def _wait_for_response_completion( + page: AsyncPage, + prompt_textarea_locator: Locator, + submit_button_locator: Locator, + edit_button_locator: Locator, + req_id: str, + check_client_disconnected_func: Callable, + current_chat_id: Optional[str], + prompt_length: int, + initial_wait_ms=INITIAL_WAIT_MS_BEFORE_POLLING, + timeout: Optional[float] = None, +) -> bool: + """Wait for response completion""" + from playwright.async_api import TimeoutError + + # [FIX-03] Dynamic TTFB Timeout - Rotation Aware + if timeout is None: + base_timeout_seconds = 5 + (prompt_length / 1000.0) + + # Rotation-aware adjustments + if GlobalState.IS_QUOTA_EXCEEDED: + rotation_overhead = 20 # 20 second overhead for rotation + timeout_seconds = max( + base_timeout_seconds + rotation_overhead, 30 + ) # Minimum 30s during rotation + logger.info( + f"[{req_id}] (WaitV3) Rotation detected - applying extended timeout: {timeout_seconds:.2f}s" + ) + else: + timeout_seconds = max(base_timeout_seconds, 10) + timeout_seconds = min(timeout_seconds, 120) + + if prompt_length > 5000: + timeout_seconds *= 1.5 + logger.info( + f"[{req_id}] (WaitV3) Large prompt detected - extending timeout by 50%: {timeout_seconds:.2f}s" + ) + else: + timeout_seconds = timeout + if GlobalState.IS_QUOTA_EXCEEDED and timeout_seconds < 30: + timeout_seconds = 30 + logger.info( + f"[{req_id}] (WaitV3) Rotation detected - enforcing minimum timeout: {timeout_seconds:.2f}s" + ) + + _timeout_ms = timeout_seconds * 1000 + + logger.info( + f"[{req_id}] (WaitV3) Waiting for response completion... (Dynamic Timeout: {timeout_seconds:.2f}s)" + ) + await asyncio.sleep(initial_wait_ms / 1000) + + start_time = time.time() + + from config import UI_GENERATION_WAIT_TIMEOUT_MS + + wait_timeout_ms_short = UI_GENERATION_WAIT_TIMEOUT_MS + + consecutive_empty_input_submit_disabled_count = 0 + + current_timeout_seconds = timeout_seconds + + while True: + # [FIX-SCROLL] Active Viewport Tracking (Auto-Scroll) + try: + await page.evaluate( + """([scrollSel, contentSel, lastTurnSel]) => { + const scrollContainer = document.querySelector(scrollSel); + if (scrollContainer) { + scrollContainer.scrollTop = scrollContainer.scrollHeight; + } + + const sessionContent = document.querySelector(contentSel); + if (sessionContent) { + sessionContent.scrollTop = sessionContent.scrollHeight; + } + + const lastTurn = document.querySelector(lastTurnSel); + if (lastTurn) { + lastTurn.scrollIntoView({behavior: "instant", block: "end"}); + } + + window.scrollTo(0, document.body.scrollHeight); + }""", + [ + SCROLL_CONTAINER_SELECTOR, + CHAT_SESSION_CONTENT_SELECTOR, + LAST_CHAT_TURN_SELECTOR, + ], + ) + except Exception: + pass + + await check_quota_limit(page, req_id) + + try: + check_client_disconnected_func("Wait for completion - loop start") + except ClientDisconnectedError: + logger.info(f"[{req_id}] (WaitV3) Client disconnected, aborting wait.") + return False + + time_elapsed = time.time() - start_time + if time_elapsed > current_timeout_seconds: + is_thinking = await page.locator( + 'button[aria-label="Stop generating"]' + ).is_visible() + if is_thinking: + logger.warning( + f"[{req_id}] 🚨 TIMEOUT REACHED despite active UI! Forcing stream completion." + ) + else: + logger.warning( + f"[{req_id}] ⏰ (WaitV3) Timed out waiting for response completion ({current_timeout_seconds:.1f}s). Aborting." + ) + await save_error_snapshot(f"wait_completion_v3_overall_timeout_{req_id}") + return False + + try: + check_client_disconnected_func("Wait for completion - after timeout check") + except ClientDisconnectedError: + return False + + # C. Check if "Thinking" (UI is busy) + stop_button_locator = page.locator('button[aria-label="Stop generating"]') + is_thinking = await stop_button_locator.is_visible() + if is_thinking: + if DEBUG_LOGS_ENABLED: + logger.debug( + f"[{req_id}] (WaitV3) UI shows thinking, but NOT resetting timeout (Network State Priority)" + ) + + # --- Primary conditions: Input empty & Submit disabled --- + is_input_empty = await prompt_textarea_locator.input_value() == "" + is_submit_disabled = False + try: + is_submit_disabled = await submit_button_locator.is_disabled( + timeout=wait_timeout_ms_short + ) + except TimeoutError: + logger.warning( + f"[{req_id}] (WaitV3) Timed out checking if submit button is disabled. Assuming not disabled for this check." + ) + + try: + check_client_disconnected_func( + "Wait for completion - after button status check" + ) + except ClientDisconnectedError: + return False + + if is_input_empty and is_submit_disabled: + consecutive_empty_input_submit_disabled_count += 1 + if DEBUG_LOGS_ENABLED: + logger.debug( + f"[{req_id}] (WaitV3) Primary conditions met: Input empty, submit disabled (count: {consecutive_empty_input_submit_disabled_count})." + ) + + # --- Final confirmation: Edit button visible --- + try: + if await edit_button_locator.is_visible(timeout=wait_timeout_ms_short): + logger.info( + f"[{req_id}] (WaitV3) ✅ Response complete: Input empty, submit disabled, edit button visible." + ) + return True + except TimeoutError: + if DEBUG_LOGS_ENABLED: + logger.debug( + f"[{req_id}] (WaitV3) After primary conditions met, check for edit button visibility timed out." + ) + + try: + check_client_disconnected_func( + "Wait for completion - after edit button check" + ) + except ClientDisconnectedError: + return False + + # Heuristic completion: if primary conditions stay met but edit button doesn't appear + if consecutive_empty_input_submit_disabled_count >= 3: + logger.warning( + f"[{req_id}] (WaitV3) Response might be complete (heuristic): Input empty, submit disabled, but edit button still hasn't appeared after {consecutive_empty_input_submit_disabled_count} checks. Assuming complete." + ) + return True + else: + consecutive_empty_input_submit_disabled_count = 0 + if DEBUG_LOGS_ENABLED: + reasons = [] + if not is_input_empty: + reasons.append("input not empty") + if not is_submit_disabled: + reasons.append("submit button not disabled") + logger.debug( + f"[{req_id}] (WaitV3) Primary conditions not met ({', '.join(reasons)}). Continuing polling..." + ) + + await asyncio.sleep(0.5) + + +async def _get_final_response_content( + page: AsyncPage, req_id: str, check_client_disconnected: Callable +) -> Optional[str]: + """Get final response content""" + logger.info( + f"[{req_id}] (Helper GetContent) Starting to get final response content..." + ) + response_content = await get_response_via_edit_button( + page, req_id, check_client_disconnected + ) + if response_content is not None: + logger.info( + f"[{req_id}] (Helper GetContent) ✅ Successfully obtained content via edit button." + ) + return response_content + + logger.warning( + f"[{req_id}] (Helper GetContent) Edit button method failed or returned empty, falling back to copy button method..." + ) + response_content = await get_response_via_copy_button( + page, req_id, check_client_disconnected + ) + if response_content is not None: + logger.info( + f"[{req_id}] (Helper GetContent) ✅ Successfully obtained content via copy button." + ) + return response_content + + logger.error( + f"[{req_id}] (Helper GetContent) All response content retrieval methods failed." + ) + await save_error_snapshot(f"get_content_all_methods_failed_{req_id}") + return None diff --git a/browser_utils/operations_modules/__init__.py b/browser_utils/operations_modules/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..c9e83e22332b10103bcc649048c83915003f66a4 --- /dev/null +++ b/browser_utils/operations_modules/__init__.py @@ -0,0 +1 @@ +# browser_utils/operations_modules package diff --git a/browser_utils/operations_modules/errors.py b/browser_utils/operations_modules/errors.py new file mode 100644 index 0000000000000000000000000000000000000000..fd1e8667ec397293256e27ba98cd63c9617ded21 --- /dev/null +++ b/browser_utils/operations_modules/errors.py @@ -0,0 +1,396 @@ +# --- browser_utils/operations_modules/errors.py --- +import asyncio +import json +import logging +import traceback +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Optional + +from playwright.async_api import Error as PlaywrightAsyncError +from playwright.async_api import Page as AsyncPage +from playwright.async_api import TimeoutError as PlaywrightTimeoutError + +from config import ERROR_TOAST_SELECTOR +from logging_utils import set_request_id + +logger = logging.getLogger("AIStudioProxyServer") + + +class ErrorCategory(Enum): + """Error type classification for standardized error snapshot saving behavior.""" + + TIMEOUT = ( + "timeout" # Timeout errors (Playwright TimeoutError, asyncio.TimeoutError) + ) + PLAYWRIGHT = "playwright" # Playwright browser errors + NETWORK = "network" # Network/connection errors + CLIENT = "client" # Client disconnected + VALIDATION = "validation" # Validation errors (ValueError, TypeError) + CANCELLED = "cancelled" # Task cancelled + UNKNOWN = "unknown" # Unclassified errors + + +def categorize_error(exception: BaseException) -> ErrorCategory: + """ + Automatically categorize error based on exception type. + + Args: + exception: The exception object to classify + + Returns: + ErrorCategory: The classified error category + """ + exc_type = type(exception) + exc_name = exc_type.__name__.lower() + exc_module = exc_type.__module__ or "" + + # Cancellation error - special handling + if isinstance(exception, asyncio.CancelledError): + return ErrorCategory.CANCELLED + + # Timeout errors + if isinstance(exception, (PlaywrightTimeoutError, asyncio.TimeoutError)): + return ErrorCategory.TIMEOUT + if "timeout" in exc_name: + return ErrorCategory.TIMEOUT + + # Playwright errors + if isinstance(exception, PlaywrightAsyncError): + return ErrorCategory.PLAYWRIGHT + if "playwright" in exc_module.lower(): + return ErrorCategory.PLAYWRIGHT + + # Network/connection errors + network_keywords = ["connection", "network", "socket", "http", "ssl", "connect"] + if any(kw in exc_name for kw in network_keywords): + return ErrorCategory.NETWORK + if any(kw in str(exception).lower() for kw in ["connection", "network", "socket"]): + return ErrorCategory.NETWORK + + # Client disconnect + if "clientdisconnected" in exc_name or "disconnect" in exc_name: + return ErrorCategory.CLIENT + + # Validation errors + if isinstance(exception, (ValueError, TypeError, AttributeError)): + return ErrorCategory.VALIDATION + + return ErrorCategory.UNKNOWN + + +async def detect_and_extract_page_error(page: AsyncPage, req_id: str) -> Optional[str]: + """Detect and extract page errors""" + set_request_id(req_id) + error_toast_locator = page.locator(ERROR_TOAST_SELECTOR).last + try: + await error_toast_locator.wait_for(state="visible", timeout=500) + message_locator = error_toast_locator.locator("span.content-text") + error_message = await message_locator.text_content(timeout=500) + if error_message: + logger.error(f"Detected and extracted error message: {error_message}") + return error_message.strip() + else: + logger.warning("Detected error toast, but could not extract message.") + return "Error toast detected, but specific message could not be extracted." + except PlaywrightAsyncError: + return None + except asyncio.CancelledError: + raise + except Exception as e: + logger.warning(f"Error checking for page errors: {e}") + return None + + +async def save_minimal_snapshot( + error_name: str, + req_id: str = "unknown", + error_category: Optional[ErrorCategory] = None, + error_exception: Optional[BaseException] = None, + additional_context: Optional[dict] = None, +) -> str: + """ + Save minimal error snapshot (no browser/page required). + + Saves valuable debug info even when browser or page is unavailable. + Includes environment variables, queue status, lock status, and a summary. + + Args: + error_name: Error name + req_id: Request ID + error_category: Error category (optional) + error_exception: Exception that triggered the snapshot (optional) + additional_context: Extra context info (optional) + + Returns: + str: Snapshot directory path, empty string on failure + """ + try: + import os + import platform + import sys + + # Generate timestamp (using local time) + now = datetime.now().astimezone() + iso_timestamp = now.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + date_str = iso_timestamp.split("T")[0] + time_component = iso_timestamp.split("T")[1].replace(":", "-").replace(".", "-") + + # Create directory structure (using errors_py in project root) + # Path: operations_modules -> browser_utils -> project_root + base_error_dir = Path(__file__).parent.parent.parent / "errors_py" + date_dir = base_error_dir / date_str + snapshot_dir_name = f"{time_component}_{req_id}_{error_name}_minimal" + snapshot_dir = date_dir / snapshot_dir_name + snapshot_dir.mkdir(parents=True, exist_ok=True) + + # Auto-categorize error (if not provided and exception is available) + if error_category is None and error_exception is not None: + error_category = categorize_error(error_exception) + + # === 1. Build detailed metadata === + metadata: dict = { + "snapshot_info": { + "type": "minimal", + "reason": "Browser/page unavailable", + "timestamp_iso": iso_timestamp, + "timestamp_local": now.astimezone().strftime("%Y-%m-%d %H:%M:%S %Z"), + }, + "error": { + "name": error_name, + "category": error_category.value if error_category else "unknown", + "req_id": req_id, + }, + "system": { + "platform": platform.platform(), + "python_version": sys.version.split()[0], + "pid": os.getpid(), + "cwd": os.getcwd(), + }, + } + + # Add exception details + if error_exception is not None: + tb_lines = traceback.format_exception( + type(error_exception), error_exception, error_exception.__traceback__ + ) + metadata["exception"] = { + "type": type(error_exception).__name__, + "module": type(error_exception).__module__, + "message": str(error_exception), + "args": [str(a) for a in getattr(error_exception, "args", [])[:5]], + "traceback": "".join(tb_lines), + } + + # Add additional context + if additional_context: + metadata["additional_context"] = additional_context + + # === 2. Capture application state === + try: + from api_utils.server_state import state + + # Basic flags + metadata["application_state"] = { + "flags": { + "is_playwright_ready": getattr(state, "is_playwright_ready", None), + "is_browser_connected": getattr( + state, "is_browser_connected", None + ), + "is_page_ready": getattr(state, "is_page_ready", None), + "is_initializing": getattr(state, "is_initializing", None), + }, + "current_model": getattr(state, "current_ai_studio_model_id", None), + "excluded_models_count": len(getattr(state, "excluded_model_ids", [])), + } + + # Queue status + rq = getattr(state, "request_queue", None) + if rq: + try: + metadata["application_state"]["request_queue_size"] = rq.qsize() + except Exception: + metadata["application_state"]["request_queue_size"] = "N/A" + + # Lock status + pl = getattr(state, "processing_lock", None) + ml = getattr(state, "model_switching_lock", None) + metadata["application_state"]["locks"] = { + "processing_lock": pl.locked() + if pl and hasattr(pl, "locked") + else None, + "model_switching_lock": ml.locked() + if ml and hasattr(ml, "locked") + else None, + } + + # Stream queue + sq = getattr(state, "STREAM_QUEUE", None) + metadata["application_state"]["stream_queue_active"] = sq is not None + + except Exception as server_err: + metadata["application_state"] = {"error": str(server_err)} + + # === 3. Environment variables (security filtered) === + safe_env_keys = [ + "HEADLESS", + "DEBUG_LOGS_ENABLED", + "DEFAULT_MODEL", + "LAUNCH_MODE", + "RESPONSE_COMPLETION_TIMEOUT", + "HOST_OS_FOR_SHORTCUT", + "PORT", + "STREAM_PROXY_PORT", + "LOG_LEVEL", + ] + metadata["environment"] = { + k: os.environ.get(k, "not set") for k in safe_env_keys + } + + # === 4. Save metadata.json === + metadata_path = snapshot_dir / "metadata.json" + with open(metadata_path, "w", encoding="utf-8") as f: + json.dump(metadata, f, indent=2, ensure_ascii=False) + + # === 5. Create human-readable SUMMARY.txt === + summary_path = snapshot_dir / "SUMMARY.txt" + summary_lines = [ + "=" * 60, + "ERROR SNAPSHOT SUMMARY", + "=" * 60, + "", + f"Timestamp: {metadata['snapshot_info']['timestamp_local']}", + f"Request ID: {req_id}", + f"Error Name: {error_name}", + f"Category: {error_category.value if error_category else 'unknown'}", + "Snapshot Type: MINIMAL (browser unavailable)", + "", + "-" * 60, + "EXCEPTION DETAILS", + "-" * 60, + ] + + if error_exception: + summary_lines.extend( + [ + f"Type: {type(error_exception).__name__}", + f"Message: {error_exception}", + "", + "Traceback:", + metadata["exception"]["traceback"], + ] + ) + else: + summary_lines.append("No exception provided") + + summary_lines.extend( + [ + "", + "-" * 60, + "APPLICATION STATE", + "-" * 60, + ] + ) + + app_state = metadata.get("application_state", {}) + flags = app_state.get("flags", {}) + for key, val in flags.items(): + summary_lines.append(f" {key}: {val}") + + summary_lines.extend( + [ + f" Current Model: {app_state.get('current_model', 'N/A')}", + f" Queue Size: {app_state.get('request_queue_size', 'N/A')}", + "", + "-" * 60, + "FILES IN SNAPSHOT", + "-" * 60, + " - SUMMARY.txt (this file)", + " - metadata.json (full details)", + "", + "=" * 60, + ] + ) + + with open(summary_path, "w", encoding="utf-8") as f: + f.write("\n".join(summary_lines)) + + logger.info(f"[Snapshot] Saved minimal snapshot: {snapshot_dir.name}") + return str(snapshot_dir) + + except asyncio.CancelledError: + raise + except Exception as e: + logger.warning(f"[Snapshot] Failed to save minimal snapshot: {e}") + return "" + + +async def save_error_snapshot( + error_name: str = "error", + error_exception: Optional[Exception] = None, + error_stage: str = "", + additional_context: Optional[dict] = None, + locators: Optional[dict] = None, +): + """ + Save error snapshot (Robust wrapper with guaranteed save). + + Ensures that SOMETHING is always saved when called. + Falls back to minimal snapshot if browser/page is unavailable. + + Args: + error_name: Error name with optional req_id suffix (e.g., "error_hbfu521") + error_exception: The exception that triggered the snapshot (optional) + error_stage: Description of the error stage (optional) + additional_context: Extra context dict to include in metadata (optional) + locators: Dict of named locators to capture states for (optional) + """ + # Parse req_id + name_parts = error_name.split("_") + req_id = ( + name_parts[-1] + if len(name_parts) > 1 and len(name_parts[-1]) == 7 + else "unknown" + ) + + # Auto-categorize error + error_category = None + if error_exception is not None: + error_category = categorize_error(error_exception) + # Skip snapshot for cancellation errors + if error_category == ErrorCategory.CANCELLED: + logger.debug( + f"[Snapshot] Skipping snapshot for cancelled error: {error_name}" + ) + return + + # Add category to context + context = additional_context.copy() if additional_context else {} + if error_category: + context["error_category"] = error_category.value + + try: + from browser_utils.debug_utils import save_error_snapshot_enhanced + + await save_error_snapshot_enhanced( + error_name, + error_exception=error_exception, + error_stage=error_stage, + additional_context=context, + locators=locators, + ) + except asyncio.CancelledError: + raise + except Exception as enhanced_err: + # Fall back to minimal snapshot if enhanced snapshot fails + logger.warning( + f"[Snapshot] Enhanced snapshot failed ({enhanced_err}), falling back to minimal snapshot..." + ) + await save_minimal_snapshot( + error_name=error_name, + req_id=req_id, + error_category=error_category, + error_exception=error_exception, + additional_context=context, + ) diff --git a/browser_utils/operations_modules/interactions.py b/browser_utils/operations_modules/interactions.py new file mode 100644 index 0000000000000000000000000000000000000000..65b2680a5fc6c1f4512c182b3cb133615dba8b91 --- /dev/null +++ b/browser_utils/operations_modules/interactions.py @@ -0,0 +1,502 @@ +# --- browser_utils/operations_modules/interactions.py --- +import asyncio +import logging +import time +from typing import Callable, Optional + +from playwright.async_api import ( + Error as PlaywrightAsyncError, +) +from playwright.async_api import ( + Locator, +) +from playwright.async_api import ( + Page as AsyncPage, +) + +from browser_utils.operations_modules.errors import save_error_snapshot +from config import ( + CHAT_TURN_SELECTOR, + CLICK_TIMEOUT_MS, + DEBUG_LOGS_ENABLED, + INITIAL_WAIT_MS_BEFORE_POLLING, + RESPONSE_COMPLETION_TIMEOUT, +) +from models import ClientDisconnectedError + +logger = logging.getLogger("AIStudioProxyServer") + + +async def get_raw_text_content( + response_element: Locator, previous_text: str, req_id: str +) -> str: + """Get raw text content from the response element.""" + raw_text = previous_text + try: + await response_element.wait_for(state="attached", timeout=1000) + pre_element = response_element.locator("pre").last + pre_found_and_visible = False + try: + await pre_element.wait_for(state="visible", timeout=250) + pre_found_and_visible = True + except PlaywrightAsyncError: + pass + + if pre_found_and_visible: + try: + raw_text = await pre_element.inner_text(timeout=500) + except PlaywrightAsyncError as pre_err: + if DEBUG_LOGS_ENABLED: + logger.debug( + f"(GetRawText) Failed to get inner text of pre element: {pre_err}" + ) + else: + try: + raw_text = await response_element.inner_text(timeout=500) + except PlaywrightAsyncError as e_parent: + if DEBUG_LOGS_ENABLED: + logger.debug( + f"(GetRawText) Failed to get inner text of response element: {e_parent}" + ) + except PlaywrightAsyncError as e_parent: + if DEBUG_LOGS_ENABLED: + logger.debug(f"(GetRawText) Response element not ready: {e_parent}") + except asyncio.CancelledError: + raise + except Exception as e_unexpected: + logger.warning(f"(GetRawText) Unexpected error: {e_unexpected}") + + if raw_text != previous_text: + if DEBUG_LOGS_ENABLED: + preview = raw_text[:100].replace("\n", "\\n") + logger.debug( + f"(GetRawText) Text updated, length: {len(raw_text)}, Preview: '{preview}...'" + ) + return raw_text + + +async def get_response_via_edit_button( + page: AsyncPage, req_id: str, check_client_disconnected: Callable +) -> Optional[str]: + """Get response via the edit button.""" + logger.info("(Helper) Attempting to get response via edit button...") + last_message_container = page.locator(CHAT_TURN_SELECTOR).last + edit_button = last_message_container.get_by_label("Edit") + finish_edit_button = last_message_container.get_by_label("Stop editing") + autosize_textarea_locator = last_message_container.locator("ms-autosize-textarea") + actual_textarea_locator = last_message_container.locator("textarea") + + try: + logger.info( + "- Attempting to hover over the last message to show 'Edit' button..." + ) + try: + # Perform hover on message container + await last_message_container.hover( + timeout=CLICK_TIMEOUT_MS / 2 + ) # Use half click timeout for hover + await asyncio.sleep(0.3) # Wait for hover effect + check_client_disconnected("Edit Response - after hover: ") + except asyncio.CancelledError: + raise + except ClientDisconnectedError: + raise + except Exception as hover_err: + logger.warning( + f" - (get_response_via_edit_button) Hover over last message failed (ignoring): {type(hover_err).__name__}" + ) + + logger.info("- Locating and clicking 'Edit' button...") + try: + from playwright.async_api import expect as expect_async + + await expect_async(edit_button).to_be_visible(timeout=CLICK_TIMEOUT_MS) + check_client_disconnected("Edit Response - 'Edit' button visible: ") + await edit_button.click(timeout=CLICK_TIMEOUT_MS) + logger.info("- 'Edit' button clicked.") + except asyncio.CancelledError: + raise + except Exception as edit_btn_err: + logger.error( + f" - 'Edit' button not visible or click failed: {edit_btn_err}", + exc_info=True, + ) + await save_error_snapshot(f"edit_response_edit_button_failed_{req_id}") + return None + + check_client_disconnected("Edit Response - after clicking 'Edit' button: ") + await asyncio.sleep(0.3) + check_client_disconnected( + "Edit Response - after delay following 'Edit' click: " + ) + + logger.info("- Retrieving content from textarea...") + response_content = None + textarea_failed = False + + try: + target_locator = autosize_textarea_locator + if await target_locator.count() == 0: + target_locator = actual_textarea_locator + + if await target_locator.count() == 0: + raise RuntimeError("No editable textarea found") + + await expect_async(target_locator).to_be_visible(timeout=CLICK_TIMEOUT_MS) + check_client_disconnected("Edit Response - textarea visible: ") + + if await autosize_textarea_locator.count() > 0 and response_content is None: + try: + data_value_content = await autosize_textarea_locator.get_attribute( + "data-value" + ) + check_client_disconnected( + "Edit Response - after get_attribute data-value: " + ) + if data_value_content is not None: + response_content = str(data_value_content) + logger.info("- Successfully obtained content from data-value.") + except asyncio.CancelledError: + raise + except Exception as data_val_err: + logger.warning(f"- Failed to get data-value: {data_val_err}") + check_client_disconnected( + "Edit Response - after get_attribute data-value error: " + ) + + if response_content is None and await actual_textarea_locator.count() > 0: + logger.info( + " - data-value retrieval failed or does not exist, attempting input_value from textarea..." + ) + try: + await expect_async(actual_textarea_locator).to_be_visible( + timeout=CLICK_TIMEOUT_MS / 2 + ) + input_val_content = await actual_textarea_locator.input_value( + timeout=CLICK_TIMEOUT_MS / 2 + ) + check_client_disconnected("Edit Response - after input_value: ") + response_content = str(input_val_content) + logger.info("- Successfully obtained content from input_value.") + except asyncio.CancelledError: + raise + except Exception as input_val_err: + logger.warning( + f"- Failed to get input_value as well: {input_val_err}" + ) + check_client_disconnected( + "Edit Response - after input_value error: " + ) + + if response_content is not None: + response_content = response_content.strip() + content_preview = response_content[:100].replace("\\n", "\\\\n") + logger.info( + f" - Final content retrieved (length={len(response_content)}): '{content_preview}...'" + ) + else: + logger.warning( + " - All content retrieval methods (data-value, input_value) failed or returned None." + ) + textarea_failed = True + + except asyncio.CancelledError: + raise + except Exception as textarea_err: + logger.error( + f" - Failed to locate or process textarea: {textarea_err}", + exc_info=True, + ) + textarea_failed = True + response_content = None + check_client_disconnected("Edit Response - after textarea error: ") + + if not textarea_failed: + logger.info("- Locating and clicking 'Stop editing' button...") + try: + await expect_async(finish_edit_button).to_be_visible( + timeout=CLICK_TIMEOUT_MS + ) + check_client_disconnected( + "Edit Response - 'Stop editing' button visible: " + ) + await finish_edit_button.click(timeout=CLICK_TIMEOUT_MS) + logger.info("- 'Stop editing' button clicked.") + except asyncio.CancelledError: + raise + except Exception as finish_btn_err: + logger.warning( + f" - 'Stop editing' button not visible or click failed: {finish_btn_err}" + ) + await save_error_snapshot( + f"edit_response_finish_button_failed_{req_id}" + ) + check_client_disconnected("Edit Response - after clicking 'Stop editing': ") + await asyncio.sleep(0.2) + check_client_disconnected( + "Edit Response - after delay following 'Stop editing' click: " + ) + else: + logger.info( + "- Skipping 'Stop editing' button click due to textarea read failure." + ) + + return response_content + + except ClientDisconnectedError: + logger.info("(Helper Edit) Client disconnected.") + raise + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Unexpected error during response retrieval via edit button") + await save_error_snapshot(f"edit_response_unexpected_error_{req_id}") + return None + + +async def get_response_via_copy_button( + page: AsyncPage, req_id: str, check_client_disconnected: Callable +) -> Optional[str]: + """Get response via the copy button.""" + logger.info("(Helper) Attempting to get response via copy button...") + last_message_container = page.locator(CHAT_TURN_SELECTOR).last + more_options_button = last_message_container.get_by_label("Open options") + copy_markdown_button = page.get_by_role("menuitem", name="Copy markdown") + + try: + logger.info("- Attempting to hover over the last message to show options...") + await last_message_container.hover(timeout=CLICK_TIMEOUT_MS) + check_client_disconnected("Copy Response - after hover: ") + await asyncio.sleep(0.5) + check_client_disconnected("Copy Response - after hover delay: ") + logger.info("- Hovered.") + + logger.info("- Locating and clicking 'More options' button...") + try: + from playwright.async_api import expect as expect_async + + await expect_async(more_options_button).to_be_visible( + timeout=CLICK_TIMEOUT_MS + ) + check_client_disconnected("Copy Response - 'More options' button visible: ") + await more_options_button.click(timeout=CLICK_TIMEOUT_MS) + logger.info("- 'More options' clicked (via get_by_label).") + except asyncio.CancelledError: + raise + except Exception as more_opts_err: + logger.error( + f" - 'More options' button (via get_by_label) not visible or click failed: {more_opts_err}" + ) + await save_error_snapshot(f"copy_response_more_options_failed_{req_id}") + return None + + check_client_disconnected("Copy Response - after clicking more options: ") + await asyncio.sleep(0.5) + check_client_disconnected( + "Copy Response - after delay following more options click: " + ) + + logger.info("- Locating and clicking 'Copy markdown' button...") + copy_success = False + try: + await expect_async(copy_markdown_button).to_be_visible( + timeout=CLICK_TIMEOUT_MS + ) + check_client_disconnected("Copy Response - copy button visible: ") + await copy_markdown_button.click(timeout=CLICK_TIMEOUT_MS, force=True) + copy_success = True + logger.info("- 'Copy markdown' clicked (via get_by_role).") + except asyncio.CancelledError: + raise + except Exception as copy_err: + logger.error( + f" - 'Copy markdown' button (via get_by_role) click failed: {copy_err}" + ) + await save_error_snapshot(f"copy_response_copy_button_failed_{req_id}") + return None + + if not copy_success: + logger.error("- Failed to click 'Copy markdown' button.") + return None + + check_client_disconnected("Copy Response - after clicking copy button: ") + await asyncio.sleep(0.5) + check_client_disconnected( + "Copy Response - after delay following copy button click: " + ) + + logger.info("- Reading clipboard content...") + try: + clipboard_content = await page.evaluate("navigator.clipboard.readText()") + check_client_disconnected("Copy Response - after reading clipboard: ") + if clipboard_content: + content_preview = clipboard_content[:100].replace("\n", "\\\\n") + logger.info( + f" - Successfully obtained clipboard content (length={len(clipboard_content)}): '{content_preview}...'" + ) + return clipboard_content + else: + logger.error("- Clipboard content is empty.") + return None + except asyncio.CancelledError: + raise + except Exception as clipboard_err: + if "clipboard-read" in str(clipboard_err): + logger.error( + f" - Clipboard read failed: possible permissions issue. Error: {clipboard_err}" + ) + else: + logger.error(f"- Clipboard read failed: {clipboard_err}", exc_info=True) + await save_error_snapshot(f"copy_response_clipboard_read_failed_{req_id}") + return None + + except ClientDisconnectedError: + logger.info("(Helper Copy) Client disconnected.") + raise + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Unexpected error during response retrieval via copy button") + await save_error_snapshot(f"copy_response_unexpected_error_{req_id}") + return None + + +async def _wait_for_response_completion( + page: AsyncPage, + prompt_textarea_locator: Locator, + submit_button_locator: Locator, + edit_button_locator: Locator, + req_id: str, + check_client_disconnected_func: Callable, + timeout_ms=RESPONSE_COMPLETION_TIMEOUT, + initial_wait_ms=INITIAL_WAIT_MS_BEFORE_POLLING, +) -> bool: + """Wait for response completion.""" + from playwright.async_api import TimeoutError + + logger.info( + f"(WaitV3) Waiting for response completion... (Timeout: {timeout_ms}ms)" + ) + await asyncio.sleep(initial_wait_ms / 1000) + + start_time = time.time() + wait_timeout_ms_short = 3000 + + consecutive_empty_input_submit_disabled_count = 0 + + while True: + try: + check_client_disconnected_func("Wait for completion - loop start") + except ClientDisconnectedError: + logger.info("(WaitV3) Client disconnected, aborting wait.") + return False + + current_time_elapsed_ms = (time.time() - start_time) * 1000 + if current_time_elapsed_ms > timeout_ms: + logger.error( + f"(WaitV3) Timed out waiting for response completion ({timeout_ms}ms)." + ) + await save_error_snapshot(f"wait_completion_v3_overall_timeout_{req_id}") + return False + + try: + check_client_disconnected_func("Wait for completion - after timeout check") + except ClientDisconnectedError: + return False + + # --- Primary conditions: Input empty & Submit disabled --- + is_input_empty = await prompt_textarea_locator.input_value() == "" + is_submit_disabled = False + try: + is_submit_disabled = await submit_button_locator.is_disabled( + timeout=wait_timeout_ms_short + ) + except TimeoutError: + logger.warning( + "(WaitV3) Timed out checking if submit button is disabled. Assuming not disabled for this check." + ) + + try: + check_client_disconnected_func( + "Wait for completion - after button status check" + ) + except ClientDisconnectedError: + return False + + if is_input_empty and is_submit_disabled: + consecutive_empty_input_submit_disabled_count += 1 + if DEBUG_LOGS_ENABLED: + logger.debug( + f"(WaitV3) Primary conditions met: Input empty, submit disabled (count: {consecutive_empty_input_submit_disabled_count})." + ) + + # --- Final confirmation: Edit button visible --- + try: + if await edit_button_locator.is_visible(timeout=wait_timeout_ms_short): + logger.info( + "(WaitV3) Response complete: Input empty, submit disabled, edit button visible." + ) + return True + except TimeoutError: + if DEBUG_LOGS_ENABLED: + logger.debug( + "(WaitV3) After primary conditions met, check for edit button visibility timed out." + ) + + try: + check_client_disconnected_func( + "Wait for completion - after edit button check" + ) + except ClientDisconnectedError: + return False + + # Heuristic completion + if consecutive_empty_input_submit_disabled_count >= 3: + logger.warning( + f"(WaitV3) Response might be complete (heuristic): Input empty, submit disabled, but edit button still has not appeared after {consecutive_empty_input_submit_disabled_count} checks. Assuming complete." + ) + return True + else: + consecutive_empty_input_submit_disabled_count = 0 + if DEBUG_LOGS_ENABLED: + reasons = [] + if not is_input_empty: + reasons.append("input not empty") + if not is_submit_disabled: + reasons.append("submit button not disabled") + logger.debug( + f"(WaitV3) Primary conditions not met ({', '.join(reasons)}). Continuing polling..." + ) + + await asyncio.sleep(0.5) + + +async def _get_final_response_content( + page: AsyncPage, req_id: str, check_client_disconnected: Callable +) -> Optional[str]: + """Get final response content.""" + logger.info("(Helper GetContent) Starting to get final response content...") + response_content = await get_response_via_edit_button( + page, req_id, check_client_disconnected + ) + if response_content is not None: + logger.info( + "(Helper GetContent) Successfully obtained content via edit button." + ) + return response_content + + logger.warning( + "(Helper GetContent) Edit button method failed or returned empty, falling back to copy button method..." + ) + response_content = await get_response_via_copy_button( + page, req_id, check_client_disconnected + ) + if response_content is not None: + logger.info( + "(Helper GetContent) Successfully obtained content via copy button." + ) + return response_content + + logger.error("(Helper GetContent) All response content retrieval methods failed.") + await save_error_snapshot(f"get_content_all_methods_failed_{req_id}") + return None diff --git a/browser_utils/operations_modules/parsers.py b/browser_utils/operations_modules/parsers.py new file mode 100644 index 0000000000000000000000000000000000000000..6e8816d85d35e7818f3b26211e30b6c3e70008e4 --- /dev/null +++ b/browser_utils/operations_modules/parsers.py @@ -0,0 +1,408 @@ +# --- browser_utils/operations_modules/parsers.py --- +import asyncio +import json +import logging +import os +import time +from typing import Any + +from config import ( + DEBUG_LOGS_ENABLED, + MODELS_ENDPOINT_URL_CONTAINS, +) + +logger = logging.getLogger("AIStudioProxyServer") + + +async def _handle_model_list_response(response: Any): + """Handle model list response""" + # Need to access global variables + from api_utils.server_state import state + + getattr(state, "global_model_list_raw_json", None) + getattr(state, "parsed_model_list", []) + model_list_fetch_event = getattr(state, "model_list_fetch_event", None) + excluded_model_ids = getattr(state, "excluded_model_ids", set()) + + if MODELS_ENDPOINT_URL_CONTAINS in response.url and response.ok: + # Check if in login flow + launch_mode = os.environ.get("LAUNCH_MODE", "debug") + is_in_login_flow = launch_mode in ["debug"] and not getattr( + state, "is_page_ready", False + ) + + if is_in_login_flow: + # During login flow, handle silently to avoid interfering with user input + pass # Silent handling to avoid interfering with user input + else: + logger.debug( + f"[Network] Captured model list response ({response.status} OK)" + ) + try: + data = await response.json() + models_array_container = None + if isinstance(data, list) and data: + if ( + isinstance(data[0], list) + and data[0] + and isinstance(data[0][0], list) + ): + # [Parse] log moved to count change check + models_array_container = data[0] + elif ( + isinstance(data[0], list) + and data[0] + and isinstance(data[0][0], str) + ): + # [Parse] log moved to count change check + models_array_container = data + elif isinstance(data[0], dict): + # [Parse] log moved to count change check + models_array_container = data + else: + logger.warning( + f"Unknown list nesting structure. data[0] type: {type(data[0]) if data else 'N/A'}. data[0] preview: {str(data[0])[:200] if data else 'N/A'}" + ) + elif isinstance(data, dict): + if "data" in data and isinstance(data["data"], list): + models_array_container = data["data"] + elif "models" in data and isinstance(data["models"], list): + models_array_container = data["models"] + else: + for key, value in data.items(): + if ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], (dict, list)) + ): + models_array_container = value + logger.info( + f"Model list data found under '{key}' key via heuristic search." + ) + break + if models_array_container is None: + logger.warning( + "Could not auto-locate model list array in dict response." + ) + if ( + model_list_fetch_event + and not model_list_fetch_event.is_set() + ): + model_list_fetch_event.set() + return + else: + logger.warning( + f"Received model list data is neither list nor dict: {type(data)}" + ) + if model_list_fetch_event and not model_list_fetch_event.is_set(): + model_list_fetch_event.set() + return + + if models_array_container is not None: + new_parsed_list = [] + excluded_during_parse: list[str] = [] # Collect excluded model IDs + for entry_in_container in models_array_container: + model_fields_list = None + if isinstance(entry_in_container, dict): + potential_id = entry_in_container.get( + "id", + entry_in_container.get( + "model_id", entry_in_container.get("modelId") + ), + ) + if potential_id: + model_fields_list = entry_in_container + else: + model_fields_list = list(entry_in_container.values()) + elif isinstance(entry_in_container, list): + model_fields_list = entry_in_container + else: + logger.debug( + f"Skipping entry of unknown type: {type(entry_in_container)}" + ) + continue + + if not model_fields_list: + logger.debug( + "Skipping entry because model_fields_list is empty or None." + ) + continue + + model_id_path_str = None + display_name_candidate = "" + description_candidate = "N/A" + default_max_output_tokens_val = None + default_top_p_val = None + default_temperature_val = 1.0 + supported_max_output_tokens_val = None + current_model_id_for_log = "UnknownModelYet" + + try: + if isinstance(model_fields_list, list): + if not ( + len(model_fields_list) > 0 + and isinstance(model_fields_list[0], (str, int, float)) + ): + logger.debug( + f"Skipping list-based model_fields due to invalid first element: {str(model_fields_list)[:100]}" + ) + continue + model_id_path_str = str(model_fields_list[0]) + current_model_id_for_log = ( + model_id_path_str.split("/")[-1] + if model_id_path_str and "/" in model_id_path_str + else model_id_path_str + ) + display_name_candidate = ( + str(model_fields_list[3]) + if len(model_fields_list) > 3 + else "" + ) + description_candidate = ( + str(model_fields_list[4]) + if len(model_fields_list) > 4 + else "N/A" + ) + + if ( + len(model_fields_list) > 6 + and model_fields_list[6] is not None + ): + try: + val_int = int(model_fields_list[6]) + default_max_output_tokens_val = val_int + supported_max_output_tokens_val = val_int + except (ValueError, TypeError): + logger.warning( + f"Model {current_model_id_for_log}: Cannot parse list index 6 value '{model_fields_list[6]}' as max_output_tokens." + ) + + if ( + len(model_fields_list) > 9 + and model_fields_list[9] is not None + ): + try: + raw_top_p = float(model_fields_list[9]) + if not (0.0 <= raw_top_p <= 1.0): + logger.warning( + f"Model {current_model_id_for_log}: Raw top_p value {raw_top_p} (from list index 9) exceeds [0,1] range, will be clipped." + ) + default_top_p_val = max( + 0.0, min(1.0, raw_top_p) + ) + else: + default_top_p_val = raw_top_p + except (ValueError, TypeError): + logger.warning( + f"Model {current_model_id_for_log}: Cannot parse list index 9 value '{model_fields_list[9]}' as top_p." + ) + + elif isinstance(model_fields_list, dict): + model_id_path_str = str( + model_fields_list.get( + "id", + model_fields_list.get( + "model_id", model_fields_list.get("modelId") + ), + ) + ) + current_model_id_for_log = ( + model_id_path_str.split("/")[-1] + if model_id_path_str and "/" in model_id_path_str + else model_id_path_str + ) + display_name_candidate = str( + model_fields_list.get( + "displayName", + model_fields_list.get( + "display_name", + model_fields_list.get("name", ""), + ), + ) + ) + description_candidate = str( + model_fields_list.get("description", "N/A") + ) + + mot_parsed = model_fields_list.get( + "maxOutputTokens", + model_fields_list.get( + "defaultMaxOutputTokens", + model_fields_list.get("outputTokenLimit"), + ), + ) + if mot_parsed is not None: + try: + val_int = int(mot_parsed) + default_max_output_tokens_val = val_int + supported_max_output_tokens_val = val_int + except (ValueError, TypeError): + logger.warning( + f"Model {current_model_id_for_log}: Cannot parse dict value '{mot_parsed}' as max_output_tokens." + ) + + top_p_parsed = model_fields_list.get( + "topP", model_fields_list.get("defaultTopP") + ) + if top_p_parsed is not None: + try: + raw_top_p = float(top_p_parsed) + if not (0.0 <= raw_top_p <= 1.0): + logger.warning( + f"Model {current_model_id_for_log}: Raw top_p value {raw_top_p} (from dict) exceeds [0,1] range, will be clipped." + ) + default_top_p_val = max( + 0.0, min(1.0, raw_top_p) + ) + else: + default_top_p_val = raw_top_p + except (ValueError, TypeError): + logger.warning( + f"Model {current_model_id_for_log}: Cannot parse dict value '{top_p_parsed}' as top_p." + ) + + temp_parsed = model_fields_list.get( + "temperature", + model_fields_list.get("defaultTemperature"), + ) + if temp_parsed is not None: + try: + default_temperature_val = float(temp_parsed) + except (ValueError, TypeError): + logger.warning( + f"Model {current_model_id_for_log}: Cannot parse dict value '{temp_parsed}' as temperature." + ) + else: + logger.debug( + f"Skipping entry because model_fields_list is not list or dict: {type(model_fields_list)}" + ) + continue + except Exception as e_parse_fields: + logger.error( + f"Error parsing model fields for entry {str(entry_in_container)[:100]}: {e_parse_fields}" + ) + continue + + if model_id_path_str and model_id_path_str.lower() != "none": + simple_model_id_str = ( + model_id_path_str.split("/")[-1] + if "/" in model_id_path_str + else model_id_path_str + ) + if simple_model_id_str in excluded_model_ids: + excluded_during_parse.append(simple_model_id_str) + continue + + final_display_name_str = ( + display_name_candidate + if display_name_candidate + else simple_model_id_str.replace("-", " ").title() + ) + model_entry_dict = { + "id": simple_model_id_str, + "object": "model", + "created": int(time.time()), + "owned_by": "ai_studio", + "display_name": final_display_name_str, + "description": description_candidate, + "raw_model_path": model_id_path_str, + "default_temperature": default_temperature_val, + "default_max_output_tokens": default_max_output_tokens_val, + "supported_max_output_tokens": supported_max_output_tokens_val, + "default_top_p": default_top_p_val, + } + new_parsed_list.append(model_entry_dict) + else: + logger.debug( + f"Skipping entry due to invalid model_id_path: {model_id_path_str} from entry {str(entry_in_container)[:100]}" + ) + + # Excluded model log moved to count change check + excluded_count = ( + len(excluded_during_parse) if excluded_during_parse else 0 + ) + + if new_parsed_list: + # Check if network interception already injected models + has_network_injected_models = False + if models_array_container: + for entry_in_container in models_array_container: + if ( + isinstance(entry_in_container, list) + and len(entry_in_container) > 10 + ): + # Check for network injection marker + if "__NETWORK_INJECTED__" in entry_in_container: + has_network_injected_models = True + break + + if has_network_injected_models and not is_in_login_flow: + logger.info( + "Detected network interception already injected models" + ) + + # Note: No longer adding injected models on backend + # If frontend didn't inject via network interception, these models won't be usable anyway + # So we only rely on network interception for injection + + state.parsed_model_list = sorted( + new_parsed_list, key=lambda m: m.get("display_name", "").lower() + ) + state.global_model_list_raw_json = json.dumps( + {"data": state.parsed_model_list, "object": "list"} + ) + if DEBUG_LOGS_ENABLED: + # Only print full model list on first load or count change + previous_count = getattr(state, "_last_model_count", 0) or 0 + current_count = len(state.parsed_model_list) + if previous_count != current_count or previous_count == 0: + # Only show detailed parsing info when list changes + if excluded_count > 0 and not is_in_login_flow: + logger.debug( + f"[Model] Excluded {excluded_count} models" + ) + log_output = ( + f"[Model] List updated: {current_count} models\n" + ) + for i, item in enumerate( + state.parsed_model_list[ + : min(3, len(state.parsed_model_list)) + ] + ): + log_output += f" {i + 1}. {item.get('id')} (MaxTok={item.get('default_max_output_tokens')})\n" + logger.debug(log_output.rstrip()) + state._last_model_count = current_count # type: ignore + else: + logger.debug(f"[Model] List unchanged ({current_count})") + else: + logger.info( + f"[Model] List updated (total {len(state.parsed_model_list)} models)" + ) + if model_list_fetch_event and not model_list_fetch_event.is_set(): + model_list_fetch_event.set() + elif not state.parsed_model_list: + logger.warning("Model list still empty after parsing.") + if model_list_fetch_event and not model_list_fetch_event.is_set(): + model_list_fetch_event.set() + else: + logger.warning( + "models_array_container is None, cannot parse model list." + ) + if model_list_fetch_event and not model_list_fetch_event.is_set(): + model_list_fetch_event.set() + except json.JSONDecodeError as json_err: + logger.error( + f"Failed to parse model list JSON: {json_err}. Response (first 500 chars): {await response.text()[:500]}" + ) + except asyncio.CancelledError: + raise + except Exception as e_handle_list_resp: + logger.exception( + f"Unknown error processing model list response: {e_handle_list_resp}" + ) + finally: + if model_list_fetch_event and not model_list_fetch_event.is_set(): + logger.info( + "Finished processing model list response, forcing model_list_fetch_event set." + ) + model_list_fetch_event.set() diff --git a/browser_utils/page_controller.py b/browser_utils/page_controller.py new file mode 100644 index 0000000000000000000000000000000000000000..38c3dda05a2edb4be0e45de4282015116dcaabf7 --- /dev/null +++ b/browser_utils/page_controller.py @@ -0,0 +1,375 @@ +""" +PageController Module +Encapsulates all complex logic for direct interaction with Playwright pages. +""" + +import asyncio +import re +from typing import Any, Callable, Dict, List, Optional, Tuple + +from playwright.async_api import Page as AsyncPage +from playwright.async_api import expect as expect_async + +from config import ( + CLEAR_CHAT_BUTTON_SELECTOR, + CLEAR_CHAT_CONFIRM_BUTTON_SELECTOR, + CLICK_TIMEOUT_MS, + DEFAULT_MAX_OUTPUT_TOKENS, + DEFAULT_STOP_SEQUENCES, + DEFAULT_TEMPERATURE, + DEFAULT_TOP_P, + EDIT_MESSAGE_BUTTON_SELECTOR, + ENABLE_URL_CONTEXT, + PROMPT_TEXTAREA_SELECTOR, + SUBMIT_BUTTON_SELECTOR, + UPLOAD_BUTTON_SELECTOR, +) +from models import ClientDisconnectedError, QuotaExceededError + +from .initialization import enable_temporary_chat_mode +from .operations import ( + _get_final_response_content, + _wait_for_response_completion, + check_quota_limit, + get_response_via_copy_button, + get_response_via_edit_button, +) +from .page_controller_modules.base import BaseController +from .page_controller_modules.chat import ChatController +from .page_controller_modules.function_calling import FunctionCallingController +from .page_controller_modules.input import InputController +from .page_controller_modules.parameters import ParameterController +from .page_controller_modules.response import ResponseController +from .page_controller_modules.thinking import ThinkingController + + +class PageController( + ParameterController, + InputController, + ChatController, + ResponseController, + ThinkingController, + FunctionCallingController, + BaseController, +): + """Encapsulates all operations for interacting with the AI Studio page.""" + + def __init__(self, page: AsyncPage, logger, req_id: str): + self.page = page + self.logger = logger + self.req_id = req_id + + async def _check_disconnect(self, check_client_disconnected: Callable, stage: str): + if check_client_disconnected(stage): + raise ClientDisconnectedError( + f"[{self.req_id}] Client disconnected at stage: {stage}" + ) + + async def adjust_parameters( + self, + request_params: Dict[str, Any], + page_params_cache: Dict[str, Any], + params_cache_lock: asyncio.Lock, + model_id_to_use: Optional[str], + parsed_model_list: List[Dict[str, Any]], + check_client_disconnected: Callable, + is_streaming: bool = True, + ): + self.logger.info(f"[{self.req_id}] Adjusting parameters...") + await self._check_disconnect( + check_client_disconnected, "Start Parameter Adjustment" + ) + temp = request_params.get("temperature", DEFAULT_TEMPERATURE) + await self._adjust_temperature( + temp, page_params_cache, params_cache_lock, check_client_disconnected + ) + max_tokens = request_params.get("max_output_tokens", DEFAULT_MAX_OUTPUT_TOKENS) + await self._adjust_max_tokens( + max_tokens, + page_params_cache, + params_cache_lock, + model_id_to_use, + parsed_model_list, + check_client_disconnected, + ) + stop = request_params.get("stop", DEFAULT_STOP_SEQUENCES) + await self._adjust_stop_sequences( + stop, page_params_cache, params_cache_lock, check_client_disconnected + ) + top_p = request_params.get("top_p", DEFAULT_TOP_P) + await self._adjust_top_p(top_p, check_client_disconnected) + await self._ensure_tools_panel_expanded(check_client_disconnected) + + # Force disable URL context if function calling is active + is_fc_enabled = await self.is_function_calling_enabled( + check_client_disconnected + ) + if is_fc_enabled: + await self._adjust_url_context(False, check_client_disconnected) + elif ENABLE_URL_CONTEXT: + await self._adjust_url_context(True, check_client_disconnected) + + await self._handle_thinking_budget( + request_params, + page_params_cache, + params_cache_lock, + model_id_to_use, + check_client_disconnected, + is_streaming, + ) + await self._adjust_google_search( + request_params, model_id_to_use, check_client_disconnected + ) + + async def clear_chat_history(self, check_client_disconnected: Callable): + """Clear chat history and invalidate function calling cache.""" + self.logger.info(f"[{self.req_id}] Clearing chat history...") + + # Invalidate FC cache since we're starting a new chat + self.invalidate_fc_cache("new_chat") + + btn = self.page.locator(CLEAR_CHAT_BUTTON_SELECTOR) + if await btn.is_enabled(timeout=5000): + await btn.click(timeout=CLICK_TIMEOUT_MS) + confirm = self.page.locator(CLEAR_CHAT_CONFIRM_BUTTON_SELECTOR) + if await confirm.is_visible(timeout=2000): + await confirm.click(timeout=CLICK_TIMEOUT_MS) + await enable_temporary_chat_mode(self.page) + + async def submit_prompt( + self, prompt: str, image_list: List, check_client_disconnected: Callable + ): + """Submit prompt to the page with retries and keyboard fallbacks.""" + max_retries = 2 + for attempt in range(max_retries): + try: + self.logger.info( + f"[{self.req_id}] Filling and submitting prompt (Attempt {attempt + 1}/{max_retries})..." + ) + textarea = self.page.locator(PROMPT_TEXTAREA_SELECTOR) + await expect_async(textarea).to_be_visible(timeout=10000) + await self._check_disconnect( + check_client_disconnected, "After Input Visible" + ) + + # Fill textarea using centralized logic (inherited from InputController if possible, or direct) + await textarea.evaluate( + "(el, t) => { el.value = t; el.dispatchEvent(new Event('input', {bubbles:true})); el.dispatchEvent(new Event('change', {bubbles:true})); }", + prompt, + ) + await self._check_disconnect( + check_client_disconnected, "After Input Fill" + ) + + if image_list: + await self._open_upload_menu_and_choose_file(image_list) + + # Wait for submit button to be enabled + submit = self.page.locator(SUBMIT_BUTTON_SELECTOR) + button_clicked = False + is_btn_enabled = False + try: + await expect_async(submit).to_be_enabled(timeout=10000) + is_btn_enabled = True + except Exception: + self.logger.warning( + f"[{self.req_id}] Submit button not enabled within timeout, trying keyboard fallback." + ) + + await self._check_disconnect( + check_client_disconnected, "After Submit Button Check" + ) + + if is_btn_enabled: + try: + # Defensive workarounds before click: handle dialogs, backdrops and tooltips + await self._handle_post_upload_dialog() + await self._dismiss_backdrops() + if hasattr(self, "_dismiss_tooltip_overlays"): + await self._dismiss_tooltip_overlays() + + await submit.click(timeout=5000) + button_clicked = True + self.logger.info(f"[{self.req_id}] Submit button clicked.") + await check_quota_limit(self.page, self.req_id) + except QuotaExceededError: + raise + except Exception as click_err: + self.logger.warning( + f"[{self.req_id}] Button click failed: {click_err}. Trying keyboard fallback." + ) + + if not button_clicked: + # Keyboard fallbacks (using logic inherited from InputController) + self.logger.info( + f"[{self.req_id}] Attempting Enter key submission..." + ) + if await self._try_enter_submit( + textarea, check_client_disconnected + ): + button_clicked = True + else: + self.logger.info( + f"[{self.req_id}] Attempting Combo key submission..." + ) + if await self._try_combo_submit( + textarea, check_client_disconnected + ): + button_clicked = True + + if not button_clicked: + raise Exception( + "Failed to submit prompt via button or keyboard shortcuts." + ) + + await self._check_disconnect(check_client_disconnected, "After Submit") + return + except QuotaExceededError: + raise + except Exception as e: + self.logger.warning( + f"[{self.req_id}] Error during submit (Attempt {attempt + 1}): {e}" + ) + if attempt < max_retries - 1: + await self._safe_reload_page() + await asyncio.sleep(2) + else: + raise e + + async def _open_upload_menu_and_choose_file(self, files_list: List[str]) -> bool: + """Upload files via menu.""" + await self.page.locator(UPLOAD_BUTTON_SELECTOR).first.click() + btn = self.page.locator("div[role='menu'] button[role='menuitem']").filter( + has_text="Upload File" + ) + if await btn.count() == 0: + btn = self.page.locator("div[role='menu'] button[role='menuitem']").filter( + has_text="Upload a file" + ) + async with self.page.expect_file_chooser() as fc_info: + await btn.first.click() + await (await fc_info.value).set_files(files_list) + return True + + async def _safe_reload_page(self): + """Reload page safely.""" + await self.page.reload(timeout=30000) + await self.page.wait_for_load_state("domcontentloaded", timeout=30000) + + async def get_response( + self, + check_client_disconnected: Callable, + prompt_length: int = 0, + timeout: Optional[float] = None, + ) -> str: + """Retrieve response content.""" + submit_btn = self.page.locator(SUBMIT_BUTTON_SELECTOR) + edit_btn = self.page.locator(EDIT_MESSAGE_BUTTON_SELECTOR) + input_field = self.page.locator(PROMPT_TEXTAREA_SELECTOR) + await _wait_for_response_completion( + self.page, + input_field, + submit_btn, + edit_btn, + self.req_id, + check_client_disconnected, + None, + prompt_length=prompt_length, + timeout=timeout, + ) + content = await _get_final_response_content( + self.page, self.req_id, check_client_disconnected + ) + if not content or not content.strip(): + verified = await self.verify_response_integrity(check_client_disconnected) + return verified.get("content", "") + return content + + async def verify_response_integrity( + self, check_client_disconnected: Callable, trigger_reason: str = "" + ) -> Dict[str, str]: + """Verify integrity via DOM.""" + await asyncio.sleep(1) + final = await self._extract_complete_response_content() + content, reasoning = self._separate_thinking_and_response(final) + return {"content": content, "reasoning_content": reasoning} + + async def get_response_with_integrity_check( + self, + check_client_disconnected: Callable, + prompt_length: int = 0, + timeout: Optional[float] = None, + ) -> Dict[str, Any]: + """Retrieve response content with full integrity check and function calls.""" + content = await self.get_response( + check_client_disconnected, prompt_length, timeout + ) + + # Parse function calls from DOM as well + has_fc, function_calls, text_content = await self.parse_function_calls( + check_client_disconnected + ) + + c, r = self._separate_thinking_and_response(content) + + result = { + "content": c, + "reasoning_content": r, + "recovery_method": "direct", + "has_function_calls": has_fc, + "function_calls": function_calls, + } + + if has_fc: + # If function calls found, use the text content (with calls removed) as content + # But we need to separate thinking from it too + c_fc, r_fc = self._separate_thinking_and_response(text_content) + result["content"] = c_fc + # Keep original reasoning if not found in text_content + if r_fc: + result["reasoning_content"] = r_fc + + return result + + def _separate_thinking_and_response(self, content: str) -> Tuple[str, str]: + """Separate thinking and response.""" + if not content: + return "", "" + m = re.findall(r"\[THINKING\](.*?)\[/THINKING\]", content, re.DOTALL) + r = "\n".join(m).strip() + c = re.sub( + r"\[THINKING\](.*?)\[/THINKING\]", "", content, flags=re.DOTALL + ).strip() + return c, r + + async def _emergency_stability_wait( + self, check_client_disconnected: Callable + ) -> bool: + """Wait for DOM stability.""" + await asyncio.sleep(2) + return True + + async def _check_generation_activity(self) -> bool: + """Check if generation is in progress.""" + stop_btn = self.page.locator('button[aria-label="Stop generating"]') + return await stop_btn.is_visible(timeout=500) + + async def _extract_dom_content(self) -> str: + """Extract content from DOM.""" + from config.selectors import FINAL_RESPONSE_SELECTOR + + elem = self.page.locator(FINAL_RESPONSE_SELECTOR).last + return await elem.inner_text() if await elem.count() > 0 else "" + + async def _extract_complete_response_content(self) -> str: + """Extract complete response content.""" + c = await get_response_via_edit_button(self.page, self.req_id, lambda x: None) + if not c: + c = await get_response_via_copy_button( + self.page, self.req_id, lambda x: None + ) + return c if c else await self._extract_dom_content() + + async def get_body_text_only_from_dom(self) -> str: + """Extract body text only.""" + return await self._extract_dom_content() diff --git a/browser_utils/page_controller_modules/__init__.py b/browser_utils/page_controller_modules/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..37fbbaa8a07067750de76fba5f8642c9222c1b89 --- /dev/null +++ b/browser_utils/page_controller_modules/__init__.py @@ -0,0 +1,17 @@ +from .base import BaseController +from .chat import ChatController +from .function_calling import FunctionCallingController +from .input import InputController +from .parameters import ParameterController +from .response import ResponseController +from .thinking import ThinkingController + +__all__ = [ + "BaseController", + "ChatController", + "FunctionCallingController", + "InputController", + "ParameterController", + "ResponseController", + "ThinkingController", +] diff --git a/browser_utils/page_controller_modules/base.py b/browser_utils/page_controller_modules/base.py new file mode 100644 index 0000000000000000000000000000000000000000..ac7399f4a2cb77083cd52773bd652ef9840db97b --- /dev/null +++ b/browser_utils/page_controller_modules/base.py @@ -0,0 +1,21 @@ +from typing import Callable + +from playwright.async_api import Page as AsyncPage + +from models import ClientDisconnectedError + + +class BaseController: + """Base controller providing common functionality.""" + + def __init__(self, page: AsyncPage, logger, req_id: str): + self.page = page + self.logger = logger + self.req_id = req_id + + async def _check_disconnect(self, check_client_disconnected: Callable, stage: str): + """Check if the client has disconnected.""" + if check_client_disconnected(stage): + raise ClientDisconnectedError( + f"[{self.req_id}] Client disconnected at stage: {stage}" + ) diff --git a/browser_utils/page_controller_modules/chat.py b/browser_utils/page_controller_modules/chat.py new file mode 100644 index 0000000000000000000000000000000000000000..25314b3f777a0fd6505ad3c310df505a3e40d853 --- /dev/null +++ b/browser_utils/page_controller_modules/chat.py @@ -0,0 +1,395 @@ +import asyncio +from typing import Callable + +from playwright.async_api import TimeoutError +from playwright.async_api import expect as expect_async + +from browser_utils.initialization import enable_temporary_chat_mode +from browser_utils.operations import save_error_snapshot +from config import ( + CLEAR_CHAT_BUTTON_SELECTOR, + CLEAR_CHAT_CONFIRM_BUTTON_SELECTOR, + CLEAR_CHAT_VERIFY_TIMEOUT_MS, + CLICK_TIMEOUT_MS, + OVERLAY_SELECTOR, + RESPONSE_CONTAINER_SELECTOR, + SUBMIT_BUTTON_SELECTOR, + WAIT_FOR_ELEMENT_TIMEOUT_MS, +) +from models import ClientDisconnectedError + +from .base import BaseController + + +class ChatController(BaseController): + """Handles chat history management.""" + + async def clear_chat_history(self, check_client_disconnected: Callable): + """Clear chat history.""" + self.logger.debug("[Chat] Starting to clear chat history") + await self._check_disconnect(check_client_disconnected, "Start Clear Chat") + + try: + # Usually encountered when using stream proxy, stream output ended but AI keeps replying on page, + # locking the clear button while page remains at /new_chat, skipping subsequent clear operation + # leading to stuck requests, so check and click submit button first (acting as stop feature) + submit_button_locator = self.page.locator(SUBMIT_BUTTON_SELECTOR) + try: + self.logger.debug("[Chat] Checking submit button status...") + # Use short timeout (1s) to avoid long blocking as this isn't a common step in clear flow + await expect_async(submit_button_locator).to_be_enabled(timeout=1000) + self.logger.debug( + "[Chat] Submit button available, clicking and waiting 1 second..." + ) + await submit_button_locator.click(timeout=CLICK_TIMEOUT_MS) + try: + await expect_async(submit_button_locator).to_be_disabled( + timeout=1200 + ) + except Exception: + pass + self.logger.debug("[Chat] Submit button click completed") + except asyncio.CancelledError: + raise + except Exception: + # If submit button unavailable, timeout, or Playwright error occurs, log and continue + self.logger.debug( + "[Cleanup] Submit button unavailable/Playwright error (expected), continuing to check clear button" + ) + + clear_chat_button_locator = self.page.locator(CLEAR_CHAT_BUTTON_SELECTOR) + confirm_button_locator = self.page.locator( + CLEAR_CHAT_CONFIRM_BUTTON_SELECTOR + ) + overlay_locator = self.page.locator(OVERLAY_SELECTOR) + + can_attempt_clear = False + try: + await expect_async(clear_chat_button_locator).to_be_enabled( + timeout=3000 + ) + can_attempt_clear = True + self.logger.debug("[Chat] Clear button available") + except Exception as e_enable: + is_new_chat_url = "/prompts/new_chat" in self.page.url.rstrip("/") + if is_new_chat_url: + self.logger.info( + '"Clear Chat" button unavailable (expected on new_chat page). Skipping clear operation.' + ) + else: + self.logger.warning( + f'Waiting for "Clear Chat" button to become enabled failed: {e_enable}. Clear operation may not be executed.' + ) + + await self._check_disconnect( + check_client_disconnected, + 'Clear Chat - after "Clear Chat" button availability check', + ) + + if can_attempt_clear: + await self._execute_chat_clear( + clear_chat_button_locator, + confirm_button_locator, + overlay_locator, + check_client_disconnected, + ) + await self._verify_chat_cleared(check_client_disconnected) + self.logger.debug("[Chat] Re-enabling temporary chat mode") + await enable_temporary_chat_mode(self.page) + + except Exception as e_clear: + if isinstance(e_clear, asyncio.CancelledError): + raise + self.logger.error(f"Error occurred during clearing chat: {e_clear}") + error_name = getattr(e_clear, "name", "") + if not ( + isinstance(e_clear, ClientDisconnectedError) + or (error_name and "Disconnect" in error_name) + ): + await save_error_snapshot( + f"clear_chat_error_{self.req_id}", + extra_context={ + "error_exception": str(e_clear), + "error_stage": "Clear chat flow exception", + "page_url": self.page.url, + "is_new_chat_page": "/prompts/new_chat" in self.page.url, + }, + ) + raise + + async def _execute_chat_clear( + self, + clear_chat_button_locator, + confirm_button_locator, + overlay_locator, + check_client_disconnected: Callable, + ): + """Execute clear chat operation""" + overlay_initially_visible = False + try: + if await overlay_locator.is_visible(timeout=1000): + overlay_initially_visible = True + self.logger.debug( + "[Chat] Confirmation dialog already visible, clicking 'Continue' directly" + ) + except TimeoutError: + overlay_initially_visible = False + except Exception as e_vis_check: + self.logger.warning( + f"Error checking overlay visibility: {e_vis_check}. Assuming invisible." + ) + overlay_initially_visible = False + + await self._check_disconnect( + check_client_disconnected, "Clear Chat - after initial overlay check" + ) + + if overlay_initially_visible: + self.logger.debug("[Chat] Clicking 'Continue' button") + await confirm_button_locator.click(timeout=CLICK_TIMEOUT_MS) + else: + self.logger.debug("[Chat] Clicking 'Clear Chat' button") + # If transparent overlays intercept pointer events, try to clear first + try: + await self._dismiss_backdrops() + except asyncio.CancelledError: + raise + except Exception: + pass + try: + try: + await clear_chat_button_locator.scroll_into_view_if_needed() + except asyncio.CancelledError: + raise + except Exception: + pass + await clear_chat_button_locator.click(timeout=CLICK_TIMEOUT_MS) + except asyncio.CancelledError: + raise + except Exception as first_click_err: + self.logger.warning( + f"First click on clear button failed, attempting to clear overlay and force click: {first_click_err}" + ) + try: + await self._dismiss_backdrops() + except asyncio.CancelledError: + raise + except Exception: + pass + try: + await clear_chat_button_locator.click( + timeout=CLICK_TIMEOUT_MS, force=True + ) + except asyncio.CancelledError: + raise + except Exception as force_click_err: + self.logger.error( + f"Force click on clear button still failed: {force_click_err}" + ) + raise + await self._check_disconnect( + check_client_disconnected, 'Clear Chat - after clicking "Clear Chat"' + ) + + try: + self.logger.debug("[Chat] Waiting for confirmation dialog...") + await expect_async(overlay_locator).to_be_visible( + timeout=WAIT_FOR_ELEMENT_TIMEOUT_MS + ) + except TimeoutError: + error_msg = f"Timed out waiting for clear chat confirmation overlay (after clicking clear button). Request ID: {self.req_id}" + self.logger.error(error_msg) + await save_error_snapshot(f"clear_chat_overlay_timeout_{self.req_id}") + raise Exception(error_msg) + + await self._check_disconnect( + check_client_disconnected, "Clear Chat - after overlay appeared" + ) + self.logger.debug("[Chat] Clicking 'Continue' button") + try: + await confirm_button_locator.scroll_into_view_if_needed() + except asyncio.CancelledError: + raise + except Exception: + pass + try: + await confirm_button_locator.click(timeout=CLICK_TIMEOUT_MS) + except asyncio.CancelledError: + raise + except Exception as confirm_err: + # Check if button/dialog has disappeared (operation succeeded) + err_str = str(confirm_err).lower() + if "detached" in err_str or "not stable" in err_str: + try: + is_dialog_visible = await overlay_locator.is_visible( + timeout=500 + ) + if not is_dialog_visible: + self.logger.debug( + "[Chat] Dialog disappeared upon click, clear operation succeeded" + ) + return # Success + except asyncio.CancelledError: + raise + except Exception: + pass + self.logger.warning( + f'First click on "Continue" failed, attempting force click: {confirm_err}' + ) + try: + await confirm_button_locator.click( + timeout=CLICK_TIMEOUT_MS, force=True + ) + except asyncio.CancelledError: + raise + except Exception as confirm_force_err: + # Check again if dialog has disappeared + force_err_str = str(confirm_force_err).lower() + if "detached" in force_err_str or "not stable" in force_err_str: + try: + is_dialog_visible = await overlay_locator.is_visible( + timeout=500 + ) + if not is_dialog_visible: + self.logger.debug( + "[Chat] Dialog disappeared upon force click, clear operation succeeded" + ) + return + except asyncio.CancelledError: + raise + except Exception: + pass + self.logger.error( + f'Force click on "Continue" button still failed: {confirm_force_err}' + ) + raise + + await self._check_disconnect( + check_client_disconnected, 'Clear Chat - after clicking "Continue"' + ) + + # Wait for dialog to disappear + max_retries_disappear = 3 + for attempt_disappear in range(max_retries_disappear): + try: + self.logger.debug( + f"[Chat] Waiting for dialog to disappear ({attempt_disappear + 1}/{max_retries_disappear})" + ) + await expect_async(confirm_button_locator).to_be_hidden( + timeout=CLEAR_CHAT_VERIFY_TIMEOUT_MS + ) + await expect_async(overlay_locator).to_be_hidden(timeout=1000) + self.logger.debug("[Chat] Dialog disappeared") + break + except TimeoutError: + self.logger.warning( + f"Timed out waiting for clear chat confirmation dialog to disappear (attempt {attempt_disappear + 1}/{max_retries_disappear})." + ) + if attempt_disappear < max_retries_disappear - 1: + await self._check_disconnect( + check_client_disconnected, + f"Clear Chat - before retry disappear check {attempt_disappear + 1}", + ) + continue + else: + error_msg = f"Reached maximum retries. Clear chat confirmation dialog did not disappear. Request ID: {self.req_id}" + self.logger.error(error_msg) + await save_error_snapshot( + f"clear_chat_dialog_disappear_timeout_{self.req_id}" + ) + raise Exception(error_msg) + except ClientDisconnectedError: + self.logger.info( + "Client disconnected while waiting for clear confirmation dialog to disappear." + ) + raise + except Exception as other_err: + if isinstance(other_err, asyncio.CancelledError): + raise + self.logger.warning( + f"Unexpected error waiting for clear confirmation dialog to disappear: {other_err}" + ) + if attempt_disappear < max_retries_disappear - 1: + continue + else: + raise + + async def _dismiss_backdrops(self): + """Attempt to close potentially remaining cdk transparent overlays to avoid intercepting clicks, + and remove interfering iframes like google-hats-survey. + """ + try: + # 1. Remove Google Survey Iframe + try: + survey_iframe = self.page.locator( + 'iframe[id*="google-hats-survey"], iframe[src*="google_hats"]' + ) + if await survey_iframe.count() > 0: + self.logger.info( + f"[{self.req_id}] Detected Google Survey iframe, attempting removal..." + ) + await self.page.evaluate( + """ + () => { + const iframes = document.querySelectorAll('iframe[id*="google-hats-survey"], iframe[src*="google_hats"]'); + iframes.forEach(el => el.remove()); + } + """ + ) + except Exception as e_survey: + self.logger.warning( + f"[{self.req_id}] Error removing Survey iframe (non-fatal): {e_survey}" + ) + + # 2. Handle CDK Overlays + backdrop = self.page.locator( + "div.cdk-overlay-backdrop.cdk-overlay-backdrop-showing, div.cdk-overlay-backdrop.cdk-overlay-transparent-backdrop.cdk-overlay-backdrop-showing" + ) + for i in range(3): + cnt = 0 + try: + cnt = await backdrop.count() + except asyncio.CancelledError: + raise + except Exception: + cnt = 0 + if cnt and cnt > 0: + self.logger.debug( + f"Detected transparent overlay ({cnt}), sending ESC to close (attempt {i + 1}/3)." + ) + try: + await self.page.keyboard.press("Escape") + try: + await expect_async(backdrop).to_be_hidden(timeout=500) + except asyncio.CancelledError: + raise + except Exception: + pass + except asyncio.CancelledError: + raise + except Exception: + pass + else: + break + except asyncio.CancelledError: + raise + except Exception: + pass + + async def _verify_chat_cleared(self, check_client_disconnected: Callable): + """Verify chat has been cleared""" + last_response_container = self.page.locator(RESPONSE_CONTAINER_SELECTOR).last + await self._check_disconnect( + check_client_disconnected, "After Clear Post-Check" + ) + try: + await expect_async(last_response_container).to_be_hidden( + timeout=CLEAR_CHAT_VERIFY_TIMEOUT_MS - 500 + ) + self.logger.debug("[Chat] Verification passed, response container hidden") + except asyncio.CancelledError: + raise + except Exception as verify_err: + self.logger.warning( + f"Warning: Clear chat verification failed (last response container not hidden): {verify_err}" + ) diff --git a/browser_utils/page_controller_modules/function_calling.py b/browser_utils/page_controller_modules/function_calling.py new file mode 100644 index 0000000000000000000000000000000000000000..9e69c19316f16cc4688aaa54f5eafbf89bfe1f95 --- /dev/null +++ b/browser_utils/page_controller_modules/function_calling.py @@ -0,0 +1,950 @@ +""" +Function Calling Controller Mixin + +Provides browser automation for AI Studio's native function calling UI. +Handles enabling/disabling function calling toggle and managing function declarations. + +Includes caching mechanism to skip redundant UI operations when same tools are used. +""" + +import asyncio +import json +import time +from typing import Callable, List, Optional + +from playwright.async_api import expect as expect_async + +from config import ( + CLICK_TIMEOUT_MS, + FUNCTION_CALLING_CONTAINER_SELECTOR, + FUNCTION_CALLING_TOGGLE_SELECTOR, + FUNCTION_DECLARATIONS_CLOSE_BUTTON_SELECTOR, + FUNCTION_DECLARATIONS_CODE_EDITOR_TAB_SELECTOR, + FUNCTION_DECLARATIONS_DIALOG_SELECTOR, + FUNCTION_DECLARATIONS_EDIT_BUTTON_SELECTOR, + FUNCTION_DECLARATIONS_RESET_BUTTON_SELECTOR, + FUNCTION_DECLARATIONS_SAVE_BUTTON_SELECTOR, + FUNCTION_DECLARATIONS_TEXTAREA_SELECTOR, + SELECTOR_VISIBILITY_TIMEOUT_MS, +) +from config.settings import FUNCTION_CALLING_DEBUG, FUNCTION_CALLING_UI_TIMEOUT +from logging_utils.fc_debug import FCModule, get_fc_logger +from models import ClientDisconnectedError + +from .base import BaseController + +# FC debug logger for UI automation events +fc_logger = get_fc_logger() + + +class FunctionCallingController(BaseController): + """ + Controller mixin for function calling UI automation. + + Provides methods to: + - Check if function calling is enabled + - Enable/disable function calling toggle + - Open function declarations dialog + - Input function declarations JSON + - Save and close dialog + + Integrates with FunctionCallingCache to skip redundant UI operations + when the same tools are used in subsequent requests. + """ + + # Instance-level cache for quick toggle state lookup + _fc_toggle_cached: Optional[bool] = None + + def _get_fc_cache(self): + """Get the function calling cache instance (lazy import to avoid circular deps).""" + try: + from api_utils.utils_ext.function_calling_cache import FunctionCallingCache + + return FunctionCallingCache.get_instance(self.logger) + except ImportError: + return None + + def invalidate_fc_cache(self, reason: str = "manual") -> None: + """ + Invalidate the function calling cache. + + Call this on model switch, new chat creation, or explicit clear request. + + Args: + reason: Reason for invalidation (for logging). + """ + self._fc_toggle_cached = None + cache = self._get_fc_cache() + if cache: + cache.invalidate(reason=reason, req_id=self.req_id) + + async def is_function_calling_enabled( + self, + check_client_disconnected: Callable, + use_cache: bool = True, + ) -> bool: + """ + Check if function calling toggle is currently enabled. + + Args: + check_client_disconnected: Callback to check client connection. + use_cache: If True, check instance cache first (default True). + + Returns: + True if function calling is enabled, False otherwise. + """ + # Check instance-level cache first for fast path + if use_cache and self._fc_toggle_cached is not None: + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] [FC:Cache] Toggle state from instance cache: " + f"{'enabled' if self._fc_toggle_cached else 'disabled'}" + ) + return self._fc_toggle_cached + + await self._check_disconnect( + check_client_disconnected, "Function calling - check enabled" + ) + + start_time = time.perf_counter() + + try: + toggle_locator = self.page.locator(FUNCTION_CALLING_TOGGLE_SELECTOR) + + # Wait for toggle to be visible with a short timeout + try: + await expect_async(toggle_locator.first).to_be_visible( + timeout=FUNCTION_CALLING_UI_TIMEOUT + ) + except Exception: + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] [FC:UI] Toggle not visible, assuming disabled" + ) + self._fc_toggle_cached = False + return False + + # Check aria-checked state + is_checked_str = await toggle_locator.first.get_attribute("aria-checked") + is_enabled = is_checked_str == "true" + + elapsed = time.perf_counter() - start_time + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] [FC:UI] Toggle check complete in {elapsed:.3f}s: " + f"{'enabled' if is_enabled else 'disabled'}" + ) + if FUNCTION_CALLING_DEBUG: + fc_logger.log_ui_action( + self.req_id, + "check", + "fc_toggle", + elapsed_ms=elapsed * 1000, + ) + + # Update instance cache + self._fc_toggle_cached = is_enabled + + return is_enabled + + except asyncio.CancelledError: + raise + except ClientDisconnectedError: + raise + except Exception as e: + if FUNCTION_CALLING_DEBUG: + self.logger.warning( + f"[{self.req_id}] [FC] Error checking function calling state: {e}" + ) + return False + + async def _set_function_calling_toggle( + self, + enable: bool, + check_client_disconnected: Callable, + ) -> bool: + """ + Internal method to set function calling toggle state. + + Args: + enable: True to enable, False to disable + check_client_disconnected: Callback to check client connection + + Returns: + True if toggle was set successfully, False otherwise. + """ + action = "enable" if enable else "disable" + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] [FC:UI] Attempting to {action} function calling" + ) + + await self._check_disconnect( + check_client_disconnected, f"Function calling - {action}" + ) + + start_time = time.perf_counter() + + try: + toggle_locator = self.page.locator(FUNCTION_CALLING_TOGGLE_SELECTOR) + + # Wait for toggle to be visible + await expect_async(toggle_locator.first).to_be_visible( + timeout=FUNCTION_CALLING_UI_TIMEOUT + ) + + # Check current state + is_checked_str = await toggle_locator.first.get_attribute("aria-checked") + is_currently_enabled = is_checked_str == "true" + + if is_currently_enabled == enable: + elapsed = time.perf_counter() - start_time + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] [FC:UI] Toggle already {'enabled' if enable else 'disabled'} " + f"(checked in {elapsed:.3f}s)" + ) + self._fc_toggle_cached = enable + return True + + # Click to toggle + await self._check_disconnect( + check_client_disconnected, f"Function calling - before {action} click" + ) + + # Try to scroll into view first + try: + await toggle_locator.first.scroll_into_view_if_needed() + except Exception: + pass # Ignore scroll errors + + await toggle_locator.first.click(timeout=CLICK_TIMEOUT_MS) + + # Wait for state change + await asyncio.sleep(0.3) + + # Verify the change + new_state_str = await toggle_locator.first.get_attribute("aria-checked") + new_state = new_state_str == "true" + + elapsed = time.perf_counter() - start_time + + if new_state == enable: + if FUNCTION_CALLING_DEBUG: + self.logger.info( + f"[{self.req_id}] [FC:UI] Toggle {action}d successfully in {elapsed:.2f}s" + ) + if FUNCTION_CALLING_DEBUG: + fc_logger.log_ui_action( + self.req_id, + action, + "fc_toggle", + elapsed_ms=elapsed * 1000, + ) + # Update instance cache + self._fc_toggle_cached = enable + # Update global cache + cache = self._get_fc_cache() + if cache: + cache.update_toggle_state(enable, req_id=self.req_id) + return True + else: + if FUNCTION_CALLING_DEBUG: + self.logger.warning( + f"[{self.req_id}] [FC:UI] Toggle state change failed. " + f"Expected: {enable}, Actual: {new_state}" + ) + return False + + except asyncio.CancelledError: + raise + except ClientDisconnectedError: + raise + except Exception as e: + if FUNCTION_CALLING_DEBUG: + self.logger.error( + f"[{self.req_id}] [FC] Error {action}ing function calling: {e}" + ) + from browser_utils.operations import save_error_snapshot + + await save_error_snapshot(f"function_calling_{action}_error_{self.req_id}") + return False + + async def enable_function_calling( + self, + check_client_disconnected: Callable, + ) -> bool: + """ + Enable function calling toggle. + + Args: + check_client_disconnected: Callback to check client connection + + Returns: + True if enabled successfully, False otherwise. + """ + return await self._set_function_calling_toggle( + enable=True, + check_client_disconnected=check_client_disconnected, + ) + + async def disable_function_calling( + self, + check_client_disconnected: Callable, + ) -> bool: + """ + Disable function calling toggle. + + Args: + check_client_disconnected: Callback to check client connection + + Returns: + True if disabled successfully, False otherwise. + """ + return await self._set_function_calling_toggle( + enable=False, + check_client_disconnected=check_client_disconnected, + ) + + async def _open_function_declarations_dialog( + self, + check_client_disconnected: Callable, + ) -> bool: + """ + Open the function declarations editor dialog. + + Returns: + True if dialog opened successfully, False otherwise. + """ + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] [FC:UI] Opening function declarations dialog" + ) + + await self._check_disconnect( + check_client_disconnected, "Function declarations - opening dialog" + ) + + start_time = time.perf_counter() + + try: + # Find and click the edit button + edit_button = self.page.locator(FUNCTION_DECLARATIONS_EDIT_BUTTON_SELECTOR) + + await expect_async(edit_button.first).to_be_visible( + timeout=FUNCTION_CALLING_UI_TIMEOUT + ) + + # Try to scroll into view + try: + await edit_button.first.scroll_into_view_if_needed() + except Exception: + pass + + await edit_button.first.click(timeout=CLICK_TIMEOUT_MS) + + # Wait for dialog to appear + dialog = self.page.locator(FUNCTION_DECLARATIONS_DIALOG_SELECTOR) + await expect_async(dialog.first).to_be_visible( + timeout=SELECTOR_VISIBILITY_TIMEOUT_MS + ) + + elapsed = time.perf_counter() - start_time + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] [FC:Perf] Dialog opened in {elapsed:.2f}s" + ) + fc_logger.log_ui_action( + self.req_id, + "open", + "declarations_dialog", + elapsed_ms=elapsed * 1000, + ) + return True + + except asyncio.CancelledError: + raise + except ClientDisconnectedError: + raise + except Exception as e: + if FUNCTION_CALLING_DEBUG: + self.logger.error( + f"[{self.req_id}] [FC:UI] Failed to open function declarations dialog: {e}" + ) + from browser_utils.operations import save_error_snapshot + + await save_error_snapshot(f"function_dialog_open_error_{self.req_id}") + return False + + async def _switch_to_code_editor_tab( + self, + check_client_disconnected: Callable, + ) -> bool: + """ + Switch to the Code Editor tab in the function declarations dialog. + + Returns: + True if switched successfully or already on Code Editor tab, False otherwise. + """ + if FUNCTION_CALLING_DEBUG: + self.logger.debug(f"[{self.req_id}] UI: Clicking Code Editor tab") + + await self._check_disconnect( + check_client_disconnected, "Function declarations - switch to code editor" + ) + + try: + code_editor_tab = self.page.locator( + FUNCTION_DECLARATIONS_CODE_EDITOR_TAB_SELECTOR + ) + + # Check if tab exists + if await code_editor_tab.count() == 0: + # Might already be in Code Editor mode or single-mode dialog + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] UI: Code Editor tab not found, assuming single-mode" + ) + return True + + # Check if already selected + is_selected = await code_editor_tab.first.get_attribute("aria-selected") + if is_selected == "true": + if FUNCTION_CALLING_DEBUG: + self.logger.debug(f"[{self.req_id}] UI: Already on Code Editor tab") + return True + + # Click to switch + await code_editor_tab.first.click(timeout=CLICK_TIMEOUT_MS) + await asyncio.sleep(0.3) + + if FUNCTION_CALLING_DEBUG: + self.logger.debug(f"[{self.req_id}] UI: Switched to Code Editor tab") + return True + + except asyncio.CancelledError: + raise + except ClientDisconnectedError: + raise + except Exception as e: + if FUNCTION_CALLING_DEBUG: + self.logger.warning( + f"[{self.req_id}] UI: Error switching to Code Editor tab: {e}" + ) + return True # Continue anyway, might work + + async def _input_function_declarations_json( + self, + declarations_json: str, + check_client_disconnected: Callable, + ) -> bool: + """ + Input function declarations JSON into the textarea. + + Args: + declarations_json: JSON string of function declarations + check_client_disconnected: Callback to check client connection + + Returns: + True if input was successful, False otherwise. + """ + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] UI: Pasting JSON ({len(declarations_json)} chars)" + ) + + await self._check_disconnect( + check_client_disconnected, "Function declarations - input JSON" + ) + + try: + textarea = self.page.locator(FUNCTION_DECLARATIONS_TEXTAREA_SELECTOR) + + await expect_async(textarea.first).to_be_visible( + timeout=FUNCTION_CALLING_UI_TIMEOUT + ) + + # Clear existing content and input new JSON + # Use evaluate for reliable content replacement + await textarea.first.evaluate( + """(el, json) => { + el.value = json; + el.dispatchEvent(new Event('input', {bubbles: true})); + el.dispatchEvent(new Event('change', {bubbles: true})); + }""", + declarations_json, + ) + + await asyncio.sleep(0.2) + + if FUNCTION_CALLING_DEBUG: + self.logger.debug(f"[{self.req_id}] UI: JSON input complete") + return True + + except asyncio.CancelledError: + raise + except ClientDisconnectedError: + raise + except Exception as e: + if FUNCTION_CALLING_DEBUG: + self.logger.error( + f"[{self.req_id}] UI: Error inputting function declarations: {e}" + ) + from browser_utils.operations import save_error_snapshot + + await save_error_snapshot(f"function_input_error_{self.req_id}") + return False + + async def _save_and_close_dialog( + self, + check_client_disconnected: Callable, + ) -> bool: + """ + Save function declarations and close the dialog. + + Returns: + True if saved and closed successfully, False otherwise. + """ + if FUNCTION_CALLING_DEBUG: + self.logger.debug(f"[{self.req_id}] UI: Saving and closing dialog") + + await self._check_disconnect( + check_client_disconnected, "Function declarations - save and close" + ) + + try: + # Find and click save button + save_button = self.page.locator(FUNCTION_DECLARATIONS_SAVE_BUTTON_SELECTOR) + + await expect_async(save_button.first).to_be_visible( + timeout=FUNCTION_CALLING_UI_TIMEOUT + ) + + await save_button.first.click(timeout=CLICK_TIMEOUT_MS) + + # Wait for dialog to close + await asyncio.sleep(0.5) + + # Verify dialog is closed + dialog = self.page.locator(FUNCTION_DECLARATIONS_DIALOG_SELECTOR) + try: + await expect_async(dialog.first).not_to_be_visible(timeout=3000) + if FUNCTION_CALLING_DEBUG: + self.logger.debug(f"[{self.req_id}] UI: Dialog closed successfully") + return True + except Exception: + # Dialog might still be open, try close button + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] UI: Dialog still visible, trying close button" + ) + close_button = self.page.locator( + FUNCTION_DECLARATIONS_CLOSE_BUTTON_SELECTOR + ) + if await close_button.count() > 0: + await close_button.first.click(timeout=CLICK_TIMEOUT_MS) + await asyncio.sleep(0.3) + + return True + + except asyncio.CancelledError: + raise + except ClientDisconnectedError: + raise + except Exception as e: + if FUNCTION_CALLING_DEBUG: + self.logger.error(f"[{self.req_id}] UI: Error saving declarations: {e}") + from browser_utils.operations import save_error_snapshot + + await save_error_snapshot(f"function_save_error_{self.req_id}") + return False + + async def set_function_declarations( + self, + declarations: List[dict], + check_client_disconnected: Callable, + tools_digest: Optional[str] = None, + model_name: Optional[str] = None, + tools: Optional[List[dict]] = None, + ) -> bool: + """ + Set function declarations in the AI Studio UI. + + This method: + 0. Checks cache to skip redundant operations if same tools + 1. Disables Google Search (required as it blocks function calling) + 2. Enables function calling if not already enabled + 3. Opens the function declarations dialog + 4. Switches to Code Editor tab + 5. Inputs the JSON declarations + 6. Saves and closes the dialog + 7. Updates cache on success + + Args: + declarations: List of function declaration dictionaries (Gemini format) + check_client_disconnected: Callback to check client connection + tools_digest: Optional pre-computed digest for caching + model_name: Optional model name for cache validation + tools: Optional original tools list (OpenAI format) for caching tool names + + Returns: + True if declarations were set successfully, False otherwise. + """ + total_start = time.perf_counter() + + # Check cache first + cache = self._get_fc_cache() + if cache and tools_digest: + if cache.is_cache_valid(tools_digest, model_name, req_id=self.req_id): + cached_state = cache.get_cached_state() + if cached_state and cached_state.declarations_set: + elapsed = time.perf_counter() - total_start + if FUNCTION_CALLING_DEBUG: + self.logger.info( + f"[{self.req_id}] [FC:Cache] HIT - skipping UI configuration " + f"(saved ~2-4s, check took {elapsed:.3f}s)" + ) + return True + else: + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] [FC:Cache] Valid digest but declarations not set" + ) + + if FUNCTION_CALLING_DEBUG: + self.logger.info( + f"[{self.req_id}] [FC:UI] Setting {len(declarations)} function declaration(s)" + ) + fc_logger.info( + FCModule.UI, + f"Setting {len(declarations)} function declaration(s)", + req_id=self.req_id, + ) + + try: + # Step 0: Disable Google Search and URL Context if enabled (blocks FC) + from config import ( + GROUNDING_WITH_GOOGLE_SEARCH_TOGGLE_SELECTOR, + USE_URL_CONTEXT_SELECTOR, + ) + + # 0a. Disable Google Search + search_toggle = self.page.locator( + GROUNDING_WITH_GOOGLE_SEARCH_TOGGLE_SELECTOR + ) + if await search_toggle.count() > 0: + try: + if await search_toggle.is_visible(timeout=2000): + is_checked = await search_toggle.get_attribute("aria-checked") + if is_checked == "true": + if FUNCTION_CALLING_DEBUG: + self.logger.info( + f"[{self.req_id}] [FC:UI] Disabling Google Search (blocks FC)" + ) + await search_toggle.click(timeout=CLICK_TIMEOUT_MS) + await asyncio.sleep(0.5) + except Exception: + pass # Ignore if not visible + + # 0b. Disable URL Context + url_toggle = self.page.locator(USE_URL_CONTEXT_SELECTOR) + if await url_toggle.count() > 0: + try: + if await url_toggle.is_visible(timeout=2000): + is_checked = await url_toggle.get_attribute("aria-checked") + if is_checked == "true": + if FUNCTION_CALLING_DEBUG: + self.logger.info( + f"[{self.req_id}] [FC:UI] Disabling URL Context (blocks FC)" + ) + await url_toggle.click(timeout=CLICK_TIMEOUT_MS) + await asyncio.sleep(0.5) + except Exception: + pass # Ignore if not visible + + # Step 1: Enable function calling if not already enabled + toggle_start = time.perf_counter() + if not await self.is_function_calling_enabled(check_client_disconnected): + if not await self.enable_function_calling(check_client_disconnected): + if FUNCTION_CALLING_DEBUG: + self.logger.error( + f"[{self.req_id}] [FC] Failed to enable function calling" + ) + return False + toggle_elapsed = time.perf_counter() - toggle_start + + await self._check_disconnect( + check_client_disconnected, "Function declarations - after enable" + ) + + # Step 2: Open the function declarations dialog + dialog_start = time.perf_counter() + if not await self._open_function_declarations_dialog( + check_client_disconnected + ): + if FUNCTION_CALLING_DEBUG: + self.logger.error( + f"[{self.req_id}] [FC] Failed to open function declarations dialog" + ) + return False + dialog_elapsed = time.perf_counter() - dialog_start + + await self._check_disconnect( + check_client_disconnected, "Function declarations - after dialog open" + ) + + # Step 3: Switch to Code Editor tab + if not await self._switch_to_code_editor_tab(check_client_disconnected): + if FUNCTION_CALLING_DEBUG: + self.logger.warning( + f"[{self.req_id}] [FC:UI] Could not switch to Code Editor tab, continuing" + ) + + await self._check_disconnect( + check_client_disconnected, "Function declarations - after tab switch" + ) + + # Step 4: Convert declarations to JSON and input + declarations_json = json.dumps(declarations, indent=2) + input_start = time.perf_counter() + if not await self._input_function_declarations_json( + declarations_json, check_client_disconnected + ): + if FUNCTION_CALLING_DEBUG: + self.logger.error( + f"[{self.req_id}] [FC] Failed to input function declarations JSON" + ) + return False + input_elapsed = time.perf_counter() - input_start + + await self._check_disconnect( + check_client_disconnected, "Function declarations - after input" + ) + + # Step 5: Save and close + save_start = time.perf_counter() + if not await self._save_and_close_dialog(check_client_disconnected): + if FUNCTION_CALLING_DEBUG: + self.logger.error( + f"[{self.req_id}] [FC] Failed to save function declarations" + ) + return False + save_elapsed = time.perf_counter() - save_start + + total_elapsed = time.perf_counter() - total_start + + # Update cache on success + if cache and tools_digest: + cache.update_cache( + tools_digest=tools_digest, + toggle_enabled=True, + declarations_set=True, + model_name=model_name, + req_id=self.req_id, + tools=tools, + ) + self._fc_toggle_cached = True + + if FUNCTION_CALLING_DEBUG: + self.logger.info( + f"[{self.req_id}] [FC:Perf] Function declarations set successfully " + f"(total={total_elapsed:.2f}s, toggle={toggle_elapsed:.2f}s, " + f"dialog={dialog_elapsed:.2f}s, input={input_elapsed:.2f}s, save={save_elapsed:.2f}s)" + ) + fc_logger.info( + FCModule.UI, + f"Declarations set successfully in {total_elapsed:.2f}s", + req_id=self.req_id, + ) + return True + + except asyncio.CancelledError: + raise + except ClientDisconnectedError: + raise + except Exception as e: + if FUNCTION_CALLING_DEBUG: + self.logger.error( + f"[{self.req_id}] [FC] Error setting function declarations: {e}" + ) + from browser_utils.operations import save_error_snapshot + + await save_error_snapshot(f"set_function_declarations_error_{self.req_id}") + return False + + async def clear_function_declarations( + self, + check_client_disconnected: Callable, + invalidate_cache: bool = True, + ) -> bool: + """ + Clear all function declarations. + + This method opens the dialog and uses the reset button to clear all declarations, + or sets an empty array if reset button is not available. + + Args: + check_client_disconnected: Callback to check client connection + invalidate_cache: If True, invalidate the FC cache (default True) + + Returns: + True if declarations were cleared successfully, False otherwise. + """ + if FUNCTION_CALLING_DEBUG: + self.logger.info(f"[{self.req_id}] [FC:UI] Clearing function declarations") + + start_time = time.perf_counter() + + try: + # Check if function calling is enabled + if not await self.is_function_calling_enabled(check_client_disconnected): + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] [FC] Function calling not enabled, nothing to clear" + ) + if invalidate_cache: + self.invalidate_fc_cache("clear - not enabled") + return True + + await self._check_disconnect( + check_client_disconnected, "Clear function declarations - start" + ) + + # Open dialog + if not await self._open_function_declarations_dialog( + check_client_disconnected + ): + if FUNCTION_CALLING_DEBUG: + self.logger.error( + f"[{self.req_id}] [FC] Failed to open dialog for clearing" + ) + return False + + await self._check_disconnect( + check_client_disconnected, + "Clear function declarations - after dialog open", + ) + + # Try to use reset button first + reset_button = self.page.locator( + FUNCTION_DECLARATIONS_RESET_BUTTON_SELECTOR + ) + if await reset_button.count() > 0: + try: + await reset_button.first.click(timeout=CLICK_TIMEOUT_MS) + await asyncio.sleep(0.3) + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] [FC:UI] Used reset button to clear declarations" + ) + except Exception: + # Fall back to clearing textarea + pass + + # Switch to code editor and clear + await self._switch_to_code_editor_tab(check_client_disconnected) + + # Input empty array + if not await self._input_function_declarations_json( + "[]", check_client_disconnected + ): + if FUNCTION_CALLING_DEBUG: + self.logger.warning( + f"[{self.req_id}] [FC:UI] Failed to input empty declarations" + ) + + # Save and close + if not await self._save_and_close_dialog(check_client_disconnected): + if FUNCTION_CALLING_DEBUG: + self.logger.error( + f"[{self.req_id}] [FC] Failed to save cleared declarations" + ) + return False + + # Optionally disable function calling toggle + if await self.is_function_calling_enabled( + check_client_disconnected, use_cache=False + ): + await self.disable_function_calling(check_client_disconnected) + + # Invalidate cache + if invalidate_cache: + self.invalidate_fc_cache("declarations cleared") + + elapsed = time.perf_counter() - start_time + if FUNCTION_CALLING_DEBUG: + self.logger.info( + f"[{self.req_id}] [FC:Perf] Declarations cleared in {elapsed:.2f}s" + ) + return True + + except asyncio.CancelledError: + raise + except ClientDisconnectedError: + raise + except Exception as e: + if FUNCTION_CALLING_DEBUG: + self.logger.error( + f"[{self.req_id}] [FC] Error clearing function declarations: {e}" + ) + from browser_utils.operations import save_error_snapshot + + await save_error_snapshot( + f"clear_function_declarations_error_{self.req_id}" + ) + return False + + async def is_function_calling_available( + self, + check_client_disconnected: Callable, + ) -> bool: + """ + Check if function calling UI is available on the current page/model. + + Some models may not support function calling, so this method checks + if the function calling container is present in the UI. + + Args: + check_client_disconnected: Callback to check client connection + + Returns: + True if function calling is available, False otherwise. + """ + await self._check_disconnect( + check_client_disconnected, "Function calling - check available" + ) + + start_time = time.perf_counter() + + try: + container = self.page.locator(FUNCTION_CALLING_CONTAINER_SELECTOR) + + # Quick check with short timeout + try: + await expect_async(container.first).to_be_visible( + timeout=FUNCTION_CALLING_UI_TIMEOUT // 2 + ) + elapsed = time.perf_counter() - start_time + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] [FC:UI] Function calling available (checked in {elapsed:.3f}s)" + ) + return True + except Exception: + elapsed = time.perf_counter() - start_time + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] [FC:UI] Function calling not available (checked in {elapsed:.3f}s)" + ) + return False + + except asyncio.CancelledError: + raise + except ClientDisconnectedError: + raise + except Exception as e: + if FUNCTION_CALLING_DEBUG: + self.logger.warning( + f"[{self.req_id}] [FC] Error checking function calling availability: {e}" + ) + return False diff --git a/browser_utils/page_controller_modules/input.py b/browser_utils/page_controller_modules/input.py new file mode 100644 index 0000000000000000000000000000000000000000..b7d8798b30d3eabadac09cf02fddb4dbb467770e --- /dev/null +++ b/browser_utils/page_controller_modules/input.py @@ -0,0 +1,636 @@ +import asyncio +from typing import Callable, List + +from playwright.async_api import TimeoutError +from playwright.async_api import expect as expect_async + +from browser_utils.operations import save_error_snapshot +from config import ( + CDK_OVERLAY_CONTAINER_SELECTOR, + PROMPT_TEXTAREA_SELECTOR, + RESPONSE_CONTAINER_SELECTOR, + SUBMIT_BUTTON_SELECTOR, + UPLOAD_BUTTON_SELECTOR, +) +from config.selector_utils import ( + AUTOSIZE_WRAPPER_SELECTORS, + build_combined_selector, +) +from logging_utils import set_request_id +from models import ClientDisconnectedError + +from .base import BaseController + + +class InputController(BaseController): + """Handles prompt input and submission.""" + + async def submit_prompt( + self, prompt: str, image_list: List, check_client_disconnected: Callable + ): + """Submit prompt to the page.""" + set_request_id(self.req_id) + self.logger.debug(f"[Input] Filling prompt ({len(prompt)} chars)") + prompt_textarea_locator = self.page.locator(PROMPT_TEXTAREA_SELECTOR) + # Use centralized selectors supporting new and old UI structures + autosize_wrapper_locator = self.page.locator( + build_combined_selector( + AUTOSIZE_WRAPPER_SELECTORS[:2] + ) # .text-wrapper element + ) + legacy_autosize_wrapper = self.page.locator( + build_combined_selector( + AUTOSIZE_WRAPPER_SELECTORS[2:] + ) # ms-autosize-textarea element + ) + submit_button_locator = self.page.locator(SUBMIT_BUTTON_SELECTOR) + + try: + await expect_async(prompt_textarea_locator).to_be_visible(timeout=5000) + await self._check_disconnect( + check_client_disconnected, "After Input Visible" + ) + + # Fill text using JavaScript + await prompt_textarea_locator.evaluate( + """ + (element, text) => { + element.value = text; + element.dispatchEvent(new Event('input', { bubbles: true, cancelable: true })); + element.dispatchEvent(new Event('change', { bubbles: true, cancelable: true })); + } + """, + prompt, + ) + autosize_target = autosize_wrapper_locator + if await autosize_target.count() == 0: + autosize_target = legacy_autosize_wrapper + if await autosize_target.count() > 0: + try: + await autosize_target.first.evaluate( + '(element, text) => { element.setAttribute("data-value", text); }', + prompt, + ) + except Exception as autosize_err: + self.logger.debug( + f"autosize wrapper update skipped: {autosize_err}" + ) + await self._check_disconnect(check_client_disconnected, "After Input Fill") + + # Attachment upload handled below if needed + if len(image_list) > 0: + ok = await self._open_upload_menu_and_choose_file(image_list) + if not ok: + self.logger.error( + "Error during file upload: Failed to set files via menu method" + ) + + # Wait for submit button to be enabled (using configurable fast-fail timeout) + from config.timeouts import SUBMIT_BUTTON_ENABLE_TIMEOUT_MS + + wait_timeout_ms_submit_enabled = SUBMIT_BUTTON_ENABLE_TIMEOUT_MS + start_time = asyncio.get_event_loop().time() + self.logger.debug( + f"[Input] Waiting for submit button (max {wait_timeout_ms_submit_enabled}ms)" + ) + + try: + while True: + await self._check_disconnect( + check_client_disconnected, "Waiting for Submit Button Enabled" + ) + + try: + # Use short timeout polling to respond to interruption signals + if await submit_button_locator.is_enabled(timeout=500): + self.logger.debug("[Input] Submit button enabled") + break + except Exception: + # Ignore temporary errors (e.g. element not present yet) + pass + + if ( + asyncio.get_event_loop().time() - start_time + ) * 1000 > wait_timeout_ms_submit_enabled: + raise TimeoutError( + f"Submit button not enabled within {wait_timeout_ms_submit_enabled}ms" + ) + + await asyncio.sleep(0.5) + + except Exception as e_pw_enabled: + self.logger.error( + f"Timeout or error waiting for submit button enabled: {e_pw_enabled}" + ) + await save_error_snapshot(f"submit_button_enable_timeout_{self.req_id}") + raise + + await self._check_disconnect( + check_client_disconnected, "After Submit Button Enabled" + ) + await asyncio.sleep(0.3) + + # Try clicking button first, then Enter, then Combo keys + button_clicked = False + try: + self.logger.debug("[Input] Attempting to click submit button...") + # Handle potential dialogs before submit + await self._handle_post_upload_dialog() + # Try to clear tooltip overlays + await self._dismiss_tooltip_overlays() + try: + await submit_button_locator.click(timeout=5000) + except Exception: + # If normal click fails (possibly blocked by tooltip), use JavaScript click + self.logger.debug( + "[Input] Normal click failed, attempting JavaScript click..." + ) + js_clicked = await self._js_click_submit_button( + submit_button_locator + ) + if not js_clicked: + # Finally try force click + self.logger.debug( + "[Input] JavaScript click failed, attempting force click..." + ) + await submit_button_locator.click(timeout=5000, force=True) + self.logger.debug("[Input] Submit button click complete") + button_clicked = True + except Exception as click_err: + self.logger.error(f"Submit button click failed: {click_err}") + await save_error_snapshot(f"submit_button_click_fail_{self.req_id}") + + if not button_clicked: + self.logger.info( + "Button submit failed, attempting Enter key submission..." + ) + submitted_successfully = await self._try_enter_submit( + prompt_textarea_locator, check_client_disconnected + ) + if not submitted_successfully: + self.logger.info( + "Enter submission failed, attempting combo key submission..." + ) + combo_ok = await self._try_combo_submit( + prompt_textarea_locator, check_client_disconnected + ) + if not combo_ok: + self.logger.error("Combo key submission also failed.") + raise Exception( + "Submit failed: Button, Enter, and Combo key all failed" + ) + + await self._check_disconnect(check_client_disconnected, "After Submit") + + except Exception as e_input_submit: + if isinstance(e_input_submit, asyncio.CancelledError): + raise + self.logger.error( + f"Error during input and submit process: {e_input_submit}" + ) + if not isinstance(e_input_submit, ClientDisconnectedError): + await save_error_snapshot(f"input_submit_error_{self.req_id}") + raise + + async def _open_upload_menu_and_choose_file(self, files_list: List[str]) -> bool: + """Select 'Upload' from the 'Insert assets' menu and set files.""" + try: + # If a transparent overlay from a previous menu/dialog exists, try to close it + try: + tb = self.page.locator( + "div.cdk-overlay-backdrop.cdk-overlay-transparent-backdrop.cdk-overlay-backdrop-showing" + ) + if await tb.count() > 0 and await tb.first.is_visible(timeout=300): + await self.page.keyboard.press("Escape") + await asyncio.sleep(0.2) + except Exception: + pass + + trigger = self.page.locator(UPLOAD_BUTTON_SELECTOR).first + await expect_async(trigger).to_be_visible(timeout=3000) + await trigger.click() + menu_container = self.page.locator(CDK_OVERLAY_CONTAINER_SELECTOR) + # Wait for menu to show + try: + await expect_async( + menu_container.locator("div[role='menu']").first + ).to_be_visible(timeout=3000) + except Exception: + # Try clicking again + try: + await trigger.click() + await expect_async( + menu_container.locator("div[role='menu']").first + ).to_be_visible(timeout=3000) + except Exception: + self.logger.warning("Failed to show upload menu panel.") + return False + + # Use menu item with aria-label or text match + try: + # Prefer new UI match + upload_btn = menu_container.locator( + "div[role='menu'] button[role='menuitem'][aria-label='Upload a file']" + ) + if await upload_btn.count() == 0: + # Fallback to old UI match + upload_btn = menu_container.locator( + "div[role='menu'] button[role='menuitem'][aria-label='Upload File']" + ) + if await upload_btn.count() == 0: + # Fallback to text match (new UI) + upload_btn = menu_container.locator( + "div[role='menu'] button[role='menuitem']:has-text('Upload a file')" + ) + if await upload_btn.count() == 0: + # Fallback to text match (old UI) + upload_btn = menu_container.locator( + "div[role='menu'] button[role='menuitem']:has-text('Upload File')" + ) + if await upload_btn.count() == 0: + self.logger.warning( + "Could not find 'Upload a file' or 'Upload File' menu item." + ) + return False + btn = upload_btn.first + await expect_async(btn).to_be_visible(timeout=2000) + # Prefer internal hidden input[type=file] + input_loc = btn.locator('input[type="file"]') + if await input_loc.count() > 0: + await input_loc.set_input_files(files_list) + self.logger.info( + f"Files successfully set via hidden input in menu item (Upload): {len(files_list)} files" + ) + else: + # Fallback to native file chooser + async with self.page.expect_file_chooser() as fc_info: + await btn.click() + file_chooser = await fc_info.value + await file_chooser.set_files(files_list) + self.logger.info( + f"Files successfully set via native file chooser: {len(files_list)} files" + ) + except Exception as e_set: + self.logger.error(f"Failed to set files: {e_set}") + return False + # Close leftover menu overlay + try: + backdrop = self.page.locator( + "div.cdk-overlay-backdrop.cdk-overlay-backdrop-showing, div.cdk-overlay-backdrop.cdk-overlay-transparent-backdrop.cdk-overlay-backdrop-showing" + ) + if await backdrop.count() > 0: + await self.page.keyboard.press("Escape") + await asyncio.sleep(0.2) + except Exception: + pass + # Handle potential authorization popups + await self._handle_post_upload_dialog() + return True + except Exception as e: + if isinstance(e, asyncio.CancelledError): + raise + self.logger.error(f"Failed to set files via upload menu: {e}") + return False + + async def _handle_post_upload_dialog(self): + """Handle authorization/copyright confirmation dialogs that may appear after upload.""" + try: + overlay_container = self.page.locator(CDK_OVERLAY_CONTAINER_SELECTOR) + if await overlay_container.count() == 0: + return + + # Candidate agreement button texts + agree_texts = [ + "Agree", + "I agree", + "Allow", + "Continue", + "OK", + "Confirm", + "Yes", + ] + # Search for visible buttons within the overlay container + for text in agree_texts: + try: + btn = overlay_container.locator(f"button:has-text('{text}')") + if await btn.count() > 0 and await btn.first.is_visible( + timeout=300 + ): + await btn.first.click() + self.logger.info( + f"Post-upload dialog: Clicked button '{text}'." + ) + await asyncio.sleep(0.3) + break + except Exception: + continue + # If copyright acknowledgment button exists (via aria-label) + try: + acknow_btn_locator = self.page.locator( + 'button[aria-label*="copyright" i], button[aria-label*="acknowledge" i]' + ) + if ( + await acknow_btn_locator.count() > 0 + and await acknow_btn_locator.first.is_visible(timeout=300) + ): + await acknow_btn_locator.first.click() + self.logger.info( + "Post-upload dialog: Clicked copyright acknowledgment button (aria-label match)." + ) + await asyncio.sleep(0.3) + except Exception: + pass + + # Wait for overlay to disappear + try: + overlay_backdrop = self.page.locator( + "div.cdk-overlay-backdrop.cdk-overlay-backdrop-showing" + ) + if await overlay_backdrop.count() > 0: + try: + await expect_async(overlay_backdrop).to_be_hidden(timeout=3000) + self.logger.info("Post-upload dialog overlay hidden.") + except Exception: + self.logger.warning( + "Post-upload dialog overlay still exists, subsequent submit might be blocked." + ) + except Exception: + pass + except asyncio.CancelledError: + raise + except Exception: + pass + + async def _dismiss_tooltip_overlays(self): + """Close tooltip overlays that may block clicks - directly remove from DOM.""" + try: + # Try to move mouse to make tooltips disappear naturally + await self.page.mouse.move(0, 0) + await asyncio.sleep(0.1) + + # Use JavaScript to force remove potential tooltip/overlay elements + removed_count = await self.page.evaluate(""" + () => { + const selectors = [ + '.mdc-tooltip', + '.mat-mdc-tooltip', + '.mdc-tooltip__surface', + '.mat-mdc-tooltip-surface', + '.cdk-overlay-pane:has(.mdc-tooltip)', + '.mat-tooltip-panel', + '[role="tooltip"]' + ]; + let count = 0; + for (const sel of selectors) { + const elements = document.querySelectorAll(sel); + elements.forEach(el => { + el.remove(); + count++; + }); + } + return count; + } + """) + if removed_count > 0: + self.logger.debug(f"[Input] Removed {removed_count} tooltip elements") + await asyncio.sleep(0.1) + except asyncio.CancelledError: + raise + except Exception as e: + self.logger.debug(f"[Input] Tooltip cleanup exception: {e}") + + async def _js_click_submit_button(self, submit_button_locator) -> bool: + """Use JavaScript to trigger the submit button click event directly.""" + try: + await submit_button_locator.evaluate("el => el.click()") + self.logger.debug("[Input] JavaScript click on submit button successful") + return True + except asyncio.CancelledError: + raise + except Exception as e: + self.logger.debug(f"[Input] JavaScript click failed: {e}") + return False + + async def _try_enter_submit( + self, prompt_textarea_locator, check_client_disconnected: Callable + ) -> bool: + """Submit using the Enter key.""" + + try: + await prompt_textarea_locator.focus(timeout=5000) + await self._check_disconnect(check_client_disconnected, "After Input Focus") + await asyncio.sleep(0.1) + + # Record content before submit for verification + original_content = "" + try: + original_content = ( + await prompt_textarea_locator.input_value(timeout=2000) or "" + ) + except Exception: + pass + + # Try Enter key submission + self.logger.info("Attempting Enter key submission") + try: + await self.page.keyboard.press("Enter") + except asyncio.CancelledError: + raise + except Exception: + try: + await prompt_textarea_locator.press("Enter") + except Exception: + pass + + await self._check_disconnect(check_client_disconnected, "After Enter Press") + await asyncio.sleep(2.0) + + # Verify submission + submission_success = False + try: + # Method 1: Check if input area is cleared + current_content = ( + await prompt_textarea_locator.input_value(timeout=2000) or "" + ) + if original_content and not current_content.strip(): + self.logger.info( + "Verification method 1: Input cleared, Enter key submission successful" + ) + submission_success = True + + # Method 2: Check submit button status + if not submission_success: + submit_button_locator = self.page.locator(SUBMIT_BUTTON_SELECTOR) + try: + is_disabled = await submit_button_locator.is_disabled( + timeout=2000 + ) + if is_disabled: + self.logger.info( + "Verification method 2: Submit button disabled, Enter key submission successful" + ) + submission_success = True + except Exception: + pass + + # Method 3: Check for response container + if not submission_success: + try: + response_container = self.page.locator( + RESPONSE_CONTAINER_SELECTOR + ) + container_count = await response_container.count() + if container_count > 0: + last_container = response_container.last + is_vis = await last_container.is_visible(timeout=1000) + if is_vis: + self.logger.info( + "Verification method 3: Response container detected, Enter key submission successful" + ) + submission_success = True + except Exception: + pass + except Exception as verify_err: + self.logger.warning( + f"Error during Enter key submission verification: {verify_err}" + ) + submission_success = True + + if submission_success: + self.logger.info("Enter key submission successful") + return True + else: + self.logger.warning("Enter key submission verification failed") + return False + except asyncio.CancelledError: + raise + except Exception as shortcut_err: + self.logger.warning(f"Enter key submission failed: {shortcut_err}") + return False + + async def _try_combo_submit( + self, prompt_textarea_locator, check_client_disconnected: Callable + ) -> bool: + """Attempt submission using combo keys (Meta/Control + Enter).""" + import os + + try: + host_os_from_launcher = os.environ.get("HOST_OS_FOR_SHORTCUT") + is_mac_determined = False + if host_os_from_launcher == "Darwin": + is_mac_determined = True + elif host_os_from_launcher in ["Windows", "Linux"]: + is_mac_determined = False + else: + try: + user_agent_data_platform = await self.page.evaluate( + "() => navigator.userAgentData?.platform || ''" + ) + except Exception: + user_agent_string = await self.page.evaluate( + "() => navigator.userAgent || ''" + ) + user_agent_string_lower = user_agent_string.lower() + if ( + "macintosh" in user_agent_string_lower + or "mac os x" in user_agent_string_lower + ): + user_agent_data_platform = "macOS" + else: + user_agent_data_platform = "Other" + is_mac_determined = "mac" in user_agent_data_platform.lower() + + shortcut_modifier = "Meta" if is_mac_determined else "Control" + shortcut_key = "Enter" + + await prompt_textarea_locator.focus(timeout=5000) + await self._check_disconnect(check_client_disconnected, "After Input Focus") + await asyncio.sleep(0.1) + + # Record content before submit for verification + original_content = "" + try: + original_content = ( + await prompt_textarea_locator.input_value(timeout=2000) or "" + ) + except Exception: + pass + + self.logger.info( + f"Attempting combo submission: {shortcut_modifier}+{shortcut_key}" + ) + try: + await self.page.keyboard.press(f"{shortcut_modifier}+{shortcut_key}") + except asyncio.CancelledError: + raise + except Exception: + try: + await self.page.keyboard.down(shortcut_modifier) + await asyncio.sleep(0.05) + await self.page.keyboard.press(shortcut_key) + await asyncio.sleep(0.05) + await self.page.keyboard.up(shortcut_modifier) + except Exception: + pass + + await self._check_disconnect(check_client_disconnected, "After Combo Press") + await asyncio.sleep(2.0) + + submission_success = False + try: + current_content = ( + await prompt_textarea_locator.input_value(timeout=2000) or "" + ) + if original_content and not current_content.strip(): + self.logger.info( + "Verification method 1: Input cleared, combo submission successful" + ) + submission_success = True + if not submission_success: + submit_button_locator = self.page.locator(SUBMIT_BUTTON_SELECTOR) + try: + is_disabled = await submit_button_locator.is_disabled( + timeout=2000 + ) + if is_disabled: + self.logger.info( + "Verification method 2: Submit button disabled, combo submission successful" + ) + submission_success = True + except Exception: + pass + if not submission_success: + try: + response_container = self.page.locator( + RESPONSE_CONTAINER_SELECTOR + ) + container_count = await response_container.count() + if container_count > 0: + last_container = response_container.last + is_vis = await last_container.is_visible(timeout=1000) + if is_vis: + self.logger.info( + "Verification method 3: Response container detected, combo submission successful" + ) + submission_success = True + except Exception: + pass + except Exception as verify_err: + if isinstance(verify_err, asyncio.CancelledError): + raise + self.logger.warning( + f"Error during combo submission verification: {verify_err}" + ) + submission_success = True + + if submission_success: + self.logger.info("Combo submission successful") + return True + else: + self.logger.warning("Combo submission verification failed") + return False + except Exception as combo_err: + if isinstance(combo_err, asyncio.CancelledError): + raise + self.logger.warning(f"Combo submission failed: {combo_err}") + return False diff --git a/browser_utils/page_controller_modules/parameters.py b/browser_utils/page_controller_modules/parameters.py new file mode 100644 index 0000000000000000000000000000000000000000..4390bbac7b4317743cab111af2210bb70ba08d02 --- /dev/null +++ b/browser_utils/page_controller_modules/parameters.py @@ -0,0 +1,707 @@ +import asyncio +import re +from typing import Any, Callable, Dict, List, Optional + +from playwright.async_api import expect as expect_async + +from config import ( + CLICK_TIMEOUT_MS, + DEFAULT_MAX_OUTPUT_TOKENS, + DEFAULT_STOP_SEQUENCES, + DEFAULT_TEMPERATURE, + DEFAULT_TOP_P, + ENABLE_GOOGLE_SEARCH, + ENABLE_URL_CONTEXT, + GROUNDING_WITH_GOOGLE_SEARCH_TOGGLE_SELECTOR, + MAX_OUTPUT_TOKENS_SELECTOR, + STOP_SEQUENCE_INPUT_SELECTOR, + TEMPERATURE_INPUT_SELECTOR, + TOP_P_INPUT_SELECTOR, + USE_URL_CONTEXT_SELECTOR, +) +from models import ClientDisconnectedError + +from .base import BaseController + + +class ParameterController(BaseController): + """Handles parameter adjustments (temperature, tokens, etc.).""" + + async def adjust_parameters( + self, + request_params: Dict[str, Any], + page_params_cache: Dict[str, Any], + params_cache_lock: asyncio.Lock, + model_id_to_use: Optional[str], + parsed_model_list: List[Dict[str, Any]], + check_client_disconnected: Callable, + ): + """Adjust all request parameters.""" + await self._check_disconnect( + check_client_disconnected, "Start Parameter Adjustment" + ) + + # Adjust Temperature + temp_to_set = request_params.get("temperature", DEFAULT_TEMPERATURE) + await self._adjust_temperature( + temp_to_set, page_params_cache, params_cache_lock, check_client_disconnected + ) + await self._check_disconnect( + check_client_disconnected, "After Temperature Adjustment" + ) + + # Adjust Max Tokens + max_tokens_to_set = request_params.get( + "max_output_tokens", DEFAULT_MAX_OUTPUT_TOKENS + ) + await self._adjust_max_tokens( + max_tokens_to_set, + page_params_cache, + params_cache_lock, + model_id_to_use, + parsed_model_list, + check_client_disconnected, + ) + await self._check_disconnect( + check_client_disconnected, "After Max Tokens Adjustment" + ) + + # Adjust Stop Sequences + stop_to_set = request_params.get("stop", DEFAULT_STOP_SEQUENCES) + await self._adjust_stop_sequences( + stop_to_set, page_params_cache, params_cache_lock, check_client_disconnected + ) + await self._check_disconnect( + check_client_disconnected, "After Stop Sequences Adjustment" + ) + + # Adjust Top P + top_p_to_set = request_params.get("top_p", DEFAULT_TOP_P) + await self._adjust_top_p(top_p_to_set, check_client_disconnected) + await self._check_disconnect( + check_client_disconnected, "End Parameter Adjustment" + ) + + # Ensure tools panel is expanded + await self._ensure_tools_panel_expanded(check_client_disconnected) + + # Determine if function calling is active to disable conflicting features + # Grounding (Google Search) and URL Context MUST be disabled for Function Calling + is_fc_active = False + is_fc_enabled_fn = getattr(self, "is_function_calling_enabled", None) + if is_fc_enabled_fn: + is_fc_active = await is_fc_enabled_fn(check_client_disconnected) + + # Adjust URL CONTEXT - Force disable if function calling is active + if is_fc_active: + await self._adjust_url_context(False, check_client_disconnected) + elif ENABLE_URL_CONTEXT: + await self._adjust_url_context(True, check_client_disconnected) + else: + self.logger.debug( + "[Param] URL Context feature disabled, skipping adjustment" + ) + + # Adjust Thinking Budget + thinking_handler = getattr(self, "_handle_thinking_budget", None) + if thinking_handler: + await thinking_handler( + request_params, model_id_to_use, check_client_disconnected + ) + + # Adjust Google Search Switch + await self._adjust_google_search( + request_params, model_id_to_use, check_client_disconnected + ) + + async def _adjust_temperature( + self, + temperature: float, + page_params_cache: dict, + params_cache_lock: asyncio.Lock, + check_client_disconnected: Callable, + ): + """Adjust temperature parameter.""" + async with params_cache_lock: + clamped_temp = max(0.0, min(2.0, temperature)) + if clamped_temp != temperature: + self.logger.warning( + f"Temperature {temperature} out of range [0, 2], clamped to {clamped_temp}" + ) + + cached_temp = page_params_cache.get("temperature") + if cached_temp is not None and abs(cached_temp - clamped_temp) < 0.001: + self.logger.debug(f"[Param] Temperature: {clamped_temp} (Cached)") + return + + temp_input_locator = self.page.locator(TEMPERATURE_INPUT_SELECTOR) + + try: + await expect_async(temp_input_locator).to_be_visible(timeout=5000) + await self._check_disconnect( + check_client_disconnected, + "Temperature adjustment - after input visible", + ) + + current_temp_str = await temp_input_locator.input_value(timeout=3000) + await self._check_disconnect( + check_client_disconnected, + "Temperature adjustment - after reading value", + ) + + current_temp_float = float(current_temp_str) + + if abs(current_temp_float - clamped_temp) < 0.001: + self.logger.debug( + f"[Param] Temperature: {clamped_temp} (Matches page)" + ) + page_params_cache["temperature"] = current_temp_float + else: + self.logger.debug( + f"[Param] Temperature: {current_temp_float} -> {clamped_temp}" + ) + await temp_input_locator.fill(str(clamped_temp), timeout=5000) + await self._check_disconnect( + check_client_disconnected, "Temperature adjustment - after fill" + ) + + await asyncio.sleep(0.1) + new_temp_str = await temp_input_locator.input_value(timeout=3000) + new_temp_float = float(new_temp_str) + + if abs(new_temp_float - clamped_temp) < 0.001: + self.logger.debug( + f"[Param] Temperature: Updated -> {new_temp_float}" + ) + page_params_cache["temperature"] = new_temp_float + else: + self.logger.warning( + f"Temperature update failed. Page shows: {new_temp_float}, expected: {clamped_temp}." + ) + page_params_cache.pop("temperature", None) + from browser_utils.operations import save_error_snapshot + + await save_error_snapshot( + f"temperature_verify_fail_{self.req_id}" + ) + + except ValueError as ve: + self.logger.error( + f"Error converting temperature to float: {ve}. Clearing cache." + ) + page_params_cache.pop("temperature", None) + from browser_utils.operations import save_error_snapshot + + await save_error_snapshot(f"temperature_value_error_{self.req_id}") + except Exception as pw_err: + if isinstance(pw_err, asyncio.CancelledError): + raise + self.logger.error( + f"Error operating temperature input: {pw_err}. Clearing cache." + ) + page_params_cache.pop("temperature", None) + from browser_utils.operations import save_error_snapshot + + await save_error_snapshot(f"temperature_playwright_error_{self.req_id}") + if isinstance(pw_err, ClientDisconnectedError): + raise + + async def _adjust_max_tokens( + self, + max_tokens: int, + page_params_cache: dict, + params_cache_lock: asyncio.Lock, + model_id_to_use: Optional[str], + parsed_model_list: list, + check_client_disconnected: Callable, + ): + """Adjust max output tokens parameter.""" + async with params_cache_lock: + min_val_for_tokens = 1 + max_val_for_tokens_from_model = 65536 + + if model_id_to_use and parsed_model_list: + current_model_data = next( + (m for m in parsed_model_list if m.get("id") == model_id_to_use), + None, + ) + if ( + current_model_data + and current_model_data.get("supported_max_output_tokens") + is not None + ): + try: + supported_tokens = int( + current_model_data["supported_max_output_tokens"] + ) + if supported_tokens > 0: + max_val_for_tokens_from_model = supported_tokens + else: + self.logger.warning( + f"Model {model_id_to_use} has invalid supported_max_output_tokens: {supported_tokens}" + ) + except (ValueError, TypeError): + self.logger.warning( + f"Model {model_id_to_use} supported_max_output_tokens parse failed" + ) + + clamped_max_tokens = max( + min_val_for_tokens, min(max_val_for_tokens_from_model, max_tokens) + ) + if clamped_max_tokens != max_tokens: + self.logger.debug( + f"[Param] Max Tokens: {max_tokens} -> {clamped_max_tokens} (Clamped)" + ) + + cached_max_tokens = page_params_cache.get("max_output_tokens") + if ( + cached_max_tokens is not None + and cached_max_tokens == clamped_max_tokens + ): + self.logger.debug(f"[Param] Max Tokens: {clamped_max_tokens} (Cached)") + return + + max_tokens_input_locator = self.page.locator(MAX_OUTPUT_TOKENS_SELECTOR) + + try: + await expect_async(max_tokens_input_locator).to_be_visible(timeout=5000) + await self._check_disconnect( + check_client_disconnected, + "Max Tokens adjustment - after input visible", + ) + + current_max_tokens_str = await max_tokens_input_locator.input_value( + timeout=3000 + ) + current_max_tokens_int = int(current_max_tokens_str) + + if current_max_tokens_int == clamped_max_tokens: + self.logger.debug( + f"[Param] Max Tokens: {clamped_max_tokens} (Matches page)" + ) + page_params_cache["max_output_tokens"] = current_max_tokens_int + else: + self.logger.debug( + f"[Param] Max Tokens: {current_max_tokens_int} -> {clamped_max_tokens}" + ) + await max_tokens_input_locator.fill( + str(clamped_max_tokens), timeout=5000 + ) + await self._check_disconnect( + check_client_disconnected, "Max Tokens adjustment - after fill" + ) + + await asyncio.sleep(0.1) + new_max_tokens_str = await max_tokens_input_locator.input_value( + timeout=3000 + ) + new_max_tokens_int = int(new_max_tokens_str) + + if new_max_tokens_int == clamped_max_tokens: + self.logger.debug( + f"[Param] Max Tokens: Updated -> {new_max_tokens_int}" + ) + page_params_cache["max_output_tokens"] = new_max_tokens_int + else: + self.logger.warning( + f"Max Tokens update failed. Page shows: {new_max_tokens_int}, expected: {clamped_max_tokens}." + ) + page_params_cache.pop("max_output_tokens", None) + from browser_utils.operations import save_error_snapshot + + await save_error_snapshot( + f"max_tokens_verify_fail_{self.req_id}" + ) + + except (ValueError, TypeError) as ve: + self.logger.error( + f"Error converting Max Tokens value: {ve}. Clearing cache." + ) + page_params_cache.pop("max_output_tokens", None) + from browser_utils.operations import save_error_snapshot + + await save_error_snapshot(f"max_tokens_value_error_{self.req_id}") + except Exception as e: + if isinstance(e, asyncio.CancelledError): + raise + self.logger.error( + f"Error adjusting Max Output Tokens: {e}. Clearing cache." + ) + page_params_cache.pop("max_output_tokens", None) + from browser_utils.operations import save_error_snapshot + + await save_error_snapshot(f"max_tokens_error_{self.req_id}") + if isinstance(e, ClientDisconnectedError): + raise + + async def _get_current_stop_sequences(self) -> set: + """Read current displayed stop sequences from the page.""" + try: + remove_btns = self.page.locator( + 'mat-chip button.remove-button[aria-label*="Remove"]' + ) + count = await remove_btns.count() + current_stops = set() + + for i in range(count): + label = await remove_btns.nth(i).get_attribute("aria-label") + if label and label.startswith("Remove "): + text = label[7:].strip() + if text: + current_stops.add(text) + else: + self.logger.warning( + f"Found remove button but aria-label format mismatch: {label}" + ) + + self.logger.debug(f"[Param] Current page Stop Sequences: {current_stops}") + return current_stops + except asyncio.CancelledError: + raise + except Exception as e: + self.logger.warning(f"Failed to read current stop sequences: {e}") + return set() + + async def _adjust_stop_sequences( + self, + stop_sequences, + page_params_cache: dict, + params_cache_lock: asyncio.Lock, + check_client_disconnected: Callable, + ): + """Adjust stop sequences parameter.""" + async with params_cache_lock: + self.logger.debug( + f"[Param] Stop Sequences input: {stop_sequences} (Type: {type(stop_sequences).__name__})" + ) + + # Normalize input to set + normalized_requested_stops: set = set() + if stop_sequences is not None: + if isinstance(stop_sequences, str): + if stop_sequences.strip(): + normalized_requested_stops.add(stop_sequences.strip()) + elif isinstance(stop_sequences, list): + for s in stop_sequences: + if isinstance(s, str) and s.strip(): + normalized_requested_stops.add(s.strip()) + + # Read current page state + current_page_stops = await self._get_current_stop_sequences() + + if current_page_stops == normalized_requested_stops: + self.logger.debug("[Param] Stop Sequences already match page") + page_params_cache["stop_sequences"] = normalized_requested_stops + return + + stop_input_locator = self.page.locator(STOP_SEQUENCE_INPUT_SELECTOR) + + # Calculate delta + to_add = normalized_requested_stops - current_page_stops + to_remove = current_page_stops - normalized_requested_stops + + try: + # 1. Remove excess sequences + if to_remove: + for text_to_remove in to_remove: + await self._check_disconnect( + check_client_disconnected, + f"Removing stop: {text_to_remove}", + ) + selector = f'mat-chip button.remove-button[aria-label="Remove {text_to_remove}"]' + remove_btn = self.page.locator(selector) + + if await remove_btn.count() > 0: + await remove_btn.first.click(timeout=2000) + else: + fallback_selector = f'mat-chip button.remove-button[aria-label*="Remove {text_to_remove}"]' + fallback_btn = self.page.locator(fallback_selector) + if await fallback_btn.count() > 0: + await fallback_btn.first.click(timeout=2000) + + # 2. Add missing sequences + if to_add: + await expect_async(stop_input_locator).to_be_visible(timeout=5000) + for seq in to_add: + await self._check_disconnect( + check_client_disconnected, f"Adding stop: {seq}" + ) + await stop_input_locator.fill(seq, timeout=3000) + await stop_input_locator.press("Enter", timeout=3000) + await asyncio.sleep(0.2) + + # 3. Verify final state + final_page_stops = await self._get_current_stop_sequences() + if final_page_stops == normalized_requested_stops: + page_params_cache["stop_sequences"] = normalized_requested_stops + self.logger.debug("[Param] Stop Sequences updated successfully") + else: + self.logger.warning( + f"Stop Sequences verification failed. " + f"Expected: {normalized_requested_stops}, Actual: {final_page_stops}" + ) + page_params_cache["stop_sequences"] = final_page_stops + from browser_utils.operations import save_error_snapshot + + await save_error_snapshot( + f"stop_sequence_verify_fail_{self.req_id}" + ) + + except Exception as e: + if isinstance(e, asyncio.CancelledError): + raise + self.logger.error(f"Stop Sequences error: {e}") + page_params_cache.pop("stop_sequences", None) + from browser_utils.operations import save_error_snapshot + + await save_error_snapshot(f"stop_sequence_error_{self.req_id}") + if isinstance(e, ClientDisconnectedError): + raise + + async def _adjust_top_p(self, top_p: float, check_client_disconnected: Callable): + """Adjust Top P parameter.""" + clamped_top_p = max(0.0, min(1.0, top_p)) + + if abs(clamped_top_p - top_p) > 1e-9: + self.logger.warning( + f"Top P {top_p} out of range [0, 1], clamped to {clamped_top_p}" + ) + + top_p_input_locator = self.page.locator(TOP_P_INPUT_SELECTOR) + try: + await expect_async(top_p_input_locator).to_be_visible(timeout=5000) + await self._check_disconnect( + check_client_disconnected, "Top P adjustment - after input visible" + ) + + current_top_p_str = await top_p_input_locator.input_value(timeout=3000) + current_top_p_float = float(current_top_p_str) + + if abs(current_top_p_float - clamped_top_p) > 1e-9: + self.logger.debug( + f"[Param] Top P: {current_top_p_float} -> {clamped_top_p}" + ) + await top_p_input_locator.fill(str(clamped_top_p), timeout=5000) + await self._check_disconnect( + check_client_disconnected, "Top P adjustment - after fill" + ) + + await asyncio.sleep(0.1) + new_top_p_str = await top_p_input_locator.input_value(timeout=3000) + new_top_p_float = float(new_top_p_str) + + if abs(new_top_p_float - clamped_top_p) <= 1e-9: + self.logger.debug(f"[Param] Top P: Updated -> {new_top_p_float}") + else: + self.logger.warning( + f"Top P update failed. Page shows: {new_top_p_float}, expected: {clamped_top_p}." + ) + from browser_utils.operations import save_error_snapshot + + await save_error_snapshot(f"top_p_verify_fail_{self.req_id}") + else: + self.logger.debug(f"[Param] Top P: {clamped_top_p} (Matches page)") + + except (ValueError, TypeError) as ve: + self.logger.error(f"Error converting Top P value: {ve}") + from browser_utils.operations import save_error_snapshot + + await save_error_snapshot(f"top_p_value_error_{self.req_id}") + except Exception as e: + if isinstance(e, asyncio.CancelledError): + raise + self.logger.error(f"Error adjusting Top P: {e}") + from browser_utils.operations import save_error_snapshot + + await save_error_snapshot(f"top_p_error_{self.req_id}") + if isinstance(e, ClientDisconnectedError): + raise + + async def _ensure_tools_panel_expanded(self, check_client_disconnected: Callable): + """Ensure tools panel is expanded.""" + self.logger.debug("[Param] Checking tools panel state...") + try: + collapse_tools_locator = self.page.locator( + 'button[aria-label="Expand or collapse tools"]' + ) + await expect_async(collapse_tools_locator).to_be_visible(timeout=5000) + + grandparent_locator = collapse_tools_locator.locator("xpath=../..") + class_string = await grandparent_locator.get_attribute( + "class", timeout=3000 + ) + + if class_string and "expanded" not in class_string.split(): + self.logger.debug("[Param] Tools panel not expanded, expanding...") + await collapse_tools_locator.click(timeout=CLICK_TIMEOUT_MS) + await self._check_disconnect( + check_client_disconnected, "After expanding tools panel" + ) + await expect_async(grandparent_locator).to_have_class( + re.compile(r".*expanded.*"), timeout=5000 + ) + self.logger.debug("[Param] Tools panel successfully expanded") + else: + self.logger.debug("[Param] Tools panel already expanded") + except Exception as e: + if isinstance(e, asyncio.CancelledError): + raise + self.logger.error(f"Error expanding tools panel: {e}") + if isinstance(e, ClientDisconnectedError): + raise + + async def _adjust_url_context( + self, enable: bool, check_client_disconnected: Callable + ): + """Enable or disable URL Context.""" + action = "enabling" if enable else "disabling" + try: + self.logger.info(f"Checking and {action} URL Context...") + use_url_content_selector = self.page.locator(USE_URL_CONTEXT_SELECTOR) + + # Use a shorter timeout to check visibility + if await use_url_content_selector.count() == 0: + self.logger.debug( + f"[Param] URL Context toggle not found, skipping {action}" + ) + return + + await expect_async(use_url_content_selector).to_be_visible(timeout=2000) + + is_checked = await use_url_content_selector.get_attribute("aria-checked") + is_currently_enabled = is_checked == "true" + + if is_currently_enabled != enable: + self.logger.info( + f"URL Context {'not enabled' if enable else 'enabled'}, {action}..." + ) + await use_url_content_selector.click(timeout=CLICK_TIMEOUT_MS) + await self._check_disconnect( + check_client_disconnected, f"After {action} URL Context" + ) + self.logger.info(f"URL Context {action[:-3]}ed.") + else: + self.logger.info( + f"URL Context already {'enabled' if enable else 'disabled'}." + ) + except Exception as e: + if isinstance(e, asyncio.CancelledError): + raise + self.logger.error(f"Error operating URL Context: {e}") + if isinstance(e, ClientDisconnectedError): + raise + + async def _open_url_content(self, check_client_disconnected: Callable): + """Enable URL Context (legacy wrapper).""" + await self._adjust_url_context(True, check_client_disconnected) + + def _should_enable_google_search(self, request_params: Dict[str, Any]) -> bool: + """Determine if Google Search should be enabled.""" + if "tools" in request_params and request_params.get("tools") is not None: + tools = request_params.get("tools") + has_google_search_tool = False + if isinstance(tools, list): + for tool in tools: + if isinstance(tool, dict): + if tool.get("google_search_retrieval") is not None: + has_google_search_tool = True + break + if tool.get("function", {}).get("name") == "googleSearch": + has_google_search_tool = True + break + self.logger.debug( + f"[Param] Google Search tool detected: {has_google_search_tool}" + ) + return has_google_search_tool + else: + self.logger.debug( + f"[Param] Google Search using default: {ENABLE_GOOGLE_SEARCH}" + ) + return ENABLE_GOOGLE_SEARCH + + def _supports_google_search(self, model_id: Optional[str]) -> bool: + """Check if model supports Google Search.""" + if not model_id: + return True + model_lower = model_id.lower() + if "gemini-2.0" in model_lower or "gemini2.0" in model_lower: + return False + return True + + async def _adjust_google_search( + self, + request_params: Dict[str, Any], + model_id: Optional[str], + check_client_disconnected: Callable, + ): + """Adjust Google Search toggle.""" + if not self._supports_google_search(model_id): + self.logger.debug( + "[Param] Google Search: Model does not support this feature, skipping" + ) + return + + should_enable_search = self._should_enable_google_search(request_params) + desired_state = "On" if should_enable_search else "Off" + + toggle_selector = GROUNDING_WITH_GOOGLE_SEARCH_TOGGLE_SELECTOR + + try: + toggle_locator = self.page.locator(toggle_selector) + await expect_async(toggle_locator).to_be_visible(timeout=5000) + await self._check_disconnect( + check_client_disconnected, "Google Search toggle visible" + ) + + is_checked_str = await toggle_locator.get_attribute("aria-checked") + is_currently_checked = is_checked_str == "true" + + if should_enable_search == is_currently_checked: + self.logger.debug( + f"[Param] Google Search: {desired_state} (Matches page)" + ) + return + + self.logger.debug( + f"[Param] Google Search: {'On' if is_currently_checked else 'Off'} -> {desired_state}" + ) + + # Check if the toggle is disabled (e.g., when function calling is enabled) + is_disabled = await toggle_locator.get_attribute("disabled") + toggle_class = await toggle_locator.get_attribute("class") or "" + if is_disabled is not None or "mdc-switch--disabled" in toggle_class: + self.logger.debug( + "[Param] Google Search: Toggle is disabled (likely due to function calling being enabled), skipping" + ) + return + + try: + await toggle_locator.scroll_into_view_if_needed() + except asyncio.CancelledError: + raise + except Exception: + pass + await toggle_locator.click(timeout=CLICK_TIMEOUT_MS) + await self._check_disconnect( + check_client_disconnected, "Google Search toggle clicked" + ) + await asyncio.sleep(0.5) + new_state = await toggle_locator.get_attribute("aria-checked") + if (new_state == "true") == should_enable_search: + self.logger.debug(f"[Param] Google Search: {desired_state} (Updated)") + else: + self.logger.warning( + f"Google Search toggle failed. Expected: {desired_state}, Actual: {'On' if new_state == 'true' else 'Off'}" + ) + + except Exception as e: + if isinstance(e, asyncio.CancelledError): + raise + if isinstance(e, AssertionError) and "visible" in str(e).lower(): + self.logger.debug( + "[Param] Google Search: Model does not support this feature, skipping" + ) + else: + self.logger.error(f"Google Search toggle error: {e}") + if isinstance(e, ClientDisconnectedError): + raise diff --git a/browser_utils/page_controller_modules/response.py b/browser_utils/page_controller_modules/response.py new file mode 100644 index 0000000000000000000000000000000000000000..02cc51ea462007991446d622f207f9f6534bf1aa --- /dev/null +++ b/browser_utils/page_controller_modules/response.py @@ -0,0 +1,303 @@ +import asyncio +from typing import Any, Callable, Dict, List, Optional, Tuple + +from playwright.async_api import expect as expect_async + +from browser_utils.operations import ( + _get_final_response_content, + _wait_for_response_completion, + save_error_snapshot, +) +from config import ( + EDIT_MESSAGE_BUTTON_SELECTOR, + PROMPT_TEXTAREA_SELECTOR, + RESPONSE_CONTAINER_SELECTOR, + RESPONSE_TEXT_SELECTOR, + SUBMIT_BUTTON_SELECTOR, +) +from config.settings import FUNCTION_CALLING_DEBUG +from logging_utils import set_request_id +from models import ClientDisconnectedError + +from .base import BaseController + + +class ResponseController(BaseController): + """Handles retrieval of AI responses.""" + + async def get_response( + self, + check_client_disconnected: Callable, + prompt_length: int = 0, + timeout: Optional[float] = None, + ) -> str: + """Retrieve response content.""" + set_request_id(self.req_id) + self.logger.debug("[Response] Waiting for and retrieving response...") + + try: + # Wait for response container + response_container_locator = self.page.locator( + RESPONSE_CONTAINER_SELECTOR + ).last + response_element_locator = response_container_locator.locator( + RESPONSE_TEXT_SELECTOR + ) + + self.logger.debug( + "[Response] Waiting for response element to be attached to DOM..." + ) + await expect_async(response_element_locator).to_be_attached(timeout=90000) + await self._check_disconnect( + check_client_disconnected, + "Retrieve Response - Response element attached", + ) + + # Wait for response completion + submit_button_locator = self.page.locator(SUBMIT_BUTTON_SELECTOR) + edit_button_locator = self.page.locator(EDIT_MESSAGE_BUTTON_SELECTOR) + input_field_locator = self.page.locator(PROMPT_TEXTAREA_SELECTOR) + + self.logger.debug("[Response] Waiting for response completion...") + completion_detected = await _wait_for_response_completion( + self.page, + input_field_locator, + submit_button_locator, + edit_button_locator, + self.req_id, + check_client_disconnected, + None, + prompt_length=prompt_length, + timeout=timeout, + ) + + if not completion_detected: + self.logger.warning( + "Response completion detection failed, attempting to retrieve current content" + ) + else: + self.logger.debug("[Response] Response completion detection successful") + + # Get final response content + final_content = await _get_final_response_content( + self.page, self.req_id, check_client_disconnected + ) + + if not final_content or not final_content.strip(): + self.logger.warning("Retrieved response content is empty") + await save_error_snapshot(f"empty_response_{self.req_id}") + # Do not raise exception, return empty content to let caller handle + return "" + + self.logger.debug( + f"[Response] Successfully retrieved content ({len(final_content)} chars)" + ) + return final_content + + except Exception as e: + if isinstance(e, asyncio.CancelledError): + self.logger.info("Retrieve response task cancelled") + raise + self.logger.error(f"Error retrieving response: {e}") + if not isinstance(e, ClientDisconnectedError): + await save_error_snapshot(f"get_response_error_{self.req_id}") + raise + + async def ensure_generation_stopped( + self, check_client_disconnected: Callable + ) -> None: + """ + Ensure generation has stopped. + If submit button is still enabled, click it to stop generation. + Wait until submit button becomes disabled. + """ + submit_button_locator = self.page.locator(SUBMIT_BUTTON_SELECTOR) + + # Check client connection status + check_client_disconnected("Ensure generation stopped - pre-check") + await asyncio.sleep(0.5) # Give UI time to update + + # Check if button is still enabled, if so click to stop + try: + is_button_enabled = await submit_button_locator.is_enabled(timeout=2000) + + if is_button_enabled: + # Button still enabled after stream completion, click to stop + self.logger.debug( + "[Cleanup] Submit button state: ENABLED -> Clicking stop" + ) + await submit_button_locator.click(timeout=5000, force=True) + else: + self.logger.debug( + "[Cleanup] Submit button state: DISABLED (no action needed)" + ) + except Exception as button_check_err: + if isinstance(button_check_err, asyncio.CancelledError): + raise + self.logger.warning(f"Failed to check button state: {button_check_err}") + + # Wait for button to be disabled + try: + await expect_async(submit_button_locator).to_be_disabled(timeout=30000) + except Exception as e: + if isinstance(e, asyncio.CancelledError): + raise + self.logger.warning(f"Timeout or error ensuring generation stopped: {e}") + # Do not raise even on timeout as this is just a cleanup step + + async def detect_function_calls( + self, + check_client_disconnected: Callable, + ) -> bool: + """ + Detect if the current response contains function calls. + + This is a fast check without full parsing. Uses the + FunctionCallResponseParser for DOM-based detection. + + Args: + check_client_disconnected: Callback to check client connection. + + Returns: + True if function calls are detected, False otherwise. + """ + try: + from api_utils.utils_ext.function_call_response_parser import ( + FunctionCallResponseParser, + ) + + parser = FunctionCallResponseParser( + page=self.page, + logger=self.logger, + req_id=self.req_id, + ) + + return await parser.detect_function_calls(check_client_disconnected) + + except Exception as e: + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + f"[{self.req_id}] Error detecting function calls: {e}" + ) + return False + + async def parse_function_calls( + self, + check_client_disconnected: Callable, + ) -> Tuple[bool, List[Dict[str, Any]], str]: + """ + Parse function calls from the current response. + + Args: + check_client_disconnected: Callback to check client connection. + + Returns: + Tuple of: + - has_function_calls: Whether any function calls were found + - function_calls: List of function call dicts with 'name' and 'params' + - text_content: Any remaining text content + """ + try: + from api_utils.utils_ext.function_call_response_parser import ( + FunctionCallResponseParser, + ) + + parser = FunctionCallResponseParser( + page=self.page, + logger=self.logger, + req_id=self.req_id, + ) + + result = await parser.parse_function_calls(check_client_disconnected) + + # Convert ParsedFunctionCall to dict format + function_calls: List[Dict[str, Any]] = [] + for fc in result.function_calls: + function_calls.append( + { + "name": fc.name, + "params": fc.arguments, + } + ) + + return result.has_function_calls, function_calls, result.text_content + + except Exception as e: + if FUNCTION_CALLING_DEBUG: + self.logger.error(f"[{self.req_id}] Error parsing function calls: {e}") + return False, [], "" + + async def get_response_with_function_calls( + self, + check_client_disconnected: Callable, + prompt_length: int = 0, + timeout: Optional[float] = None, + ) -> Dict[str, Any]: + """ + Retrieve response content with function call detection. + + This method extends get_response by also checking for function calls + in the response and returning them in a structured format. + + Args: + check_client_disconnected: Callback to check client connection. + prompt_length: Length of the prompt for timeout calculation. + timeout: Optional explicit timeout. + + Returns: + Dict with: + - content: Text content of the response + - has_function_calls: Whether function calls were detected + - function_calls: List of function call dicts + - raw_content: The raw content string + """ + set_request_id(self.req_id) + if FUNCTION_CALLING_DEBUG: + self.logger.debug( + "[Response] Getting response with function call detection..." + ) + + result: Dict[str, Any] = { + "content": "", + "has_function_calls": False, + "function_calls": [], + "raw_content": "", + } + + try: + # Get the raw response content first + raw_content = await self.get_response( + check_client_disconnected, + prompt_length=prompt_length, + timeout=timeout, + ) + result["raw_content"] = raw_content + + # Check for function calls + has_fc, function_calls, text_content = await self.parse_function_calls( + check_client_disconnected + ) + + if has_fc: + if FUNCTION_CALLING_DEBUG: + self.logger.info( + f"[{self.req_id}] Detected {len(function_calls)} function call(s) in response" + ) + result["has_function_calls"] = True + result["function_calls"] = function_calls + result["content"] = text_content + else: + result["content"] = raw_content + + except Exception as e: + if isinstance(e, asyncio.CancelledError): + raise + if FUNCTION_CALLING_DEBUG: + self.logger.error( + f"[{self.req_id}] Error getting response with FC: {e}" + ) + if not isinstance(e, ClientDisconnectedError): + await save_error_snapshot(f"get_response_fc_error_{self.req_id}") + raise + + return result diff --git a/browser_utils/page_controller_modules/thinking.py b/browser_utils/page_controller_modules/thinking.py new file mode 100644 index 0000000000000000000000000000000000000000..af75087b6a6262e8d370947dd299008b5d962503 --- /dev/null +++ b/browser_utils/page_controller_modules/thinking.py @@ -0,0 +1,789 @@ +import asyncio +from enum import Enum, auto +from typing import Any, Callable, Dict, Optional + +from playwright.async_api import TimeoutError +from playwright.async_api import expect as expect_async + +from browser_utils.operations import save_error_snapshot +from browser_utils.thinking_normalizer import ( + format_directive_log, + normalize_reasoning_effort_with_stream_check, +) +from config import ( + CLICK_TIMEOUT_MS, + DEFAULT_THINKING_LEVEL_FLASH, + DEFAULT_THINKING_LEVEL_PRO, + ENABLE_THINKING_MODE_TOGGLE_SELECTOR, + SET_THINKING_BUDGET_TOGGLE_SELECTOR, + THINKING_BUDGET_INPUT_SELECTOR, + THINKING_BUDGET_TOGGLE_OLD_ROOT_SELECTOR, + THINKING_BUDGET_TOGGLE_PARENT_SELECTOR, + THINKING_LEVEL_OPTION_HIGH_SELECTOR, + THINKING_LEVEL_OPTION_LOW_SELECTOR, + THINKING_LEVEL_OPTION_MEDIUM_SELECTOR, + THINKING_LEVEL_OPTION_MINIMAL_SELECTOR, + THINKING_LEVEL_SELECT_SELECTOR, + THINKING_MODE_TOGGLE_OLD_ROOT_SELECTOR, + THINKING_MODE_TOGGLE_PARENT_SELECTOR, +) +from models import ClientDisconnectedError + +from .base import BaseController + + +class ThinkingCategory(Enum): + """Model thinking capability categories.""" + + NON_THINKING = auto() # No thinking UI at all (gemini-2.0-*, gemini-1.5-*) + THINKING_FLASH = auto() # Toggleable thinking mode + budget (gemini-2.5-flash*) + THINKING_PRO = auto() # Always-on thinking, budget toggle/slider (gemini-2.5-pro*) + THINKING_LEVEL = auto() # 2-level dropdown only (gemini-3-pro*) + THINKING_LEVEL_FLASH = auto() # 4-level dropdown (gemini-3-flash*) + + +class ThinkingController(BaseController): + """Handles thinking mode and budget logic.""" + + async def _handle_thinking_budget( + self, + request_params: Dict[str, Any], + page_params_cache: Dict[str, Any], + params_cache_lock: asyncio.Lock, + model_id_to_use: Optional[str], + check_client_disconnected: Callable, + is_streaming: bool = True, + ): + """Handle adjustments for thinking mode and budget.""" + reasoning_effort = request_params.get("reasoning_effort") + + try: + async with params_cache_lock: + if ( + "reasoning_effort" in page_params_cache + and page_params_cache["reasoning_effort"] == reasoning_effort + ): + self.logger.debug( + f"[Thinking] Reasoning effort {reasoning_effort} matches cache, skipping" + ) + return + + # Determine processing logic based on model category + category = self._get_thinking_category(model_id_to_use) + if category == ThinkingCategory.NON_THINKING: + self.logger.debug( + "[Thinking] This model does not support thinking mode, skipping config" + ) + page_params_cache["reasoning_effort"] = reasoning_effort + return + + directive = normalize_reasoning_effort_with_stream_check( + reasoning_effort, is_streaming + ) + self.logger.debug( + f"[Thinking] Directive: {format_directive_log(directive)}" + ) + + # More resilient level check: check if dropdown exists even if category doesn't strictly require it + actually_has_dropdown = await self._has_thinking_dropdown() + uses_level = ( + category + in ( + ThinkingCategory.THINKING_LEVEL, + ThinkingCategory.THINKING_LEVEL_FLASH, + ) + or actually_has_dropdown + ) + + if actually_has_dropdown and category not in ( + ThinkingCategory.THINKING_LEVEL, + ThinkingCategory.THINKING_LEVEL_FLASH, + ): + self.logger.warning( + f"[Thinking] Detected level dropdown for model category {category}. Switching to level-based logic." + ) + + def _should_enable_from_raw(rv: Any) -> bool: + try: + if isinstance(rv, str): + rs = rv.strip().lower() + if rs in ["high", "medium", "low", "minimal", "-1"]: + return True + if rs == "none": + return False + v = int(rs) + return v > 0 + if isinstance(rv, int): + return rv > 0 or rv == -1 + except Exception: + return False + return False + + desired_enabled = directive.thinking_enabled or _should_enable_from_raw( + reasoning_effort + ) + + # Special logic: for models using levels (Gemini 3 Pro), if reasoning_effort is not specified, + # we default to enabled (or at least check and apply default level) + if reasoning_effort is None and uses_level: + desired_enabled = True + + has_main_toggle = category == ThinkingCategory.THINKING_FLASH + if has_main_toggle: + self.logger.info( + f"Setting main thinking toggle to: {'ON' if desired_enabled else 'OFF'}" + ) + await self._control_thinking_mode_toggle( + should_be_enabled=desired_enabled, + check_client_disconnected=check_client_disconnected, + ) + else: + self.logger.info( + "This model has no main thinking toggle, skipping toggle setting." + ) + + if not desired_enabled: + # Skip models without budget toggle + if category in ( + ThinkingCategory.THINKING_LEVEL, + ThinkingCategory.THINKING_LEVEL_FLASH, + ): + page_params_cache["reasoning_effort"] = reasoning_effort + return + # Flash/Flash Lite models: after turning off main thinking toggle, budget toggle is hidden + if has_main_toggle: + self.logger.info( + "Flash model main thinking toggle turned off, skipping budget toggle operation (hidden)" + ) + page_params_cache["reasoning_effort"] = reasoning_effort + return + # If thinking is disabled, ensure budget toggle is off (legacy UI compatibility) + await self._control_thinking_budget_toggle( + should_be_checked=False, + check_client_disconnected=check_client_disconnected, + ) + page_params_cache["reasoning_effort"] = reasoning_effort + return + + # 2) Thinking enabled: Set level or budget based on model type + if uses_level: + rv = reasoning_effort + level_to_set = None + is_flash_4_level = category == ThinkingCategory.THINKING_LEVEL_FLASH + + if isinstance(rv, str): + rs = rv.strip().lower() + if is_flash_4_level: + # Gemini 3 Flash: 4 levels (minimal, low, medium, high) + if rs in ["minimal", "low", "medium", "high"]: + level_to_set = rs + elif rs in ["none", "-1"]: + level_to_set = "high" + else: + try: + v = int(rs) + if v >= 16000: + level_to_set = "high" + elif v >= 8000: + level_to_set = "medium" + elif v >= 1024: + level_to_set = "low" + else: + level_to_set = "minimal" + except Exception: + level_to_set = None + else: + # Gemini 3 Pro: 2 levels (low, high) + if rs == "low" or rs == "minimal": + level_to_set = "low" + elif rs in ["high", "medium", "none", "-1"]: + level_to_set = "high" + else: + try: + v = int(rs) + level_to_set = "high" if v >= 8000 else "low" + except Exception: + level_to_set = None + elif isinstance(rv, int): + if is_flash_4_level: + # Gemini 3 Flash: 4 levels + if rv >= 16000 or rv == -1: + level_to_set = "high" + elif rv >= 8000: + level_to_set = "medium" + elif rv >= 1024: + level_to_set = "low" + else: + level_to_set = "minimal" + else: + # Gemini 3 Pro: 2 levels + level_to_set = "high" if rv >= 8000 or rv == -1 else "low" + + if level_to_set is None and rv is None: + # Use model-specific default + level_to_set = ( + DEFAULT_THINKING_LEVEL_FLASH + if is_flash_4_level + else DEFAULT_THINKING_LEVEL_PRO + ) + # Ensure Pro only gets valid levels (high/low) + if not is_flash_4_level and level_to_set not in ["high", "low"]: + level_to_set = ( + "high" if level_to_set in ["high", "medium"] else "low" + ) + + if level_to_set is None: + self.logger.info( + "Unable to parse reasoning level, keeping current level." + ) + else: + await self._set_thinking_level( + level_to_set, check_client_disconnected + ) + page_params_cache["reasoning_effort"] = reasoning_effort + return + + # Fallback path + if desired_enabled and not directive.thinking_enabled: + self.logger.info("Attempting to turn off main thinking toggle...") + success = await self._control_thinking_mode_toggle( + should_be_enabled=False, + check_client_disconnected=check_client_disconnected, + ) + + if not success: + self.logger.warning( + "Main thinking toggle unavailable, using fallback: Setting budget to 0" + ) + await self._control_thinking_budget_toggle( + should_be_checked=True, + check_client_disconnected=check_client_disconnected, + ) + await self._set_thinking_budget_value( + 0, check_client_disconnected + ) + page_params_cache["reasoning_effort"] = reasoning_effort + return + + # Scenario 2 & 3: Enable thinking mode + if not has_main_toggle: + self.logger.info("Enabling main thinking toggle...") + await self._control_thinking_mode_toggle( + should_be_enabled=True, + check_client_disconnected=check_client_disconnected, + ) + + # Scenario 2: Enable thinking, no budget limit + if not directive.budget_enabled: + self.logger.info("Disabling manual budget limit...") + await self._control_thinking_budget_toggle( + should_be_checked=False, + check_client_disconnected=check_client_disconnected, + ) + + # Scenario 3: Enable thinking, with budget limit + else: + value_to_set = directive.budget_value or 0 + model_lower = (model_id_to_use or "").lower() + if "gemini-2.5-pro" in model_lower: + value_to_set = min(value_to_set, 32768) + elif "flash-lite" in model_lower: + value_to_set = min(value_to_set, 24576) + elif "flash" in model_lower: + value_to_set = min(value_to_set, 24576) + self.logger.info( + f"Enabling manual budget limit and setting budget value: {value_to_set} tokens" + ) + await self._control_thinking_budget_toggle( + should_be_checked=True, + check_client_disconnected=check_client_disconnected, + ) + await self._set_thinking_budget_value( + value_to_set, check_client_disconnected + ) + + page_params_cache["reasoning_effort"] = reasoning_effort + + except asyncio.CancelledError: + self.logger.info( + f"[{self.req_id}] Thinking budget adjustment task was cancelled." + ) + raise + + async def _has_thinking_dropdown(self) -> bool: + try: + locator = self.page.locator(THINKING_LEVEL_SELECT_SELECTOR) + count = await locator.count() + if count == 0: + return False + try: + await expect_async(locator.first).to_be_visible(timeout=2000) + return True + except asyncio.CancelledError: + self.logger.info(f"[{self.req_id}] Thinking dropdown check cancelled.") + raise + except Exception: + return True + except asyncio.CancelledError: + self.logger.info(f"[{self.req_id}] Thinking dropdown check cancelled.") + raise + except Exception: + return False + + def _get_thinking_category(self, model_id: Optional[str]) -> ThinkingCategory: + """Return thinking category based on model ID.""" + if not model_id: + return ThinkingCategory.NON_THINKING + + mid = model_id.lower() + + if "gemini-3" in mid and "flash" in mid: + return ThinkingCategory.THINKING_LEVEL_FLASH + + if "gemini-3" in mid and "pro" in mid: + return ThinkingCategory.THINKING_LEVEL + + if "gemini-2.5-pro" in mid: + return ThinkingCategory.THINKING_PRO + + if "gemini-2.5-flash" in mid: + return ThinkingCategory.THINKING_FLASH + + if mid == "gemini-flash-latest" or mid == "gemini-flash-lite-latest": + return ThinkingCategory.THINKING_FLASH + + return ThinkingCategory.NON_THINKING + + async def _set_thinking_level( + self, level: str, check_client_disconnected: Callable + ): + """Set thinking level in the dropdown.""" + level_lower = level.lower() + if level_lower == "high": + target_option_selector = THINKING_LEVEL_OPTION_HIGH_SELECTOR + elif level_lower == "medium": + target_option_selector = THINKING_LEVEL_OPTION_MEDIUM_SELECTOR + elif level_lower == "low": + target_option_selector = THINKING_LEVEL_OPTION_LOW_SELECTOR + elif level_lower == "minimal": + target_option_selector = THINKING_LEVEL_OPTION_MINIMAL_SELECTOR + else: + target_option_selector = THINKING_LEVEL_OPTION_HIGH_SELECTOR + try: + trigger = self.page.locator(THINKING_LEVEL_SELECT_SELECTOR) + await expect_async(trigger).to_be_visible(timeout=5000) + await trigger.scroll_into_view_if_needed() + await trigger.click(timeout=CLICK_TIMEOUT_MS) + await self._check_disconnect( + check_client_disconnected, "After opening Thinking Level" + ) + option = self.page.locator(target_option_selector) + await expect_async(option).to_be_visible(timeout=5000) + await option.click(timeout=CLICK_TIMEOUT_MS) + await asyncio.sleep(0.2) + try: + await expect_async( + self.page.locator( + '[role="listbox"][aria-label="Thinking Level"], [role="listbox"][aria-label="Thinking level"]' + ).first + ).to_be_hidden(timeout=2000) + except asyncio.CancelledError: + self.logger.info(f"[{self.req_id}] Thinking level set cancelled.") + raise + except Exception: + try: + await self.page.keyboard.press("Escape") + except Exception: + pass + await asyncio.sleep(0.1) + value_text = await trigger.locator( + ".mat-mdc-select-value-text .mat-mdc-select-min-line" + ).inner_text(timeout=3000) + if value_text.strip().lower() == level.lower(): + self.logger.info(f"Thinking Level successfully set to {level}") + else: + self.logger.warning( + f"Thinking Level verification failed, page value: {value_text}, expected: {level}" + ) + except Exception as e: + if isinstance(e, asyncio.CancelledError): + self.logger.info(f"[{self.req_id}] Thinking level set cancelled.") + raise + self.logger.error(f"Error setting Thinking Level: {e}") + if isinstance(e, ClientDisconnectedError): + raise + + async def _set_thinking_budget_value( + self, token_budget: int, check_client_disconnected: Callable + ): + """Set specific thinking budget value.""" + self.logger.info(f"Setting thinking budget value: {token_budget} tokens") + + budget_input_locator = self.page.locator(THINKING_BUDGET_INPUT_SELECTOR) + + try: + await expect_async(budget_input_locator).to_be_visible(timeout=5000) + await self._check_disconnect( + check_client_disconnected, + "Thinking budget adjustment - after input visible", + ) + + adjusted_budget = token_budget + + try: + await self.page.evaluate( + "([selector, desired]) => {\n" + " const num = Number(desired);\n" + " const el = document.querySelector(selector);\n" + " if (!el) return false;\n" + " const container = el.closest('[data-test-slider]') || el.parentElement;\n" + " const inputs = container ? container.querySelectorAll('input') : [el];\n" + " const ranges = container ? container.querySelectorAll('input[type=\"range\"]') : [];\n" + " inputs.forEach(inp => {\n" + " try {\n" + " if (Number.isFinite(num)) {\n" + " const curMaxAttr = inp.getAttribute('max');\n" + " const curMax = curMaxAttr ? Number(curMaxAttr) : undefined;\n" + " if (curMax !== undefined && curMax < num) {\n" + " inp.setAttribute('max', String(num));\n" + " }\n" + " if (inp.max && Number(inp.max) < num) {\n" + " inp.max = String(num);\n" + " }\n" + " inp.value = String(num);\n" + " inp.dispatchEvent(new Event('input', { bubbles: true }));\n" + " inp.dispatchEvent(new Event('change', { bubbles: true }));\n" + " inp.dispatchEvent(new Event('blur', { bubbles: true }));\n" + " }\n" + " } catch (_) {}\n" + " });\n" + " ranges.forEach(r => {\n" + " try {\n" + " if (Number.isFinite(num)) {\n" + " const curMaxAttr = r.getAttribute('max');\n" + " const curMax = curMaxAttr ? Number(curMaxAttr) : undefined;\n" + " if (curMax !== undefined && curMax < num) {\n" + " r.setAttribute('max', String(num));\n" + " }\n" + " if (r.max && Number(r.max) < num) {\n" + " r.max = String(num);\n" + " }\n" + " r.value = String(num);\n" + " r.dispatchEvent(new Event('input', { bubbles: true }));\n" + " r.dispatchEvent(new Event('change', { bubbles: true }));\n" + " }\n" + " } catch (_) {}\n" + " });\n" + " return true;\n" + "}", + [THINKING_BUDGET_INPUT_SELECTOR, adjusted_budget], + ) + except asyncio.CancelledError: + self.logger.info( + f"[{self.req_id}] Thinking budget value set cancelled." + ) + raise + except Exception: + pass + + self.logger.info(f"Setting thinking budget to: {adjusted_budget}") + await budget_input_locator.fill(str(adjusted_budget), timeout=5000) + await self._check_disconnect( + check_client_disconnected, "Thinking budget adjustment - after fill" + ) + + # Verify + try: + await expect_async(budget_input_locator).to_have_value( + str(adjusted_budget), timeout=3000 + ) + self.logger.info( + f"Thinking budget successfully updated to: {adjusted_budget}" + ) + except Exception: + new_value_str = await budget_input_locator.input_value(timeout=3000) + try: + new_value_int = int(new_value_str) + except Exception: + new_value_int = -1 + if new_value_int == adjusted_budget: + self.logger.info( + f"Thinking budget successfully updated to: {new_value_str}" + ) + else: + # Fallback: if page max is less than requested, try filling with page max + try: + page_max_str = await budget_input_locator.get_attribute("max") + page_max_val = ( + int(page_max_str) if page_max_str is not None else None + ) + except Exception: + page_max_val = None + if page_max_val is not None and page_max_val < adjusted_budget: + self.logger.warning( + f"Page max budget is {page_max_val}, requested budget {adjusted_budget} adjusted to {page_max_val}" + ) + try: + await self.page.evaluate( + "([selector, desired]) => {\n" + " const num = Number(desired);\n" + " const el = document.querySelector(selector);\n" + " if (!el) return false;\n" + " const container = el.closest('[data-test-slider]') || el.parentElement;\n" + " const inputs = container ? container.querySelectorAll('input') : [el];\n" + " inputs.forEach(inp => {\n" + " try { inp.value = String(num); inp.dispatchEvent(new Event('input', { bubbles: true })); inp.dispatchEvent(new Event('change', { bubbles: true })); } catch (_) {}\n" + " });\n" + " return true;\n" + "}", + [THINKING_BUDGET_INPUT_SELECTOR, page_max_val], + ) + except asyncio.CancelledError: + self.logger.info( + f"[{self.req_id}] Thinking budget value set cancelled." + ) + raise + except Exception: + pass + await budget_input_locator.fill(str(page_max_val), timeout=5000) + try: + await expect_async(budget_input_locator).to_have_value( + str(page_max_val), timeout=2000 + ) + except asyncio.CancelledError: + self.logger.info( + f"[{self.req_id}] Thinking budget value set cancelled." + ) + raise + except Exception: + pass + else: + self.logger.warning( + f"Thinking budget verification failed after update. Page shows: {new_value_str}, expected: {adjusted_budget}" + ) + + except Exception as e: + if isinstance(e, asyncio.CancelledError): + self.logger.info( + f"[{self.req_id}] Thinking budget value set cancelled." + ) + raise + self.logger.error(f"Error adjusting thinking budget: {e}") + if isinstance(e, ClientDisconnectedError): + raise + + async def _control_thinking_mode_toggle( + self, should_be_enabled: bool, check_client_disconnected: Callable + ) -> bool: + """Control main thinking toggle to enable/disable thinking mode.""" + toggle_selector = ENABLE_THINKING_MODE_TOGGLE_SELECTOR + self.logger.info( + f"Controlling main thinking toggle, expected state: {'ON' if should_be_enabled else 'OFF'}..." + ) + + try: + toggle_locator = self.page.locator(toggle_selector) + + element_count = await toggle_locator.count() + if element_count == 0: + if not should_be_enabled: + self.logger.info( + "Main thinking toggle not found (unsupported), skipping disable." + ) + return True + else: + self.logger.warning( + "Main thinking toggle not found (unsupported), cannot enable." + ) + return False + + await expect_async(toggle_locator).to_be_visible(timeout=5000) + try: + await toggle_locator.scroll_into_view_if_needed() + except asyncio.CancelledError: + self.logger.info( + f"[{self.req_id}] Thinking mode toggle control cancelled." + ) + raise + except Exception: + pass + await self._check_disconnect( + check_client_disconnected, "Main thinking toggle - after visible" + ) + + is_checked_str = await toggle_locator.get_attribute("aria-checked") + current_state_is_enabled = is_checked_str == "true" + self.logger.info( + f"Main thinking toggle current state: {is_checked_str} (Enabled: {current_state_is_enabled})" + ) + + if current_state_is_enabled != should_be_enabled: + action = "enable" if should_be_enabled else "disable" + self.logger.info( + f"Main thinking toggle mismatch, clicking to {action} thinking mode..." + ) + + try: + await toggle_locator.click(timeout=CLICK_TIMEOUT_MS) + except asyncio.CancelledError: + self.logger.info( + f"[{self.req_id}] Thinking mode toggle control cancelled." + ) + raise + except Exception: + try: + alt_toggle = self.page.locator( + THINKING_MODE_TOGGLE_PARENT_SELECTOR + ) + if await alt_toggle.count() > 0: + await alt_toggle.click(timeout=CLICK_TIMEOUT_MS) + else: + root = self.page.locator( + THINKING_MODE_TOGGLE_OLD_ROOT_SELECTOR + ) + label = root.locator("label.mdc-label") + await expect_async(label).to_be_visible(timeout=2000) + await label.click(timeout=CLICK_TIMEOUT_MS) + except Exception: + raise + await self._check_disconnect( + check_client_disconnected, + f"Main thinking toggle - after click {action}", + ) + + new_state_str = await toggle_locator.get_attribute("aria-checked") + new_state_is_enabled = new_state_str == "true" + + if new_state_is_enabled == should_be_enabled: + self.logger.info( + f"Main thinking toggle successfully {action}d. New state: {new_state_str}" + ) + return True + else: + self.logger.warning( + f"Main thinking toggle {action} verification failed. Expected: {should_be_enabled}, Actual: {new_state_str}" + ) + return False + else: + self.logger.info("Main thinking toggle already in expected state.") + return True + + except TimeoutError: + self.logger.warning( + "Main thinking toggle element not found or invisible (unsupported)" + ) + return False + except Exception as e: + if isinstance(e, asyncio.CancelledError): + self.logger.info( + f"[{self.req_id}] Thinking mode toggle control cancelled." + ) + raise + self.logger.error(f"Error operating main thinking toggle: {e}") + await save_error_snapshot(f"thinking_mode_toggle_error_{self.req_id}") + if isinstance(e, ClientDisconnectedError): + raise + return False + + async def _control_thinking_budget_toggle( + self, should_be_checked: bool, check_client_disconnected: Callable + ): + """Control 'Thinking Budget' toggle state based on should_be_checked.""" + toggle_selector = SET_THINKING_BUDGET_TOGGLE_SELECTOR + self.logger.info( + f"Controlling 'Thinking Budget' toggle, expected state: {'Checked' if should_be_checked else 'Unchecked'}..." + ) + + try: + toggle_locator = self.page.locator(toggle_selector) + + element_count = await toggle_locator.count() + if element_count == 0: + if not should_be_checked: + self.logger.info( + "Thinking budget toggle not found, skipping disable." + ) + return + else: + self.logger.warning( + "Thinking budget toggle not found, cannot enable." + ) + return + + await expect_async(toggle_locator).to_be_visible(timeout=5000) + try: + await toggle_locator.scroll_into_view_if_needed() + except asyncio.CancelledError: + self.logger.info( + f"[{self.req_id}] Thinking budget toggle control cancelled." + ) + raise + except Exception: + pass + await self._check_disconnect( + check_client_disconnected, "Thinking budget toggle - after visible" + ) + + is_checked_str = await toggle_locator.get_attribute("aria-checked") + current_state_is_checked = is_checked_str == "true" + self.logger.info( + f"Thinking budget toggle current 'aria-checked': {is_checked_str} (Checked: {current_state_is_checked})" + ) + + if current_state_is_checked != should_be_checked: + action = "enable" if should_be_checked else "disable" + self.logger.info( + f"Thinking budget toggle mismatch, clicking to {action}..." + ) + try: + await toggle_locator.click(timeout=CLICK_TIMEOUT_MS) + except asyncio.CancelledError: + self.logger.info( + f"[{self.req_id}] Thinking budget toggle control cancelled." + ) + raise + except Exception: + try: + alt_toggle = self.page.locator( + THINKING_BUDGET_TOGGLE_PARENT_SELECTOR + ) + if await alt_toggle.count() > 0: + await alt_toggle.click(timeout=CLICK_TIMEOUT_MS) + else: + root = self.page.locator( + THINKING_BUDGET_TOGGLE_OLD_ROOT_SELECTOR + ) + label = root.locator("label.mdc-label") + await expect_async(label).to_be_visible(timeout=2000) + await label.click(timeout=CLICK_TIMEOUT_MS) + except Exception: + raise + await self._check_disconnect( + check_client_disconnected, + f"Thinking budget toggle - after click {action}", + ) + + await asyncio.sleep(0.5) + new_state_str = await toggle_locator.get_attribute("aria-checked") + new_state_is_checked = new_state_str == "true" + + if new_state_is_checked == should_be_checked: + self.logger.info( + f"'Thinking Budget' toggle successfully {action}d. New state: {new_state_str}" + ) + else: + self.logger.warning( + f"'Thinking Budget' toggle verification failed after {action}. Expected: {should_be_checked}, Actual: {new_state_str}" + ) + else: + self.logger.info("'Thinking Budget' toggle already in expected state.") + + except Exception as e: + if isinstance(e, asyncio.CancelledError): + self.logger.info( + f"[{self.req_id}] Thinking budget toggle control cancelled." + ) + raise + self.logger.error(f"Error operating 'Thinking Budget toggle': {e}") + if isinstance(e, ClientDisconnectedError): + raise diff --git a/browser_utils/thinking_normalizer.py b/browser_utils/thinking_normalizer.py new file mode 100644 index 0000000000000000000000000000000000000000..d1aa1a98156e78ea85ef91927f01fca9edf0f61e --- /dev/null +++ b/browser_utils/thinking_normalizer.py @@ -0,0 +1,222 @@ +""" +Thinking Mode Parameter Normalization Module +Normalizes the reasoning_effort parameter into a standardized thinking directive. + +This module is responsible for converting various formats of the reasoning_effort parameter into a unified internal directive structure. +""" + +from dataclasses import dataclass +from typing import Any, Optional + +from config import DEFAULT_THINKING_BUDGET, ENABLE_THINKING_BUDGET +from config.settings import ( + DISABLE_THINKING_BUDGET_ON_STREAMING_DISABLE, + THINKING_BUDGET_HIGH, + THINKING_BUDGET_LOW, + THINKING_BUDGET_MEDIUM, +) + + +@dataclass +class ThinkingDirective: + """Standardized thinking directive + + Attributes: + thinking_enabled: Whether thinking mode is enabled (master switch) + budget_enabled: Whether to limit thinking budget + budget_value: Budget token count (valid only when budget_enabled=True) + original_value: Original reasoning_effort value (for logging) + """ + + thinking_enabled: bool + budget_enabled: bool + budget_value: Optional[int] + original_value: Any + + +def normalize_reasoning_effort( + reasoning_effort: Optional[Any], is_streaming: bool = True +) -> ThinkingDirective: + """Normalize reasoning_effort parameter into a standardized thinking directive + + Args: + reasoning_effort: reasoning_effort parameter in API request, possible values: + - None: Use default configuration + - 0 or "0": Disable thinking mode + - Positive integer: Enable thinking, set specific budget value + - "low"/"medium"/"high": Enable thinking, use preset budget + - "none" or "-1" or -1: Enable thinking, unlimited budget + is_streaming: Whether it is a streaming request (affects DISABLE_THINKING_BUDGET_ON_STREAMING_DISABLE config) + + Returns: + ThinkingDirective: Standardized thinking directive + + Example: + >>> normalize_reasoning_effort(None) + ThinkingDirective(thinking_enabled=False, budget_enabled=False, budget_value=None, ...) + + >>> normalize_reasoning_effort(0) + ThinkingDirective(thinking_enabled=False, budget_enabled=False, budget_value=None, ...) + + >>> normalize_reasoning_effort("medium") + ThinkingDirective(thinking_enabled=True, budget_enabled=True, budget_value=8000, ...) + + >>> normalize_reasoning_effort("none") + ThinkingDirective(thinking_enabled=True, budget_enabled=False, budget_value=None, ...) + """ + + # Scenario 1: User unspecified, use default configuration + if reasoning_effort is None: + return ThinkingDirective( + thinking_enabled=ENABLE_THINKING_BUDGET, + budget_enabled=ENABLE_THINKING_BUDGET, + budget_value=DEFAULT_THINKING_BUDGET if ENABLE_THINKING_BUDGET else None, + original_value=None, + ) + + # Scenario 2: Disable thinking mode (reasoning_effort = 0 or "0") + if reasoning_effort == 0 or ( + isinstance(reasoning_effort, str) and reasoning_effort.strip() == "0" + ): + return ThinkingDirective( + thinking_enabled=False, + budget_enabled=False, + budget_value=None, + original_value=reasoning_effort, + ) + + # Scenario 3: Enable thinking but unlimited budget (reasoning_effort = "none" / "-1" / -1) + if isinstance(reasoning_effort, str): + reasoning_str = reasoning_effort.strip().lower() + # "none"/"-1" → enable thinking, unlimited budget + if reasoning_str in ["none", "-1"]: + return ThinkingDirective( + thinking_enabled=True, + budget_enabled=False, + budget_value=None, + original_value=reasoning_effort, + ) + # "high"/"low"/"medium" → enable thinking, use _should_enable_from_raw logic + # Note: these values are handled by _should_enable_from_raw in _handle_thinking_budget + # Returning thinking_enabled=True here to avoid conflict with desired_enabled + if reasoning_str in ["high", "low", "medium"]: + return ThinkingDirective( + thinking_enabled=True, + budget_enabled=False, # Actual value determined by _should_enable_from_raw + budget_value=None, + original_value=reasoning_effort, + ) + elif reasoning_effort == -1: + return ThinkingDirective( + thinking_enabled=True, + budget_enabled=False, + budget_value=None, + original_value=reasoning_effort, + ) + + # Scenario 4: Enable thinking and limit budget (specific number or preset value) + budget_value = _parse_budget_value(reasoning_effort) + + if budget_value is not None and budget_value > 0: + return ThinkingDirective( + thinking_enabled=True, + budget_enabled=True, + budget_value=budget_value, + original_value=reasoning_effort, + ) + + # Invalid value: Use default configuration + return ThinkingDirective( + thinking_enabled=ENABLE_THINKING_BUDGET, + budget_enabled=ENABLE_THINKING_BUDGET, + budget_value=DEFAULT_THINKING_BUDGET if ENABLE_THINKING_BUDGET else None, + original_value=reasoning_effort, + ) + + +def normalize_reasoning_effort_with_stream_check( + reasoning_effort: Optional[Any], is_streaming: bool = True +) -> ThinkingDirective: + """Normalize thinking directive with stream check + + Decides whether to disable thinking budget in non-streaming mode based on DISABLE_THINKING_BUDGET_ON_STREAMING_DISABLE configuration. + + Args: + reasoning_effort: reasoning_effort parameter from API request + is_streaming: Whether it is a streaming request + + Returns: + ThinkingDirective: Standardized thinking directive + """ + # First get the basic thinking directive + directive = normalize_reasoning_effort(reasoning_effort, is_streaming) + + # If not streaming and configured to disable budget on streaming disable, then disable + if not is_streaming and DISABLE_THINKING_BUDGET_ON_STREAMING_DISABLE: + return ThinkingDirective( + thinking_enabled=False, + budget_enabled=False, + budget_value=None, + original_value=reasoning_effort, + ) + + # Otherwise return original directive (allowing thinking budget to remain enabled in non-streaming mode) + return directive + + +def _parse_budget_value(reasoning_effort: Any) -> Optional[int]: + """Parse budget value + + Args: + reasoning_effort: reasoning_effort parameter value + + Returns: + int: Budget token count, or None if parsing fails + """ + # If integer, return directly + if isinstance(reasoning_effort, int) and reasoning_effort > 0: + return reasoning_effort + + # If string, try to parse as number + if isinstance(reasoning_effort, str): + effort_str = reasoning_effort.strip().lower() + + # Preset value mapping - use values from environment configuration + effort_map = { + "low": THINKING_BUDGET_LOW, + "medium": THINKING_BUDGET_MEDIUM, + "high": THINKING_BUDGET_HIGH, + } + + # Try preset values first + if effort_str in effort_map: + return effort_map[effort_str] + + # Then try parsing as number + try: + value = int(effort_str) + if value > 0: + return value + except (ValueError, TypeError): + pass + + return None + + +def format_directive_log(directive: ThinkingDirective) -> str: + """Format thinking directive as log string + + Args: + directive: Thinking directive + + Returns: + str: Formatted log string + """ + if not directive.thinking_enabled: + return f"Thinking mode disabled (Original: {directive.original_value})" + elif directive.budget_enabled and directive.budget_value is not None: + return f"Thinking enabled with budget: {directive.budget_value} tokens (Original: {directive.original_value})" + else: + return ( + f"Thinking enabled, unlimited budget (Original: {directive.original_value})" + ) diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ee270563b3e35192644e5420be6712d1774bfa2e --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,158 @@ +""" +Configuration Module Entry Point +Exports all configuration items for easy import by other modules. +""" + +# Import all configuration items from individual config files +from .constants import * +from .global_state import GlobalState +from .selector_utils import * +from .selectors import * +from .settings import * +from .timeouts import * + +# Explicitly export main configuration items (for IDE autocomplete and type checking) +__all__ = [ + # Constant Configuration + "MODEL_NAME", + "CHAT_COMPLETION_ID_PREFIX", + "DEFAULT_FALLBACK_MODEL_ID", + "DEFAULT_TEMPERATURE", + "DEFAULT_MAX_OUTPUT_TOKENS", + "DEFAULT_TOP_P", + "DEFAULT_STOP_SEQUENCES", + "AI_STUDIO_URL_PATTERN", + "MODELS_ENDPOINT_URL_CONTAINS", + "USER_INPUT_START_MARKER_SERVER", + "USER_INPUT_END_MARKER_SERVER", + "EXCLUDED_MODELS_FILENAME", + "STREAM_TIMEOUT_LOG_STATE", + # Timeout Configuration + "RESPONSE_COMPLETION_TIMEOUT", + "INITIAL_WAIT_MS_BEFORE_POLLING", + "POLLING_INTERVAL", + "POLLING_INTERVAL_STREAM", + "SILENCE_TIMEOUT_MS", + "POST_SPINNER_CHECK_DELAY_MS", + "FINAL_STATE_CHECK_TIMEOUT_MS", + "POST_COMPLETION_BUFFER", + "CLEAR_CHAT_VERIFY_TIMEOUT_MS", + "CLEAR_CHAT_VERIFY_INTERVAL_MS", + "CLICK_TIMEOUT_MS", + "CLIPBOARD_READ_TIMEOUT_MS", + "WAIT_FOR_ELEMENT_TIMEOUT_MS", + "PSEUDO_STREAM_DELAY", + "SUBMIT_BUTTON_ENABLE_TIMEOUT_MS", + "SELECTOR_EXISTENCE_CHECK_TIMEOUT_MS", + "SELECTOR_VISIBILITY_TIMEOUT_MS", + "STARTUP_SELECTOR_VISIBILITY_TIMEOUT_MS", + # Selector Configuration + "PROMPT_TEXTAREA_SELECTOR", + "INPUT_SELECTOR", + "INPUT_SELECTOR2", + "SUBMIT_BUTTON_SELECTOR", + "CLEAR_CHAT_BUTTON_SELECTOR", + "CLEAR_CHAT_CONFIRM_BUTTON_SELECTOR", + "RESPONSE_CONTAINER_SELECTOR", + "RESPONSE_TEXT_SELECTOR", + "LOADING_SPINNER_SELECTOR", + "OVERLAY_SELECTOR", + "ERROR_TOAST_SELECTOR", + "QUOTA_EXCEEDED_SELECTOR", + "EDIT_MESSAGE_BUTTON_SELECTOR", + "MESSAGE_TEXTAREA_SELECTOR", + "FINISH_EDIT_BUTTON_SELECTOR", + "MORE_OPTIONS_BUTTON_SELECTOR", + "COPY_MARKDOWN_BUTTON_SELECTOR", + "COPY_MARKDOWN_BUTTON_SELECTOR_ALT", + "MAX_OUTPUT_TOKENS_SELECTOR", + "STOP_SEQUENCE_INPUT_SELECTOR", + "MAT_CHIP_REMOVE_BUTTON_SELECTOR", + "TOP_P_INPUT_SELECTOR", + "TEMPERATURE_INPUT_SELECTOR", + "USE_URL_CONTEXT_SELECTOR", + "UPLOAD_BUTTON_SELECTOR", + "MODEL_NAME_SELECTOR", + "CDK_OVERLAY_CONTAINER_SELECTOR", + "CHAT_TURN_SELECTOR", + "SCROLL_CONTAINER_SELECTOR", + "CHAT_SESSION_CONTENT_SELECTOR", + "LAST_CHAT_TURN_SELECTOR", + # Thinking Mode Related Selectors + "ENABLE_THINKING_MODE_TOGGLE_SELECTOR", + "SET_THINKING_BUDGET_TOGGLE_SELECTOR", + "THINKING_BUDGET_INPUT_SELECTOR", + "THINKING_LEVEL_DROPDOWN_SELECTOR", + "THINKING_LEVEL_SELECT_SELECTOR", + "THINKING_LEVEL_OPTION_LOW_SELECTOR", + "THINKING_LEVEL_OPTION_MEDIUM_SELECTOR", + "THINKING_LEVEL_OPTION_HIGH_SELECTOR", + "THINKING_LEVEL_OPTION_MINIMAL_SELECTOR", + "THINKING_MODE_TOGGLE_PARENT_SELECTOR", + "THINKING_MODE_TOGGLE_OLD_ROOT_SELECTOR", + "THINKING_BUDGET_TOGGLE_PARENT_SELECTOR", + "THINKING_BUDGET_TOGGLE_OLD_ROOT_SELECTOR", + # Google Search Related Selectors + "GROUNDING_WITH_GOOGLE_SEARCH_TOGGLE_SELECTOR", + # Function Calling Selectors + "FUNCTION_CALLING_CONTAINER_SELECTOR", + "FUNCTION_CALLING_TOGGLE_SELECTOR", + "FUNCTION_DECLARATIONS_EDIT_BUTTON_SELECTOR", + "FUNCTION_DECLARATIONS_DIALOG_SELECTOR", + "FUNCTION_DECLARATIONS_CODE_EDITOR_TAB_SELECTOR", + "FUNCTION_DECLARATIONS_VISUAL_EDITOR_TAB_SELECTOR", + "FUNCTION_DECLARATIONS_TEXTAREA_SELECTOR", + "FUNCTION_DECLARATIONS_SAVE_BUTTON_SELECTOR", + "FUNCTION_DECLARATIONS_RESET_BUTTON_SELECTOR", + "FUNCTION_DECLARATIONS_CLOSE_BUTTON_SELECTOR", + # Function Call Response Selectors (DOM parsing) + "FUNCTION_CALL_WIDGET_SELECTOR", + "FUNCTION_CALL_NAME_SELECTOR", + "FUNCTION_CALL_ARGS_SELECTOR", + "FUNCTION_CALL_CODE_BLOCK_SELECTOR", + # Thinking Visibility Selectors + "THINKING_CONTAINER_SELECTOR", + "THINKING_HEADER_SELECTOR", + "THINKING_CONTENT_SELECTOR", + "THINKING_DIV_SELECTOR", + "THINKING_ACCORDION_SELECTOR", + "FINAL_RESPONSE_SELECTOR", + "ANSWER_TEXT_SELECTOR", + "COMPLETE_RESPONSE_CONTAINER_SELECTOR", + "GENERATION_STATUS_SELECTOR", + # Settings Configuration + "DEBUG_LOGS_ENABLED", + "TRACE_LOGS_ENABLED", + "AUTO_SAVE_AUTH", + "AUTH_SAVE_TIMEOUT", + "AUTO_CONFIRM_LOGIN", + "AUTH_PROFILES_DIR", + "ACTIVE_AUTH_DIR", + "SAVED_AUTH_DIR", + "LOG_DIR", + "APP_LOG_FILE_PATH", + "NO_PROXY_ENV", + "ENABLE_SCRIPT_INJECTION", + "NETWORK_INTERCEPTION_ENABLED", + "USERSCRIPT_PATH", + # Feature Toggle Settings + "ENABLE_THINKING_BUDGET", + "DEFAULT_THINKING_BUDGET", + "DEFAULT_THINKING_LEVEL", + "DEFAULT_THINKING_LEVEL_PRO", + "DEFAULT_THINKING_LEVEL_FLASH", + "ENABLE_GOOGLE_SEARCH", + "ENABLE_URL_CONTEXT", + # Cookie Refresh Settings + "COOKIE_REFRESH_ENABLED", + "COOKIE_REFRESH_INTERVAL_SECONDS", + "COOKIE_REFRESH_ON_REQUEST_ENABLED", + "COOKIE_REFRESH_REQUEST_INTERVAL", + "COOKIE_REFRESH_ON_SHUTDOWN", + # Utility Functions + "get_environment_variable", + "get_boolean_env", + "get_int_env", + # Global State + "GlobalState", +] diff --git a/config/constants.py b/config/constants.py new file mode 100644 index 0000000000000000000000000000000000000000..aa7c1a493488b8d18dcb506c73a10a935956d7f5 --- /dev/null +++ b/config/constants.py @@ -0,0 +1,72 @@ +""" +Constants Configuration Module +Contains all fixed constant definitions, such as model names, markers, filenames, etc. +""" + +import json +import os + +from dotenv import load_dotenv + +# Load .env file +load_dotenv() + +# --- Model Related Constants --- +MODEL_NAME = os.environ.get('MODEL_NAME', 'AI-Studio_Proxy_API') +CHAT_COMPLETION_ID_PREFIX = os.environ.get('CHAT_COMPLETION_ID_PREFIX', 'chatcmpl-') +DEFAULT_FALLBACK_MODEL_ID = os.environ.get('DEFAULT_FALLBACK_MODEL_ID', "no model list") + +# --- Default Parameter Values --- +DEFAULT_TEMPERATURE = float(os.environ.get('DEFAULT_TEMPERATURE', '1.0')) +DEFAULT_MAX_OUTPUT_TOKENS = int(os.environ.get('DEFAULT_MAX_OUTPUT_TOKENS', '65536')) +DEFAULT_TOP_P = float(os.environ.get('DEFAULT_TOP_P', '0.95')) + +# --- Default Feature Toggles --- +ENABLE_URL_CONTEXT = os.environ.get('ENABLE_URL_CONTEXT', 'false').lower() in ('true', '1', 'yes') +ENABLE_THINKING_BUDGET = os.environ.get('ENABLE_THINKING_BUDGET', 'false').lower() in ('true', '1', 'yes') +DEFAULT_THINKING_BUDGET = int(os.environ.get('DEFAULT_THINKING_BUDGET', '8192')) + +# Separate defaults for Pro (2 levels) and Flash (4 levels) +_raw_level_pro = os.environ.get("DEFAULT_THINKING_LEVEL_PRO", "high").lower() +DEFAULT_THINKING_LEVEL_PRO = ( + _raw_level_pro if _raw_level_pro in ("high", "low") else "high" +) +_raw_level_flash = os.environ.get("DEFAULT_THINKING_LEVEL_FLASH", "high").lower() +DEFAULT_THINKING_LEVEL_FLASH = ( + _raw_level_flash + if _raw_level_flash in ("high", "medium", "low", "minimal") + else "high" +) + +ENABLE_GOOGLE_SEARCH = os.environ.get('ENABLE_GOOGLE_SEARCH', 'false').lower() in ('true', '1', 'yes') + +# Default Stop Sequences - Support JSON format configuration +try: + DEFAULT_STOP_SEQUENCES = json.loads(os.environ.get('DEFAULT_STOP_SEQUENCES', '["User:"]')) +except (json.JSONDecodeError, TypeError): + DEFAULT_STOP_SEQUENCES = ["User:"] # Fallback to default value + +# --- URL Patterns --- +AI_STUDIO_URL_PATTERN = os.environ.get('AI_STUDIO_URL_PATTERN', 'aistudio.google.com/') +MODELS_ENDPOINT_URL_CONTAINS = os.environ.get('MODELS_ENDPOINT_URL_CONTAINS', "MakerSuiteService/ListModels") + +# --- Input Markers --- +USER_INPUT_START_MARKER_SERVER = os.environ.get('USER_INPUT_START_MARKER_SERVER', "__USER_INPUT_START__") +USER_INPUT_END_MARKER_SERVER = os.environ.get('USER_INPUT_END_MARKER_SERVER', "__USER_INPUT_END__") + +# --- Filename Constants --- +EXCLUDED_MODELS_FILENAME = os.environ.get('EXCLUDED_MODELS_FILENAME', "excluded_models.txt") + +# --- Stream State Configuration --- +STREAM_TIMEOUT_LOG_STATE = { + "consecutive_timeouts": 0, + "last_error_log_time": 0.0, # Use time.monotonic() + "suppress_until_time": 0.0, # Use time.monotonic() + "max_initial_errors": int(os.environ.get("STREAM_MAX_INITIAL_ERRORS", "3")), + "warning_interval_after_suppress": float( + os.environ.get("STREAM_WARNING_INTERVAL_AFTER_SUPPRESS", "60.0") + ), + "suppress_duration_after_initial_burst": float( + os.environ.get("STREAM_SUPPRESS_DURATION_AFTER_INITIAL_BURST", "400.0") + ), +} diff --git a/config/global_state.py b/config/global_state.py new file mode 100644 index 0000000000000000000000000000000000000000..bf307c87070c111a631f152b98617354f2f27a66 --- /dev/null +++ b/config/global_state.py @@ -0,0 +1,205 @@ +import asyncio +import logging +import threading +import time +from collections import defaultdict +from typing import Dict, Optional, Set + +from config.settings import MODEL_QUOTA_LIMITS, QUOTA_HARD_LIMIT, QUOTA_SOFT_LIMIT +from models.exceptions import QuotaExceededError + +logger = logging.getLogger(__name__) + + +class GlobalState: + """ + Singleton class to hold global application state, specifically for Quota Exceeded logic. + """ + + _instance = None + IS_QUOTA_EXCEEDED = False + NEEDS_ROTATION = False # [GR-01] Soft Signal Flag + QUOTA_EXCEEDED_TIMESTAMP = 0.0 + + # Global Event for holding requests during auth rotation + # Initially set to True (allowed) by init_rotation_lock() + AUTH_ROTATION_LOCK = asyncio.Event() + + # Global Event to signal Quota Exceeded immediately + QUOTA_EXCEEDED_EVENT = asyncio.Event() + + # Event to signal that a rotation operation has completed. + rotation_complete_event = asyncio.Event() + + # Track the type of the last error for adaptive cooldowns + # Values: 'RATE_LIMIT', 'QUOTA_EXCEEDED', or None + last_error_type = None + + # Token usage tracking for proactive rotation + # [QUOTA-02] Changed from single int to dict for model-specific tracking + current_profile_model_usage: Dict[str, int] = defaultdict(int) + current_profile_exhausted_models: Set[str] = set() + + # [FINAL-02] Dynamic Rotation Guard: Track queued requests + queued_request_count = 0 + + # Global Shutdown Event (Thread-safe for signal handlers) + # Used to circuit-break logic during aggressive shutdown + IS_SHUTTING_DOWN = threading.Event() + + # [ID-01] Global Recovery State Manager + # Flag to indicate if a recovery operation (auth rotation) is currently active + IS_RECOVERING = False + # Flag to indicate if the system is in emergency operation mode (e.g. all profiles exhausted) + DEPLOYMENT_EMERGENCY_MODE = False + # Event to signal that recovery is complete and streams can resume + RECOVERY_EVENT = asyncio.Event() + # [FIX-RACE] Track last rotation timestamp to handle race conditions + LAST_ROTATION_TIMESTAMP = 0.0 + + # [CONCURRENCY-FIX] Track the currently active stream request ID + # Used to gate consumers and prevent zombie streams from processing data + CURRENT_STREAM_REQ_ID: Optional[str] = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super(GlobalState, cls).__new__(cls) + return cls._instance + + @classmethod + def start_recovery(cls): + """ + Signals the start of a recovery operation (e.g., auth rotation). + Pauses streams and sets the recovery flag. + """ + cls.IS_RECOVERING = True + cls.RECOVERY_EVENT.clear() + logger.info("🔄 SYSTEM: Recovery Mode Initiated. Streams will pause.") + + @classmethod + def finish_recovery(cls): + """ + Signals the completion of a recovery operation. + Resumes streams and clears the recovery flag. + """ + cls.IS_RECOVERING = False + cls.RECOVERY_EVENT.set() + # [FIX-RACE] Update timestamp on finish + cls.LAST_ROTATION_TIMESTAMP = time.time() + logger.info( + f"✅ SYSTEM: Recovery Mode Finished at {cls.LAST_ROTATION_TIMESTAMP}. Streams resuming." + ) + + @classmethod + def init_rotation_lock(cls): + """Initialize the rotation lock to allow requests.""" + cls.AUTH_ROTATION_LOCK.set() + logger.info("🔐 Global Auth Rotation Lock initialized (OPEN).") + + @classmethod + def set_quota_exceeded(cls, message: str = "", model_id: Optional[str] = None): + """ + Sets the global quota exceeded flag and logs a critical warning. + Also determines the error type based on the message for adaptive cooldowns. + Optionally accepts a model_id to flag specific model exhaustion. + """ + if not cls.IS_QUOTA_EXCEEDED: + cls.IS_QUOTA_EXCEEDED = True + cls.QUOTA_EXCEEDED_TIMESTAMP = time.time() + cls.QUOTA_EXCEEDED_EVENT.set() + + # Determine error type + safe_message = message if message else "" + msg_lower = safe_message.lower() + if ( + "429" in msg_lower + or "rate limit" in msg_lower + or "resource has been exhausted" in msg_lower + ): + # API "RESOURCE_EXHAUSTED" usually means 429/quota shared behavior, + # but "rate limit" specifically implies a temporary 429. + # However, Gemini "Resource has been exhausted" is often a harder limit. + # Let's verify standard Gemini strings: + # "429: Too Many Requests" -> Rate Limit + # "429: Resource has been exhausted" -> Quota + if "too many requests" in msg_lower: + cls.last_error_type = "RATE_LIMIT" + else: + cls.last_error_type = "QUOTA_EXCEEDED" + elif "quota" in msg_lower: + cls.last_error_type = "QUOTA_EXCEEDED" + else: + # Default fallback if unknown + cls.last_error_type = "QUOTA_EXCEEDED" + + # [FIX] If model_id is provided, immediately mark it as exhausted so rotation logic knows + if model_id and cls.last_error_type == "QUOTA_EXCEEDED": + cls.current_profile_exhausted_models.add(model_id.lower()) + logger.warning(f"⛔ Identified specific model exhaustion: {model_id}") + + logger.critical( + f"⛔ GLOBAL ALERT: Quota Exceeded! Type: {cls.last_error_type} (Event Signal Sent)" + ) + + @classmethod + def reset_quota_status(cls): + """ + Resets the global quota exceeded flag. + """ + cls.IS_QUOTA_EXCEEDED = False + cls.NEEDS_ROTATION = False # Reset soft flag too + cls.QUOTA_EXCEEDED_TIMESTAMP = 0.0 + cls.last_error_type = None + cls.QUOTA_EXCEEDED_EVENT.clear() + + # [QUOTA-02] Reset model usage stats + cls.current_profile_model_usage.clear() + cls.current_profile_exhausted_models.clear() + + logger.info("✅ GLOBAL ALERT: Quota status manually reset.") + + @classmethod + def increment_token_count(cls, count: int, model_id: str = "default"): + """ + Increments the token count for the current profile and checks if it exceeds the limit. + [GR-01] Implements Graceful Rotation logic. + """ + if count <= 0: + return + + # Ensure model_id is a valid string for key usage + safe_model_id = model_id if model_id else "default" + model_key = safe_model_id.lower() + + cls.current_profile_model_usage[model_key] += count + current_usage = cls.current_profile_model_usage[model_key] + + # Retrieve limit (fallback to global hard limit) + limit = MODEL_QUOTA_LIMITS.get(model_key, QUOTA_HARD_LIMIT) + + # Check Hard Limit (Emergency Kill / Model Exhaustion) + if current_usage >= limit: + logger.critical( + f"⛔ HARD LIMIT REACHED ({model_key}): {current_usage} >= {limit}. Marking model as exhausted." + ) + cls.current_profile_exhausted_models.add(model_key) + # Trigger global rotation signal + cls.set_quota_exceeded(message=f"Quota exceeded for model {model_key}") + # Raise exception to propagate up to request processor + raise QuotaExceededError( + f"Quota exceeded for model {model_key} ({current_usage} >= {limit})" + ) + + # Check Soft Limit (Graceful Signal) + # Note: Using global soft limit as baseline for rotation signal + if current_usage >= QUOTA_SOFT_LIMIT and not cls.NEEDS_ROTATION: + logger.warning( + f"🔄 SOFT LIMIT REACHED ({model_key}): {current_usage} >= {QUOTA_SOFT_LIMIT}. Setting NEEDS_ROTATION flag." + ) + cls.NEEDS_ROTATION = True + + # Log status + limit_str = f"{QUOTA_SOFT_LIMIT}(Soft)/{limit}(Hard)" + logger.info( + f"📊 Token usage updated ({model_key}): +{count} => {current_usage} (Limits: {limit_str}) | Rotation Pending: {cls.NEEDS_ROTATION}" + ) diff --git a/config/model_capabilities.json b/config/model_capabilities.json new file mode 100644 index 0000000000000000000000000000000000000000..c555515d229e3b5488db7198c3e142e7ec633613 --- /dev/null +++ b/config/model_capabilities.json @@ -0,0 +1,79 @@ +{ + "categories": { + "gemini3Flash": { + "thinkingType": "level", + "levels": ["minimal", "low", "medium", "high"], + "defaultLevel": "high", + "supportsGoogleSearch": true + }, + "gemini3Pro": { + "thinkingType": "level", + "levels": ["low", "high"], + "defaultLevel": "high", + "supportsGoogleSearch": true + }, + "gemini25Pro": { + "thinkingType": "budget", + "alwaysOn": true, + "budgetRange": [1024, 32768], + "defaultBudget": 32768, + "supportsGoogleSearch": true + }, + "gemini25Flash": { + "thinkingType": "budget", + "alwaysOn": false, + "budgetRange": [512, 24576], + "defaultBudget": 24576, + "supportsGoogleSearch": true + }, + "gemini2": { + "thinkingType": "none", + "supportsGoogleSearch": false + }, + "geminiRobotics": { + "thinkingType": "none", + "supportsGoogleSearch": true + }, + "other": { + "thinkingType": "none", + "supportsGoogleSearch": true + } + }, + "matchers": [ + { + "pattern": "gemini-3\\.1.*pro|gemini3\\.1.*pro", + "category": "gemini3Pro", + "description": "Gemini 3.1 Pro models with 2-level thinking" + }, + { + "pattern": "gemini-3.*flash|gemini3.*flash", + "category": "gemini3Flash", + "description": "Gemini 3 Flash models with 4-level thinking" + }, + { + "pattern": "gemini-3.*pro|gemini3.*pro", + "category": "gemini3Pro", + "description": "Gemini 3 Pro models with 2-level thinking" + }, + { + "pattern": "gemini-2\\.5-pro|gemini-2\\.5pro", + "category": "gemini25Pro", + "description": "Gemini 2.5 Pro with always-on thinking budget" + }, + { + "pattern": "gemini-2\\.5-flash|gemini-2\\.5flash|gemini-flash-latest|gemini-flash-lite-latest", + "category": "gemini25Flash", + "description": "Gemini 2.5 Flash with optional thinking budget" + }, + { + "pattern": "gemini-2\\.0|gemini2\\.0", + "category": "gemini2", + "description": "Gemini 2.0 models without thinking" + }, + { + "pattern": "gemini-robotics", + "category": "geminiRobotics", + "description": "Gemini Robotics models" + } + ] +} diff --git a/config/selector_utils.py b/config/selector_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..1b151863877b2b7b67af40eecf2b6164775c1b05 --- /dev/null +++ b/config/selector_utils.py @@ -0,0 +1,151 @@ +# --- config/selector_utils.py --- +""" +Selector Utilities Module +Provides fallback logic for handling dynamic UI structures +""" + +import asyncio +import logging +from typing import List, Optional, Tuple + +from playwright.async_api import Locator, Page + +from config.timeouts import ( + SELECTOR_EXISTENCE_CHECK_TIMEOUT_MS, + SELECTOR_VISIBILITY_TIMEOUT_MS, +) + +logger = logging.getLogger("AIStudioProxyServer") + + +# --- Input area container selectors (sorted by priority) --- +# Google AI Studio periodically changes UI structure, this list contains all known container selectors +# Priority: try current UI first, fall back to older UIs +# Note: Order matters! First selector is tried first, each failed selector adds to startup time +INPUT_WRAPPER_SELECTORS: List[str] = [ + # Current UI structure (confirmed working 2024-12) + "ms-chunk-editor", + # Fallback UI structure (may work in other versions or regions) + "ms-prompt-input-wrapper .prompt-input-wrapper", + "ms-prompt-input-wrapper", + # Transitional UI (ms-prompt-box) - legacy version, kept as fallback + "ms-prompt-box .prompt-box-container", + "ms-prompt-box", +] + +# --- Autosize wrapper selectors --- +AUTOSIZE_WRAPPER_SELECTORS: List[str] = [ + # Current UI structure + "ms-prompt-input-wrapper .text-wrapper", + "ms-prompt-input-wrapper ms-autosize-textarea", + "ms-chunk-input .text-wrapper", + "ms-autosize-textarea", + # Transitional UI (ms-prompt-box) - deprecated but kept as fallback + "ms-prompt-box .text-wrapper", + "ms-prompt-box ms-autosize-textarea", +] + + +async def find_first_visible_locator( + page: Page, + selectors: List[str], + description: str = "element", + timeout_per_selector: int = SELECTOR_VISIBILITY_TIMEOUT_MS, + existence_check_timeout: int = SELECTOR_EXISTENCE_CHECK_TIMEOUT_MS, # kept for API compat + fallback_timeout_per_selector: int = SELECTOR_VISIBILITY_TIMEOUT_MS, # kept for API compat +) -> Tuple[Optional[Locator], Optional[str]]: + """ + Try multiple selectors and return the Locator of the first visible element. + + Uses active DOM listening strategy (Playwright MutationObserver): + - Uses longer timeout for first selector (primary, most likely to succeed) + - Uses shorter timeout for subsequent selectors as fallbacks + + Args: + page: Playwright page instance + selectors: List of selectors to try (sorted by priority) + description: Element description (for logging) + timeout_per_selector: Timeout for primary selector (milliseconds) + + Returns: + Tuple[Optional[Locator], Optional[str]]: + - Locator of visible element, or None if all failed + - Successful selector string, or None if all failed + """ + from playwright.async_api import expect as expect_async + + if not selectors: + logger.warning(f"[Selector] {description}: No selectors provided") + return None, None + + # Primary selector uses longer timeout (most likely to succeed, worth waiting for) + primary_selector = selectors[0] + primary_timeout = timeout_per_selector + + # Fallback selectors use shorter timeout + fallback_timeout = min(2000, timeout_per_selector // 2) + + logger.debug( + f"[Selector] {description}: Starting active listening for '{primary_selector}' (timeout: {primary_timeout}ms)" + ) + + # Try primary selector (using Playwright's MutationObserver active listening) + try: + locator = page.locator(primary_selector) + await expect_async(locator).to_be_visible(timeout=primary_timeout) + logger.debug(f"[Selector] {description}: '{primary_selector}' element visible") + return locator, primary_selector + except asyncio.CancelledError: + raise + except Exception as e: + logger.debug( + f"[Selector] {description}: '{primary_selector}' timeout ({primary_timeout}ms) - {type(e).__name__}" + ) + + # Fall back to other selectors + if len(selectors) > 1: + logger.debug( + f"[Selector] {description}: Trying {len(selectors) - 1} fallback selectors (timeout: {fallback_timeout}ms)" + ) + for idx, selector in enumerate(selectors[1:], 2): + try: + locator = page.locator(selector) + await expect_async(locator).to_be_visible(timeout=fallback_timeout) + logger.debug( + f"[Selector] {description}: '{selector}' element visible (fallback {idx}/{len(selectors)})" + ) + return locator, selector + except asyncio.CancelledError: + raise + except Exception: + logger.debug( + f"[Selector] {description}: '{selector}' timeout (fallback {idx}/{len(selectors)})" + ) + + logger.warning( + f"[Selector] {description}: No visible element found for any selector " + f"(tried {len(selectors)} selectors)" + ) + return None, None + + +def build_combined_selector(selectors: List[str]) -> str: + """ + Combine multiple selectors into a single CSS selector string (comma-separated). + + This is useful for creating selectors that can match multiple UI structures. + + Args: + selectors: List of selectors to combine + + Returns: + str: Combined selector string + + Example: + combined = build_combined_selector([ + "ms-prompt-box .text-wrapper", + "ms-prompt-input-wrapper .text-wrapper" + ]) + # Returns: "ms-prompt-box .text-wrapper, ms-prompt-input-wrapper .text-wrapper" + """ + return ", ".join(selectors) diff --git a/config/selectors.py b/config/selectors.py new file mode 100644 index 0000000000000000000000000000000000000000..1d90d33dd9cec6998f612dc7a3e3e4d9aee82904 --- /dev/null +++ b/config/selectors.py @@ -0,0 +1,298 @@ +""" +CSS Selector Configuration Module +Contains all CSS selectors used for page element location. +""" + +# --- Input Related Selectors --- +# Main input textarea compatible with current and old UI structures +PROMPT_TEXTAREA_SELECTOR = ( + "textarea.textarea, " + "ms-autosize-textarea textarea, " + "ms-chunk-input textarea, " + "ms-prompt-input-wrapper ms-autosize-textarea textarea, " + 'ms-prompt-input-wrapper textarea[aria-label*="prompt" i], ' + "ms-prompt-input-wrapper textarea, " + "ms-prompt-box ms-autosize-textarea textarea, " + 'ms-prompt-box textarea[aria-label="Enter a prompt"], ' + "ms-prompt-box textarea" +) +INPUT_SELECTOR = PROMPT_TEXTAREA_SELECTOR +INPUT_SELECTOR2 = PROMPT_TEXTAREA_SELECTOR + +# --- Button Selectors --- +# Submit button: prioritize primary submit button in prompt area +SUBMIT_BUTTON_SELECTOR = ( + # Current UI structure + 'ms-run-button button[type="submit"].ms-button-primary, ' + 'ms-run-button button[type="submit"], ' + # Legacy selectors + 'ms-prompt-input-wrapper ms-run-button button[aria-label="Run"], ' + 'ms-prompt-input-wrapper button[aria-label="Run"][type="submit"], ' + 'button[aria-label="Run"].run-button, ' + 'ms-run-button button[type="submit"].run-button, ' + 'ms-prompt-box ms-run-button button[aria-label="Run"], ' + 'ms-prompt-box button[aria-label="Run"][type="submit"]' +) + +REGENERATE_BUTTON_SELECTOR = 'button[aria-label="Regenerate draft"], button[aria-label="Regenerate response"], [data-testid*="regenerate"]' + +CLEAR_CHAT_BUTTON_SELECTOR = 'button[data-test-clear="outside"][aria-label="New chat"], button[aria-label="New chat"]' +CLEAR_CHAT_CONFIRM_BUTTON_SELECTOR = ( + 'button.ms-button-primary:has-text("Discard and continue")' +) +UPLOAD_BUTTON_SELECTOR = ( + 'button[data-test-id="add-media-button"], ' + 'button[aria-label^="Insert assets"], ' + 'button[aria-label^="Insert images"]' +) + +# --- Response Selectors --- +RESPONSE_CONTAINER_SELECTOR = "ms-chat-turn .chat-turn-container.model" +RESPONSE_TEXT_SELECTOR = "ms-cmark-node.cmark-node" + +# --- Loading and Status Selectors --- +LOADING_SPINNER_SELECTOR = 'button[aria-label="Run"].run-button svg .stoppable-spinner' +OVERLAY_SELECTOR = ".mat-mdc-dialog-inner-container" + +# --- Error Notification Selectors --- +ERROR_TOAST_SELECTOR = "div.toast.warning, div.toast.error" +QUOTA_EXCEEDED_SELECTOR = "ms-callout.error-callout .message" + +# --- Edit Related Selectors --- +EDIT_MESSAGE_BUTTON_SELECTOR = ( + "ms-chat-turn:last-child .actions-container button.toggle-edit-button" +) +MESSAGE_TEXTAREA_SELECTOR = ( + "ms-chat-turn:last-child textarea, ms-chat-turn:last-child ms-text-chunk textarea" +) +FINISH_EDIT_BUTTON_SELECTOR = 'ms-chat-turn:last-child .actions-container button.toggle-edit-button[aria-label="Stop editing"]' + +# --- Menu and Copy Selectors --- +MORE_OPTIONS_BUTTON_SELECTOR = ( + "div.actions-container div ms-chat-turn-options div > button" +) +COPY_MARKDOWN_BUTTON_SELECTOR = "button.mat-mdc-menu-item:nth-child(4)" +COPY_MARKDOWN_BUTTON_SELECTOR_ALT = 'div[role="menu"] button:has-text("Copy Markdown")' + +# --- Settings Selectors --- +MAX_OUTPUT_TOKENS_SELECTOR = 'input[aria-label="Maximum output tokens"]' +STOP_SEQUENCE_INPUT_SELECTOR = 'input[aria-label="Add stop token"]' +MAT_CHIP_REMOVE_BUTTON_SELECTOR = 'mat-chip button.remove-button[aria-label*="Remove"]' +TOP_P_INPUT_SELECTOR = ( + 'ms-slider input[type="number"][max="1"], ' + 'ms-slider input.slider-number-input[aria-valuemax="1"], ' + 'input.slider-number-input[aria-valuemax="1"]' +) +TEMPERATURE_INPUT_SELECTOR = ( + 'ms-slider input[type="number"][max="2"], ' + 'ms-slider input.slider-number-input[aria-valuemax="2"], ' + 'input.slider-number-input[aria-valuemax="2"]' +) +USE_URL_CONTEXT_SELECTOR = 'button[aria-label="Browse the url context"]' + +# --- Thinking Mode Selectors --- +THINKING_CONTAINER_SELECTOR = "ms-thought-accordion, ms-thought-chunk, [data-testid*='thinking'], [data-testid*='reasoning']" +THINKING_HEADER_SELECTOR = "ms-thought-accordion .header, ms-thought-chunk .header, [data-testid*='thinking'] .header, [data-testid*='reasoning'] .header" +THINKING_CONTENT_SELECTOR = "ms-thought-chunk .mat-expansion-panel-body, ms-thought-accordion .content, ms-thought-accordion .markdown-content, [data-testid*='thinking'] .content, [data-testid*='reasoning'] .content" +THINKING_DIV_SELECTOR = "div.thinking-process, div.reasoning-process, [class*='thinking'], [class*='reasoning'], [class*='analysis']" +THINKING_ACCORDION_SELECTOR = "ms-thought-accordion, ms-thought-chunk, [data-testid*='accordion'], [class*='accordion'][data-testid*='thinking'], [class*='accordion'][class*='thinking']" + +FINAL_RESPONSE_SELECTOR = "ms-text-chunk:not(:has(ms-thought-chunk)), ms-cmark-node.cmark-node:not(ms-thought-accordion .content):not(ms-thought-chunk .mat-expansion-panel-body), [data-testid*='response'], [class*='response-content'], .chat-response" +ANSWER_TEXT_SELECTOR = "ms-cmark-node.cmark-node" + +COMPLETE_RESPONSE_CONTAINER_SELECTOR = "ms-chat-turn .chat-turn-container.model, [data-testid*='chat-turn'], [class*='chat-turn']" +GENERATION_STATUS_SELECTOR = "button[aria-label*='Stop'], button[aria-label*='Generating'], [data-testid*='generating']" + +ENABLE_THINKING_MODE_TOGGLE_SELECTOR = ( + 'button[role="switch"][aria-label="Toggle thinking mode"], ' + 'mat-slide-toggle[data-test-toggle="enable-thinking"] button[role="switch"].mdc-switch, ' + '[data-test-toggle="enable-thinking"] button[role="switch"].mdc-switch' +) + +SET_THINKING_BUDGET_TOGGLE_SELECTOR = ( + 'button[role="switch"][aria-label="Toggle thinking budget between auto and manual"], ' + 'mat-slide-toggle[data-test-toggle="manual-budget"] button[role="switch"].mdc-switch, ' + '[data-test-toggle="manual-budget"] button[role="switch"].mdc-switch' +) + +THINKING_BUDGET_INPUT_SELECTOR = ( + '[data-test-id="user-setting-budget-animation-wrapper"] input[type="number"]' +) + +THINKING_LEVEL_DROPDOWN_SELECTOR = 'mat-select[aria-label="Thinking Level"]' +THINKING_LEVEL_SELECT_SELECTOR = '[role="combobox"][aria-label="Thinking Level"], mat-select[aria-label="Thinking Level"], [role="combobox"][aria-label="Thinking level"], mat-select[aria-label="Thinking level"]' +THINKING_LEVEL_OPTION_LOW_SELECTOR = '[role="listbox"][aria-label="Thinking Level"] [role="option"]:has-text("Low"), [role="listbox"][aria-label="Thinking level"] [role="option"]:has-text("Low")' +THINKING_LEVEL_OPTION_HIGH_SELECTOR = '[role="listbox"][aria-label="Thinking Level"] [role="option"]:has-text("High"), [role="listbox"][aria-label="Thinking level"] [role="option"]:has-text("High")' +THINKING_LEVEL_OPTION_MEDIUM_SELECTOR = '[role="listbox"][aria-label="Thinking Level"] [role="option"]:has-text("Medium"), [role="listbox"][aria-label="Thinking level"] [role="option"]:has-text("Medium")' +THINKING_LEVEL_OPTION_MINIMAL_SELECTOR = '[role="listbox"][aria-label="Thinking Level"] [role="option"]:has-text("Minimal"), [role="listbox"][aria-label="Thinking level"] [role="option"]:has-text("Minimal")' + +GROUNDING_WITH_GOOGLE_SEARCH_TOGGLE_SELECTOR = ( + 'div[data-test-id="searchAsAToolTooltip"] mat-slide-toggle button' +) + +SCROLL_CONTAINER_SELECTOR = "ms-autoscroll-container" +CHAT_SESSION_CONTENT_SELECTOR = ".chat-session-content" +LAST_CHAT_TURN_SELECTOR = "ms-chat-turn:last-of-type" + +MODEL_NAME_SELECTOR = '[data-test-id="model-name"]' +CDK_OVERLAY_CONTAINER_SELECTOR = "div.cdk-overlay-container" +CHAT_TURN_SELECTOR = "ms-chat-turn" + +THINKING_MODE_TOGGLE_PARENT_SELECTOR = ( + 'mat-slide-toggle:has(button[aria-label="Toggle thinking mode"])' +) +THINKING_MODE_TOGGLE_OLD_ROOT_SELECTOR = ( + 'mat-slide-toggle[data-test-toggle="enable-thinking"]' +) +THINKING_BUDGET_TOGGLE_PARENT_SELECTOR = 'mat-slide-toggle:has(button[aria-label="Toggle thinking budget between auto and manual"])' +THINKING_BUDGET_TOGGLE_OLD_ROOT_SELECTOR = ( + 'mat-slide-toggle[data-test-toggle="manual-budget"]' +) + +# --- Function Call Response Selectors --- +# Selectors for detecting and parsing function call widgets in AI Studio responses. +# These are displayed when the model wants to call a function. + +# Native Function Call Response Selectors (AI Studio's built-in function calling UI) +# These are used when native function calling is enabled and the model returns tool calls. + +# Native function call chunk container (primary selector for native FC responses) +NATIVE_FUNCTION_CALL_CHUNK_SELECTOR = ( + "ms-function-call-chunk, ms-prompt-chunk:has(ms-function-call-chunk)" +) + +# Native function call code block (contains function name and args) +NATIVE_FUNCTION_CALL_CODE_BLOCK_SELECTOR = ( + 'ms-function-call-chunk ms-code-block[icon="function"], ' + "ms-function-call-chunk ms-code-block" +) + +# Native function call name selector (in the expansion panel header) +NATIVE_FUNCTION_CALL_NAME_SELECTOR = ( + "ms-function-call-chunk ms-code-block mat-panel-title span:not(.material-symbols-outlined), " + "ms-function-call-chunk ms-code-block .mat-expansion-panel-header-title span:nth-child(2)" +) + +# Native function call arguments selector (JSON in pre > code block) +NATIVE_FUNCTION_CALL_ARGS_SELECTOR = ( + "ms-function-call-chunk ms-code-block pre code, " + "ms-function-call-chunk ms-code-block .mat-expansion-panel-body pre code, " + "ms-function-call-chunk pre code" +) + +# Function call widget container (wraps the entire function call block) - legacy/fallback +FUNCTION_CALL_WIDGET_SELECTOR = ( + "ms-function-call-chunk, " + "ms-function-call, " + "[data-test-id='function-call'], " + "[data-testid='function-call'], " + ".function-call-widget, " + ".function-call-container, " + "ms-chat-turn .function-call" +) + +# Function call header containing the function name +FUNCTION_CALL_NAME_SELECTOR = ( + "ms-function-call-chunk ms-code-block mat-panel-title span:not(.material-symbols-outlined), " + "ms-function-call .function-name, " + "[data-test-id='function-call'] .function-name, " + ".function-call-widget .function-name, " + ".function-call-name, " + "ms-function-call [data-testid='function-name'], " + "[data-test-id='function-call-name']" +) + +# Function call arguments/parameters container (usually JSON or formatted view) +FUNCTION_CALL_ARGS_SELECTOR = ( + "ms-function-call-chunk ms-code-block pre code, " + "ms-function-call .function-args, " + "ms-function-call .function-arguments, " + "ms-function-call pre, " + "ms-function-call code, " + "[data-test-id='function-call'] .arguments, " + "[data-test-id='function-call'] pre, " + "[data-test-id='function-call'] code, " + ".function-call-widget .arguments, " + ".function-call-arguments, " + "[data-testid='function-arguments']" +) + +# Code block containing function call JSON (alternative to structured widget) +FUNCTION_CALL_CODE_BLOCK_SELECTOR = ( + "ms-function-call-chunk ms-code-block, " + "ms-chat-turn pre:has(code.language-json), " + "ms-chat-turn pre:has(code.language-tool_code), " + "ms-chat-turn .code-block:has-text('function_call'), " + "ms-chat-turn .code-block:has-text('tool_call')" +) + +# --- Function Calling Selectors --- +# Container for the function calling toggle and edit button +FUNCTION_CALLING_CONTAINER_SELECTOR = '[data-test-id="functionCallingTooltip"]' + +# Toggle switch to enable/disable function calling +FUNCTION_CALLING_TOGGLE_SELECTOR = ( + '[data-test-id="functionCallingTooltip"] mat-slide-toggle button[role="switch"].mdc-switch, ' + '[data-test-id="functionCallingTooltip"] .function-calling-toggle button[role="switch"], ' + '[data-test-id="functionCallingTooltip"] button.mdc-switch' +) + +# Button to open the function declarations editor modal +FUNCTION_DECLARATIONS_EDIT_BUTTON_SELECTOR = ( + '[data-test-id="functionCallingTooltip"] .edit-function-declarations-button, ' + 'button.edit-function-declarations-button[aria-label="Edit function declarations"], ' + "button.edit-function-declarations-button" +) + +# Function declarations modal dialog container +FUNCTION_DECLARATIONS_DIALOG_SELECTOR = ( + 'mat-dialog-container:has(h2:has-text("Function declarations")), ' + 'mat-mdc-dialog-container:has(h2:has-text("Function declarations")), ' + ".mat-mdc-dialog-container" +) + +# Code Editor tab in the function declarations modal +FUNCTION_DECLARATIONS_CODE_EDITOR_TAB_SELECTOR = ( + 'mat-dialog-container ms-tab-group button[role="tab"]:has-text("Code Editor"), ' + 'mat-mdc-dialog-container ms-tab-group button[role="tab"]:has-text("Code Editor")' +) + +# Visual Editor tab in the function declarations modal +FUNCTION_DECLARATIONS_VISUAL_EDITOR_TAB_SELECTOR = ( + 'mat-dialog-container ms-tab-group button[role="tab"]:has-text("Visual Editor"), ' + 'mat-mdc-dialog-container ms-tab-group button[role="tab"]:has-text("Visual Editor")' +) + +# Textarea for entering function declarations JSON in Code Editor mode +FUNCTION_DECLARATIONS_TEXTAREA_SELECTOR = ( + "mat-dialog-container ms-text-editor textarea, " + "mat-mdc-dialog-container ms-text-editor textarea, " + 'mat-dialog-container textarea[placeholder*="Tab"], ' + 'mat-mdc-dialog-container textarea[placeholder*="Tab"]' +) + +# Save button in the function declarations modal +FUNCTION_DECLARATIONS_SAVE_BUTTON_SELECTOR = ( + 'mat-dialog-container button[aria-label="Save the current function declarations"], ' + 'mat-mdc-dialog-container button[aria-label="Save the current function declarations"], ' + 'mat-dialog-container button:has-text("Save"), ' + 'mat-mdc-dialog-container button:has-text("Save")' +) + +# Reset button in the function declarations modal +FUNCTION_DECLARATIONS_RESET_BUTTON_SELECTOR = ( + 'mat-dialog-container button[aria-label="Reset the function declarations"], ' + 'mat-mdc-dialog-container button[aria-label="Reset the function declarations"], ' + 'mat-dialog-container button:has-text("Reset"), ' + 'mat-mdc-dialog-container button:has-text("Reset")' +) + +# Close/Cancel button in the function declarations modal (typically an X or Cancel button) +FUNCTION_DECLARATIONS_CLOSE_BUTTON_SELECTOR = ( + 'mat-dialog-container button[aria-label="Close"], ' + 'mat-mdc-dialog-container button[aria-label="Close"], ' + "mat-dialog-container button.close-button, " + "mat-mdc-dialog-container button.close-button, " + 'mat-dialog-container button:has-text("Cancel"), ' + 'mat-mdc-dialog-container button:has-text("Cancel")' +) diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000000000000000000000000000000000000..ba1c1fb17ed8ec1f7b530d876fef65ef34d1c762 --- /dev/null +++ b/config/settings.py @@ -0,0 +1,209 @@ +""" +Main Settings Configuration Module +Contains runtime settings such as environment variable configuration, path configuration, proxy configuration, etc. +""" + +import os +from pathlib import Path + +from dotenv import load_dotenv + +# Load .env file +load_dotenv() + +# --- Global Log Control Configuration --- +DEBUG_LOGS_ENABLED = os.environ.get("DEBUG_LOGS_ENABLED", "false").lower() in ( + "true", + "1", + "yes", +) +TRACE_LOGS_ENABLED = os.environ.get("TRACE_LOGS_ENABLED", "false").lower() in ( + "true", + "1", + "yes", +) +JSON_LOGS_ENABLED = os.environ.get("JSON_LOGS", "false").lower() in ( + "true", + "1", + "yes", +) + +# --- Log Rotation Configuration --- +LOG_FILE_MAX_BYTES = int( + os.environ.get("LOG_FILE_MAX_BYTES", str(10 * 1024 * 1024)) +) # 10MB default +LOG_FILE_BACKUP_COUNT = int(os.environ.get("LOG_FILE_BACKUP_COUNT", "5")) + +# --- Auth Related Configuration --- +AUTO_SAVE_AUTH = os.environ.get("AUTO_SAVE_AUTH", "").lower() in ("1", "true", "yes") +AUTH_SAVE_TIMEOUT = int(os.environ.get("AUTH_SAVE_TIMEOUT", "30")) +AUTO_CONFIRM_LOGIN = os.environ.get("AUTO_CONFIRM_LOGIN", "true").lower() in ( + "1", + "true", + "yes", +) + +# --- Path Configuration (Using pathlib) --- +_CONFIG_DIR = Path(__file__).parent +_PROJECT_ROOT = _CONFIG_DIR.parent + +AUTH_PROFILES_DIR = str(_PROJECT_ROOT / "auth_profiles") +ACTIVE_AUTH_DIR = str(_PROJECT_ROOT / "auth_profiles" / "active") +SAVED_AUTH_DIR = str(_PROJECT_ROOT / "auth_profiles" / "saved") +LOG_DIR = str(_PROJECT_ROOT / "logs") +APP_LOG_FILE_PATH = str(_PROJECT_ROOT / "logs" / "app.log") +UPLOAD_FILES_DIR = str(_PROJECT_ROOT / "upload_files") + + +def get_environment_variable(key: str, default: str = "") -> str: + """Get environment variable value""" + return os.environ.get(key, default) + + +def get_boolean_env(key: str, default: bool = False) -> bool: + """Get boolean environment variable""" + value = os.environ.get(key, "").lower() + if default: + return value not in ("false", "0", "no", "off") + else: + return value in ("true", "1", "yes", "on") + + +def get_int_env(key: str, default: int = 0) -> int: + """Get integer environment variable""" + try: + return int(os.environ.get(key, str(default))) + except (ValueError, TypeError): + return default + + +# --- Proxy Configuration --- +NO_PROXY_ENV = os.environ.get("NO_PROXY") + +# --- Script Injection Configuration --- +ENABLE_SCRIPT_INJECTION = get_boolean_env("ENABLE_SCRIPT_INJECTION", True) +NETWORK_INTERCEPTION_ENABLED = get_boolean_env("NETWORK_INTERCEPTION_ENABLED", True) +USERSCRIPT_PATH = os.environ.get( + "USERSCRIPT_PATH", str(_PROJECT_ROOT / "browser_utils" / "more_models.js") +) +ONLY_COLLECT_CURRENT_USER_ATTACHMENTS = get_boolean_env( + "ONLY_COLLECT_CURRENT_USER_ATTACHMENTS", False +) + +# --- Response Integrity Verification Configuration --- +EMERGENCY_WAIT_SECONDS = get_int_env("EMERGENCY_WAIT_SECONDS", 3) + +# --- Thinking Budget Configuration --- +DISABLE_THINKING_BUDGET_ON_STREAMING_DISABLE = get_boolean_env( + "DISABLE_THINKING_BUDGET_ON_STREAMING_DISABLE", default=True +) + +# --- Proactive Rotation Configuration --- +QUOTA_SOFT_LIMIT = get_int_env("QUOTA_SOFT_LIMIT", 650000) +QUOTA_HARD_LIMIT = get_int_env("QUOTA_HARD_LIMIT", 800000) + +MODEL_QUOTA_LIMITS = {} +for key, value in os.environ.items(): + if key.upper().startswith("QUOTA_LIMIT_"): + try: + model_id = key[12:].lower() + MODEL_QUOTA_LIMITS[model_id] = int(value) + except ValueError: + continue + +PROACTIVE_ROTATION_TOKEN_LIMIT = QUOTA_HARD_LIMIT + +# --- Dynamic Rotation Guard Configuration --- +HIGH_TRAFFIC_QUEUE_THRESHOLD = get_int_env("HIGH_TRAFFIC_QUEUE_THRESHOLD", 5) +ROTATION_DEPLETION_GUARD_HIGH_TRAFFIC = get_int_env( + "ROTATION_DEPLETION_GUARD_HIGH_TRAFFIC", 10 +) + +# --- Granular Configuration for Auth and Budget --- +AUTO_ROTATE_AUTH_PROFILE = get_boolean_env("AUTO_ROTATE_AUTH_PROFILE", True) + + +def _get_thinking_budget_value(env_key: str, default: int, level_name: str) -> int: + try: + value = get_int_env(env_key, default) + if value <= 0: + return default + return value + except (ValueError, TypeError): + return default + + +THINKING_BUDGET_LOW = _get_thinking_budget_value("THINKING_BUDGET_LOW", 8000, "LOW") +THINKING_BUDGET_MEDIUM = _get_thinking_budget_value( + "THINKING_BUDGET_MEDIUM", 16000, "MEDIUM" +) +THINKING_BUDGET_HIGH = _get_thinking_budget_value("THINKING_BUDGET_HIGH", 32000, "HIGH") + +DEFAULT_THINKING_LEVEL = os.environ.get("DEFAULT_THINKING_LEVEL", "low") + +# --- Function Calling Configuration --- +# Mode: "emulated" | "native" | "auto" +# - "emulated": Current text-based approach (default, backwards compatible) +# - "native": AI Studio UI-driven function calling +# - "auto": Native with automatic fallback to emulated on failure +FUNCTION_CALLING_MODE = os.environ.get("FUNCTION_CALLING_MODE", "emulated").lower() + +# Enable automatic fallback to emulated mode when native mode fails +FUNCTION_CALLING_NATIVE_FALLBACK = get_boolean_env( + "FUNCTION_CALLING_NATIVE_FALLBACK", True +) + +# Timeout for function calling UI operations (milliseconds) +FUNCTION_CALLING_UI_TIMEOUT = get_int_env("FUNCTION_CALLING_UI_TIMEOUT", 5000) + +# Native mode retry attempts before fallback +FUNCTION_CALLING_NATIVE_RETRY_COUNT = get_int_env( + "FUNCTION_CALLING_NATIVE_RETRY_COUNT", 2 +) + +# Clear function definitions between requests (stateless behavior) +FUNCTION_CALLING_CLEAR_BETWEEN_REQUESTS = get_boolean_env( + "FUNCTION_CALLING_CLEAR_BETWEEN_REQUESTS", True +) + +# Enable detailed function calling debug logs +FUNCTION_CALLING_DEBUG = get_boolean_env("FUNCTION_CALLING_DEBUG", False) + +# --- Function Calling Cache Configuration --- +# Enable caching of function calling toggle state and tool declarations +# Skips redundant UI operations when same tools are used in subsequent requests +FUNCTION_CALLING_CACHE_ENABLED = get_boolean_env("FUNCTION_CALLING_CACHE_ENABLED", True) + +# Cache TTL in seconds (0 = no expiration within session) +# Cache is automatically invalidated on model switch or new chat +FUNCTION_CALLING_CACHE_TTL = get_int_env("FUNCTION_CALLING_CACHE_TTL", 0) + +# --- New Function Calling Configuration (FC Improvements) --- +# Add thoughtSignature to functionCall parts for Gemini 3 compatibility +# Reference: ADR-002-thoughtSignature-support.md +FUNCTION_CALLING_THOUGHT_SIGNATURE = get_boolean_env( + "FUNCTION_CALLING_THOUGHT_SIGNATURE", True +) + +# Convert JSON Schema types to UPPERCASE (e.g., "string" -> "STRING") +# Default is false for backwards compatibility - enable only after UI verification +# Reference: ADR-005-type-case-normalization.md +FUNCTION_CALLING_UPPERCASE_TYPES = get_boolean_env( + "FUNCTION_CALLING_UPPERCASE_TYPES", False +) + +# --- Cookie Refresh Configuration --- +# Enable automatic cookie refresh to keep auth profiles fresh +COOKIE_REFRESH_ENABLED = get_boolean_env("COOKIE_REFRESH_ENABLED", True) + +# Periodic refresh interval in seconds (default: 30 minutes) +COOKIE_REFRESH_INTERVAL_SECONDS = get_int_env("COOKIE_REFRESH_INTERVAL_SECONDS", 1800) + +# Enable cookie save on successful API requests (saves after every N requests) +COOKIE_REFRESH_ON_REQUEST_ENABLED = get_boolean_env( + "COOKIE_REFRESH_ON_REQUEST_ENABLED", True +) +COOKIE_REFRESH_REQUEST_INTERVAL = get_int_env("COOKIE_REFRESH_REQUEST_INTERVAL", 10) + +# Enable cookie save on graceful shutdown +COOKIE_REFRESH_ON_SHUTDOWN = get_boolean_env("COOKIE_REFRESH_ON_SHUTDOWN", True) diff --git a/config/timeouts.py b/config/timeouts.py new file mode 100644 index 0000000000000000000000000000000000000000..a96a3096049a2372592c083796882abf93629682 --- /dev/null +++ b/config/timeouts.py @@ -0,0 +1,62 @@ +""" +Timeouts and Timing Configuration Module +Contains all time-related configurations such as timeouts and polling intervals. +""" + +import os + +from dotenv import load_dotenv + +# Load .env file +load_dotenv() + +# --- Response Wait Configuration --- +RESPONSE_COMPLETION_TIMEOUT = int(os.environ.get('RESPONSE_COMPLETION_TIMEOUT', '300000')) # 5 minutes total timeout (in ms) +INITIAL_WAIT_MS_BEFORE_POLLING = int(os.environ.get('INITIAL_WAIT_MS_BEFORE_POLLING', '500')) # ms, initial wait before polling for response completion + +# --- Polling Interval Configuration --- +POLLING_INTERVAL = int(os.environ.get('POLLING_INTERVAL', '300')) # ms +POLLING_INTERVAL_STREAM = int(os.environ.get('POLLING_INTERVAL_STREAM', '180')) # ms + +# --- Silence Timeout Configuration --- +SILENCE_TIMEOUT_MS = int(os.environ.get('SILENCE_TIMEOUT_MS', '60000')) # ms + +# --- Page Operation Timeout Configuration --- +POST_SPINNER_CHECK_DELAY_MS = int(os.environ.get('POST_SPINNER_CHECK_DELAY_MS', '500')) +FINAL_STATE_CHECK_TIMEOUT_MS = int(os.environ.get('FINAL_STATE_CHECK_TIMEOUT_MS', '1500')) +POST_COMPLETION_BUFFER = int(os.environ.get('POST_COMPLETION_BUFFER', '700')) + +# --- Chat Clearing Timeout Configuration --- +CLEAR_CHAT_VERIFY_TIMEOUT_MS = int(os.environ.get('CLEAR_CHAT_VERIFY_TIMEOUT_MS', '5000')) +CLEAR_CHAT_VERIFY_INTERVAL_MS = int(os.environ.get('CLEAR_CHAT_VERIFY_INTERVAL_MS', '2000')) + +# --- Click and Clipboard Operation Timeout --- +CLICK_TIMEOUT_MS = int(os.environ.get('CLICK_TIMEOUT_MS', '3000')) +CLIPBOARD_READ_TIMEOUT_MS = int(os.environ.get('CLIPBOARD_READ_TIMEOUT_MS', '3000')) + +# --- Element Wait Timeout --- +WAIT_FOR_ELEMENT_TIMEOUT_MS = int(os.environ.get('WAIT_FOR_ELEMENT_TIMEOUT_MS', '10000')) # Timeout for waiting for elements like overlays + +# --- UI Generation Wait Configuration --- +UI_GENERATION_WAIT_TIMEOUT_MS = int(os.environ.get('UI_GENERATION_WAIT_TIMEOUT_MS', '3000')) + +# --- Adaptive Cooldown Configuration --- +# Rate Limit (429) - Shorter cooldown (default 5 minutes) +RATE_LIMIT_COOLDOWN_SECONDS = int(os.environ.get('RATE_LIMIT_COOLDOWN_SECONDS', '300')) +# Quota Exceeded (Resource Exhausted) - Longer cooldown (default 4 hours) +QUOTA_EXCEEDED_COOLDOWN_SECONDS = int(os.environ.get('QUOTA_EXCEEDED_COOLDOWN_SECONDS', '14400')) + +# --- Stream Related Configuration --- +PSEUDO_STREAM_DELAY = float(os.environ.get('PSEUDO_STREAM_DELAY', '0.01')) + +# --- Fast-Fail Configuration --- +# Submit button enable timeout - Lowered for fast-fail detection +SUBMIT_BUTTON_ENABLE_TIMEOUT_MS = int(os.environ.get("SUBMIT_BUTTON_ENABLE_TIMEOUT_MS", "5000")) + +# --- Selector Timeout Configuration --- +# Quick existence check timeout (for rapid detection of elements in DOM) +SELECTOR_EXISTENCE_CHECK_TIMEOUT_MS = int(os.environ.get("SELECTOR_EXISTENCE_CHECK_TIMEOUT_MS", "500")) +# Element visibility wait timeout (general UI operations) +SELECTOR_VISIBILITY_TIMEOUT_MS = int(os.environ.get("SELECTOR_VISIBILITY_TIMEOUT_MS", "5000")) +# Startup selector visibility timeout (longer for page load) +STARTUP_SELECTOR_VISIBILITY_TIMEOUT_MS = int(os.environ.get("STARTUP_SELECTOR_VISIBILITY_TIMEOUT_MS", "30000")) diff --git a/docker/.env.docker b/docker/.env.docker new file mode 100644 index 0000000000000000000000000000000000000000..b72357a8613cc6670c8c176cfbd90043e4db86c6 --- /dev/null +++ b/docker/.env.docker @@ -0,0 +1,150 @@ +# Docker 环境配置文件示例 +# 复制此文件为 .env 并根据需要修改配置 + +# ============================================================================= +# Docker 主机端口配置 +# ============================================================================= + +# 主机上映射的端口 (外部访问端口) +HOST_FASTAPI_PORT=2048 +HOST_STREAM_PORT=3120 + +# ============================================================================= +# 容器内服务端口配置 +# ============================================================================= + +# FastAPI 服务端口 (容器内) +PORT=8000 +DEFAULT_FASTAPI_PORT=2048 +DEFAULT_CAMOUFOX_PORT=9222 + +# 流式代理服务配置 +STREAM_PORT=3120 + +# ============================================================================= +# 代理配置 +# ============================================================================= + +# HTTP/HTTPS 代理设置 +# HTTP_PROXY=http://host.docker.internal:7890 +# HTTPS_PROXY=http://host.docker.internal:7890 + +# 统一代理配置 (优先级高于 HTTP_PROXY/HTTPS_PROXY) +# UNIFIED_PROXY_CONFIG=http://host.docker.internal:7890 + +# 代理绕过列表 (用分号分隔) +# NO_PROXY=localhost;127.0.0.1;*.local + +# ============================================================================= +# 日志配置 +# ============================================================================= + +# 服务器日志级别 (DEBUG, INFO, WARNING, ERROR, CRITICAL) +SERVER_LOG_LEVEL=INFO + +# 是否重定向 print 输出到日志 +SERVER_REDIRECT_PRINT=false + +# 启用调试日志 +DEBUG_LOGS_ENABLED=false + +# 启用跟踪日志 +TRACE_LOGS_ENABLED=false + +# ============================================================================= +# 认证配置 +# ============================================================================= + +# 自动保存认证信息 +AUTO_SAVE_AUTH=false + +# 认证保存超时时间 (秒) +AUTH_SAVE_TIMEOUT=30 + +# 自动确认登录 +AUTO_CONFIRM_LOGIN=true + +# ============================================================================= +# 浏览器配置 +# ============================================================================= + +# 启动模式 (normal, headless, virtual_display, direct_debug_no_browser) +LAUNCH_MODE=headless + +# ============================================================================= +# API 默认参数配置 +# ============================================================================= + +# 默认温度值 (0.0-2.0) +DEFAULT_TEMPERATURE=1.0 + +# 默认最大输出令牌数 +DEFAULT_MAX_OUTPUT_TOKENS=65536 + +# 默认 Top-P 值 (0.0-1.0) +DEFAULT_TOP_P=0.95 + +# Default stop sequences (JSON array format) +DEFAULT_STOP_SEQUENCES=["User:"] + +# ============================================================================= +# 超时配置 (毫秒) +# ============================================================================= + +# 响应完成总超时时间 +RESPONSE_COMPLETION_TIMEOUT=300000 + +# 轮询间隔 +POLLING_INTERVAL=300 +POLLING_INTERVAL_STREAM=180 + +# 静默超时 +SILENCE_TIMEOUT_MS=60000 + +# ============================================================================= +# 脚本注入配置 +# ============================================================================= + +# 是否启用油猴脚本注入功能 +ENABLE_SCRIPT_INJECTION=false + +# 油猴脚本文件路径(相对于容器内 /app 目录) +USERSCRIPT_PATH=browser_utils/more_models.js + +# 注意:MODEL_CONFIG_PATH 已废弃 +# 模型数据现在直接从 USERSCRIPT_PATH 指定的油猴脚本中解析 + +# ============================================================================= +# Docker 特定配置 +# ============================================================================= + +# 容器内存限制 +# 默认不限制。如需限制容器资源,请在你的 .env 文件中取消注释并设置以下值。 +# 例如: DOCKER_MEMORY_LIMIT=1g或DOCKER_MEMORY_LIMIT=1024m +# 注意:DOCKER_MEMORY_LIMIT和DOCKER_MEMSWAP_LIMIT相同时,不会使用SWAP +# DOCKER_MEMORY_LIMIT= +# DOCKER_MEMSWAP_LIMIT= + +# 容器重启策略相关 +# 这些配置项在 docker-compose.yml 中使用 + +# 健康检查间隔 (秒) +HEALTHCHECK_INTERVAL=30 + +# 健康检查超时 (秒) +HEALTHCHECK_TIMEOUT=10 + +# 健康检查重试次数 +HEALTHCHECK_RETRIES=3 + +# ============================================================================= +# 网络配置说明 +# ============================================================================= + +# 在 Docker 环境中访问主机服务,请使用: +# - Linux: host.docker.internal +# - macOS: host.docker.internal +# - Windows: host.docker.internal +# +# 例如,如果主机上有代理服务运行在 7890 端口: +# HTTP_PROXY=http://host.docker.internal:7890 diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..c7f1a83db3c25b3c46623500578c5067452a0e0f --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,116 @@ +# Dockerfile + +#ARG PROXY_ADDR="http://host.docker.internal:7890" Linxux 下使用 host.docker.internal 可能会有问题,建议使用实际的代理地址 +FROM python:3.10-slim-bookworm AS builder + +ARG DEBIAN_FRONTEND=noninteractive +ARG PROXY_ADDR + +RUN if [ -n "$PROXY_ADDR" ]; then \ + printf 'Acquire::http::Proxy "%s";\nAcquire::https::Proxy "%s";\n' "$PROXY_ADDR" "$PROXY_ADDR" > /etc/apt/apt.conf.d/99proxy; \ + fi && \ + apt-get update && \ + apt-get install -y --no-install-recommends curl \ + && apt-get clean && rm -rf /var/lib/apt/lists/* && \ + if [ -n "$PROXY_ADDR" ]; then rm -f /etc/apt/apt.conf.d/99proxy; fi + +ENV HTTP_PROXY=${PROXY_ADDR} +ENV HTTPS_PROXY=${PROXY_ADDR} + +ENV POETRY_HOME="/opt/poetry" +ENV POETRY_VERSION=1.8.3 +RUN curl -sSL https://install.python-poetry.org | python3 - --version ${POETRY_VERSION} +ENV PATH="${POETRY_HOME}/bin:${PATH}" + +WORKDIR /app_builder +COPY pyproject.toml poetry.lock ./ +RUN poetry config virtualenvs.create false --local && \ + poetry install --no-root --no-dev --no-interaction --no-ansi + +FROM python:3.10-slim-bookworm + +ARG DEBIAN_FRONTEND=noninteractive +ARG PROXY_ADDR + +ENV HTTP_PROXY=${PROXY_ADDR} +ENV HTTPS_PROXY=${PROXY_ADDR} + +# 步骤 1: 安装所有系统依赖。 +# Playwright 的依赖也在这里一并安装。 +RUN \ + if [ -n "$PROXY_ADDR" ]; then \ + printf 'Acquire::http::Proxy "%s";\nAcquire::https::Proxy "%s";\n' "$PROXY_ADDR" "$PROXY_ADDR" > /etc/apt/apt.conf.d/99proxy; \ + fi && \ + apt-get update && \ + apt-get install -y --no-install-recommends \ + libatk1.0-0 libatk-bridge2.0-0 libcups2 libdbus-1-3 libdrm2 libgbm1 libgtk-3-0 libnspr4 libnss3 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libxrender1 libxtst6 ca-certificates fonts-liberation libasound2 libpangocairo-1.0-0 libpango-1.0-0 libu2f-udev \ + supervisor curl \ + && \ + # 清理工作 + apt-get clean && \ + rm -rf /var/lib/apt/lists/* && \ + if [ -n "$PROXY_ADDR" ]; then rm -f /etc/apt/apt.conf.d/99proxy; fi + +RUN groupadd -r appgroup && useradd -r -g appgroup -s /bin/bash -d /app appuser + +WORKDIR /app + +# 步骤 2: 复制 Python 包和可执行文件。 +# 这是关键的顺序调整:在使用 playwright 之前先把它复制进来。 +COPY --from=builder /usr/local/lib/python3.10/site-packages/ /usr/local/lib/python3.10/site-packages/ +COPY --from=builder /usr/local/bin/ /usr/local/bin/ +COPY --from=builder /opt/poetry/bin/poetry /usr/local/bin/poetry + +# 复制应用代码 +COPY . . + +# 步骤 3: 现在 Python 模块已存在,可以安全地运行这些命令。 +# 注意:我们不再需要 `playwright install-deps`,因为依赖已在上面的 apt-get 中安装。 +RUN camoufox fetch && \ + python -m playwright install firefox + +# 创建目录和设置权限 +RUN mkdir -p /app/logs && \ + mkdir -p /app/auth_profiles/active && \ + mkdir -p /app/auth_profiles/saved && \ + mkdir -p /app/certs && \ + mkdir -p /app/browser_utils/custom_scripts && \ + mkdir -p /home/appuser/.cache/ms-playwright && \ + mkdir -p /home/appuser/.mozilla && \ + chown -R appuser:appgroup /app && \ + chown -R appuser:appgroup /home/appuser + +COPY supervisord.conf /etc/supervisor/conf.d/app.conf + +# 修复 camoufox 缓存逻辑 +RUN mkdir -p /var/cache/camoufox && \ + if [ -d /root/.cache/camoufox ]; then cp -a /root/.cache/camoufox/* /var/cache/camoufox/; fi && \ + mkdir -p /app/.cache && \ + ln -s /var/cache/camoufox /app/.cache/camoufox + +RUN python scripts/update_browserforge_data.py + +# 清理代理环境变量 +ENV HTTP_PROXY="" +ENV HTTPS_PROXY="" + +EXPOSE 2048 +EXPOSE 3120 + +USER appuser +ENV HOME=/app +ENV PLAYWRIGHT_BROWSERS_PATH=/home/appuser/.cache/ms-playwright + +ENV PYTHONUNBUFFERED=1 + +ENV PORT=8000 +ENV DEFAULT_FASTAPI_PORT=2048 +ENV DEFAULT_CAMOUFOX_PORT=9222 +ENV STREAM_PORT=3120 +ENV SERVER_LOG_LEVEL=INFO +ENV DEBUG_LOGS_ENABLED=false +ENV AUTO_CONFIRM_LOGIN=true +ENV SERVER_PORT=2048 +ENV INTERNAL_CAMOUFOX_PROXY="" + +CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/app.conf"] \ No newline at end of file diff --git a/docker/README-Docker.md b/docker/README-Docker.md new file mode 100644 index 0000000000000000000000000000000000000000..4f04677ca7352887d0c62d6f8e0547ea26fc1f9b --- /dev/null +++ b/docker/README-Docker.md @@ -0,0 +1,494 @@ +# Docker 部署指南 (AI Studio Proxy API) + +> 📁 **注意**: 主要的 Docker 配置文件(Dockerfile, docker-compose.yml)位于 `docker/` 目录中。部分通用配置文件(如 `.dockerignore`, `supervisord.conf`)位于项目根目录。 + +本文档提供了使用 Docker 构建和运行 AI Studio Proxy API 项目的完整指南,包括 Poetry 依赖管理、`.env` 配置管理和脚本注入功能。 + +## 🐳 概述 + +Docker 部署提供了以下优势: + +- ✅ **环境隔离**: 容器化部署,避免环境冲突 +- ✅ **Poetry 依赖管理**: 使用现代化的 Python 依赖管理工具 +- ✅ **统一配置**: 基于 `.env` 文件的配置管理 +- ✅ **版本更新无忧**: `bash update.sh` 即可完成更新 +- ✅ **跨平台支持**: 支持 x86_64 和 ARM64 架构 +- ✅ **配置持久化**: 认证文件和日志持久化存储 +- ✅ **多阶段构建**: 优化镜像大小和构建速度 + +## 先决条件 + +- **Docker**: 确保您的系统已正确安装并正在运行 Docker。您可以从 [Docker 官方网站](https://www.docker.com/get-started) 下载并安装 Docker Desktop (适用于 Windows 和 macOS) 或 Docker Engine (适用于 Linux)。 +- **项目代码**: 项目代码已下载到本地。 +- **认证文件**: ⚠️ **关键步骤** - 首次运行需要在主机上完成认证文件获取。由于 Docker 容器运行在无头模式(Headless)下,无法处理 Google 登录交互,因此必须先在本地生成认证文件。 + +## 🔧 Docker 环境规格 + +- **基础镜像**: Python 3.10-slim-bookworm (稳定且轻量) +- **Python 版本**: 3.10 (在容器内运行,与主机 Python 版本无关) +- **依赖管理**: Poetry (现代化 Python 依赖管理) +- **构建方式**: 多阶段构建 (builder + runtime) +- **架构支持**: x86_64 和 ARM64 (Apple Silicon) +- **模块化设计**: 完全支持项目的模块化架构 +- **虚拟环境**: Poetry 自动管理虚拟环境 + +## 1. 理解项目中的 Docker 相关文件 + +在项目根目录下,您会找到以下与 Docker 配置相关的文件: + +- **[`Dockerfile`](./Dockerfile):** 这是构建 Docker 镜像的蓝图。它定义了基础镜像、依赖项安装、代码复制、端口暴露以及容器启动时执行的命令。 +- **[`../.dockerignore`](../.dockerignore):** (位于项目根目录) 这个文件列出了在构建 Docker 镜像时应忽略的文件和目录。这有助于减小镜像大小并加快构建速度,例如排除 `.git` 目录、本地开发环境文件等。 +- **[`../supervisord.conf`](../supervisord.conf):** (位于项目根目录) Supervisor 是一个进程控制系统,用于在容器内同时管理主服务和流服务。此配置文件定义了进程的启动命令和参数。 + +## 2. 构建 Docker 镜像 + +要构建 Docker 镜像,请在项目根目录下打开终端或命令行界面,然后执行以下命令: + +```bash +# 方法 1: 使用 docker compose (推荐) +cd docker +docker compose build + +# 方法 2: 直接使用 docker build (在项目根目录执行) +docker build -f docker/Dockerfile -t ai-studio-proxy:latest . +``` + +**命令解释:** + +- `docker build`: 这是 Docker CLI 中用于构建镜像的命令。 +- `-t ai-studio-proxy:latest`: `-t` 参数用于为镜像指定一个名称和可选的标签 (tag),格式为 `name:tag`。 + - `ai-studio-proxy`: 是您为镜像选择的名称。 + - `latest`: 是标签,通常表示这是该镜像的最新版本。您可以根据版本控制策略选择其他标签,例如 `ai-studio-proxy:1.0`。 +- `.`: (末尾的点号) 指定了 Docker 构建上下文的路径。构建上下文是指包含 [`Dockerfile`](./Dockerfile:1) 以及构建镜像所需的所有其他文件和目录的本地文件系统路径。点号表示当前目录。Docker 守护进程会访问此路径下的文件来执行构建。 + +构建过程可能需要一些时间,具体取决于您的网络速度和项目依赖项的多少。成功构建后,您可以使用 `docker images` 命令查看本地已有的镜像列表,其中应包含 `ai-studio-proxy:latest`。 + +## 3. 运行 Docker 容器 + +镜像构建完成后,您可以选择以下两种方式来运行容器: + +### 方式 A: 使用 Docker Compose (推荐) + +Docker Compose 提供了更简洁的配置管理方式,特别适合使用 `.env` 文件: + +```bash +# 1. 准备配置文件 (进入 docker 目录) +cd docker +cp .env.docker .env +# 编辑 .env 文件以适应您的需求 + +# 2. 使用 Docker Compose 启动 (在 docker 目录下) +docker compose up -d + +# 3. 查看日志 +docker compose logs -f + +# 4. 停止服务 +docker compose down +``` + +### 方式 B: 使用 Docker 命令 + +您也可以使用传统的 Docker 命令来创建并运行容器。 + +**注意**: 以下命令假设您当前位于 `docker/` 目录下。如果您在项目根目录,请相应调整 `-v` 挂载路径。 + +### 方法 1: 使用 .env 文件 (推荐) + +```bash +docker run -d \ + -p <宿主机_服务端口>:2048 \ + -p <宿主机_流端口>:3120 \ + -v "$(pwd)/../auth_profiles":/app/auth_profiles \ + -v "$(pwd)/.env":/app/.env \ + # 可选: 如果您想使用自己的 SSL/TLS 证书,请取消下面一行的注释。 + # 请确保宿主机上的 'certs/' 目录存在,并且其中包含应用程序所需的证书文件。 + # -v "$(pwd)/../certs":/app/certs \ + --name ai-studio-proxy-container \ + ai-studio-proxy:latest +``` + +### 方法 2: 使用环境变量 (传统方式) + +```bash +docker run -d \ + -p <宿主机_服务端口>:2048 \ + -p <宿主机_流端口>:3120 \ + -v "$(pwd)/../auth_profiles":/app/auth_profiles \ + # 可选: 如果您想使用自己的 SSL/TLS 证书,请取消下面一行的注释。 + # 请确保宿主机上的 'certs/' 目录存在,并且其中包含应用程序所需的证书文件。 + # -v "$(pwd)/../certs":/app/certs \ + -e PORT=8000 \ + -e DEFAULT_FASTAPI_PORT=2048 \ + -e DEFAULT_CAMOUFOX_PORT=9222 \ + -e STREAM_PORT=3120 \ + -e SERVER_LOG_LEVEL=INFO \ + -e DEBUG_LOGS_ENABLED=false \ + -e AUTO_CONFIRM_LOGIN=true \ + # 可选: 如果您需要设置代理,请取消下面的注释 + # -e HTTP_PROXY="http://your_proxy_address:port" \ + # -e HTTPS_PROXY="http://your_proxy_address:port" \ + # -e UNIFIED_PROXY_CONFIG="http://your_proxy_address:port" \ + --name ai-studio-proxy-container \ + ai-studio-proxy:latest +``` + +**命令解释:** + +- `docker run`: 这是 Docker CLI 中用于从镜像创建并启动容器的命令。 +- `-d`: 以“分离模式”(detached mode) 运行容器。这意味着容器将在后台运行,您的终端提示符将立即可用,而不会被容器的日志输出占用。 +- `-p <宿主机_服务端口>:2048`: 端口映射 (Port mapping)。 + - **建议仅修改冒号左侧的宿主机端口**。 + - 此参数将宿主机的某个端口映射到容器内部的 `2048` 端口。`2048` 是应用程序主服务在容器内监听的默认端口。 + - 例如:`-p 8080:2048` 表示通过宿主机的 8080 端口访问服务。 +- `-p <宿主机_流端口>:3120`: + - 将宿主机的端口映射到容器内部的 `3120` 流服务端口。 + - 例如:`-p 8081:3120`。 +- `-v "$(pwd)/../auth_profiles":/app/auth_profiles`: 卷挂载 (Volume mounting)。 + - 此参数将宿主机当前工作目录 (`$(pwd)`) 下的 `auth_profiles/` 目录挂载到容器内的 `/app/auth_profiles/` 目录。 + - 这样做的好处是: + - **持久化数据:** 即使容器被删除,`auth_profiles/` 中的数据仍保留在宿主机上。 + - **方便配置:** 您可以直接在宿主机上修改 `auth_profiles/` 中的文件,更改会实时反映到容器中 (取决于应用程序如何读取这些文件)。 + - **重要:** 在运行命令前,请确保宿主机上的 `auth_profiles/` 目录已存在。如果应用程序期望在此目录中找到特定的配置文件,请提前准备好。 +- `# -v "$(pwd)/../certs":/app/certs` (可选,已注释): 挂载自定义证书。 + - 如果您希望应用程序使用您自己的 SSL/TLS 证书而不是自动生成的证书,可以取消此行的注释。 + - 它会将宿主机当前工作目录下的 `certs/` 目录挂载到容器内的 `/app/certs/` 目录。 + - **重要:** 如果启用此选项,请确保宿主机上的 `certs/` 目录存在,并且其中包含应用程序所需的证书文件 (通常是 `server.crt` 和 `server.key` 或类似名称的文件)。应用程序也需要被配置为从 `/app/certs/` 读取这些证书。 +- `-e SERVER_PORT=2048`: 设置环境变量。 + - `-e` 参数用于在容器内设置环境变量。 + - 这里,我们将 `SERVER_PORT` 环境变量设置为 `2048`。应用程序在容器内会读取此变量来确定其主服务应监听哪个端口。这应与 [`Dockerfile`](./Dockerfile:1) 中 `EXPOSE` 指令以及 [`supervisord.conf`](./supervisord.conf:1) (如果使用) 中的配置相匹配。 +- `-e STREAM_PORT=3120`: 类似地,设置 `STREAM_PORT` 环境变量为 `3120`,供应用程序的流服务使用。 +- `# -e INTERNAL_CAMOUFOX_PROXY="http://your_proxy_address:port"` (可选,已注释): 设置内部 Camoufox 代理。 + - 如果您的应用程序需要通过一个特定的内部代理服务器来访问 Camoufox 或其他外部服务,可以取消此行的注释,并将 `"http://your_proxy_address:port"` 替换为实际的代理服务器地址和端口 (例如 `http://10.0.0.5:7890` 或 `socks5://proxy-user:proxy-pass@10.0.0.10:1080`)。 +- `--name ai-studio-proxy-container`: 为正在运行的容器指定一个名称。 + - 这使得管理容器更加方便。例如,您可以使用 `docker stop ai-studio-proxy-container` 来停止这个容器,或使用 `docker logs ai-studio-proxy-container` 来查看其日志。 + - 如果您不指定名称,Docker 会自动为容器生成一个随机名称。 +- `ai-studio-proxy:latest`: 指定要运行的镜像的名称和标签。这必须与您在 `docker build` 命令中使用的名称和标签相匹配。 + +**首次运行前的重要准备:** + +### 配置文件准备 + +1. **创建 `.env` 配置文件 (推荐):** + + ```bash + # 复制配置模板 (在项目 docker 目录下执行) + cp .env.docker .env + + # 编辑配置文件 + nano .env # 或使用其他编辑器 + ``` + + **`.env` 文件的优势:** + + - ✅ **版本更新无忧**: 一个 `git pull` 就完成更新,无需重新配置 + - ✅ **配置集中管理**: 所有配置项统一在 `.env` 文件中 + - ✅ **Docker 兼容**: 容器会自动读取挂载的 `.env` 文件 + - ✅ **安全性**: `.env` 文件已被 `.gitignore` 忽略,不会泄露配置 + +2. **准备认证文件 (必需):** + + Docker 容器无法进行初始登录交互,您必须在主机上运行程序(使用 `python launch_camoufox.py --debug`)完成登录,生成认证文件,然后将其放入 `auth_profiles/active/` 目录。 + + **目录结构示例:** + + ```text + 项目根目录/ + ├── auth_profiles/ + │ └── active/ + │ └── account_xxx.json <-- 必需的认证文件 + ├── docker/ + │ ├── .env + │ └── docker-compose.yml + ``` + +3. **(可选) 创建 `certs/` 目录:** 如果您计划使用自己的证书并取消了相关卷挂载行的注释,请在项目根目录下创建一个名为 `certs` 的目录,并将您的证书文件 (例如 `server.crt`, `server.key`) 放入其中。 + +## 4. 环境变量配置详解 + +### 使用 .env 文件配置 (推荐) + +项目现在支持通过 `.env` 文件进行配置管理。在 Docker 环境中,您只需要将 `.env` 文件挂载到容器中即可: + +```bash +# 挂载 .env 文件到容器 +-v "$(pwd)/.env":/app/.env +``` + +### 常用配置项 + +以下是 Docker 环境中常用的配置项: + +```env +# 服务端口配置 +# 主机映射端口 (外部访问端口) +HOST_FASTAPI_PORT=2048 +HOST_STREAM_PORT=3120 + +# 容器内部端口 (通常无需修改) +SERVER_PORT=2048 +DEFAULT_FASTAPI_PORT=2048 +STREAM_PORT=3120 + +# 代理配置 +HTTP_PROXY=http://127.0.0.1:7890 +HTTPS_PROXY=http://127.0.0.1:7890 +UNIFIED_PROXY_CONFIG=http://127.0.0.1:7890 + +# 日志配置 +SERVER_LOG_LEVEL=INFO +DEBUG_LOGS_ENABLED=false +TRACE_LOGS_ENABLED=false + +# 认证配置 +AUTO_CONFIRM_LOGIN=true +# 注意:AUTO_SAVE_AUTH 仅在 debug 模式下用于保存新认证文件 +# Docker 容器运行于 headless 模式,使用预先生成的认证文件,此设置无效 +AUTO_SAVE_AUTH=false +AUTH_SAVE_TIMEOUT=30 + +# 脚本注入配置 +ENABLE_SCRIPT_INJECTION=true +USERSCRIPT_PATH=browser_utils/more_models.js +# 注意:MODEL_CONFIG_PATH 已废弃,现在直接从油猴脚本解析模型数据 +# 使用 Playwright 原生网络拦截 + +# API 默认参数 +DEFAULT_TEMPERATURE=1.0 +DEFAULT_MAX_OUTPUT_TOKENS=65536 +DEFAULT_TOP_P=0.95 +``` + +### 配置优先级 + +在 Docker 环境中,配置的优先级顺序为: + +1. **Docker 运行时环境变量** (`-e` 参数) - 最高优先级 +2. **挂载的 .env 文件** - 中等优先级 +3. **Dockerfile 中的 ENV** - 最低优先级 + +### 示例:完整的 Docker 运行命令 + +```bash +# 使用 .env 文件的完整示例 +docker run -d \ + -p 8080:2048 \ + -p 8081:3120 \ + -v "$(pwd)/../auth_profiles":/app/auth_profiles \ + -v "$(pwd)/.env":/app/.env \ + --name ai-studio-proxy-container \ + ai-studio-proxy:latest +``` + +## 5. 管理正在运行的容器 + +一旦容器启动,您可以使用以下 Docker 命令来管理它: + +- **查看正在运行的容器:** + + ```bash + docker ps + ``` + + (如果您想查看所有容器,包括已停止的,请使用 `docker ps -a`) + +- **查看容器日志:** + + ```bash + docker logs ai-studio-proxy-container + ``` + + (如果您想持续跟踪日志输出,可以使用 `-f` 参数: `docker logs -f ai-studio-proxy-container`) + +- **停止容器:** + + ```bash + docker stop ai-studio-proxy-container + ``` + +- **启动已停止的容器:** + + ```bash + docker start ai-studio-proxy-container + ``` + +- **重启容器:** + + ```bash + docker restart ai-studio-proxy-container + ``` + +- **进入容器内部 (获取一个交互式 shell):** + + ```bash + docker exec -it ai-studio-proxy-container /bin/bash + ``` + + (或者 `/bin/sh`,取决于容器基础镜像中可用的 shell。这对于调试非常有用。) + +- **删除容器:** + 首先需要停止容器,然后才能删除它。 + ```bash + docker stop ai-studio-proxy-container + docker rm ai-studio-proxy-container + ``` + (如果您想强制删除正在运行的容器,可以使用 `docker rm -f ai-studio-proxy-container`,但不建议这样做,除非您知道自己在做什么。) + +## 5. 更新应用程序 + +当您更新了应用程序代码并希望部署新版本时,通常需要执行以下步骤: + +1. **停止并删除旧的容器** (如果它正在使用相同的端口或名称): + ```bash + docker stop ai-studio-proxy-container + docker rm ai-studio-proxy-container + ``` +2. **重新构建 Docker 镜像** (确保您在包含最新代码和 [`Dockerfile`](./Dockerfile:1) 的目录中): + ```bash + docker build -t ai-studio-proxy:latest . + ``` +3. **使用新的镜像运行新的容器** (使用与之前相同的 `docker run` 命令,或根据需要进行调整): + ```bash + docker run -d \ + -p <宿主机_服务端口>:2048 \ + # ... (其他参数与之前相同) ... + --name ai-studio-proxy-container \ + ai-studio-proxy:latest + ``` + +## 6. 清理 + +- **删除指定的 Docker 镜像:** + + ```bash + docker rmi ai-studio-proxy:latest + ``` + + (注意:如果存在基于此镜像的容器,您需要先删除这些容器。) + +- **删除所有未使用的 (悬空) 镜像、容器、网络和卷:** + ```bash + docker system prune + ``` + (如果想删除所有未使用的镜像,不仅仅是悬空的,可以使用 `docker system prune -a`) + **警告:** `prune` 命令会删除数据,请谨慎使用。 + +希望本教程能帮助您成功地通过 Docker 部署和运行 AI Studio Proxy API 项目! + +## 脚本注入配置 + +### 概述 + +Docker 环境完全支持脚本注入功能,提供以下改进: + +- **🚀 Playwright 原生拦截**: 使用 Playwright 路由拦截,提供高可靠性 +- **🔄 双重保障机制**: 网络拦截 + 脚本注入,提高稳定性 +- **📝 直接脚本解析**: 从油猴脚本中自动解析模型列表,无需配置文件 +- **🔗 前后端同步**: 前端和后端使用相同的模型数据源,保持一致 +- **⚙️ 零配置维护**: 无需手动维护模型配置文件,脚本更新自动生效 +- **🔄 自动适配**: 油猴脚本更新时无需手动更新配置 + +### 配置选项 + +在 `.env` 文件中配置以下选项: + +```env +# 是否启用脚本注入功能 +ENABLE_SCRIPT_INJECTION=true + +# 油猴脚本文件路径(容器内路径) +# 模型数据直接从此脚本文件中解析,无需额外配置文件 +USERSCRIPT_PATH=browser_utils/more_models.js +``` + +### 自定义脚本和模型配置 + +如果您想使用自定义的脚本或模型配置: + +1. **自定义脚本配置**: + + ```bash + # 在主机上创建自定义脚本文件 + cp browser_utils/more_models.js browser_utils/my_script.js + # 编辑 my_script.js 中的 MODELS_TO_INJECT 数组 + + # 在 docker-compose.yml 中取消注释并修改挂载行: + # - ../browser_utils/my_script.js:/app/browser_utils/more_models.js:ro + + # 或者在 .env 中修改路径: + # USERSCRIPT_PATH=browser_utils/my_script.js + ``` + +2. **自定义脚本**: + + ```bash + # 将自定义脚本放在 browser_utils/ 目录 + cp your_custom_script.js browser_utils/custom_script.js + + # 在 .env 中修改路径: + # USERSCRIPT_PATH=browser_utils/custom_script.js + ``` + +### Docker Compose 挂载配置 + +在 `docker-compose.yml` 中,您可以取消注释以下行来挂载自定义文件: + +```yaml +volumes: + # 挂载自定义脚本目录 + - ../browser_utils/custom_scripts:/app/browser_utils/custom_scripts:ro +``` + +### 注意事项 + +- 脚本或配置文件更新后需要重启容器 +- 如果脚本注入失败,不会影响主要功能 +- 可以通过容器日志查看脚本注入状态 + +## 注意事项 + +1. **认证文件**: Docker 部署需要预先在主机上获取有效的认证文件,并将其放置在 `auth_profiles/active/` 目录中。 +2. **模块化架构**: 项目采用模块化设计,所有配置和代码都已经过优化,无需手动修改。 +3. **端口配置**: 确保宿主机上的端口未被占用,默认使用 2048 (主服务) 和 3120 (流式代理)。 +4. **日志查看**: 可以通过 `docker logs` 命令查看容器运行日志,便于调试和监控。 +5. **脚本注入**: 新增的脚本注入功能默认启用,可通过 `ENABLE_SCRIPT_INJECTION=false` 禁用。 + +## 配置管理总结 + +### 统一的 .env 配置 + +Docker 部署完全支持 `.env` 文件配置管理: + +✅ **统一配置**: 使用 `.env` 文件管理所有配置 +✅ **版本更新无忧**: `git pull` + `docker compose up -d` 即可完成更新 +✅ **配置隔离**: 开发、测试、生产环境可使用不同的 `.env` 文件 +✅ **安全性**: `.env` 文件不会被提交到版本控制 + +### 推荐的 Docker 工作流程 + +```bash +# 1. 初始设置 +git clone +cd /docker +cp .env.docker .env +# 编辑 .env 文件 + +# 2. 启动服务 +docker compose up -d + +# 3. 版本更新 +bash update.sh + +# 4. 查看状态 +docker compose ps +docker compose logs -f +``` + +### 配置文件说明 + +- **`.env`**: 您的实际配置文件 (从 `.env.docker` 复制并修改) +- **`.env.docker`**: Docker 环境的配置模板 +- **`.env.example`**: 通用配置模板 (适用于所有环境) +- **`docker-compose.yml`**: Docker Compose 配置文件 + +这样的配置管理方式确保了 Docker 部署与本地开发的一致性,同时简化了配置和更新流程。 diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000000000000000000000000000000000000..f32b1dbde69ebcb5b0f3c9ba7d1a74b8f13dd9bf --- /dev/null +++ b/docker/README.md @@ -0,0 +1,88 @@ +# Docker 部署文件 + +这个目录包含了 AI Studio Proxy API 项目的所有 Docker 相关文件。 + +## 📁 文件说明 + +- **`Dockerfile`** - Docker 镜像构建文件 +- **`docker-compose.yml`** - Docker Compose 配置文件 +- **`.env.docker`** - Docker 环境配置模板 +- **`README-Docker.md`** - 详细的 Docker 部署指南 + +## 🚀 快速开始 + +### 0. 准备认证文件 (重要) + +Docker 模式仅支持运行已认证的会话。请确保项目根目录下存在 `auth_profiles` 目录,并且其中包含有效的认证文件(通常由主机直接运行程序生成)。 + +**目录结构示例:** + +```text +项目根目录/ + ├── auth_profiles/ + │ └── active/ + │ └── account_xxx.json <-- 必需的认证文件 + └── docker/ +``` + +### 1. 准备配置文件 + +```bash +cd docker +cp .env.docker .env +nano .env # 编辑配置文件 +``` + +### 2. 启动服务 + +```bash +# 构建并启动服务 +docker compose up -d + +# 查看日志 +docker compose logs -f +``` + +### 3. 版本更新 + +```bash +# 在 docker 目录下 +bash update.sh +``` + +## 📖 详细文档 + +完整的 Docker 部署指南请参见:[README-Docker.md](README-Docker.md) + +## 🔧 常用命令 + +```bash +# 查看服务状态 +docker compose ps + +# 查看日志 +docker compose logs -f + +# 停止服务 +docker compose down + +# 重启服务 +docker compose restart + +# 进入容器 +docker compose exec ai-studio-proxy /bin/bash +``` + +## 🌟 主要优势 + +- ✅ **统一配置**: 使用 `.env` 文件管理所有配置 +- ✅ **版本更新无忧**: `bash update.sh` 即可完成更新 +- ✅ **环境隔离**: 容器化部署,避免环境冲突 +- ✅ **配置持久化**: 认证文件和日志持久化存储 + +## ⚠️ 注意事项 + +1. **认证文件**: 必须在主机上预先获取认证文件并放入 `auth_profiles/active/` 目录。 +2. **端口配置**: 默认占用主机端口 `2048` (API) 和 `3120` (Stream)。如需修改,请编辑 `.env` 文件中的 `HOST_FASTAPI_PORT` 和 `HOST_STREAM_PORT`。 +3. **配置文件**: `.env` 文件必须位于 `docker/` 目录下,以便 Docker Compose 正确加载。 +4. **脚本注入**: 如需使用脚本注入功能,请参考 [README-Docker.md](README-Docker.md) 中的详细配置说明。 diff --git a/docker/SCRIPT_INJECTION_DOCKER.md b/docker/SCRIPT_INJECTION_DOCKER.md new file mode 100644 index 0000000000000000000000000000000000000000..4fc26c027d582de4e036bf66dcaa185d69a21fa1 --- /dev/null +++ b/docker/SCRIPT_INJECTION_DOCKER.md @@ -0,0 +1,204 @@ +# Docker 环境脚本注入配置指南 + +## 概述 + +本指南专门针对 Docker 环境中的油猴脚本注入功能配置。 + +## 快速开始 + +### 1. 基础配置 + +```bash +# 进入 docker 目录 +cd docker + +# 复制配置模板 +cp .env.docker .env + +# 编辑配置文件 +nano .env +``` + +在 `.env` 文件中确保以下配置: + +```env +# 启用脚本注入 +ENABLE_SCRIPT_INJECTION=true + +# 使用默认脚本(模型数据直接从脚本解析) +USERSCRIPT_PATH=browser_utils/more_models.js +``` + +### 2. 启动容器 + +```bash +# 构建并启动 +docker compose up -d + +# 查看日志确认脚本注入状态 +docker compose logs -f | grep "脚本注入" +``` + +## 自定义配置 + +### 方法 1: 直接修改默认脚本 (需重建) + +```bash +# 1. 编辑默认脚本文件 +nano ../browser_utils/more_models.js + +# 2. 重建并重启容器 +docker compose up -d --build +``` + +### 方法 2: 挂载自定义脚本 + +```bash +# 1. 创建自定义脚本文件 +cp ../browser_utils/more_models.js ../browser_utils/my_script.js + +# 2. 编辑 docker-compose.yml,取消注释并修改: +# volumes: +# - ../browser_utils/my_script.js:/app/browser_utils/more_models.js:ro + +# 3. 重启服务 +docker compose up -d +``` + +### 方法 3: 环境变量配置 + +```bash +# 1. 在 .env 文件中修改路径 +echo "USERSCRIPT_PATH=browser_utils/my_custom_script.js" >> .env + +# 2. 创建对应的脚本文件 +cp ../browser_utils/more_models.js ../browser_utils/my_custom_script.js + +# 3. 重启容器以应用配置更改 +docker compose up -d +``` + +## 验证脚本注入 + +### 检查日志 + +```bash +# 查看脚本注入相关日志 +docker compose logs | grep -E "(脚本注入|script.*inject|模型增强)" + +# 实时监控日志 +docker compose logs -f | grep -E "(脚本注入|script.*inject|模型增强)" +``` + +### 预期日志输出 + +成功的脚本注入应该显示类似以下日志: + +``` +设置网络拦截和脚本注入... +成功设置模型列表网络拦截 +成功解析 6 个模型从油猴脚本 +添加了 6 个注入的模型到API模型列表 +✅ 脚本注入成功,模型显示效果与油猴脚本100%一致 + 解析的模型: 👑 Kingfall, ✨ Gemini Pro, 🦁 Goldmane... +``` + +### 进入容器检查 + +```bash +# 进入容器 +docker compose exec ai-studio-proxy /bin/bash + +# 检查脚本文件 +cat /app/browser_utils/more_models.js + +# 检查脚本文件列表 +ls -la /app/browser_utils/*.js + +# 退出容器 +exit +``` + +## 故障排除 + +### 脚本注入失败 + +1. **检查配置文件路径**: + + ```bash + docker compose exec ai-studio-proxy ls -la /app/browser_utils/ + ``` + +2. **检查文件权限**: + + ```bash + docker compose exec ai-studio-proxy cat /app/browser_utils/more_models.js + ``` + +3. **查看详细错误日志**: + ```bash + docker compose logs | grep -A 5 -B 5 "脚本注入" + ``` + +### 脚本文件无效 + +1. **验证 JavaScript 格式**: + + ```bash + # 在主机上验证 JavaScript 语法 + node -c browser_utils/more_models.js + ``` + +2. **检查必需字段**: + 确保每个模型都有 `name` 和 `displayName` 字段。 + +### 禁用脚本注入 + +如果遇到问题,可以临时禁用: + +```bash +# 在 .env 文件中设置 +echo "ENABLE_SCRIPT_INJECTION=false" >> .env + +# 重启容器以应用配置更改 +docker compose up -d +``` + +## 高级配置 + +### 使用自定义脚本 + +```bash +# 1. 将自定义脚本放在 browser_utils/ 目录 +cp your_custom_script.js ../browser_utils/custom_injector.js + +# 2. 在 .env 中修改脚本路径 +echo "USERSCRIPT_PATH=browser_utils/custom_injector.js" >> .env + +# 3. 重启容器以应用配置更改 +docker compose up -d +``` + +### 多环境配置 + +```bash +# 开发环境 +cp .env.docker .env.dev +# 编辑 .env.dev + +# 生产环境 +cp .env.docker .env.prod +# 编辑 .env.prod + +# 使用特定环境启动 +cp .env.prod .env +docker compose up -d +``` + +## 注意事项 + +1. **文件挂载**: 确保主机上的文件路径正确 +2. **权限问题**: Docker 容器内的文件权限可能需要调整 +3. **重启生效**: 配置更改后需要重启容器 +4. **日志监控**: 通过日志确认脚本注入状态 +5. **备份配置**: 建议备份工作的配置文件 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..f0bcc285149cc4440f7fef6af2a1b668fbb5af36 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,62 @@ +services: + ai-studio-proxy: + build: + context: .. + dockerfile: docker/Dockerfile + container_name: ai-studio-proxy-container + mem_limit: ${DOCKER_MEMORY_LIMIT:-0} + memswap_limit: ${DOCKER_MEMSWAP_LIMIT:-0} + ports: + - "${HOST_FASTAPI_PORT:-2048}:${DEFAULT_FASTAPI_PORT:-2048}" + - "${HOST_STREAM_PORT:-3120}:${STREAM_PORT:-3120}" + volumes: + # 挂载认证文件目录 (必需) + - ../auth_profiles:/app/auth_profiles + # 挂载 .env 配置文件 (推荐) + # 请将 docker/.env.docker 复制为 docker/.env 并根据需要修改 + - ../docker/.env:/app/.env:ro + # 挂载日志目录 (可选,用于持久化日志) + # 如果出现权限报错,需要修改日志目录权限 sudo chmod -R 777 ../logs + # - ../logs:/app/logs + # 挂载自定义证书 (可选) + # - ../certs:/app/certs:ro + # 挂载脚本注入相关文件 (可选,用于自定义脚本和模型配置) + # 如果您有自定义的油猴脚本或模型配置,可以取消注释以下行 + # - ../browser_utils/custom_scripts:/app/browser_utils/custom_scripts:ro + # - ../browser_utils/model_configs.json:/app/browser_utils/model_configs.json:ro + environment: + # 这些环境变量会覆盖 .env 文件中的设置 + # 如果您想使用 .env 文件,可以注释掉这些行 + - PYTHONUNBUFFERED=1 + # - PORT=${PORT:-8000} + # - DEFAULT_FASTAPI_PORT=${DEFAULT_FASTAPI_PORT:-2048} + # - DEFAULT_CAMOUFOX_PORT=${DEFAULT_CAMOUFOX_PORT:-9222} + # - STREAM_PORT=${STREAM_PORT:-3120} + # - SERVER_LOG_LEVEL=${SERVER_LOG_LEVEL:-INFO} + # - DEBUG_LOGS_ENABLED=${DEBUG_LOGS_ENABLED:-false} + # - AUTO_CONFIRM_LOGIN=${AUTO_CONFIRM_LOGIN:-true} + # 代理配置 (可选) + # - HTTP_PROXY=${HTTP_PROXY} + # - HTTPS_PROXY=${HTTPS_PROXY} + # - UNIFIED_PROXY_CONFIG=${UNIFIED_PROXY_CONFIG} + restart: unless-stopped + healthcheck: + test: + [ + "CMD", + "curl", + "-f", + "http://localhost:${DEFAULT_FASTAPI_PORT:-2048}/health", + ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + # 可选:如果需要特定的网络配置 + # networks: + # - ai-studio-network + +# 可选:自定义网络 +# networks: +# ai-studio-network: +# driver: bridge diff --git a/docker/update.sh b/docker/update.sh new file mode 100644 index 0000000000000000000000000000000000000000..16d4e6c8dcda2c9bf6dbaf3993b52a8d7762aea5 --- /dev/null +++ b/docker/update.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +# 定义颜色变量以便复用 +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +set -e + +echo -e "${GREEN}==> 正在更新并重启服务...${NC}" + +# 获取脚本所在的目录,并切换到项目根目录 +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd) +cd "$SCRIPT_DIR/.." + +echo -e "${YELLOW}--> 步骤 1/4: 拉取最新的代码...${NC}" +git pull + +cd "$SCRIPT_DIR" + +echo -e "${YELLOW}--> 步骤 2/4: 停止并移除旧的容器...${NC}" +docker compose down + +echo -e "${YELLOW}--> 步骤 3/4: 使用 Docker Compose 构建并启动新容器...${NC}" +docker compose up -d --build + +echo -e "${YELLOW}--> 步骤 4/4: 显示当前运行的容器状态...${NC}" +docker compose ps + +echo -e "${GREEN}==> 更新完成!${NC}" diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000000000000000000000000000000000000..ef820d0eb63b726e95946a49892d323b05e9f4d6 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,23 @@ +# 文档总览 + +本目录文档以当前代码实现为准,重点覆盖:上手、部署、配置、运维与开发。 + +## 文档索引 + +- [快速开始](quick-start.md) +- [部署与运维指南](deployment-and-operations.md) +- [配置参考](configuration-reference.md) +- [API 使用说明](api-usage.md) +- [函数调用模式](function-calling.md) +- [认证轮转与 Cookie 刷新](auth-rotation-cookie-refresh.md) +- [排障指南](troubleshooting.md) +- [开发、测试与发布](development-and-release.md) +- [旧文档迁移说明](migration-notes.md) + +## 适用范围 + +- 后端服务:`api_utils/`、`stream/` +- 浏览器自动化:`browser_utils/` +- 配置:`config/` 与 `.env` +- 启动器:`launch_camoufox.py`、`gui/` +- Docker:`docker/` diff --git a/docs/api-usage.md b/docs/api-usage.md new file mode 100644 index 0000000000000000000000000000000000000000..dfba5a57943e9c9eea323bea04bd1bc7e8df03f7 --- /dev/null +++ b/docs/api-usage.md @@ -0,0 +1,97 @@ +# API 使用说明 + +## 1. 核心 OpenAI 兼容接口 + +## 1.1 健康检查 + +```bash +curl http://127.0.0.1:2048/health +``` + +返回 `200` 表示核心状态正常;`503` 时请关注 `details` 字段中的 browser/page/worker 状态。 + +## 1.2 模型列表 + +```bash +curl http://127.0.0.1:2048/v1/models +``` + +## 1.3 聊天补全(非流式) + +```bash +curl -X POST http://127.0.0.1:2048/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model":"gemini-2.5-pro", + "messages":[{"role":"user","content":"请总结今天的任务"}], + "temperature":0.8 + }' +``` + +## 1.4 聊天补全(流式) + +```bash +curl -X POST http://127.0.0.1:2048/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model":"gemini-2.5-pro", + "stream":true, + "messages":[{"role":"user","content":"写一段 100 字短文"}] + }' --no-buffer +``` + +--- + +## 2. API Key 鉴权 + +当密钥文件中存在有效 key 时,`/v1/*`(除公开白名单)将开启鉴权。 + +支持两种请求头: + +- `Authorization: Bearer `(推荐) +- `X-API-Key: `(兼容) + +管理接口: + +- `GET /api/keys`:查询密钥 +- `POST /api/keys`:新增密钥 +- `POST /api/keys/test`:测试密钥 +- `DELETE /api/keys`:删除密钥 + +--- + +## 3. 队列与请求控制 + +- `GET /v1/queue`:查看排队请求、等待时长、是否被取消 +- `POST /v1/cancel/{req_id}`:取消排队中的请求 + +示例: + +```bash +curl http://127.0.0.1:2048/v1/queue +curl -X POST http://127.0.0.1:2048/v1/cancel/abc1234 +``` + +--- + +## 4. 服务端管理接口(Web UI 同源使用) + +- `GET /api/info` +- `GET /api/server/status` +- `POST /api/server/restart` +- `GET/POST /api/proxy/config` +- `POST /api/proxy/test` +- `GET/POST /api/helper/config` +- `GET/POST /api/ports/config` +- `GET /api/ports/status` +- `POST /api/ports/kill` +- `GET /api/auth/files` +- `GET /api/auth/active` +- `POST /api/auth/activate` +- `DELETE /api/auth/deactivate` +- `GET /api/model-capabilities` +- `GET /api/model-capabilities/{model_id}` +- `WS /ws/logs` + +> 这些接口主要为内置管理 UI 服务;若你要对外暴露,请在网关层做访问控制。 + diff --git a/docs/architecture/FC_DEBUG_LOGGING_DESIGN.md b/docs/architecture/FC_DEBUG_LOGGING_DESIGN.md new file mode 100644 index 0000000000000000000000000000000000000000..24b04fce7814462705ff9ea37b9fcc390503eef8 --- /dev/null +++ b/docs/architecture/FC_DEBUG_LOGGING_DESIGN.md @@ -0,0 +1,10 @@ +# FC Debug Logging Design + +函数调用调试日志采用“总开关 + 模块开关”模式: + +- 总开关:`FUNCTION_CALLING_DEBUG` +- 模块开关:`FC_DEBUG_ORCHESTRATOR/UI/CACHE/WIRE/DOM/SCHEMA/RESPONSE` +- 轮转策略:`FC_DEBUG_LOG_MAX_BYTES` + `FC_DEBUG_LOG_BACKUP_COUNT` +- 截断策略:`FC_DEBUG_TRUNCATE_*` + +日志目录:`logs/fc_debug/`。 diff --git a/docs/architecture/FUNCTION_CALLING_IMPROVEMENTS.md b/docs/architecture/FUNCTION_CALLING_IMPROVEMENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..2cd0a4d8915ae1c5d63fd6dd50affa11d5ab8421 --- /dev/null +++ b/docs/architecture/FUNCTION_CALLING_IMPROVEMENTS.md @@ -0,0 +1,17 @@ +# Function Calling Improvements + +本文件说明 `.env` 中 FC-001 ~ FC-004 相关配置的设计目的。 + +## FC-001 `FUNCTION_CALLING_THOUGHT_SIGNATURE` + +为 Gemini 3 系列模型在函数调用回放时补齐 `thoughtSignature`,降低校验失败概率。 + +## FC-004 `FUNCTION_CALLING_UPPERCASE_TYPES` + +可选地将 JSON Schema 类型转换为大写(如 `string -> STRING`)。 +默认关闭,建议仅在目标 UI 验证通过后启用。 + +## 建议默认值 + +- `FUNCTION_CALLING_THOUGHT_SIGNATURE=true` +- `FUNCTION_CALLING_UPPERCASE_TYPES=false` diff --git a/docs/auth-rotation-cookie-refresh.md b/docs/auth-rotation-cookie-refresh.md new file mode 100644 index 0000000000000000000000000000000000000000..d735dd63a162776690f6ffda2a341785fadac172 --- /dev/null +++ b/docs/auth-rotation-cookie-refresh.md @@ -0,0 +1,51 @@ +# 认证轮转与 Cookie 刷新 + +## 1. 目录角色 + +- `auth_profiles/active/`:当前生效认证(运行时读取) +- `auth_profiles/saved/`:可切换认证池 +- `auth_profiles/emergency/`:应急认证池 + +通常建议: + +1. 先在 debug 下登录并沉淀多个可用账号到 `saved/` +2. 生产运行时确保 `active/` 始终至少有一个可用 profile + +## 2. 推荐上线流程 + +1. `--debug` 完成登录。 +2. 验证 `/v1/models`、`/v1/chat/completions` 正常。 +3. 将稳定账号纳入 `saved/` 池。 +4. 切换 `--headless` 长期运行。 + +## 3. 自动轮转策略 + +关键配置: + +- `AUTO_ROTATE_AUTH_PROFILE=true` +- `AUTO_AUTH_ROTATION_ON_STARTUP=true|false` +- `QUOTA_SOFT_LIMIT`:软阈值(倾向“当前请求结束后轮转”) +- `QUOTA_HARD_LIMIT`:硬阈值(触发更强恢复策略) +- `QUOTA_LIMIT_`:模型粒度阈值 + +## 4. Cookie 自动刷新 + +建议长期运行时开启: + +- `COOKIE_REFRESH_ENABLED=true` +- `COOKIE_REFRESH_INTERVAL_SECONDS=1800` +- `COOKIE_REFRESH_ON_REQUEST_ENABLED=true` +- `COOKIE_REFRESH_REQUEST_INTERVAL=10` +- `COOKIE_REFRESH_ON_SHUTDOWN=true` + +效果: + +- 降低长期运行下 cookie 过期风险 +- 关停前自动落盘,减少重启失效 + +## 5. 管理接口(便于运维) + +- `GET /api/auth/files`:列出 saved 文件与当前 active +- `POST /api/auth/activate`:切换某个认证文件为 active +- `DELETE /api/auth/deactivate`:清空 active + diff --git a/docs/authentication-setup.md b/docs/authentication-setup.md new file mode 100644 index 0000000000000000000000000000000000000000..b491703fac4d0661477b40a4dadb9f5c575af189 --- /dev/null +++ b/docs/authentication-setup.md @@ -0,0 +1,6 @@ +# 认证设置(兼容入口) + +该文档已迁移,请阅读: + +- [认证轮转与 Cookie 刷新](auth-rotation-cookie-refresh.md) +- [快速开始](quick-start.md) diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md new file mode 100644 index 0000000000000000000000000000000000000000..88b801a5edb2e34c4a7b570136a8ddebd930dad9 --- /dev/null +++ b/docs/configuration-reference.md @@ -0,0 +1,115 @@ +# 配置参考 + +配置文件入口:项目根目录 `.env`(可由 `.env.example` 复制)。 + +> 说明:下表“默认值”以 `.env.example` 为主;若未设置,代码里也有兜底值,少数项可能不同(例如 `FUNCTION_CALLING_MODE`)。 + +## 1. 网络与端口 + +| 配置项 | 默认值 | 说明 | +| --- | --- | --- | +| `PORT` | `2048` | FastAPI 主服务端口。 | +| `STREAM_PORT` | `3120` | 流代理端口;`0` 表示关闭流代理。 | +| `DEFAULT_FASTAPI_PORT` | `2048` | 启动器默认端口(UI/CLI 提示用)。 | +| `DEFAULT_CAMOUFOX_PORT` | `9222` | 启动器默认 Camoufox 调试端口。 | +| `UNIFIED_PROXY_CONFIG` | 空 | 统一代理入口,优先级高于 HTTP/HTTPS 代理。 | +| `HTTP_PROXY` / `HTTPS_PROXY` | 空 | 兼容代理配置。 | +| `NO_PROXY` | 空 | 代理绕过规则。 | + +## 2. 启动与浏览器 + +| 配置项 | 默认值 | 说明 | +| --- | --- | --- | +| `LAUNCH_MODE` | `normal` | 启动模式:`normal/debug/headless/virtual_display/direct_debug_no_browser`。 | +| `CAMOUFOX_WS_ENDPOINT` | 空 | 外部浏览器 WebSocket 地址;常规启动时由 launcher 注入。 | +| `DIRECT_LAUNCH` | `false` | 跳过菜单直接按配置启动。 | +| `ENDPOINT_CAPTURE_TIMEOUT` | `45` | 捕获浏览器 ws 端点超时时间(秒)。 | +| `ONLY_COLLECT_CURRENT_USER_ATTACHMENTS` | `false` | 限制附件收集范围。 | + +## 3. 认证、轮转、Cookie 刷新 + +| 配置项 | 默认值 | 说明 | +| --- | --- | --- | +| `AUTO_SAVE_AUTH` | `false` | Debug 登录成功后自动保存认证。 | +| `AUTH_SAVE_TIMEOUT` | `30` | 保存认证等待超时(秒)。 | +| `AUTO_ROTATE_AUTH_PROFILE` | `true` | 配额/异常时自动轮转认证。 | +| `AUTO_AUTH_ROTATION_ON_STARTUP` | `false` | 启动时自动选取可用 profile。 | +| `AUTO_CONFIRM_LOGIN` | `true` | 自动确认登录流程。 | +| `QUOTA_SOFT_LIMIT` | `850000` | 软阈值(请求完成后轮转)。 | +| `QUOTA_HARD_LIMIT` | `950000` | 硬阈值(更强保护/更快触发恢复)。 | +| `QUOTA_LIMIT_` | 空 | 某模型的专属阈值(高级用法)。 | +| `COOKIE_REFRESH_ENABLED` | `true` | 启用周期性 cookie 刷新。 | +| `COOKIE_REFRESH_INTERVAL_SECONDS` | `1800` | 周期刷新间隔(秒)。 | +| `COOKIE_REFRESH_ON_REQUEST_ENABLED` | `true` | 按请求计数触发刷新。 | +| `COOKIE_REFRESH_REQUEST_INTERVAL` | `10` | 每成功 N 次请求触发保存。 | +| `COOKIE_REFRESH_ON_SHUTDOWN` | `true` | 优雅关停时保存 cookie。 | + +## 4. API 默认采样与能力开关 + +| 配置项 | 默认值 | 说明 | +| --- | --- | --- | +| `DEFAULT_TEMPERATURE` | `1.0` | 默认温度。 | +| `DEFAULT_MAX_OUTPUT_TOKENS` | `65536` | 默认输出 token 上限。 | +| `DEFAULT_TOP_P` | `0.95` | 默认 `top_p`。 | +| `DEFAULT_STOP_SEQUENCES` | `["User:"]` | 默认停用序列(JSON 字符串)。 | +| `ENABLE_THINKING_BUDGET` | `true` | 启用 thinking budget。 | +| `DEFAULT_THINKING_BUDGET` | `8192` | 默认 thinking budget。 | +| `THINKING_BUDGET_LOW/MEDIUM/HIGH` | `10923/21845/32768` | 分级预算。 | +| `DEFAULT_THINKING_LEVEL_PRO` | `high` | Pro 系列默认思考等级。 | +| `DEFAULT_THINKING_LEVEL_FLASH` | `high` | Flash 系列默认思考等级。 | +| `DISABLE_THINKING_BUDGET_ON_STREAMING_DISABLE` | `false` | 关闭 stream 时是否自动关闭 thinking budget。 | +| `ENABLE_GOOGLE_SEARCH` | `false` | 开启 Google Search 能力映射。 | +| `ENABLE_URL_CONTEXT` | `false` | 开启 URL Context 能力映射。 | + +## 5. Function Calling(核心) + +| 配置项 | 默认值 | 说明 | +| --- | --- | --- | +| `FUNCTION_CALLING_MODE` | `.env` 为 `auto` | 模式:`auto/native/emulated`(代码兜底为 `emulated`)。 | +| `FUNCTION_CALLING_NATIVE_FALLBACK` | `true` | native 失败后回退 emulated。 | +| `FUNCTION_CALLING_UI_TIMEOUT` | `10000` | UI 操作超时(毫秒)。 | +| `FUNCTION_CALLING_NATIVE_RETRY_COUNT` | `3` | native 重试次数。 | +| `FUNCTION_CALLING_CLEAR_BETWEEN_REQUESTS` | `true` | 请求间是否清理函数定义。 | +| `FUNCTION_CALLING_CACHE_ENABLED` | `true` | 开启 FC 状态缓存。 | +| `FUNCTION_CALLING_CACHE_TTL` | `0` | 缓存 TTL(0 表示会话内不过期)。 | +| `FUNCTION_CALLING_THOUGHT_SIGNATURE` | `true` | Gemini 3 兼容字段。 | +| `FUNCTION_CALLING_UPPERCASE_TYPES` | `false` | schema type 大写兼容模式。 | + +调试相关: + +- `FUNCTION_CALLING_DEBUG` +- `FC_DEBUG_*`(模块开关、级别、截断、合并日志) + +## 6. 日志与诊断 + +| 配置项 | 默认值 | 说明 | +| --- | --- | --- | +| `SERVER_LOG_LEVEL` | `INFO` | 主日志级别。 | +| `SERVER_REDIRECT_PRINT` | `false` | 是否将 `print` 重定向到日志。 | +| `DEBUG_LOGS_ENABLED` | `false` | DEBUG 级日志总开关。 | +| `TRACE_LOGS_ENABLED` | `false` | TRACE 级日志总开关。 | +| `JSON_LOGS` | `false` | JSON 结构化日志。 | +| `LOG_FILE_MAX_BYTES` | `10485760` | 日志切割大小。 | +| `LOG_FILE_BACKUP_COUNT` | `5` | 滚动日志保留份数。 | + +## 7. 超时与稳态参数 + +常用项(按需调优): + +- `RESPONSE_COMPLETION_TIMEOUT` +- `SILENCE_TIMEOUT_MS` +- `CLICK_TIMEOUT_MS` +- `WAIT_FOR_ELEMENT_TIMEOUT_MS` +- `STREAM_MAX_INITIAL_ERRORS` +- `STREAM_WARNING_INTERVAL_AFTER_SUPPRESS` +- `STREAM_SUPPRESS_DURATION_AFTER_INITIAL_BURST` + +## 8. GUI 相关 + +仅 GUI 启动器会使用: + +- `GUI_DEFAULT_PROXY_ADDRESS` +- `GUI_DEFAULT_STREAM_PORT` +- `GUI_DEFAULT_HELPER_ENDPOINT` +- `SKIP_FRONTEND_BUILD` + diff --git a/docs/daily-usage.md b/docs/daily-usage.md new file mode 100644 index 0000000000000000000000000000000000000000..c9e1a3b761422ac7ab13cec766cb48c7e2b3a911 --- /dev/null +++ b/docs/daily-usage.md @@ -0,0 +1,6 @@ +# 日常使用(兼容入口) + +该文档已迁移,请阅读: + +- [API 使用说明](api-usage.md) +- [排障指南](troubleshooting.md) diff --git a/docs/deployment-and-operations.md b/docs/deployment-and-operations.md new file mode 100644 index 0000000000000000000000000000000000000000..93407ba81404db9fe1e2eef4e42a697830c2ff07 --- /dev/null +++ b/docs/deployment-and-operations.md @@ -0,0 +1,201 @@ +# 部署与运维指南 + +本文档聚焦“可落地部署”和“稳定运行”两件事: + +- 本机/服务器长期运行 +- Docker 部署与升级 +- 日常运维检查与常见操作 + +--- + +## 1. 部署模式选择 + +### 模式 A:直接运行(推荐调试、灵活控制) + +适合:首次接入、需要手动登录、需要快速排障。 + +核心命令: + +```bash +poetry run python launch_camoufox.py --debug +poetry run python launch_camoufox.py --headless +``` + +### 模式 B:Docker(推荐稳定托管) + +适合:长期驻留、标准化部署、跨机器迁移。 + +核心命令: + +```bash +cd docker +docker compose up -d --build +``` + +--- + +## 2. 直接运行:生产化建议 + +## 2.1 必要目录与文件 + +确保以下目录存在(程序会自动创建,但建议提前理解结构): + +- `auth_profiles/active/`:当前启用认证 +- `auth_profiles/saved/`:可切换认证池 +- `auth_profiles/emergency/`:紧急认证池 +- `logs/`:应用日志 + +## 2.2 启动参数建议 + +- 首次登录:`--debug` +- 日常运行:`--headless` +- 无 GUI Linux:`--virtual-display` +- 关闭流代理:`--stream-port 0` +- 指定端口:`--server-port 2048 --stream-port 3120` + +## 2.3 systemd(Linux)示例 + +可使用如下服务文件(示例路径 `/etc/systemd/system/aistudio-proxy.service`): + +```ini +[Unit] +Description=AI Studio Proxy API +After=network.target + +[Service] +Type=simple +WorkingDirectory=/opt/AIstudioProxyAPI +ExecStart=/usr/bin/env poetry run python launch_camoufox.py --headless +Restart=always +RestartSec=5 +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=multi-user.target +``` + +启用与查看: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now aistudio-proxy +sudo systemctl status aistudio-proxy +journalctl -u aistudio-proxy -f +``` + +--- + +## 3. Docker 部署 + +## 3.1 关键前提 + +Docker 方式通常运行在无头模式,不适合做首次交互登录。因此请先准备认证文件: + +- 在宿主机本地执行一次 `--debug` 登录 +- 确保 `auth_profiles/active/*.json` 存在 + +## 3.2 首次启动 + +```bash +cd docker +cp .env.docker .env +# 编辑 docker/.env + +docker compose up -d --build +``` + +## 3.3 运行与检查 + +```bash +docker compose ps +docker compose logs -f +curl http://127.0.0.1:2048/health +``` + +## 3.4 常用维护命令 + +```bash +# 重启 +docker compose restart + +# 停止 +docker compose down + +# 更新(项目提供脚本) +bash update.sh + +# 进入容器排查 +docker compose exec ai-studio-proxy /bin/bash +``` + +## 3.5 多实例扩展 + +如需为多个已保存认证同时启动隔离容器,可使用外部管理脚本 [`scripts/multi-instance-manager/README.md`](../scripts/multi-instance-manager/README.md)。 + +当前 multi-instance 管理器会为每个容器显式设置独立的 `ACTIVE_AUTH_JSON_PATH`,并把选中的 profile 复制到 [`.multi-instance-runtime/active/`](../.multi-instance-runtime/) 后再挂载为容器内的 [`auth_profiles/active/`](../auth_profiles/active/) 单文件。默认同时关闭启动时自动轮转与运行时自动轮转,这样既能维持一容器一认证隔离,也能满足 Docker headless 启动阶段对 active-dir 的运行时要求。 + +镜像名方面,管理器默认仍优先查找 `ai-studio-proxy:latest`。但如果你是通过 `cd docker && docker compose build` 构建,Compose 常见产物会是 `docker-ai-studio-proxy:latest`;在未显式覆盖镜像名时,脚本会自动回退到该 Compose 镜像并打印提示。若你通过 `--image` 或 `IMAGE_NAME` 指定了其他镜像名,则脚本会严格使用该值,不会静默改写。 + +如需让单个容器重新参与跨 profile 轮转,可在脚本中使用 `--enable-auth-rotation`,但这会放宽默认的一容器一认证隔离约束。 + +该方案面向高级用户,不修改主应用、Dockerfile 或默认 Docker 启动流程。 + +--- + +## 4. 生产配置建议(重点) + +## 4.1 网络与代理 + +- 优先使用 `UNIFIED_PROXY_CONFIG` +- 仅在兼容场景下再使用 `HTTP_PROXY` / `HTTPS_PROXY` +- 有内网例外地址时补充 `NO_PROXY` + +## 4.2 稳定性参数 + +- `RESPONSE_COMPLETION_TIMEOUT` +- `SILENCE_TIMEOUT_MS` +- `WAIT_FOR_ELEMENT_TIMEOUT_MS` +- `STREAM_MAX_INITIAL_ERRORS` + +建议逐步小幅调整,并通过 `logs/` 观察变化。 + +## 4.3 认证与轮转 + +推荐开启: + +- `AUTO_ROTATE_AUTH_PROFILE=true` +- `COOKIE_REFRESH_ENABLED=true` +- `COOKIE_REFRESH_ON_SHUTDOWN=true` + +如配额波动明显,再结合: + +- `QUOTA_SOFT_LIMIT` +- `QUOTA_HARD_LIMIT` +- `QUOTA_LIMIT_`(按模型单独阈值) + +--- + +## 5. 运维巡检清单 + +- `/health` 是否为 200 +- `/v1/models` 是否返回有效模型 +- 队列积压是否异常:`/v1/queue` +- 浏览器与页面状态是否 ready(`/health.details`) +- 日志中是否持续出现 quota / reconnect / timeout + +建议至少做一个简单定时巡检: + +```bash +curl -sf http://127.0.0.1:2048/health >/dev/null || echo "health check failed" +``` + +--- + +## 6. 版本升级建议流程 + +1. 备份 `.env`、`auth_profiles/`。 +2. 拉取新代码并更新依赖。 +3. 先在 `--debug` 或测试环境验证关键模型。 +4. 再切到生产 headless。 +5. 观察 15~30 分钟日志后再宣告升级完成。 + diff --git a/docs/development-and-release.md b/docs/development-and-release.md new file mode 100644 index 0000000000000000000000000000000000000000..4148e95c056b72a7f971fbed508bf97ca8349a2f --- /dev/null +++ b/docs/development-and-release.md @@ -0,0 +1,42 @@ +# 开发、测试与发布 + +## 1. 本地开发 + +```bash +poetry install --with dev +poetry run ruff check . +poetry run pyright +poetry run pytest +``` + +## 2. 常用测试建议 + +- 先跑变更相关测试(模块级) +- 再跑全量 `pytest` +- 变更配置/轮转/队列逻辑时,优先覆盖对应 `tests/test_*` 用例 + +## 3. 调试建议 + +- 启动模式:`poetry run python launch_camoufox.py --debug` +- API 观察:`/health`、`/v1/queue`、`/ws/logs` +- Function Calling 排障:开启 `FUNCTION_CALLING_DEBUG` + 精确 `FC_DEBUG_*` + +## 4. CI/CD(仓库工作流) + +- PR 检查:lint + type check + tests +- Release:支持 tag / nightly / 手动触发 +- Upstream Sync:同步上游并自动建 PR + +## 5. 发布最小流程(建议) + +```bash +# 1) 保证主分支干净并通过测试 +poetry run ruff check . +poetry run pyright +poetry run pytest + +# 2) 打 tag +git tag v0.1.0 +git push origin v0.1.0 +``` + diff --git a/docs/environment-configuration.md b/docs/environment-configuration.md new file mode 100644 index 0000000000000000000000000000000000000000..a1ea68389fe3d3ff2c9b4a90de63fccf7354d90e --- /dev/null +++ b/docs/environment-configuration.md @@ -0,0 +1,6 @@ +# 环境配置(兼容入口) + +该文档已迁移,请阅读: + +- [配置参考](configuration-reference.md) +- [快速开始](quick-start.md) diff --git a/docs/function-calling.md b/docs/function-calling.md new file mode 100644 index 0000000000000000000000000000000000000000..a055aa4a3fd39e7868848d290f8663055d6429d6 --- /dev/null +++ b/docs/function-calling.md @@ -0,0 +1,29 @@ +# 函数调用模式 + +本项目支持 OpenAI 风格 `tools/tool_calls`,并提供三种模式: + +- `auto`:优先 native,失败自动回退 emulated(推荐) +- `native`:完全依赖 AI Studio 原生函数调用 UI +- `emulated`:文本注入方式,兼容旧流程 + +## 关键配置 + +- `FUNCTION_CALLING_MODE` +- `FUNCTION_CALLING_NATIVE_FALLBACK` +- `FUNCTION_CALLING_UI_TIMEOUT` +- `FUNCTION_CALLING_NATIVE_RETRY_COUNT` +- `FUNCTION_CALLING_CLEAR_BETWEEN_REQUESTS` + +## 缓存与调试 + +- `FUNCTION_CALLING_CACHE_ENABLED` +- `FUNCTION_CALLING_CACHE_TTL` +- `FUNCTION_CALLING_DEBUG` +- `FC_DEBUG_*`(模块级日志) + +## 兼容增强 + +- `FUNCTION_CALLING_THOUGHT_SIGNATURE` +- `FUNCTION_CALLING_UPPERCASE_TYPES` + +> 建议默认保持 `THOUGHT_SIGNATURE=true`、`UPPERCASE_TYPES=false`,除非你已验证目标 UI 行为。 diff --git a/docs/huggingface-spaces.md b/docs/huggingface-spaces.md new file mode 100644 index 0000000000000000000000000000000000000000..ed4e248da65e53b9fb155f2d70985d8a371d2107 --- /dev/null +++ b/docs/huggingface-spaces.md @@ -0,0 +1,86 @@ +# Hugging Face Spaces 部署 + +本專案可以用 Docker Space 部署。Space 只會對外暴露一個 HTTP port,因此主 API 在 Hugging Face 上固定使用 `7860`,流式代理仍可在容器內部使用 `STREAM_PORT`。 + +## 1. 先準備 Google AI Studio 認證 + +Hugging Face 的容器是 headless,不能在啟動時互動登入。請先在本機完成一次登入並產生 `auth_profiles/active/*.json`: + +```bash +poetry install +poetry run python launch_camoufox.py --debug +``` + +確認 `auth_profiles/active/` 下面有可用的 `.json` 後,把它轉成 base64,準備放到 Hugging Face Secret。 + +PowerShell: + +```powershell +[Convert]::ToBase64String([IO.File]::ReadAllBytes("auth_profiles\active\your-auth.json")) +``` + +Bash: + +```bash +base64 -w 0 auth_profiles/active/your-auth.json +``` + +## 2. 建立 Space + +在 Hugging Face 建立新的 Space: + +- SDK: Docker +- Visibility: 建議 Private +- Hardware: 建議至少 4GB RAM;瀏覽器啟動失敗時請升級硬體 + +## 3. 設定 Secrets + +在 Space 的 Settings -> Secrets 新增: + +| Secret | 必填 | 說明 | +| --- | --- | --- | +| `AISTUDIO_AUTH_JSON_B64` | 是 | 上一步產生的 base64 auth JSON | +| `AISTUDIO_PROXY_API_KEYS` | 強烈建議 | API key,多組可用換行、逗號或分號分隔 | +| `UNIFIED_PROXY_CONFIG` | 視網路需要 | 例如 `http://host:port` 或 `socks5://host:port` | +| `SERVER_LOG_LEVEL` | 否 | 預設 `INFO` | +| `STREAM_PORT` | 否 | 預設 `3120`;設 `0` 可關閉內部流式代理 | + +也可以改用 `AISTUDIO_AUTH_JSON` 放原始 JSON,但 base64 對多行內容較穩。 + +## 4. 推送到 Space + +如果你用這個 repo 直接部署: + +```bash +git remote add space https://huggingface.co/spaces// +git push space main +``` + +若 Hugging Face Space 是空 repo,也可以把本專案內容推到該 Space repo。根目錄的 `Dockerfile` 和 README metadata 會讓 Space 自動用 Docker 建置。 + +## 5. 使用 API + +部署完成後: + +```bash +curl https://-.hf.space/health +curl https://-.hf.space/v1/models +``` + +OpenAI compatible base URL: + +```text +https://-.hf.space/v1 +``` + +如果設定了 `AISTUDIO_PROXY_API_KEYS`,請用: + +```text +Authorization: Bearer +``` + +## 注意 + +- 不要把 `auth_profiles/*.json` commit 到 Git 或公開 Space。 +- Free Space 會休眠,重啟後會重新載入 Secret 中的 auth JSON。 +- Google 登入狀態可能過期,過期時需要在本機重新登入並更新 `AISTUDIO_AUTH_JSON_B64`。 diff --git a/docs/migration-notes.md b/docs/migration-notes.md new file mode 100644 index 0000000000000000000000000000000000000000..b1ff1ed852af466d0fab4fde4090254082460b9f --- /dev/null +++ b/docs/migration-notes.md @@ -0,0 +1,21 @@ +# 旧文档迁移说明 + +本次同步后,文档已按当前实现重写并重新分组。 + +## 迁移原则 + +- 以当前代码行为为准,不保留与实现不一致的旧描述。 +- 合并重复主题,减少冗余文档。 +- 保留关键运维与开发路径(配置、认证、函数调用、发布)。 + +## 主要变化 + +- 原先分散的环境/安装/日常文档合并为: + - `quick-start.md` + - `configuration-reference.md` + - `troubleshooting.md` +- 新增函数调用与认证轮转专题: + - `function-calling.md` + - `auth-rotation-cookie-refresh.md` +- 开发流程集中到: + - `development-and-release.md` diff --git a/docs/quick-start.md b/docs/quick-start.md new file mode 100644 index 0000000000000000000000000000000000000000..c09aa39c9c2e9ab20e9dc41b9e734789ebc78496 --- /dev/null +++ b/docs/quick-start.md @@ -0,0 +1,96 @@ +# 快速开始 + +> 目标:用最短路径把服务跑起来,并完成一次可用请求。 + +## 1. 运行前确认 + +- Python `>=3.9,<4.0`(推荐 3.10/3.11) +- Poetry +- 可访问 Google AI Studio 的网络(如需要可配置 `UNIFIED_PROXY_CONFIG`) +- 首次登录建议有图形界面(或使用你已经准备好的 `auth_profiles/active/*.json`) + +## 2. 安装依赖 + +```bash +git clone https://github.com/CJackHwang/AIstudioProxyAPI.git +cd AIstudioProxyAPI +poetry install --with dev +``` + +> 可选:如果你希望用项目脚本一键安装,可使用 `scripts/install.sh`(Linux/macOS)或 `scripts/install.ps1`(Windows)。 + +## 3. 初始化配置 + +```bash +cp .env.example .env +``` + +建议至少先检查这些配置: + +- `PORT`:主 API 端口(默认 `2048`) +- `STREAM_PORT`:流代理端口(默认 `3120`,设 `0` 可关闭) +- `UNIFIED_PROXY_CONFIG`:统一代理(有网络限制时必填) +- `LAUNCH_MODE`:建议首次使用 `debug` +- `AUTO_SAVE_AUTH`:首次登录调试时可设为 `true` + +## 4. 首次认证(推荐流程) + +首次运行建议用可见浏览器进行登录并保存认证态: + +```bash +poetry run python launch_camoufox.py --debug +``` + +登录成功后,确认 `auth_profiles/active/` 下已有可用 `.json` 文件。后续可切换为无头模式。 + +## 5. 日常运行 + +```bash +poetry run python launch_camoufox.py --headless +``` + +Linux 无桌面环境可选: + +```bash +poetry run python launch_camoufox.py --virtual-display +``` + +## 6. 最小可用验证 + +```bash +# 健康检查 +curl http://127.0.0.1:2048/health + +# 模型列表 +curl http://127.0.0.1:2048/v1/models + +# 聊天补全 +curl -X POST http://127.0.0.1:2048/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{"model":"gemini-2.5-pro","messages":[{"role":"user","content":"你好"}]}' +``` + +## 7. Docker 快速路径(已有认证文件时) + +1. 确保宿主机已有 `auth_profiles/active/*.json`。 +2. 进入 Docker 目录并准备配置: + +```bash +cd docker +cp .env.docker .env +``` + +3. 启动: + +```bash +docker compose up -d --build +``` + +4. 检查: + +```bash +docker compose ps +docker compose logs -f +``` + +> 详细部署、更新与排障见 `docs/deployment-and-operations.md`。 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000000000000000000000000000000000000..8c0afc5977a3e9ee70788de9ec7afdff43765b98 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,83 @@ +# 排障指南 + +## 1. 启动阶段失败 + +### 现象 A:浏览器相关初始化失败 + +优先检查: + +1. `CAMOUFOX_WS_ENDPOINT` 是否被 launcher 正确注入(或你是否手动提供了有效地址) +2. 网络与代理配置(`UNIFIED_PROXY_CONFIG`) +3. 是否已有可用认证文件(`auth_profiles/active/*.json`) + +### 现象 B:`/health` 一直是 503 + +查看响应中的 `details`: + +- `is_playwright_ready` +- `is_browser_connected` +- `is_page_ready` +- `workerRunning` + +如果 `launchMode=direct_debug_no_browser`,browser/page 不是硬依赖。 + +--- + +## 2. 聊天接口超时或挂起 + +### 排查步骤 + +1. 检查请求是否持续积压:`GET /v1/queue` +2. 查看日志中的 timeout/silence 关键字 +3. 调整超时参数: + - `RESPONSE_COMPLETION_TIMEOUT` + - `SILENCE_TIMEOUT_MS` + - `WAIT_FOR_ELEMENT_TIMEOUT_MS` +4. 在 debug 模式观察 AI Studio 页面是否卡住 + +--- + +## 3. Function Calling 异常 + +建议顺序: + +1. `FUNCTION_CALLING_MODE=auto` +2. 开启 `FUNCTION_CALLING_DEBUG=true` +3. 仅打开必要 `FC_DEBUG_*` 模块(先 `ORCHESTRATOR/UI/WIRE`) +4. 观察 `logs/fc_debug/` 具体报错 + +如是 UI 结构变化导致,可核查 `config/selectors.py` 相关选择器。 + +--- + +## 4. 认证轮转不生效 / 频繁触发 + +检查: + +- `AUTO_ROTATE_AUTH_PROFILE` +- `AUTO_AUTH_ROTATION_ON_STARTUP` +- `QUOTA_SOFT_LIMIT` / `QUOTA_HARD_LIMIT` +- `saved/` 或 `emergency/` 是否有可用 profile + +如果“轮转后仍很快触发”,优先确认账号是否都已接近配额。 + +--- + +## 5. Docker 常见问题 + +### 认证文件问题 + +容器无头运行时不能完成交互登录。必须先在宿主机生成认证文件,再挂载 `auth_profiles/`。 + +### 健康检查失败 + +```bash +docker compose logs -f +docker compose exec ai-studio-proxy /bin/bash +curl -v http://127.0.0.1:2048/health +``` + +### 日志/权限问题 + +如果你挂载了 `../logs:/app/logs`,需保证目录可写。 + diff --git a/excluded_models.txt b/excluded_models.txt new file mode 100644 index 0000000000000000000000000000000000000000..cc81a1f4c7454a67142afbd4a455298d9215aa99 --- /dev/null +++ b/excluded_models.txt @@ -0,0 +1,19 @@ +gemini-1.5-flash-001 +gemini-1.5-pro-latest +gemini-1.5-pro-001 +gemini-1.5-flash-latest +gemini-1.5-flash-001-tuning +gemini-1.5-flash-8b-001 +gemini-1.5-flash-8b-latest +gemini-2.0-flash-exp +gemini-2.0-flash-lite-001 +learnlm-1.5-pro-experimental +imagen-3.0-generate-002 +veo-2.0-generate-001 +gemini-2.0-flash-live-001 +gemini-2.5-flash-preview-tts +gemini-2.5-pro-preview-tts +gemini-2.5-flash-preview-native-audio-dialog +gemini-2.5-flash-exp-native-audio-thinking-dialog +gemini-2.5-flash-native-audio-preview-12-2025 +gemini-3-pro-image-preview \ No newline at end of file diff --git a/gui/__init__.py b/gui/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0ab946e9f989142f815c1a3c13b96cb335758f75 --- /dev/null +++ b/gui/__init__.py @@ -0,0 +1,37 @@ +""" +AI Studio Proxy API - GUI Launcher Module + +This module provides a modern, user-friendly desktop GUI for managing +the AI Studio Proxy API service. + +Features: +- Modern dark theme with custom styling +- Bilingual support (English/Chinese) +- System tray support (Linux/Windows/macOS) +- Account management (create, import, export) +- Port and proxy configuration +- Real-time log streaming +- Advanced settings with .env file management + +Architecture: +- app.py - Main GUILauncher class +- config.py - Constants, paths, colors, defaults +- i18n.py - Translations and get_text() +- styles.py - ModernStyle theme class +- theme.py - Theme management (dark/light/system) +- tray.py - TrayIcon system tray integration +- utils.py - Tooltip, ScrollableListbox, StatusBar, helpers +- widgets.py - Custom widgets (CollapsibleFrame, SettingRow, EnvSettingsPanel) +- env_manager.py - Environment file (.env) management + +Usage: + poetry run python -m gui + +Or: + from gui import GUILauncher, main + main() +""" + +from .app import GUILauncher, main + +__all__ = ["GUILauncher", "main"] diff --git a/gui/__main__.py b/gui/__main__.py new file mode 100644 index 0000000000000000000000000000000000000000..8fdde095f500a290d24dde45df4b8104ade3d84b --- /dev/null +++ b/gui/__main__.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +""" +Entry point for running the GUI launcher as a module. + +Usage: + poetry run python -m gui +""" + +from .app import main + +if __name__ == "__main__": + main() diff --git a/gui/app.py b/gui/app.py new file mode 100644 index 0000000000000000000000000000000000000000..d99dc54a715f87130c02f4c69542c401cd62bba6 --- /dev/null +++ b/gui/app.py @@ -0,0 +1,1786 @@ +""" +GUI Launcher Main Application + +Modern GUI using CustomTkinter for a sleek, contemporary look. +""" + +import json +import os +import platform +import signal +import socket +import subprocess +import sys +import threading +import time +import webbrowser +from datetime import datetime +from tkinter import messagebox +from typing import Any, Dict, List, Optional + +import customtkinter as ctk + +from .config import ( + ACTIVE_AUTH_DIR, + COLORS, + CONFIG_FILE, + DEFAULT_CONFIG, + DIMENSIONS, + DOCS_URL, + ENV_EXAMPLE_FILE, + ENV_FILE, + FONTS, + GITHUB_URL, + LAUNCH_SCRIPT, + LOG_FILE, + PROJECT_ROOT, + SAVED_AUTH_DIR, + VERSION, +) +from .env_manager import get_env_manager +from .i18n import get_language, get_text, set_language +from .styles import apply_theme, get_button_colors +from .theme import get_appearance_mode, set_appearance_mode +from .tray import TrayIcon +from .utils import ( + CTkScrollableList, + CTkStatusBar, + CTkTooltip, + copy_to_clipboard, + validate_port, +) +from .widgets import CTkEnvSettingsPanel + + +class GUILauncher: + """Modern GUI Launcher with CustomTkinter and bilingual support.""" + + def __init__(self): + # Load configuration first + self.config = self._load_config() + + # Apply theme with saved appearance mode + appearance_mode = self.config.get("appearance_mode", "dark") + apply_theme(appearance_mode=appearance_mode) + + # Initialize EnvManager for advanced settings + self.env_manager = get_env_manager(ENV_FILE, ENV_EXAMPLE_FILE) + + # Create main window + self.root = ctk.CTk() + self.root.geometry("1100x800") + self.root.minsize(900, 650) + + # Set language from config + set_language(self.config.get("language", "en")) + + # Set window title + self.root.title(get_text("title")) + + # Initialize variables + self._init_variables() + + # Process state + self.process: Optional[subprocess.Popen] = None + self.log_thread: Optional[threading.Thread] = None + self.running = False + + # Tray icon + self.tray = TrayIcon(self) + + # Create log directory + LOG_FILE.parent.mkdir(parents=True, exist_ok=True) + + # Build UI + self._create_main_ui() + + # Load accounts + self._load_accounts() + + # Bind keyboard shortcuts + self._bind_shortcuts() + + # Start tray icon + if self.config.get("minimize_to_tray", True): + self.tray.create_icon() + + # Close handler + self.root.protocol("WM_DELETE_WINDOW", self._minimize_to_tray) + + def _init_variables(self): + """Initialize tkinter variables.""" + self.selected_account = ctk.StringVar(value=self.config.get("last_account", "")) + self.run_mode = ctk.StringVar(value="headless") + self.status = ctk.StringVar(value=get_text("status_ready")) + self.fastapi_port = ctk.StringVar( + value=str(self.config.get("fastapi_port", 2048)) + ) + self.stream_port = ctk.StringVar( + value=str(self.config.get("stream_port", 3120)) + ) + self.proxy_address = ctk.StringVar(value=self.config.get("proxy_address", "")) + self.proxy_enabled = ctk.BooleanVar( + value=self.config.get("proxy_enabled", False) + ) + self.language_var = ctk.StringVar(value=get_language()) + self.appearance_mode_var = ctk.StringVar( + value=self.config.get("appearance_mode", "dark") + ) + # Advanced settings state + self.advanced_settings_expanded = ctk.BooleanVar( + value=self.config.get("advanced_settings_expanded", False) + ) + self.advanced_settings_dirty = ctk.BooleanVar(value=False) + + def _load_config(self) -> Dict[str, Any]: + """Load configuration from file.""" + try: + if CONFIG_FILE.exists(): + with open(CONFIG_FILE, "r", encoding="utf-8") as f: + return {**DEFAULT_CONFIG, **json.load(f)} + except Exception as e: + print(f"⚠️ Configuration could not be loaded: {e}") + return DEFAULT_CONFIG.copy() + + def _save_config(self): + """Save configuration to file.""" + try: + config = { + "fastapi_port": int(self.fastapi_port.get() or 2048), + "stream_port": int(self.stream_port.get() or 3120), + "proxy_address": self.proxy_address.get(), + "proxy_enabled": self.proxy_enabled.get(), + "last_account": self.selected_account.get(), + "appearance_mode": get_appearance_mode(), + "minimize_to_tray": True, + "language": get_language(), + } + with open(CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2, ensure_ascii=False) + except Exception as e: + self._log(get_text("log_config_save_error", error=str(e))) + + def _bind_shortcuts(self): + """Bind keyboard shortcuts.""" + self.root.bind("", lambda e: self._start()) + self.root.bind("", lambda e: self._start()) + self.root.bind("", lambda e: self._stop()) + self.root.bind("", lambda e: self._stop()) + self.root.bind("", lambda e: self._close_completely()) + self.root.bind("", lambda e: self._close_completely()) + self.root.bind("", lambda e: self._load_accounts()) + + # ========================================================================= + # Main UI + # ========================================================================= + def _create_main_ui(self): + """Create the main user interface.""" + # Configure grid + self.root.grid_columnconfigure(0, weight=1) + self.root.grid_rowconfigure(1, weight=1) + + # Header + self._create_header() + + # Main content with tabs + self._create_tabview() + + # Status bar + self.status_bar = CTkStatusBar(self.root) + self.status_bar.grid(row=2, column=0, sticky="ew") + self.status_bar.set_port(int(self.fastapi_port.get())) + + def _create_header(self): + """Create the header section.""" + header = ctk.CTkFrame(self.root, fg_color="transparent", height=60) + header.grid(row=0, column=0, sticky="ew", padx=20, pady=(15, 10)) + header.grid_columnconfigure(1, weight=1) + + # Title + title_label = ctk.CTkLabel( + header, + text=get_text("title"), + font=ctk.CTkFont( + family=FONTS["family"], size=FONTS["size_title"], weight="bold" + ), + text_color=COLORS["text_primary"], + ) + title_label.grid(row=0, column=0, sticky="w") + + # Status badge + self.status_badge = ctk.CTkLabel( + header, + textvariable=self.status, + font=ctk.CTkFont( + family=FONTS["family"], size=FONTS["size_normal"], weight="bold" + ), + text_color=COLORS["success"], + fg_color=COLORS["bg_medium"], + corner_radius=DIMENSIONS["corner_radius_small"], + padx=15, + pady=5, + ) + self.status_badge.grid(row=0, column=2, sticky="e") + + # Menu buttons + menu_frame = ctk.CTkFrame(header, fg_color="transparent") + menu_frame.grid(row=0, column=1, sticky="e", padx=(0, 20)) + + ctk.CTkButton( + menu_frame, + text="📖 Docs", + width=80, + height=32, + fg_color="transparent", + hover_color=COLORS["bg_medium"], + text_color=COLORS["text_primary"], + command=lambda: webbrowser.open(DOCS_URL), + ).pack(side="left", padx=5) + + ctk.CTkButton( + menu_frame, + text="⚙️ About", + width=80, + height=32, + fg_color="transparent", + hover_color=COLORS["bg_medium"], + text_color=COLORS["text_primary"], + command=self._show_about, + ).pack(side="left", padx=5) + + def _create_tabview(self): + """Create the tab view with all tabs.""" + self.tabview = ctk.CTkTabview( + self.root, + fg_color=COLORS["bg_dark"], + segmented_button_fg_color=COLORS["bg_medium"], + segmented_button_selected_color=COLORS["accent"], + segmented_button_selected_hover_color=COLORS["accent_hover"], + segmented_button_unselected_color=COLORS["bg_medium"], + segmented_button_unselected_hover_color=COLORS["bg_light"], + text_color=COLORS["text_primary"], + corner_radius=DIMENSIONS["corner_radius"], + ) + self.tabview.grid(row=1, column=0, sticky="nsew", padx=20, pady=(0, 10)) + + # Add tabs + self.tabview.add(get_text("tab_control")) + self.tabview.add(get_text("tab_accounts")) + self.tabview.add(get_text("tab_settings")) + self.tabview.add(get_text("tab_logs")) + + # Build tab content + self._create_control_tab() + self._create_accounts_tab() + self._create_settings_tab() + self._create_logs_tab() + + def _create_control_tab(self): + """Create the Control tab.""" + tab = self.tabview.tab(get_text("tab_control")) + tab.grid_columnconfigure(0, weight=1) + + # Quick Start Card + quick_card = ctk.CTkFrame( + tab, + fg_color=COLORS["bg_medium"], + corner_radius=DIMENSIONS["corner_radius"], + ) + quick_card.grid(row=0, column=0, sticky="ew", pady=(10, 15), padx=10) + quick_card.grid_columnconfigure(1, weight=1) + + # Card header + ctk.CTkLabel( + quick_card, + text=get_text("quick_start"), + font=ctk.CTkFont(size=FONTS["size_large"], weight="bold"), + text_color=COLORS["accent"], + ).grid(row=0, column=0, columnspan=4, sticky="w", padx=20, pady=(15, 10)) + + # Account selector + ctk.CTkLabel( + quick_card, + text=get_text("account_label"), + font=ctk.CTkFont(size=FONTS["size_normal"]), + text_color=COLORS["text_primary"], + ).grid(row=1, column=0, sticky="w", padx=(20, 10), pady=10) + + self.account_combo = ctk.CTkComboBox( + quick_card, + variable=self.selected_account, + width=250, + height=38, + corner_radius=DIMENSIONS["corner_radius_small"], + fg_color=COLORS["bg_light"], + border_color=COLORS["border"], + button_color=COLORS["accent"], + button_hover_color=COLORS["accent_hover"], + dropdown_fg_color=COLORS["bg_medium"], + dropdown_hover_color=COLORS["bg_light"], + text_color=COLORS["text_primary"], + dropdown_text_color=COLORS["text_primary"], + ) + self.account_combo.grid(row=1, column=1, sticky="w", padx=10, pady=10) + CTkTooltip(self.account_combo, "tooltip_account") + + # Mode selector + ctk.CTkLabel( + quick_card, + text=get_text("mode_label"), + font=ctk.CTkFont(size=FONTS["size_normal"]), + text_color=COLORS["text_primary"], + ).grid(row=1, column=2, sticky="w", padx=(30, 10), pady=10) + + mode_frame = ctk.CTkFrame(quick_card, fg_color="transparent") + mode_frame.grid(row=1, column=3, sticky="w", padx=(0, 20), pady=10) + + headless_rb = ctk.CTkRadioButton( + mode_frame, + text=get_text("mode_headless"), + variable=self.run_mode, + value="headless", + fg_color=COLORS["accent"], + hover_color=COLORS["accent_hover"], + text_color=COLORS["text_primary"], + ) + headless_rb.pack(side="left", padx=(0, 15)) + CTkTooltip(headless_rb, "tooltip_headless") + + visible_rb = ctk.CTkRadioButton( + mode_frame, + text=get_text("mode_visible"), + variable=self.run_mode, + value="debug", + fg_color=COLORS["accent"], + hover_color=COLORS["accent_hover"], + text_color=COLORS["text_primary"], + ) + visible_rb.pack(side="left") + CTkTooltip(visible_rb, "tooltip_visible") + + # Action buttons + btn_frame = ctk.CTkFrame(quick_card, fg_color="transparent") + btn_frame.grid( + row=2, column=0, columnspan=4, sticky="ew", padx=20, pady=(10, 20) + ) + btn_frame.grid_columnconfigure((0, 1), weight=1) + + self.start_btn = ctk.CTkButton( + btn_frame, + text=get_text("btn_start"), + command=self._start, + height=DIMENSIONS["button_height_large"], + font=ctk.CTkFont(size=FONTS["size_large"], weight="bold"), + fg_color=COLORS["success"], + hover_color=COLORS["success_hover"], + text_color=COLORS["text_on_color"], + corner_radius=DIMENSIONS["corner_radius"], + ) + self.start_btn.grid(row=0, column=0, sticky="ew", padx=(0, 10)) + CTkTooltip(self.start_btn, "tooltip_start") + + self.stop_btn = ctk.CTkButton( + btn_frame, + text=get_text("btn_stop"), + command=self._stop, + height=DIMENSIONS["button_height_large"], + font=ctk.CTkFont(size=FONTS["size_large"], weight="bold"), + fg_color=COLORS["error"], + hover_color=COLORS["error_hover"], + text_color=COLORS["text_on_color"], + corner_radius=DIMENSIONS["corner_radius"], + state="disabled", + ) + self.stop_btn.grid(row=0, column=1, sticky="ew", padx=(10, 0)) + CTkTooltip(self.stop_btn, "tooltip_stop") + + # Info cards row + info_frame = ctk.CTkFrame(tab, fg_color="transparent") + info_frame.grid(row=1, column=0, sticky="ew", pady=10, padx=10) + info_frame.grid_columnconfigure((0, 1), weight=1) + + # API Info card + self._create_api_info_card(info_frame) + + # Status card + self._create_status_card(info_frame) + + def _create_api_info_card(self, parent): + """Create the API info card.""" + card = ctk.CTkFrame( + parent, + fg_color=COLORS["bg_medium"], + corner_radius=DIMENSIONS["corner_radius"], + ) + card.grid(row=0, column=0, sticky="nsew", padx=(0, 10)) + + ctk.CTkLabel( + card, + text=get_text("api_info"), + font=ctk.CTkFont(size=FONTS["size_large"], weight="bold"), + text_color=COLORS["accent"], + ).pack(anchor="w", padx=20, pady=(15, 10)) + + # URL display + url_frame = ctk.CTkFrame(card, fg_color=COLORS["bg_light"], corner_radius=8) + url_frame.pack(fill="x", padx=20, pady=(0, 10)) + + self.api_url_label = ctk.CTkLabel( + url_frame, + text=f"http://127.0.0.1:{self.fastapi_port.get()}", + font=ctk.CTkFont(family=FONTS["family_mono"], size=FONTS["size_normal"]), + text_color=COLORS["text_primary"], + ) + self.api_url_label.pack(side="left", padx=15, pady=10) + + copy_btn = ctk.CTkButton( + url_frame, + text="📋", + width=40, + height=32, + fg_color="transparent", + hover_color=COLORS["bg_medium"], + command=self._copy_api_url, + ) + copy_btn.pack(side="right", padx=10) + CTkTooltip(copy_btn, "tooltip_copy_url") + + # Action buttons + btn_frame = ctk.CTkFrame(card, fg_color="transparent") + btn_frame.pack(fill="x", padx=20, pady=(0, 15)) + + ctk.CTkButton( + btn_frame, + text=get_text("btn_test"), + width=100, + command=self._api_test, + **get_button_colors("outline"), + ).pack(side="left", padx=(0, 10)) + + ctk.CTkButton( + btn_frame, + text=get_text("btn_open_browser"), + width=140, + command=self._open_in_browser, + **get_button_colors("ghost"), + ).pack(side="left") + + def _create_status_card(self, parent): + """Create the status card.""" + card = ctk.CTkFrame( + parent, + fg_color=COLORS["bg_medium"], + corner_radius=DIMENSIONS["corner_radius"], + ) + card.grid(row=0, column=1, sticky="nsew", padx=(10, 0)) + + ctk.CTkLabel( + card, + text=get_text("status_card"), + font=ctk.CTkFont(size=FONTS["size_large"], weight="bold"), + text_color=COLORS["accent"], + ).pack(anchor="w", padx=20, pady=(15, 10)) + + self.status_details = ctk.CTkLabel( + card, + text=get_text("service_stopped"), + font=ctk.CTkFont(size=FONTS["size_normal"]), + text_color=COLORS["text_secondary"], + ) + self.status_details.pack(anchor="w", padx=20, pady=5) + + self.pid_label = ctk.CTkLabel( + card, + text=f"{get_text('pid_label')} -", + font=ctk.CTkFont(size=FONTS["size_normal"]), + text_color=COLORS["text_muted"], + ) + self.pid_label.pack(anchor="w", padx=20, pady=(5, 15)) + + def _create_accounts_tab(self): + """Create the Accounts tab.""" + tab = self.tabview.tab(get_text("tab_accounts")) + tab.grid_columnconfigure(0, weight=1) + tab.grid_rowconfigure(0, weight=1) + + # Main container + main_frame = ctk.CTkFrame(tab, fg_color="transparent") + main_frame.grid(row=0, column=0, sticky="nsew", padx=10, pady=10) + main_frame.grid_columnconfigure(0, weight=1) + main_frame.grid_rowconfigure(0, weight=1) + + # Account list card + list_card = ctk.CTkFrame( + main_frame, + fg_color=COLORS["bg_medium"], + corner_radius=DIMENSIONS["corner_radius"], + ) + list_card.grid(row=0, column=0, sticky="nsew") + list_card.grid_columnconfigure(0, weight=1) + list_card.grid_rowconfigure(1, weight=1) + + # Card header + header_frame = ctk.CTkFrame(list_card, fg_color="transparent") + header_frame.grid(row=0, column=0, sticky="ew", padx=20, pady=(15, 10)) + + ctk.CTkLabel( + header_frame, + text=get_text("saved_accounts"), + font=ctk.CTkFont(size=FONTS["size_large"], weight="bold"), + text_color=COLORS["accent"], + ).pack(side="left") + + # Action buttons in header + btn_frame = ctk.CTkFrame(header_frame, fg_color="transparent") + btn_frame.pack(side="right") + + add_btn = ctk.CTkButton( + btn_frame, + text=get_text("btn_add_account"), + width=150, + height=36, + command=self._add_new_account, + fg_color=COLORS["success"], + hover_color=COLORS["success_hover"], + text_color=COLORS["text_on_color"], + ) + add_btn.pack(side="left", padx=(0, 10)) + CTkTooltip(add_btn, "tooltip_add_account") + + del_btn = ctk.CTkButton( + btn_frame, + text=get_text("btn_delete_account"), + width=130, + height=36, + command=self._delete_account, + fg_color=COLORS["error"], + hover_color=COLORS["error_hover"], + text_color=COLORS["text_on_color"], + ) + del_btn.pack(side="left", padx=(0, 10)) + CTkTooltip(del_btn, "tooltip_delete_account") + + ctk.CTkButton( + btn_frame, + text=get_text("btn_refresh"), + width=100, + height=36, + command=self._load_accounts, + **get_button_colors("outline"), + ).pack(side="left") + + # Account list + self.account_list = CTkScrollableList(list_card, height=350) + self.account_list.grid(row=1, column=0, sticky="nsew", padx=20, pady=(0, 15)) + self.account_list.bind_select(self._account_selected) + self.account_list.bind_double_click(self._account_double_click) + + # Account details card + detail_card = ctk.CTkFrame( + main_frame, + fg_color=COLORS["bg_medium"], + corner_radius=DIMENSIONS["corner_radius"], + ) + detail_card.grid(row=1, column=0, sticky="ew", pady=(15, 0)) + + ctk.CTkLabel( + detail_card, + text=get_text("account_details"), + font=ctk.CTkFont(size=FONTS["size_large"], weight="bold"), + text_color=COLORS["accent"], + ).pack(anchor="w", padx=20, pady=(15, 10)) + + self.account_detail = ctk.CTkLabel( + detail_card, + text=get_text("select_account_hint"), + font=ctk.CTkFont(size=FONTS["size_normal"]), + text_color=COLORS["text_secondary"], + justify="left", + ) + self.account_detail.pack(anchor="w", padx=20, pady=(0, 15)) + + def _create_settings_tab(self): + """Create the Settings tab with basic and advanced settings.""" + tab = self.tabview.tab(get_text("tab_settings")) + tab.grid_columnconfigure(0, weight=1) + tab.grid_rowconfigure(0, weight=1) + + # Create scrollable container for all settings + settings_scroll = ctk.CTkScrollableFrame( + tab, + fg_color=COLORS["bg_dark"], + scrollbar_fg_color=COLORS["bg_medium"], + scrollbar_button_color=COLORS["border"], + scrollbar_button_hover_color=COLORS["accent"], + ) + settings_scroll.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) + settings_scroll.grid_columnconfigure(0, weight=1) + + # Store reference for scroll binding + self._settings_scroll = settings_scroll + + # Port settings card + port_card = ctk.CTkFrame( + settings_scroll, + fg_color=COLORS["bg_medium"], + corner_radius=DIMENSIONS["corner_radius"], + ) + port_card.grid(row=0, column=0, sticky="ew", padx=5, pady=(5, 10)) + + ctk.CTkLabel( + port_card, + text=get_text("port_settings"), + font=ctk.CTkFont(size=FONTS["size_large"], weight="bold"), + text_color=COLORS["accent"], + ).grid(row=0, column=0, columnspan=4, sticky="w", padx=20, pady=(15, 10)) + + ctk.CTkLabel( + port_card, + text=get_text("fastapi_port"), + text_color=COLORS["text_primary"], + ).grid(row=1, column=0, sticky="w", padx=(20, 10), pady=10) + fastapi_entry = ctk.CTkEntry( + port_card, + textvariable=self.fastapi_port, + width=120, + height=38, + text_color=COLORS["text_primary"], + ) + fastapi_entry.grid(row=1, column=1, sticky="w", padx=10, pady=10) + CTkTooltip(fastapi_entry, "tooltip_fastapi_port") + + ctk.CTkLabel( + port_card, + text=get_text("stream_port"), + text_color=COLORS["text_primary"], + ).grid(row=1, column=2, sticky="w", padx=(30, 10), pady=10) + stream_entry = ctk.CTkEntry( + port_card, + textvariable=self.stream_port, + width=120, + height=38, + text_color=COLORS["text_primary"], + ) + stream_entry.grid(row=1, column=3, sticky="w", padx=(10, 20), pady=(10, 15)) + CTkTooltip(stream_entry, "tooltip_stream_port") + + # Proxy settings card + proxy_card = ctk.CTkFrame( + settings_scroll, + fg_color=COLORS["bg_medium"], + corner_radius=DIMENSIONS["corner_radius"], + ) + proxy_card.grid(row=1, column=0, sticky="ew", padx=5, pady=(0, 10)) + + ctk.CTkLabel( + proxy_card, + text=get_text("proxy_settings"), + font=ctk.CTkFont(size=FONTS["size_large"], weight="bold"), + text_color=COLORS["accent"], + ).pack(anchor="w", padx=20, pady=(15, 10)) + + ctk.CTkCheckBox( + proxy_card, + text=get_text("use_proxy"), + variable=self.proxy_enabled, + fg_color=COLORS["accent"], + hover_color=COLORS["accent_hover"], + text_color=COLORS["text_primary"], + ).pack(anchor="w", padx=20, pady=5) + + proxy_frame = ctk.CTkFrame(proxy_card, fg_color="transparent") + proxy_frame.pack(fill="x", padx=20, pady=(5, 5)) + + ctk.CTkLabel( + proxy_frame, + text=get_text("proxy_address"), + text_color=COLORS["text_primary"], + ).pack(side="left") + proxy_entry = ctk.CTkEntry( + proxy_frame, + textvariable=self.proxy_address, + width=300, + height=38, + placeholder_text="http://127.0.0.1:7890", + text_color=COLORS["text_primary"], + ) + proxy_entry.pack(side="left", padx=(10, 0)) + CTkTooltip(proxy_entry, "tooltip_proxy") + + ctk.CTkLabel( + proxy_card, + text=get_text("proxy_example"), + text_color=COLORS["text_muted"], + ).pack(anchor="w", padx=20, pady=(0, 15)) + + # Language settings card + lang_card = ctk.CTkFrame( + settings_scroll, + fg_color=COLORS["bg_medium"], + corner_radius=DIMENSIONS["corner_radius"], + ) + lang_card.grid(row=2, column=0, sticky="ew", padx=5, pady=(0, 10)) + + ctk.CTkLabel( + lang_card, + text=get_text("language_settings"), + font=ctk.CTkFont(size=FONTS["size_large"], weight="bold"), + text_color=COLORS["accent"], + ).pack(anchor="w", padx=20, pady=(15, 10)) + + lang_frame = ctk.CTkFrame(lang_card, fg_color="transparent") + lang_frame.pack(anchor="w", padx=20, pady=(0, 15)) + + ctk.CTkRadioButton( + lang_frame, + text="🇺🇸 English", + variable=self.language_var, + value="en", + command=self._change_language, + fg_color=COLORS["accent"], + text_color=COLORS["text_primary"], + ).pack(side="left", padx=(0, 30)) + + ctk.CTkRadioButton( + lang_frame, + text="🇨🇳 中文", + variable=self.language_var, + value="zh", + command=self._change_language, + fg_color=COLORS["accent"], + text_color=COLORS["text_primary"], + ).pack(side="left") + + # Theme settings card + theme_card = ctk.CTkFrame( + settings_scroll, + fg_color=COLORS["bg_medium"], + corner_radius=DIMENSIONS["corner_radius"], + ) + theme_card.grid(row=3, column=0, sticky="ew", padx=5, pady=(0, 10)) + + ctk.CTkLabel( + theme_card, + text=get_text("theme_settings"), + font=ctk.CTkFont(size=FONTS["size_large"], weight="bold"), + text_color=COLORS["accent"], + ).pack(anchor="w", padx=20, pady=(15, 10)) + + theme_frame = ctk.CTkFrame(theme_card, fg_color="transparent") + theme_frame.pack(anchor="w", padx=20, pady=(0, 15)) + + dark_rb = ctk.CTkRadioButton( + theme_frame, + text=get_text("theme_dark"), + variable=self.appearance_mode_var, + value="dark", + command=self._change_appearance_mode, + fg_color=COLORS["accent"], + text_color=COLORS["text_primary"], + ) + dark_rb.pack(side="left", padx=(0, 20)) + CTkTooltip(dark_rb, "tooltip_theme") + + light_rb = ctk.CTkRadioButton( + theme_frame, + text=get_text("theme_light"), + variable=self.appearance_mode_var, + value="light", + command=self._change_appearance_mode, + fg_color=COLORS["accent"], + text_color=COLORS["text_primary"], + ) + light_rb.pack(side="left", padx=(0, 20)) + + system_rb = ctk.CTkRadioButton( + theme_frame, + text=get_text("theme_system"), + variable=self.appearance_mode_var, + value="system", + command=self._change_appearance_mode, + fg_color=COLORS["accent"], + text_color=COLORS["text_primary"], + ) + system_rb.pack(side="left") + + # Basic settings save buttons + save_frame = ctk.CTkFrame(settings_scroll, fg_color="transparent") + save_frame.grid(row=4, column=0, sticky="ew", padx=5, pady=(5, 15)) + + ctk.CTkButton( + save_frame, + text=get_text("btn_save_settings"), + command=self._save_and_notify, + height=40, + fg_color=COLORS["success"], + hover_color=COLORS["success_hover"], + text_color=COLORS["text_on_color"], + ).pack(side="left", padx=(0, 10)) + + ctk.CTkButton( + save_frame, + text=get_text("btn_reset_default"), + command=self._reset_config, + height=40, + **get_button_colors("outline"), + ).pack(side="left") + + # ========================================================================= + # Advanced Settings Section (Collapsible) + # ========================================================================= + self._create_advanced_settings_section(settings_scroll, row=5) + + # Bind mouse wheel scrolling AFTER all widgets are created + self._bind_settings_scroll_events(settings_scroll) + + def _create_advanced_settings_section(self, parent, row: int): + """Create the collapsible advanced settings section.""" + # Advanced settings toggle button + self.advanced_toggle_btn = ctk.CTkButton( + parent, + text=get_text("show_advanced"), + command=self._toggle_advanced_settings, + height=40, + fg_color=COLORS["bg_medium"], + hover_color=COLORS["bg_light"], + text_color=COLORS["accent"], + border_width=1, + border_color=COLORS["accent"], + ) + self.advanced_toggle_btn.grid( + row=row, column=0, sticky="ew", padx=5, pady=(10, 5) + ) + + # Advanced settings container (hidden by default) + self.advanced_settings_frame = ctk.CTkFrame( + parent, + fg_color=COLORS["bg_dark"], + corner_radius=DIMENSIONS["corner_radius"], + ) + # Don't grid initially - will be shown when toggled + self.advanced_settings_frame.grid_columnconfigure(0, weight=1) + + # Header with hint text + header_frame = ctk.CTkFrame( + self.advanced_settings_frame, + fg_color=COLORS["bg_medium"], + corner_radius=DIMENSIONS["corner_radius"], + ) + header_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=(5, 10)) + + ctk.CTkLabel( + header_frame, + text=get_text("advanced_settings"), + font=ctk.CTkFont(size=FONTS["size_large"], weight="bold"), + text_color=COLORS["accent"], + ).pack(anchor="w", padx=20, pady=(15, 5)) + + ctk.CTkLabel( + header_frame, + text=get_text("advanced_settings_hint"), + font=ctk.CTkFont(size=FONTS["size_small"]), + text_color=COLORS["text_muted"], + ).pack(anchor="w", padx=20, pady=(0, 10)) + + # Status indicator for unsaved changes + self.advanced_status_label = ctk.CTkLabel( + header_frame, + text="", + font=ctk.CTkFont(size=FONTS["size_small"]), + text_color=COLORS["warning"], + ) + self.advanced_status_label.pack(anchor="w", padx=20, pady=(0, 15)) + + # Action buttons for advanced settings + adv_btn_frame = ctk.CTkFrame(header_frame, fg_color="transparent") + adv_btn_frame.pack(fill="x", padx=20, pady=(0, 15)) + + self.adv_save_btn = ctk.CTkButton( + adv_btn_frame, + text=get_text("btn_apply_env"), + command=self._save_advanced_settings, + height=36, + width=140, + fg_color=COLORS["success"], + hover_color=COLORS["success_hover"], + text_color=COLORS["text_on_color"], + ) + self.adv_save_btn.pack(side="left", padx=(0, 10)) + + self.adv_hot_reload_btn = ctk.CTkButton( + adv_btn_frame, + text=get_text("btn_hot_reload"), + command=self._hot_reload_advanced_settings, + height=36, + width=140, + fg_color=COLORS["warning"], + hover_color=COLORS["warning_hover"], + text_color=COLORS["text_on_color"], + ) + self.adv_hot_reload_btn.pack(side="left", padx=(0, 10)) + CTkTooltip(self.adv_hot_reload_btn, "tooltip_env_hot_reload") + + ctk.CTkButton( + adv_btn_frame, + text=get_text("btn_reload_env"), + command=self._reload_advanced_settings, + height=36, + width=140, + **get_button_colors("outline"), + ).pack(side="left", padx=(0, 10)) + + ctk.CTkButton( + adv_btn_frame, + text=get_text("btn_reset_env"), + command=self._reset_advanced_settings, + height=36, + width=140, + **get_button_colors("ghost"), + ).pack(side="left") + + # Environment settings panel with all categories + self.env_settings_panel = CTkEnvSettingsPanel( + self.advanced_settings_frame, + env_manager=self.env_manager, + on_save=lambda: self._log(get_text("log_env_saved")), + on_change=self._on_advanced_settings_change, + height=400, + ) + self.env_settings_panel.grid( + row=1, column=0, sticky="nsew", padx=5, pady=(0, 10) + ) + + # Store row for toggling + self._advanced_settings_row = row + 1 + self._advanced_settings_parent = parent + + # Initialize based on saved state + if self.advanced_settings_expanded.get(): + self._show_advanced_settings() + + def _toggle_advanced_settings(self): + """Toggle the advanced settings visibility.""" + if self.advanced_settings_expanded.get(): + self._hide_advanced_settings() + else: + self._show_advanced_settings() + + def _show_advanced_settings(self): + """Show the advanced settings section.""" + self.advanced_settings_expanded.set(True) + self.advanced_toggle_btn.configure(text=get_text("hide_advanced")) + self.advanced_settings_frame.grid( + row=self._advanced_settings_row, + column=0, + sticky="nsew", + padx=0, + pady=(0, 10), + ) + # Save expanded state + self.config["advanced_settings_expanded"] = True + self._save_config() + self._log(get_text("log_env_loaded")) + + def _hide_advanced_settings(self): + """Hide the advanced settings section.""" + # Check for unsaved changes + if self.env_settings_panel.is_dirty(): + if messagebox.askyesno( + get_text("confirm_title"), + get_text("env_unsaved_changes"), + ): + self._save_advanced_settings() + + self.advanced_settings_expanded.set(False) + self.advanced_toggle_btn.configure(text=get_text("show_advanced")) + self.advanced_settings_frame.grid_forget() + # Save collapsed state + self.config["advanced_settings_expanded"] = False + self._save_config() + + def _on_advanced_settings_change(self, is_dirty: bool): + """Handle advanced settings dirty state change.""" + self.advanced_settings_dirty.set(is_dirty) + if is_dirty: + self.advanced_status_label.configure( + text=get_text("env_modified_indicator"), + text_color=COLORS["warning"], + ) + else: + self.advanced_status_label.configure(text="") + + def _save_advanced_settings(self): + """Save advanced settings to .env file.""" + if self.env_settings_panel.save(): + self._log(get_text("log_env_saved")) + messagebox.showinfo( + get_text("success_title"), + get_text("env_saved"), + ) + else: + messagebox.showerror( + get_text("error_title"), + get_text("env_save_error"), + ) + + def _reload_advanced_settings(self): + """Reload advanced settings from .env file.""" + self.env_settings_panel.reload() + self._log(get_text("env_reloaded")) + + def _reset_advanced_settings(self): + """Reset advanced settings to defaults.""" + if messagebox.askyesno( + get_text("confirm_title"), + get_text("env_reset_confirm"), + ): + self.env_settings_panel.reset_to_defaults() + self._log(get_text("log_env_reset")) + + def _hot_reload_advanced_settings(self): + """Apply settings via hot reload to running proxy.""" + if self.running: + # Warn user that proxy is running + if not messagebox.askyesno( + get_text("warning_title"), + get_text("env_hot_reload_confirm"), + ): + return + + # Save first + if not self.env_settings_panel.save(): + messagebox.showerror( + get_text("error_title"), + get_text("env_save_error"), + ) + return + + # Apply to environment + self.env_manager.apply_to_environment() + + # Trigger hot reload callbacks + modified_count = len(self.env_settings_panel.get_modified_keys()) + self.env_manager.trigger_hot_reload() + + self._log(get_text("log_env_hot_reload", count=modified_count)) + + if self.running: + messagebox.showinfo( + get_text("success_title"), + get_text("env_hot_reload_warning"), + ) + + def _create_logs_tab(self): + """Create the Logs tab.""" + tab = self.tabview.tab(get_text("tab_logs")) + tab.grid_columnconfigure(0, weight=1) + tab.grid_rowconfigure(0, weight=1) + + # Log textbox + self.log_area = ctk.CTkTextbox( + tab, + font=ctk.CTkFont(family=FONTS["family_mono"], size=FONTS["size_small"]), + fg_color=COLORS["bg_light"], + text_color=COLORS["text_primary"], + corner_radius=DIMENSIONS["corner_radius"], + border_width=1, + border_color=COLORS["border"], + state="disabled", + ) + self.log_area.grid(row=0, column=0, sticky="nsew", padx=10, pady=(10, 10)) + + # Button row + btn_frame = ctk.CTkFrame(tab, fg_color="transparent") + btn_frame.grid(row=1, column=0, sticky="ew", padx=10, pady=(0, 10)) + + ctk.CTkButton( + btn_frame, + text=get_text("btn_clear_logs"), + command=self._clear_logs, + width=100, + **get_button_colors("ghost"), + ).pack(side="left", padx=(0, 10)) + + ctk.CTkButton( + btn_frame, + text=get_text("btn_save_logs"), + command=self._save_logs, + width=120, + **get_button_colors("outline"), + ).pack(side="left", padx=(0, 10)) + + ctk.CTkButton( + btn_frame, + text=get_text("btn_open_log_folder"), + command=self._open_log_folder, + width=140, + **get_button_colors("ghost"), + ).pack(side="left") + + # ========================================================================= + # Logging + # ========================================================================= + def _log(self, message: str, save_to_file: bool = True): + """Add message to log area.""" + timestamp = datetime.now().strftime("%H:%M:%S") + formatted = f"[{timestamp}] {message}" + + self.log_area.configure(state="normal") + self.log_area.insert("end", f"{formatted}\n") + self.log_area.see("end") + self.log_area.configure(state="disabled") + + if save_to_file: + try: + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(f"{formatted}\n") + except Exception: + pass + + def _clear_logs(self): + """Clear log area.""" + self.log_area.configure(state="normal") + self.log_area.delete("1.0", "end") + self.log_area.configure(state="disabled") + + def _save_logs(self): + """Save logs to file.""" + try: + log_content = self.log_area.get("1.0", "end") + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + save_path = PROJECT_ROOT / "logs" / f"session_{timestamp}.log" + save_path.parent.mkdir(parents=True, exist_ok=True) + + with open(save_path, "w", encoding="utf-8") as f: + f.write(log_content) + + self._log(get_text("log_logs_saved", name=save_path.name)) + messagebox.showinfo( + get_text("success_title"), f"{get_text('logs_saved')}\n{save_path}" + ) + except Exception as e: + messagebox.showerror( + get_text("error_title"), f"{get_text('logs_save_error')}\n{e}" + ) + + def _open_log_folder(self): + """Open log folder in file manager.""" + try: + log_dir = str(LOG_FILE.parent) + if platform.system() == "Linux": + subprocess.Popen(["xdg-open", log_dir]) + elif platform.system() == "Darwin": + subprocess.Popen(["open", log_dir]) + else: + os.startfile(log_dir) + except Exception as e: + messagebox.showerror( + get_text("error_title"), f"{get_text('folder_open_error')}\n{e}" + ) + + # ========================================================================= + # Account Management + # ========================================================================= + def _load_accounts(self): + """Load saved accounts.""" + accounts = [] + + if SAVED_AUTH_DIR.exists(): + for file in sorted(SAVED_AUTH_DIR.glob("*.json")): + if file.name != ".gitkeep": + accounts.append(file.stem) + + # Update combobox + self.account_combo.configure(values=accounts) + + # Update list + self.account_list.clear() + for acc in accounts: + self.account_list.add_item(acc, icon="📧") + + # Select last account + last = self.config.get("last_account", "") + if last and last in accounts: + self.selected_account.set(last) + try: + idx = accounts.index(last) + self.account_list.select(idx) + except Exception: + pass + elif accounts: + self.selected_account.set(accounts[0]) + + if accounts: + self._log(get_text("log_accounts_loaded", count=len(accounts))) + else: + self._log(get_text("log_no_accounts")) + + def _account_selected(self, index: int): + """Handle account selection.""" + items = self.account_list.get_items() + if index < len(items): + account_name = items[index] + + # Update detail view + auth_file = SAVED_AUTH_DIR / f"{account_name}.json" + if auth_file.exists(): + stat = auth_file.stat() + mod_time = datetime.fromtimestamp(stat.st_mtime).strftime( + "%Y-%m-%d %H:%M" + ) + size_kb = stat.st_size / 1024 + + self.account_detail.configure( + text=f"{get_text('file_label')} {account_name}.json\n" + f"{get_text('last_modified')} {mod_time}\n" + f"{get_text('size_label')} {size_kb:.1f} KB" + ) + + self.selected_account.set(account_name) + + def _account_double_click(self, index: int): + """Handle double-click on account - select and go to Control tab.""" + items = self.account_list.get_items() + if index < len(items): + self.selected_account.set(items[index]) + self.tabview.set(get_text("tab_control")) + + def _add_new_account(self): + """Add new account.""" + dialog = ctk.CTkInputDialog( + text=get_text("new_account_prompt"), + title=get_text("new_account_title"), + ) + file_name = dialog.get_input() + + if not file_name: + return + + if not file_name.replace("_", "").replace("-", "").isalnum(): + messagebox.showerror(get_text("error_title"), get_text("invalid_filename")) + return + + self._log(get_text("log_adding_account", name=file_name)) + self._log(get_text("log_browser_login")) + self._log(get_text("log_auto_save")) + + self._start_internal(mode="debug", save_auth_as=file_name, exit_on_save=True) + + def _delete_account(self): + """Delete selected account.""" + account_name = self.account_list.get_selected() + if not account_name: + messagebox.showwarning( + get_text("warning_title"), get_text("select_account_warning") + ) + return + + if not messagebox.askyesno( + get_text("confirm_title"), + get_text("confirm_delete", name=account_name), + ): + return + + try: + auth_file = SAVED_AUTH_DIR / f"{account_name}.json" + if auth_file.exists(): + auth_file.unlink() + + active_file = ACTIVE_AUTH_DIR / f"{account_name}.json" + if active_file.exists(): + active_file.unlink() + + self._log(get_text("log_account_deleted", name=account_name)) + self._load_accounts() + + except Exception as e: + messagebox.showerror( + get_text("error_title"), f"{get_text('account_delete_error')}\n{e}" + ) + + # ========================================================================= + # API & Browser + # ========================================================================= + def _copy_api_url(self): + """Copy API URL to clipboard.""" + url = f"http://127.0.0.1:{self.fastapi_port.get()}" + copy_to_clipboard(self.root, url) + self._log(get_text("copied_to_clipboard")) + + def _api_test(self): + """Test the API endpoint.""" + port = self.fastapi_port.get() + url = f"http://127.0.0.1:{port}/health" + + self._log(get_text("log_testing_api", url=url)) + + try: + import urllib.error + import urllib.request + + with urllib.request.urlopen(url, timeout=5) as response: + if response.status == 200: + self._log(get_text("log_api_running")) + self.status_details.configure(text=get_text("api_active")) + messagebox.showinfo( + get_text("success_title"), get_text("api_running") + ) + else: + self._log(get_text("log_api_status", code=response.status)) + except urllib.error.URLError: + self._log(get_text("log_api_error")) + self.status_details.configure(text=get_text("api_not_active")) + messagebox.showwarning( + get_text("warning_title"), get_text("api_not_responding") + ) + except Exception as e: + self._log(get_text("log_api_test_error", error=str(e))) + + def _open_in_browser(self): + """Open API in browser.""" + port = self.fastapi_port.get() + webbrowser.open(f"http://127.0.0.1:{port}") + + # ========================================================================= + # Settings + # ========================================================================= + def _change_language(self): + """Change the UI language.""" + new_lang = self.language_var.get() + if new_lang != get_language(): + set_language(new_lang) + self._save_config() + self._log(get_text("log_language_changed")) + self.root.title(get_text("title")) + messagebox.showinfo( + get_text("success_title"), + "Language changed. Some changes will take effect after restart.\n" + "语言已更改。部分更改将在重启后生效。", + ) + + def _change_appearance_mode(self): + """Change the appearance mode (dark/light/system).""" + new_mode = self.appearance_mode_var.get() + if new_mode != get_appearance_mode(): + set_appearance_mode(new_mode) + self._save_config() + self._log(get_text("log_theme_changed", mode=new_mode)) + + def _bind_settings_scroll_events(self, scrollable_frame): + """Bind mouse wheel scroll events to settings scrollable frame.""" + self._bind_scroll_to_widget(scrollable_frame, scrollable_frame) + + def _bind_scroll_to_widget(self, widget, target_scrollable): + """Recursively bind scroll events to widget and children. + + Skips any nested CTkScrollableFrame widgets since they handle + their own scrolling independently. + """ + # Skip if this is a nested scrollable frame (not the target itself) + # Let nested scrollable frames handle their own scrolling + if widget is not target_scrollable and isinstance( + widget, ctk.CTkScrollableFrame + ): + return + + if platform.system() == "Linux": + widget.bind( + "", lambda e: self._on_scroll(target_scrollable, -3), add="+" + ) + widget.bind( + "", lambda e: self._on_scroll(target_scrollable, 3), add="+" + ) + else: + widget.bind( + "", + lambda e: self._on_mousewheel(e, target_scrollable), + add="+", + ) + + # Recursively bind to children + for child in widget.winfo_children(): + self._bind_scroll_to_widget(child, target_scrollable) + + def _on_mousewheel(self, event, target): + """Handle mouse wheel on Windows/macOS.""" + if hasattr(target, "_parent_canvas") and target._parent_canvas: + if platform.system() == "Darwin": + target._parent_canvas.yview_scroll(int(-1 * event.delta), "units") + else: + target._parent_canvas.yview_scroll( + int(-1 * (event.delta / 120)), "units" + ) + + def _on_scroll(self, target, delta): + """Handle scroll on Linux.""" + if hasattr(target, "_parent_canvas") and target._parent_canvas: + target._parent_canvas.yview_scroll(delta, "units") + + def _save_and_notify(self): + """Save settings and notify user.""" + # Validate ports + if not validate_port(self.fastapi_port.get()): + messagebox.showerror(get_text("error_title"), get_text("invalid_port")) + return + if not validate_port(self.stream_port.get()): + messagebox.showerror(get_text("error_title"), get_text("invalid_port")) + return + if self.fastapi_port.get() == self.stream_port.get(): + messagebox.showerror(get_text("error_title"), get_text("port_conflict")) + return + + self._save_config() + self._log(get_text("log_settings_saved")) + messagebox.showinfo(get_text("success_title"), get_text("settings_saved")) + + # Update API URL display + self.api_url_label.configure(text=f"http://127.0.0.1:{self.fastapi_port.get()}") + self.status_bar.set_port(int(self.fastapi_port.get())) + + def _reset_config(self): + """Reset to default settings.""" + if messagebox.askyesno(get_text("confirm_title"), get_text("reset_confirm")): + self.fastapi_port.set(str(DEFAULT_CONFIG["fastapi_port"])) + self.stream_port.set(str(DEFAULT_CONFIG["stream_port"])) + self.proxy_address.set("") + self.proxy_enabled.set(False) + self._save_config() + self._log(get_text("log_settings_reset")) + + def _show_about(self): + """Show about dialog.""" + about_text = f""" +{get_text("title")} + +{get_text("about_version")} {VERSION} + +{get_text("about_description")} + +{get_text("about_credits")} +• @CJackHwang - Original author +• @beng1z - GUI Launcher +• Downstream contributors - Feature and stability improvements +• Linux.do Community + +GitHub: {GITHUB_URL} + """ + messagebox.showinfo(get_text("about_title"), about_text.strip()) + + # ========================================================================= + # Service Control + # ========================================================================= + def _is_port_in_use(self, port: int) -> bool: + """Check if port is in use.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind(("127.0.0.1", port)) + return False + except OSError: + return True + + def _find_port_pids(self, port: int) -> List[int]: + """Find PIDs using the port.""" + pids = [] + try: + if platform.system() == "Linux": + result = subprocess.run( + ["lsof", "-t", "-i", f":{port}"], + capture_output=True, + text=True, + timeout=5, + ) + if result.stdout.strip(): + pids = [int(p) for p in result.stdout.strip().split("\n") if p] + elif platform.system() == "Windows": + result = subprocess.run( + ["netstat", "-ano", "-p", "TCP"], + capture_output=True, + text=True, + timeout=5, + ) + for line in result.stdout.split("\n"): + if f":{port}" in line and "LISTENING" in line: + parts = line.split() + if parts: + try: + pids.append(int(parts[-1])) + except ValueError: + pass + except Exception as e: + self._log(get_text("log_port_pid_error", error=str(e))) + return list(set(pids)) + + def _clean_ports(self) -> bool: + """Clean up ports before starting.""" + ports = [ + int(self.fastapi_port.get() or 2048), + 9222, # Camoufox + int(self.stream_port.get() or 3120), + ] + cleaned = True + + for port in ports: + if self._is_port_in_use(port): + self._log(get_text("log_port_in_use", port=port)) + pids = self._find_port_pids(port) + + for pid in pids: + try: + if platform.system() == "Windows": + subprocess.run( + ["taskkill", "/F", "/PID", str(pid)], + capture_output=True, + timeout=5, + ) + else: + os.kill(pid, signal.SIGTERM) + time.sleep(0.5) + try: + os.kill(pid, 0) + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + self._log(get_text("log_pid_terminated", pid=pid)) + except Exception as e: + self._log(get_text("log_pid_error", pid=pid, error=str(e))) + cleaned = False + + time.sleep(1) + if self._is_port_in_use(port): + self._log(get_text("log_port_still_in_use", port=port)) + cleaned = False + + return cleaned + + def _start(self): + """Start the service.""" + if self.running: + messagebox.showwarning( + get_text("warning_title"), get_text("service_already_running") + ) + return + + account = self.selected_account.get() + if not account: + messagebox.showerror( + get_text("error_title"), get_text("select_account_error") + ) + return + + # Save last account + self.config["last_account"] = account + self._save_config() + + mode = self.run_mode.get() + self._start_internal(mode=mode, account=account) + + def _start_internal( + self, + mode: str, + account: Optional[str] = None, + save_auth_as: Optional[str] = None, + exit_on_save: bool = False, + ): + """Internal start function.""" + self._log(get_text("log_checking_ports")) + if not self._clean_ports(): + if not messagebox.askyesno( + get_text("warning_title"), get_text("log_port_clean_warning") + ): + self._log(get_text("log_user_cancelled")) + return + + # Build command + cmd = [sys.executable, str(LAUNCH_SCRIPT)] + + if mode == "headless": + cmd.append("--headless") + elif mode == "debug": + cmd.append("--debug") + + # Port settings + cmd.extend(["--server-port", self.fastapi_port.get()]) + cmd.extend(["--stream-port", self.stream_port.get()]) + + # Account + if account: + auth_file = SAVED_AUTH_DIR / f"{account}.json" + if auth_file.exists(): + cmd.extend(["--active-auth-json", str(auth_file)]) + + # Save + if save_auth_as: + cmd.extend(["--save-auth-as", save_auth_as]) + cmd.append("--auto-save-auth") + + if exit_on_save: + cmd.append("--exit-on-auth-save") + + # Proxy + if self.proxy_enabled.get() and self.proxy_address.get(): + cmd.extend(["--internal-camoufox-proxy", self.proxy_address.get()]) + + self._log(get_text("log_starting", mode=mode)) + + # Environment variables + env = os.environ.copy() + env["DIRECT_LAUNCH"] = "true" + + if save_auth_as: + env["SUPPRESS_LOGIN_WAIT"] = "true" + + try: + self.process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + stdin=subprocess.PIPE if save_auth_as else None, + text=True, + bufsize=1, + cwd=str(PROJECT_ROOT), + env=env, + ) + + self.running = True + self.status.set(get_text("status_running")) + self.start_btn.configure(state="disabled") + self.stop_btn.configure(state="normal") + self.status_details.configure(text=get_text("service_started")) + self.pid_label.configure(text=f"{get_text('pid_label')} {self.process.pid}") + + # Update status badge color + self.status_badge.configure(text_color=COLORS["success"]) + + self.tray.update_status(True) + self.status_bar.start_uptime() + + self.log_thread = threading.Thread(target=self._read_logs, daemon=True) + self.log_thread.start() + + self._log(get_text("log_service_started", pid=self.process.pid)) + + except Exception as e: + self._log(f"❌ {get_text('start_error')} {e}") + messagebox.showerror( + get_text("error_title"), f"{get_text('start_error')}\n{e}" + ) + self.running = False + + def _read_logs(self): + """Log reading thread.""" + try: + while self.process and self.process.poll() is None: + line = self.process.stdout.readline() + if line: + self.root.after( + 0, + lambda log_line=line.strip(): self._log( + log_line, save_to_file=False + ), + ) + + exit_code = self.process.returncode if self.process else -1 + self.root.after(0, lambda: self._service_ended(exit_code)) + except Exception as e: + self.root.after(0, lambda err=e: self._log(f"❌ Log error: {err}")) + + def _service_ended(self, exit_code: int): + """Handle service ending.""" + self.running = False + self.process = None + + if exit_code == 0: + self.status.set(get_text("status_stopped")) + self._log(get_text("log_service_ended")) + else: + self.status.set(f"{get_text('status_error')} ({exit_code})") + self._log(get_text("log_service_error", code=exit_code)) + + self.start_btn.configure(state="normal") + self.stop_btn.configure(state="disabled") + self.status_details.configure(text=get_text("service_stopped")) + self.pid_label.configure(text=f"{get_text('pid_label')} -") + + # Update status badge color + self.status_badge.configure(text_color=COLORS["text_secondary"]) + + self.tray.update_status(False) + self.status_bar.stop_uptime() + + # Refresh account list + self._load_accounts() + self._log(get_text("log_accounts_refreshed")) + + def _stop(self): + """Stop the service.""" + if not self.running or not self.process: + return + + self._log(get_text("log_stopping")) + self.status.set(get_text("status_stopping")) + + try: + if platform.system() == "Windows": + self.process.terminate() + else: + self.process.send_signal(signal.SIGINT) + + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self._log(get_text("log_force_closing")) + self.process.kill() + self.process.wait(timeout=3) + + self._log(get_text("log_stopped")) + except Exception as e: + self._log(get_text("log_stop_error", error=str(e))) + + self._service_ended(0) + + # ========================================================================= + # Window Management + # ========================================================================= + def _show_window(self): + """Show window.""" + self.root.deiconify() + self.root.lift() + self.root.focus_force() + + def _minimize_to_tray(self): + """Minimize to tray or close.""" + if self.tray.supported and self.running: + self.root.withdraw() + self._log(get_text("log_minimized")) + else: + self._close_completely() + + def _close_completely(self): + """Close the application completely.""" + if self.running: + if messagebox.askyesno(get_text("confirm_title"), get_text("exit_confirm")): + self._stop() + else: + return + + self._save_config() + self.tray.stop() + self.root.destroy() + + def run(self): + """Run the application.""" + self._log(get_text("log_ready")) + self.root.mainloop() + + +def main(): + """Main entry point.""" + SAVED_AUTH_DIR.mkdir(parents=True, exist_ok=True) + ACTIVE_AUTH_DIR.mkdir(parents=True, exist_ok=True) + + app = GUILauncher() + app.run() + + +if __name__ == "__main__": + main() diff --git a/gui/config.py b/gui/config.py new file mode 100644 index 0000000000000000000000000000000000000000..329d8036e26e7a15ac24a6ea3048fcea8f8415d2 --- /dev/null +++ b/gui/config.py @@ -0,0 +1,174 @@ +""" +GUI Launcher Configuration + +Contains all constants, paths, colors, and default settings. +""" + +from pathlib import Path + +# ============================================================================= +# Paths +# ============================================================================= +PROJECT_ROOT = Path(__file__).parent.parent.absolute() +GUI_DIR = Path(__file__).parent.absolute() +AUTH_PROFILES_DIR = PROJECT_ROOT / "auth_profiles" +SAVED_AUTH_DIR = AUTH_PROFILES_DIR / "saved" +ACTIVE_AUTH_DIR = AUTH_PROFILES_DIR / "active" +LAUNCH_SCRIPT = PROJECT_ROOT / "launch_camoufox.py" +CONFIG_FILE = GUI_DIR / "user_config.json" +LOG_FILE = PROJECT_ROOT / "logs" / "gui_launcher.log" +CUSTOM_THEME_FILE = GUI_DIR / "theme.json" +ENV_FILE = PROJECT_ROOT / ".env" +ENV_EXAMPLE_FILE = PROJECT_ROOT / ".env.example" + +# ============================================================================= +# Version +# ============================================================================= +VERSION = "2.0.0" # Major version bump for CustomTkinter migration + +# ============================================================================= +# Default Configuration +# ============================================================================= +DEFAULT_CONFIG = { + "fastapi_port": 2048, + "camoufox_port": 9222, + "stream_port": 3120, + "proxy_address": "", + "proxy_enabled": False, + "last_account": "", + "appearance_mode": "dark", # "dark", "light", or "system" + "minimize_to_tray": True, + "language": "en", +} + +# ============================================================================= +# CustomTkinter Theme Settings +# ============================================================================= +CTK_APPEARANCE_MODE = "dark" # "dark", "light", or "system" +CTK_COLOR_THEME = "dark-blue" # Built-in: "blue", "dark-blue", "green" + +# ============================================================================= +# Modern Color Palette (for custom widgets and fallbacks) +# CustomTkinter uses tuple format: (light_mode_color, dark_mode_color) +# ============================================================================= +COLORS = { + # Background hierarchy - (light, dark) + "bg_dark": ("#e8e8e8", "#1a1a2e"), + "bg_medium": ("#f0f0f0", "#16213e"), + "bg_light": ("#ffffff", "#0f3460"), + # Accent colors - same for both modes for brand consistency + "accent": ("#e94560", "#e94560"), + "accent_hover": ("#d63850", "#ff6b6b"), + "accent_light": ("#ff8a8a", "#ff8a8a"), + # Semantic colors - slightly adjusted for visibility in light mode + "success": ("#00b359", "#00d26a"), + "success_hover": ("#00994d", "#00ff7f"), + "warning": ("#e6ad00", "#ffc107"), + "warning_hover": ("#cc9900", "#ffda44"), + "error": ("#c82333", "#dc3545"), + "error_hover": ("#a71d2a", "#ff4d5e"), + # Text colors - (light, dark) + "text_primary": ("#1a1a1a", "#ffffff"), + "text_secondary": ("#555555", "#a0a0a0"), + "text_muted": ("#888888", "#6c757d"), + # Text for colored buttons (always white for contrast) + "text_on_color": "#ffffff", + # Border and dividers - (light, dark) + "border": ("#cccccc", "#2d2d44"), + "border_light": ("#dddddd", "#3d3d5c"), + # Special + "transparent": "transparent", +} + +# ============================================================================= +# Font Configuration +# ============================================================================= +FONTS = { + "family": "Segoe UI", # Falls back to system font if not available + "family_mono": "Consolas", + "size_small": 11, + "size_normal": 13, + "size_large": 15, + "size_header": 20, + "size_title": 24, +} + +# ============================================================================= +# Widget Dimensions +# ============================================================================= +DIMENSIONS = { + "corner_radius": 10, + "corner_radius_small": 6, + "corner_radius_large": 15, + "border_width": 2, + "button_height": 40, + "button_height_large": 50, + "entry_height": 38, + "padding": 15, + "padding_small": 8, +} + +# ============================================================================= +# URLs +# ============================================================================= +GITHUB_URL = "https://github.com/CJackHwang/AIstudioProxyAPI" +DOCS_URL = f"{GITHUB_URL}#readme" + +# ============================================================================= +# Advanced Settings Category Icons +# ============================================================================= +CATEGORY_ICONS = { + "server": "🖥️", + "logging": "📝", + "auth": "🔐", + "cookie": "🍪", + "browser": "🌐", + "api": "⚡", + "function_calling": "🔧", + "timeouts": "⏱️", + "misc": "📦", +} + + +# ============================================================================= +# Color Utility Functions +# ============================================================================= +def get_color(color_key: str, mode: str = "dark") -> str: + """ + Get the appropriate color value for the current mode. + + Args: + color_key: Key from COLORS dictionary + mode: "light" or "dark" (default: "dark") + + Returns: + Hex color string (e.g., "#e94560") + """ + color = COLORS.get(color_key, "#ffffff") + + # If it's a tuple (light, dark), extract the right one + if isinstance(color, tuple): + return color[0] if mode == "light" else color[1] + + # If it's a plain string, return as-is + return color + + +def get_current_color(color_key: str) -> str: + """ + Get color value based on current CustomTkinter appearance mode. + + Args: + color_key: Key from COLORS dictionary + + Returns: + Hex color string for current mode + """ + try: + import customtkinter as ctk + + current_mode = ctk.get_appearance_mode().lower() + except Exception: + current_mode = "dark" + + return get_color(color_key, current_mode) diff --git a/gui/env_manager.py b/gui/env_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..89d2a32c27f61ab0ded3f7e462926dea9aee5c9d --- /dev/null +++ b/gui/env_manager.py @@ -0,0 +1,558 @@ +""" +GUI Launcher Environment Manager + +Manages reading/writing .env file settings with hot reload support. +""" + +import os +import re +import shutil +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Tuple + + +class EnvManager: + """ + Manages .env file operations with structured parsing and hot reload support. + + Features: + - Preserves comments and file structure when writing + - Tracks modified values for dirty state detection + - Supports hot reload callbacks + - Validates values based on type hints + """ + + # Type definitions for env variables + # Format: (default_value, type, description, category) + ENV_SCHEMA: Dict[str, Tuple[Any, str, str, str]] = { + # Server Configuration + "PORT": (2048, "int", "FastAPI Main Service Port", "server"), + "STREAM_PORT": ( + 3120, + "int", + "Streaming Proxy Service Port (0 to disable)", + "server", + ), + "DEFAULT_FASTAPI_PORT": (2048, "int", "GUI Default FastAPI Port", "server"), + "DEFAULT_CAMOUFOX_PORT": (9222, "int", "GUI Default Camoufox Port", "server"), + # Logging & Debugging + "SERVER_LOG_LEVEL": ( + "INFO", + "choice:DEBUG,INFO,WARNING,ERROR,CRITICAL", + "Server Log Level", + "logging", + ), + "SERVER_REDIRECT_PRINT": ( + False, + "bool", + "Redirect print output to logs", + "logging", + ), + "DEBUG_LOGS_ENABLED": (False, "bool", "Enable Debug Logs", "logging"), + "TRACE_LOGS_ENABLED": (False, "bool", "Enable Trace Logs", "logging"), + "JSON_LOGS": (False, "bool", "JSON Structured Logging", "logging"), + "LOG_FILE_MAX_BYTES": (10485760, "int", "Log File Max Size (bytes)", "logging"), + "LOG_FILE_BACKUP_COUNT": (5, "int", "Log Backup File Count", "logging"), + # Authentication + "AUTO_SAVE_AUTH": (False, "bool", "Auto-save Authentication", "auth"), + "AUTH_SAVE_TIMEOUT": (30, "int", "Auth Save Timeout (seconds)", "auth"), + "AUTO_ROTATE_AUTH_PROFILE": (True, "bool", "Auto Rotate Auth Profile", "auth"), + "AUTO_AUTH_ROTATION_ON_STARTUP": ( + False, + "bool", + "Auto Auth Rotation on Startup", + "auth", + ), + "AUTO_CONFIRM_LOGIN": (True, "bool", "Auto Confirm Login", "auth"), + "QUOTA_SOFT_LIMIT": (850000, "int", "Quota Soft Limit (tokens)", "auth"), + "QUOTA_HARD_LIMIT": (950000, "int", "Quota Hard Limit (tokens)", "auth"), + # Cookie Refresh + "COOKIE_REFRESH_ENABLED": (True, "bool", "Enable Cookie Refresh", "cookie"), + "COOKIE_REFRESH_INTERVAL_SECONDS": ( + 1800, + "int", + "Cookie Refresh Interval (seconds)", + "cookie", + ), + "COOKIE_REFRESH_ON_REQUEST_ENABLED": ( + True, + "bool", + "Cookie Refresh on Request", + "cookie", + ), + "COOKIE_REFRESH_REQUEST_INTERVAL": ( + 10, + "int", + "Cookie Refresh Request Interval", + "cookie", + ), + "COOKIE_REFRESH_ON_SHUTDOWN": ( + True, + "bool", + "Cookie Refresh on Shutdown", + "cookie", + ), + # Browser & Model + "LAUNCH_MODE": ( + "normal", + "choice:normal,debug,headless,virtual_display,direct_debug_no_browser", + "Launch Mode", + "browser", + ), + "DIRECT_LAUNCH": ( + False, + "bool", + "Quick Launch (Skip Launcher Menu)", + "browser", + ), + "ONLY_COLLECT_CURRENT_USER_ATTACHMENTS": ( + False, + "bool", + "Only Collect Current User Attachments", + "browser", + ), + "ENDPOINT_CAPTURE_TIMEOUT": ( + 45, + "int", + "Camoufox Endpoint Capture Timeout (seconds)", + "browser", + ), + # API Defaults + "DEFAULT_TEMPERATURE": (1.0, "float", "Default Temperature", "api"), + "DEFAULT_MAX_OUTPUT_TOKENS": (65536, "int", "Default Max Output Tokens", "api"), + "DEFAULT_TOP_P": (0.95, "float", "Default Top P", "api"), + "ENABLE_THINKING_BUDGET": (True, "bool", "Enable Thinking Budget", "api"), + "DEFAULT_THINKING_BUDGET": ( + 8192, + "int", + "Default Thinking Budget (tokens)", + "api", + ), + "THINKING_BUDGET_LOW": (10923, "int", "Thinking Budget Low Level", "api"), + "THINKING_BUDGET_MEDIUM": (21845, "int", "Thinking Budget Medium Level", "api"), + "THINKING_BUDGET_HIGH": (32768, "int", "Thinking Budget High Level", "api"), + "DEFAULT_THINKING_LEVEL_PRO": ( + "high", + "choice:low,medium,high", + "Default Thinking Level (Pro)", + "api", + ), + "DEFAULT_THINKING_LEVEL_FLASH": ( + "high", + "choice:low,medium,high", + "Default Thinking Level (Flash)", + "api", + ), + "DISABLE_THINKING_BUDGET_ON_STREAMING_DISABLE": ( + False, + "bool", + "Disable Thinking on Streaming Disable", + "api", + ), + "ENABLE_GOOGLE_SEARCH": (False, "bool", "Enable Google Search", "api"), + "ENABLE_URL_CONTEXT": (False, "bool", "Enable URL Context", "api"), + # Function Calling + "FUNCTION_CALLING_MODE": ( + "auto", + "choice:auto,native,emulated", + "Function Calling Mode", + "function_calling", + ), + "FUNCTION_CALLING_NATIVE_FALLBACK": ( + True, + "bool", + "Native Mode Fallback to Emulated", + "function_calling", + ), + "FUNCTION_CALLING_UI_TIMEOUT": ( + 10000, + "int", + "Function Calling UI Timeout (ms)", + "function_calling", + ), + "FUNCTION_CALLING_NATIVE_RETRY_COUNT": ( + 3, + "int", + "Native Mode Retry Count", + "function_calling", + ), + "FUNCTION_CALLING_CLEAR_BETWEEN_REQUESTS": ( + True, + "bool", + "Clear Functions Between Requests", + "function_calling", + ), + "FUNCTION_CALLING_DEBUG": ( + False, + "bool", + "Function Calling Debug (Master Switch)", + "function_calling", + ), + "FUNCTION_CALLING_CACHE_ENABLED": ( + True, + "bool", + "Function Calling Cache Enabled", + "function_calling", + ), + "FUNCTION_CALLING_CACHE_TTL": ( + 0, + "int", + "Function Calling Cache TTL (seconds)", + "function_calling", + ), + # Timeouts + "RESPONSE_COMPLETION_TIMEOUT": ( + 600000, + "int", + "Response Completion Timeout (ms)", + "timeouts", + ), + "INITIAL_WAIT_MS_BEFORE_POLLING": ( + 500, + "int", + "Initial Wait Before Polling (ms)", + "timeouts", + ), + "POLLING_INTERVAL": (300, "int", "Polling Interval (ms)", "timeouts"), + "POLLING_INTERVAL_STREAM": ( + 180, + "int", + "Streaming Polling Interval (ms)", + "timeouts", + ), + "SILENCE_TIMEOUT_MS": (60000, "int", "Silence Timeout (ms)", "timeouts"), + "CLICK_TIMEOUT_MS": (3000, "int", "Click Timeout (ms)", "timeouts"), + "WAIT_FOR_ELEMENT_TIMEOUT_MS": ( + 10000, + "int", + "Wait for Element Timeout (ms)", + "timeouts", + ), + "PSEUDO_STREAM_DELAY": ( + 0.01, + "float", + "Pseudo Stream Delay (seconds)", + "timeouts", + ), + # Miscellaneous + "SKIP_FRONTEND_BUILD": (False, "bool", "Skip Frontend Build Check", "misc"), + "ENABLE_SCRIPT_INJECTION": ( + False, + "bool", + "Enable Script Injection (Deprecated)", + "misc", + ), + } + + # Category display order and names + CATEGORIES: Dict[str, str] = { + "server": "Server Configuration", + "logging": "Logging & Debugging", + "auth": "Authentication", + "cookie": "Cookie Refresh", + "browser": "Browser & Model", + "api": "API Defaults", + "function_calling": "Function Calling", + "timeouts": "Timeouts", + "misc": "Miscellaneous", + } + + def __init__(self, env_path: Path, example_path: Optional[Path] = None): + """ + Initialize the EnvManager. + + Args: + env_path: Path to the .env file + example_path: Path to .env.example (used for initialization) + """ + self.env_path = env_path + self.example_path = example_path + self._values: Dict[str, str] = {} + self._original_values: Dict[str, str] = {} + self._file_lines: List[str] = [] + self._hot_reload_callbacks: List[Callable[[Dict[str, str]], None]] = [] + + # Load the file + self.load() + + def load(self) -> None: + """Load and parse the .env file.""" + self._values = {} + self._file_lines = [] + + # If .env doesn't exist, try to copy from .env.example + if not self.env_path.exists(): + if self.example_path and self.example_path.exists(): + shutil.copy(self.example_path, self.env_path) + else: + # Create an empty .env + self.env_path.touch() + + # Read the file + try: + with open(self.env_path, "r", encoding="utf-8") as f: + self._file_lines = f.readlines() + except Exception as e: + print(f"Error reading .env file: {e}") + self._file_lines = [] + + # Parse key-value pairs + for line in self._file_lines: + stripped = line.strip() + + # Skip empty lines and comments + if not stripped or stripped.startswith("#"): + continue + + # Parse key=value + match = re.match( + r"^([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$", stripped, re.IGNORECASE + ) + if match: + key = match.group(1) + value = match.group(2) + + # Remove surrounding quotes if present + if (value.startswith('"') and value.endswith('"')) or ( + value.startswith("'") and value.endswith("'") + ): + value = value[1:-1] + + self._values[key] = value + + # Store original values for dirty detection + self._original_values = self._values.copy() + + def get(self, key: str, default: Any = None) -> Any: + """ + Get a value from the env, with type conversion. + + Args: + key: Environment variable name + default: Default value if not found + + Returns: + The typed value + """ + raw_value = self._values.get(key) + + if raw_value is None: + # Return schema default if available + if key in self.ENV_SCHEMA: + return self.ENV_SCHEMA[key][0] + return default + + # Get type from schema + if key in self.ENV_SCHEMA: + _, type_hint, _, _ = self.ENV_SCHEMA[key] + return self._convert_value(raw_value, type_hint) + + return raw_value + + def get_raw(self, key: str) -> Optional[str]: + """Get raw string value without type conversion.""" + return self._values.get(key) + + def set(self, key: str, value: Any) -> None: + """ + Set a value in the env. + + Args: + key: Environment variable name + value: Value to set (will be converted to string) + """ + # Convert boolean to lowercase string + if isinstance(value, bool): + str_value = "true" if value else "false" + else: + str_value = str(value) + + self._values[key] = str_value + + def is_dirty(self) -> bool: + """Check if any values have been modified since last load/save.""" + return self._values != self._original_values + + def get_modified_keys(self) -> List[str]: + """Get list of keys that have been modified.""" + modified = [] + for key in self._values: + if ( + key not in self._original_values + or self._values[key] != self._original_values[key] + ): + modified.append(key) + for key in self._original_values: + if key not in self._values: + modified.append(key) + return list(set(modified)) + + def save(self) -> bool: + """ + Save changes to the .env file. + + Preserves comments and structure, only updating changed values. + + Returns: + True if save was successful + """ + try: + new_lines = [] + keys_written = set() + + for line in self._file_lines: + stripped = line.strip() + + # Keep empty lines and comments + if not stripped or stripped.startswith("#"): + new_lines.append(line) + continue + + # Check if this is a key=value line + match = re.match(r"^([A-Z_][A-Z0-9_]*)\s*=", stripped, re.IGNORECASE) + if match: + key = match.group(1) + if key in self._values: + # Update the value, preserve comment if any + comment_match = re.search(r"#.*$", line) + comment = comment_match.group(0) if comment_match else "" + + value = self._values[key] + # Quote values with spaces + if " " in value and not ( + value.startswith('"') or value.startswith("'") + ): + value = f'"{value}"' + + new_line = f"{key}={value}" + if comment: + new_line += f" {comment}" + new_lines.append(new_line + "\n") + keys_written.add(key) + else: + new_lines.append(line) + else: + new_lines.append(line) + + # Add any new keys at the end + for key, value in self._values.items(): + if key not in keys_written: + new_lines.append(f"\n{key}={value}\n") + + # Write the file + with open(self.env_path, "w", encoding="utf-8") as f: + f.writelines(new_lines) + + # Reload to update line cache + self.load() + return True + + except Exception as e: + print(f"Error saving .env file: {e}") + return False + + def reset_to_defaults(self) -> None: + """Reset all values to their schema defaults.""" + for key, (default, _, _, _) in self.ENV_SCHEMA.items(): + self.set(key, default) + + def discard_changes(self) -> None: + """Discard all unsaved changes.""" + self._values = self._original_values.copy() + + def get_category_keys(self, category: str) -> List[str]: + """Get all keys belonging to a category.""" + return [ + key for key, (_, _, _, cat) in self.ENV_SCHEMA.items() if cat == category + ] + + def get_schema_info(self, key: str) -> Optional[Tuple[Any, str, str, str]]: + """Get schema info for a key: (default, type, description, category).""" + return self.ENV_SCHEMA.get(key) + + def register_hot_reload_callback( + self, callback: Callable[[Dict[str, str]], None] + ) -> None: + """ + Register a callback for hot reload notifications. + + Args: + callback: Function that receives dict of changed keys and values + """ + self._hot_reload_callbacks.append(callback) + + def unregister_hot_reload_callback( + self, callback: Callable[[Dict[str, str]], None] + ) -> None: + """Unregister a hot reload callback.""" + if callback in self._hot_reload_callbacks: + self._hot_reload_callbacks.remove(callback) + + def trigger_hot_reload(self) -> None: + """Trigger hot reload callbacks with modified values.""" + modified = {key: self._values.get(key, "") for key in self.get_modified_keys()} + for callback in self._hot_reload_callbacks: + try: + callback(modified) + except Exception as e: + print(f"Hot reload callback error: {e}") + + def apply_to_environment(self) -> None: + """Apply current values to os.environ for hot reload.""" + for key, value in self._values.items(): + os.environ[key] = value + + def _convert_value(self, value: str, type_hint: str) -> Any: + """Convert a string value based on type hint.""" + try: + if type_hint == "bool": + return value.lower() in ("true", "1", "yes", "on") + elif type_hint == "int": + return int(value) + elif type_hint == "float": + return float(value) + elif type_hint.startswith("choice:"): + # Return as-is, validation is done elsewhere + return value + else: + return value + except (ValueError, TypeError): + # Return schema default on conversion error + if type_hint == "bool": + return False + elif type_hint == "int": + return 0 + elif type_hint == "float": + return 0.0 + return value + + +# Singleton instance +_env_manager: Optional[EnvManager] = None + + +def get_env_manager( + env_path: Optional[Path] = None, example_path: Optional[Path] = None +) -> EnvManager: + """ + Get the singleton EnvManager instance. + + Args: + env_path: Path to .env file (only used on first call) + example_path: Path to .env.example (only used on first call) + + Returns: + The EnvManager singleton + """ + global _env_manager + if _env_manager is None: + if env_path is None: + raise ValueError("env_path must be provided on first call") + _env_manager = EnvManager(env_path, example_path) + return _env_manager + + +def reset_env_manager() -> None: + """Reset the singleton (mainly for testing).""" + global _env_manager + _env_manager = None diff --git a/gui/i18n.py b/gui/i18n.py new file mode 100644 index 0000000000000000000000000000000000000000..927eb49b2c9b6873819566cece98c2f54f2d3e3a --- /dev/null +++ b/gui/i18n.py @@ -0,0 +1,495 @@ +""" +GUI Launcher Internationalization (i18n) + +Contains all translations for English and Chinese. +""" + +from typing import Dict + +# Current language setting (module-level state) +_current_language = "en" + + +def get_language() -> str: + """Get current language code.""" + return _current_language + + +def set_language(lang_code: str) -> None: + """Set current language code.""" + global _current_language + if lang_code in ("en", "zh"): + _current_language = lang_code + + +def get_text(key: str, **kwargs) -> str: + """Get translated text for the given key.""" + try: + text = TRANSLATIONS[key][_current_language] + if kwargs: + return text.format(**kwargs) + return text + except KeyError: + # Fallback to English + try: + text = TRANSLATIONS[key]["en"] + if kwargs: + return text.format(**kwargs) + return text + except KeyError: + return f"<{key}>" + + +# ============================================================================= +# All Translations +# ============================================================================= +TRANSLATIONS: Dict[str, Dict[str, str]] = { + # Window title + "title": {"en": "🚀 AI Studio Proxy API", "zh": "🚀 AI Studio 代理 API"}, + # Status messages + "status_ready": {"en": "⚪ Ready", "zh": "⚪ 就绪"}, + "status_running": {"en": "🟢 Running", "zh": "🟢 运行中"}, + "status_stopping": {"en": "🟡 Stopping...", "zh": "🟡 停止中..."}, + "status_stopped": {"en": "⚪ Stopped", "zh": "⚪ 已停止"}, + "status_error": {"en": "🔴 Error", "zh": "🔴 错误"}, + # Tabs + "tab_control": {"en": "🎮 Control", "zh": "🎮 控制"}, + "tab_accounts": {"en": "👤 Accounts", "zh": "👤 账户"}, + "tab_settings": {"en": "⚙️ Settings", "zh": "⚙️ 设置"}, + "tab_logs": {"en": "📋 Logs", "zh": "📋 日志"}, + # Control tab + "quick_start": {"en": "⚡ Quick Start", "zh": "⚡ 快速启动"}, + "account_label": {"en": "Account:", "zh": "账户:"}, + "mode_label": {"en": "Mode:", "zh": "模式:"}, + "mode_headless": {"en": "Headless", "zh": "无头模式"}, + "mode_visible": {"en": "Visible", "zh": "有头模式"}, + "btn_start": {"en": "▶️ START", "zh": "▶️ 启动"}, + "btn_stop": {"en": "⏹️ STOP", "zh": "⏹️ 停止"}, + "api_info": {"en": "🌐 API Info", "zh": "🌐 API 信息"}, + "btn_test": {"en": "🔍 Test", "zh": "🔍 测试"}, + "btn_open_browser": {"en": "🌐 Open in Browser", "zh": "🌐 在浏览器中打开"}, + "status_card": {"en": "📊 Status", "zh": "📊 状态"}, + "service_stopped": {"en": "Service is stopped", "zh": "服务已停止"}, + "service_started": {"en": "Service started", "zh": "服务已启动"}, + "pid_label": {"en": "PID:", "zh": "进程ID:"}, + # Account tab + "saved_accounts": {"en": "📋 Saved Accounts", "zh": "📋 已保存账户"}, + "btn_add_account": {"en": "➕ Add New Account", "zh": "➕ 添加新账户"}, + "btn_delete_account": {"en": "🗑️ Delete Selected", "zh": "🗑️ 删除选中"}, + "btn_refresh": {"en": "🔄 Refresh", "zh": "🔄 刷新"}, + "account_details": {"en": "ℹ️ Account Details", "zh": "ℹ️ 账户详情"}, + "select_account_hint": { + "en": "Select an account to see details", + "zh": "选择账户查看详情", + }, + "file_label": {"en": "📁 File:", "zh": "📁 文件:"}, + "last_modified": {"en": "📅 Last modified:", "zh": "📅 最后修改:"}, + "size_label": {"en": "📊 Size:", "zh": "📊 大小:"}, + # Settings tab + "port_settings": {"en": "🔌 Port Settings", "zh": "🔌 端口设置"}, + "fastapi_port": {"en": "FastAPI Port:", "zh": "FastAPI 端口:"}, + "stream_port": {"en": "Stream Port:", "zh": "流代理端口:"}, + "proxy_settings": {"en": "🌍 Proxy Settings", "zh": "🌍 代理设置"}, + "use_proxy": {"en": "Use Proxy", "zh": "使用代理"}, + "proxy_address": {"en": "Address:", "zh": "地址:"}, + "proxy_example": { + "en": "Example: http://127.0.0.1:7890", + "zh": "示例: http://127.0.0.1:7890", + }, + "language_settings": {"en": "🌐 Language / 语言", "zh": "🌐 Language / 语言"}, + "btn_save_settings": {"en": "💾 Save Settings", "zh": "💾 保存设置"}, + "btn_reset_default": {"en": "🔄 Reset to Default", "zh": "🔄 恢复默认"}, + # Logs tab + "btn_clear_logs": {"en": "🗑️ Clear", "zh": "🗑️ 清空"}, + "btn_save_logs": {"en": "💾 Save to File", "zh": "💾 保存到文件"}, + "btn_open_log_folder": {"en": "📂 Open Log File", "zh": "📂 打开日志文件"}, + # Dialog messages + "new_account_title": {"en": "New Account", "zh": "新建账户"}, + "new_account_prompt": { + "en": "Enter a name for the account\n(e.g.: my_gmail_account):", + "zh": "请输入账户名称\n(例如: my_gmail_account):", + }, + "invalid_filename": { + "en": "Only letters, numbers, - and _ are allowed!", + "zh": "只允许使用字母、数字、- 和 _!", + }, + "confirm_delete": { + "en": "Are you sure you want to delete '{name}'?", + "zh": "确定要删除 '{name}' 吗?", + }, + "confirm_title": {"en": "Confirm", "zh": "确认"}, + "warning_title": {"en": "Warning", "zh": "警告"}, + "error_title": {"en": "Error", "zh": "错误"}, + "success_title": {"en": "Success", "zh": "成功"}, + "select_account_warning": { + "en": "Please select an account to delete", + "zh": "请选择要删除的账户", + }, + "select_account_error": { + "en": "Please select an account!", + "zh": "请选择一个账户!", + }, + "service_already_running": { + "en": "Service is already running!", + "zh": "服务已在运行中!", + }, + "api_running": {"en": "API is running! ✅", "zh": "API 运行正常! ✅"}, + "api_not_responding": { + "en": "Could not connect to API.\nIs the service running?", + "zh": "无法连接到 API。\n服务是否在运行?", + }, + "settings_saved": {"en": "Settings saved!", "zh": "设置已保存!"}, + "reset_confirm": { + "en": "All settings will be reset to default. Continue?", + "zh": "所有设置将恢复为默认值。继续吗?", + }, + "exit_confirm": { + "en": "Service is running. Stop and exit?", + "zh": "服务正在运行。停止并退出吗?", + }, + "logs_saved": {"en": "Logs saved:", "zh": "日志已保存:"}, + "logs_save_error": {"en": "Logs could not be saved:", "zh": "无法保存日志:"}, + "folder_open_error": {"en": "Folder could not be opened:", "zh": "无法打开文件夹:"}, + "account_delete_error": { + "en": "Account could not be deleted:", + "zh": "无法删除账户:", + }, + "start_error": {"en": "Could not start:", "zh": "无法启动:"}, + # Log messages + "log_ready": { + "en": "🚀 AI Studio Proxy Launcher ready", + "zh": "🚀 AI Studio 代理启动器就绪", + }, + "log_accounts_loaded": { + "en": "✅ {count} account(s) loaded", + "zh": "✅ 已加载 {count} 个账户", + }, + "log_no_accounts": { + "en": "⚠️ No saved accounts found", + "zh": "⚠️ 未找到已保存的账户", + }, + "log_adding_account": { + "en": "🔐 Adding new account: {name}", + "zh": "🔐 正在添加新账户: {name}", + }, + "log_browser_login": { + "en": "📌 Browser will open, log in to your Google account", + "zh": "📌 浏览器将打开,请登录您的 Google 账户", + }, + "log_auto_save": { + "en": "📌 After logging in, account will be saved automatically", + "zh": "📌 登录后,账户将自动保存", + }, + "log_account_deleted": { + "en": "🗑️ Account deleted: {name}", + "zh": "🗑️ 账户已删除: {name}", + }, + "log_testing_api": {"en": "🔍 Testing API: {url}", "zh": "🔍 正在测试 API: {url}"}, + "log_api_running": {"en": "✅ API is running!", "zh": "✅ API 运行正常!"}, + "log_api_status": { + "en": "⚠️ API responded but status code: {code}", + "zh": "⚠️ API 响应但状态码: {code}", + }, + "log_api_error": { + "en": "❌ Could not connect to API. Service may not be running.", + "zh": "❌ 无法连接到 API。服务可能未运行。", + }, + "log_api_test_error": { + "en": "❌ API test error: {error}", + "zh": "❌ API 测试错误: {error}", + }, + "log_settings_saved": {"en": "💾 Settings saved", "zh": "💾 设置已保存"}, + "log_settings_reset": { + "en": "🔄 Settings reset to default", + "zh": "🔄 设置已恢复默认", + }, + "log_checking_ports": {"en": "🔍 Checking ports...", "zh": "🔍 正在检查端口..."}, + "log_port_in_use": { + "en": "🔍 Port {port} is in use, cleaning...", + "zh": "🔍 端口 {port} 被占用,正在清理...", + }, + "log_pid_terminated": { + "en": " ✅ PID {pid} terminated", + "zh": " ✅ PID {pid} 已终止", + }, + "log_pid_error": { + "en": " ❌ PID {pid}: {error}", + "zh": " ❌ PID {pid}: {error}", + }, + "log_port_still_in_use": { + "en": " ❌ Port {port} is still in use!", + "zh": " ❌ 端口 {port} 仍被占用!", + }, + "log_starting": { + "en": "🚀 Starting: {mode} mode", + "zh": "🚀 正在启动: {mode} 模式", + }, + "log_service_started": { + "en": "✅ Service started (PID: {pid})", + "zh": "✅ 服务已启动 (PID: {pid})", + }, + "log_stopping": {"en": "🛑 Stopping...", "zh": "🛑 正在停止..."}, + "log_force_closing": {"en": "⚠️ Force closing...", "zh": "⚠️ 正在强制关闭..."}, + "log_stopped": {"en": "✅ Stopped", "zh": "✅ 已停止"}, + "log_stop_error": {"en": "❌ Stop error: {error}", "zh": "❌ 停止错误: {error}"}, + "log_service_ended": {"en": "✅ Service ended normally", "zh": "✅ 服务正常结束"}, + "log_service_error": { + "en": "⚠️ Service stopped with error: {code}", + "zh": "⚠️ 服务异常停止,错误码: {code}", + }, + "log_minimized": { + "en": "📌 Minimized to system tray", + "zh": "📌 已最小化到系统托盘", + }, + "log_accounts_refreshed": { + "en": "🔄 Account list refreshed", + "zh": "🔄 账户列表已刷新", + }, + "log_language_changed": { + "en": "🌐 Language changed to English", + "zh": "🌐 语言已切换为中文", + }, + "log_config_load_error": { + "en": "⚠️ Configuration could not be loaded: {error}", + "zh": "⚠️ 无法加载配置: {error}", + }, + "log_config_save_error": { + "en": "⚠️ Configuration could not be saved: {error}", + "zh": "⚠️ 无法保存配置: {error}", + }, + "log_logs_saved": {"en": "✅ Logs saved: {name}", "zh": "✅ 日志已保存: {name}"}, + "log_port_pid_error": { + "en": "⚠️ Could not find port PID: {error}", + "zh": "⚠️ 无法查找端口 PID: {error}", + }, + "log_user_cancelled": {"en": "❌ User cancelled", "zh": "❌ 用户取消"}, + "log_port_clean_warning": { + "en": "Some ports could not be cleaned. Continue?", + "zh": "部分端口无法清理。是否继续?", + }, + # API status + "api_active": { + "en": "✅ API is active and responding", + "zh": "✅ API 活跃且响应正常", + }, + "api_not_active": {"en": "❌ API is not responding", "zh": "❌ API 无响应"}, + # Menu bar + "menu_file": {"en": "File", "zh": "文件"}, + "menu_start_service": {"en": "Start Service", "zh": "启动服务"}, + "menu_stop_service": {"en": "Stop Service", "zh": "停止服务"}, + "menu_exit": {"en": "Exit", "zh": "退出"}, + "menu_help": {"en": "Help", "zh": "帮助"}, + "menu_about": {"en": "About", "zh": "关于"}, + "menu_documentation": {"en": "Documentation", "zh": "文档"}, + "menu_github": {"en": "GitHub Repository", "zh": "GitHub 仓库"}, + # About dialog + "about_title": {"en": "About AI Studio Proxy", "zh": "关于 AI Studio 代理"}, + "about_version": {"en": "Version:", "zh": "版本:"}, + "about_description": { + "en": "A proxy server that converts Google AI Studio's web interface into an OpenAI-compatible API.", + "zh": "将 Google AI Studio 网页界面转换为兼容 OpenAI API 的代理服务器。", + }, + "about_credits": {"en": "Credits:", "zh": "致谢:"}, + # Tooltips + "tooltip_account": { + "en": "Select the Google account to use for authentication", + "zh": "选择用于身份验证的 Google 账户", + }, + "tooltip_headless": { + "en": "Run browser in background (no visible window)", + "zh": "在后台运行浏览器(无可见窗口)", + }, + "tooltip_visible": { + "en": "Show browser window (useful for debugging)", + "zh": "显示浏览器窗口(用于调试)", + }, + "tooltip_start": { + "en": "Start the proxy service (Ctrl+S)", + "zh": "启动代理服务 (Ctrl+S)", + }, + "tooltip_stop": { + "en": "Stop the running service (Ctrl+X)", + "zh": "停止正在运行的服务 (Ctrl+X)", + }, + "tooltip_test_api": {"en": "Test if API is responding", "zh": "测试 API 是否响应"}, + "tooltip_copy_url": { + "en": "Copy API URL to clipboard", + "zh": "复制 API 地址到剪贴板", + }, + "tooltip_add_account": { + "en": "Open browser to log in with a new Google account", + "zh": "打开浏览器以使用新的 Google 账户登录", + }, + "tooltip_delete_account": { + "en": "Delete the selected account", + "zh": "删除选中的账户", + }, + "tooltip_fastapi_port": { + "en": "Port for the OpenAI-compatible API (default: 2048)", + "zh": "OpenAI 兼容 API 的端口(默认:2048)", + }, + "tooltip_stream_port": { + "en": "Port for streaming proxy (default: 3120)", + "zh": "流代理端口(默认:3120)", + }, + "tooltip_proxy": { + "en": "HTTP proxy for accessing Google (e.g., http://127.0.0.1:7890)", + "zh": "访问 Google 的 HTTP 代理(例如:http://127.0.0.1:7890)", + }, + # Status bar + "statusbar_ready": {"en": "Ready", "zh": "就绪"}, + "statusbar_running": {"en": "Running", "zh": "运行中"}, + "statusbar_uptime": {"en": "Uptime:", "zh": "运行时间:"}, + "statusbar_port": {"en": "Port:", "zh": "端口:"}, + # Copy + "copied_to_clipboard": {"en": "Copied to clipboard!", "zh": "已复制到剪贴板!"}, + "btn_copy": {"en": "📋 Copy", "zh": "📋 复制"}, + # Empty states + "no_accounts_title": {"en": "No Accounts Found", "zh": "未找到账户"}, + "no_accounts_hint": { + "en": "Click '➕ Add New Account' to get started", + "zh": "点击 '➕ 添加新账户' 开始", + }, + # Validation + "invalid_port": { + "en": "Invalid port number. Must be between 1 and 65535.", + "zh": "无效的端口号。必须在 1 到 65535 之间。", + }, + "port_conflict": { + "en": "FastAPI and Stream ports cannot be the same.", + "zh": "FastAPI 端口和流代理端口不能相同。", + }, + # Theme settings + "theme_settings": {"en": "🎨 Appearance", "zh": "🎨 外观"}, + "theme_mode": {"en": "Theme Mode:", "zh": "主题模式:"}, + "theme_dark": {"en": "🌙 Dark", "zh": "🌙 深色"}, + "theme_light": {"en": "☀️ Light", "zh": "☀️ 浅色"}, + "theme_system": {"en": "💻 System", "zh": "💻 跟随系统"}, + "log_theme_changed": { + "en": "🎨 Theme changed to {mode}", + "zh": "🎨 主题已切换为 {mode}", + }, + "tooltip_theme": { + "en": "Choose your preferred appearance mode", + "zh": "选择您偏好的外观模式", + }, + # ========================================================================= + # Advanced Settings + # ========================================================================= + "advanced_settings": {"en": "🔧 Advanced Settings", "zh": "🔧 高级设置"}, + "advanced_settings_hint": { + "en": "Configure .env file settings (click to expand/collapse)", + "zh": "配置 .env 文件设置(点击展开/折叠)", + }, + "show_advanced": {"en": "▶ Show Advanced Settings", "zh": "▶ 显示高级设置"}, + "hide_advanced": {"en": "▼ Hide Advanced Settings", "zh": "▼ 隐藏高级设置"}, + # Category names + "cat_server": {"en": "🖥️ Server Configuration", "zh": "🖥️ 服务器配置"}, + "cat_logging": {"en": "📝 Logging & Debugging", "zh": "📝 日志与调试"}, + "cat_auth": {"en": "🔐 Authentication", "zh": "🔐 认证设置"}, + "cat_cookie": {"en": "🍪 Cookie Refresh", "zh": "🍪 Cookie 刷新"}, + "cat_browser": {"en": "🌐 Browser & Model", "zh": "🌐 浏览器与模型"}, + "cat_api": {"en": "⚡ API Defaults", "zh": "⚡ API 默认参数"}, + "cat_function_calling": {"en": "🔧 Function Calling", "zh": "🔧 函数调用"}, + "cat_timeouts": {"en": "⏱️ Timeouts", "zh": "⏱️ 超时设置"}, + "cat_misc": {"en": "📦 Miscellaneous", "zh": "📦 其他设置"}, + # Action buttons + "btn_apply_env": {"en": "💾 Apply Changes", "zh": "💾 应用更改"}, + "btn_reload_env": {"en": "🔄 Reload from File", "zh": "🔄 从文件重载"}, + "btn_reset_env": {"en": "⚙️ Reset to Defaults", "zh": "⚙️ 恢复默认值"}, + "btn_hot_reload": {"en": "🔥 Hot Reload", "zh": "🔥 热重载"}, + # Status messages + "env_saved": { + "en": "Environment settings saved to .env", + "zh": "环境设置已保存到 .env", + }, + "env_save_error": {"en": "Failed to save .env file", "zh": "保存 .env 文件失败"}, + "env_reloaded": { + "en": "Settings reloaded from .env file", + "zh": "设置已从 .env 文件重新加载", + }, + "env_reset_confirm": { + "en": "Reset all advanced settings to defaults? This cannot be undone.", + "zh": "将所有高级设置恢复为默认值?此操作不可撤销。", + }, + "env_reset_done": {"en": "Settings reset to defaults", "zh": "设置已恢复默认"}, + "env_unsaved_changes": { + "en": "You have unsaved changes. Save before continuing?", + "zh": "您有未保存的更改。是否在继续前保存?", + }, + "env_hot_reload_success": { + "en": "Settings applied via hot reload", + "zh": "设置已通过热重载应用", + }, + "env_hot_reload_warning": { + "en": "Proxy is running. Some settings require restart to take effect.", + "zh": "代理正在运行。部分设置需要重启才能生效。", + }, + "env_hot_reload_confirm": { + "en": "Apply settings to running proxy? Some changes may require restart.", + "zh": "将设置应用到正在运行的代理?部分更改可能需要重启。", + }, + "env_modified_indicator": {"en": "(modified)", "zh": "(已修改)"}, + "env_file_not_found": { + "en": ".env file not found. Created from template.", + "zh": "未找到 .env 文件。已从模板创建。", + }, + # Tooltips for settings + "tooltip_env_port": { + "en": "Main API server port (default: 2048)", + "zh": "主 API 服务器端口(默认:2048)", + }, + "tooltip_env_stream_port": { + "en": "Streaming proxy port. Set to 0 to disable (default: 3120)", + "zh": "流代理端口。设置为 0 禁用(默认:3120)", + }, + "tooltip_env_log_level": { + "en": "Server log verbosity level", + "zh": "服务器日志详细程度", + }, + "tooltip_env_temperature": { + "en": "Default sampling temperature (0.0-2.0)", + "zh": "默认采样温度(0.0-2.0)", + }, + "tooltip_env_max_tokens": { + "en": "Maximum output tokens per request", + "zh": "每次请求的最大输出令牌数", + }, + "tooltip_env_auto_rotate": { + "en": "Automatically switch auth profile when quota exceeded", + "zh": "配额超限时自动切换认证配置文件", + }, + "tooltip_env_quota_soft": { + "en": "Token count that triggers rotation pending state", + "zh": "触发待轮换状态的令牌计数", + }, + "tooltip_env_quota_hard": { + "en": "Token count that triggers immediate rotation", + "zh": "触发立即轮换的令牌计数", + }, + "tooltip_env_fc_mode": { + "en": "Function calling mode: auto (recommended), native, or emulated", + "zh": "函数调用模式:auto(推荐)、native 或 emulated", + }, + "tooltip_env_hot_reload": { + "en": "Apply settings immediately without restart (some settings require restart)", + "zh": "立即应用设置无需重启(部分设置需要重启)", + }, + # Log messages for advanced settings + "log_env_loaded": { + "en": "📁 Advanced settings loaded from .env", + "zh": "📁 高级设置已从 .env 加载", + }, + "log_env_saved": { + "en": "💾 Advanced settings saved to .env", + "zh": "💾 高级设置已保存到 .env", + }, + "log_env_hot_reload": { + "en": "🔥 Hot reload applied: {count} setting(s) updated", + "zh": "🔥 热重载已应用:{count} 个设置已更新", + }, + "log_env_reset": { + "en": "⚙️ Advanced settings reset to defaults", + "zh": "⚙️ 高级设置已恢复默认", + }, +} diff --git a/gui/styles.py b/gui/styles.py new file mode 100644 index 0000000000000000000000000000000000000000..a37356c6461f1b19c1924d2f846c70a910c3fe66 --- /dev/null +++ b/gui/styles.py @@ -0,0 +1,127 @@ +""" +GUI Launcher Styles + +CustomTkinter theme configuration and custom widget styles. +""" + +from typing import Optional + +import customtkinter as ctk + +from .config import COLORS, CTK_COLOR_THEME, DIMENSIONS + + +def apply_theme(root: Optional[ctk.CTk] = None, appearance_mode: str = "dark") -> None: + """ + Apply the modern theme using CustomTkinter's built-in theming. + + CustomTkinter handles most styling automatically. This function sets + the appearance mode and color theme globally. + + Args: + root: Optional CTk root window (for any additional configuration) + appearance_mode: "dark", "light", or "system" + """ + # Import here to avoid circular imports + from .theme import set_appearance_mode + + # Set appearance mode via theme module + set_appearance_mode(appearance_mode) + + # Set color theme + ctk.set_default_color_theme(CTK_COLOR_THEME) + + +def get_button_colors(style: str = "default") -> dict: + """ + Get color configuration for CTkButton based on style. + + Args: + style: One of "default", "success", "danger", "accent", "outline" + + Returns: + Dictionary of color parameters for CTkButton + """ + styles = { + "default": { + "fg_color": COLORS["bg_light"], + "hover_color": COLORS["border_light"], + "text_color": COLORS["text_primary"], + }, + "success": { + "fg_color": COLORS["success"], + "hover_color": COLORS["success_hover"], + "text_color": COLORS["text_on_color"], # Always white for contrast + }, + "danger": { + "fg_color": COLORS["error"], + "hover_color": COLORS["error_hover"], + "text_color": COLORS["text_on_color"], # Always white for contrast + }, + "accent": { + "fg_color": COLORS["accent"], + "hover_color": COLORS["accent_hover"], + "text_color": COLORS["text_on_color"], # Always white for contrast + }, + "outline": { + "fg_color": "transparent", + "hover_color": COLORS["bg_light"], + "text_color": COLORS["text_primary"], + "border_width": DIMENSIONS["border_width"], + "border_color": COLORS["accent"], + }, + "ghost": { + "fg_color": "transparent", + "hover_color": COLORS["bg_medium"], + "text_color": COLORS["text_secondary"], + }, + } + return styles.get(style, styles["default"]) + + +def get_entry_style() -> dict: + """Get standard entry field styling.""" + return { + "corner_radius": DIMENSIONS["corner_radius_small"], + "border_width": DIMENSIONS["border_width"], + "border_color": COLORS["border"], + "fg_color": COLORS["bg_light"], + "text_color": COLORS["text_primary"], + "placeholder_text_color": COLORS["text_muted"], + } + + +def get_frame_style(variant: str = "default") -> dict: + """ + Get frame styling parameters. + + Args: + variant: One of "default", "card", "transparent" + """ + variants = { + "default": { + "fg_color": COLORS["bg_dark"], + "corner_radius": 0, + }, + "card": { + "fg_color": COLORS["bg_medium"], + "corner_radius": DIMENSIONS["corner_radius"], + "border_width": 1, + "border_color": COLORS["border"], + }, + "transparent": { + "fg_color": "transparent", + "corner_radius": 0, + }, + } + return variants.get(variant, variants["default"]) + + +# Legacy compatibility - ModernStyle class that just calls apply_theme +class ModernStyle: + """Legacy compatibility wrapper for apply_theme().""" + + @staticmethod + def apply(root) -> None: + """Apply theme - for backwards compatibility.""" + apply_theme(root) diff --git a/gui/theme.py b/gui/theme.py new file mode 100644 index 0000000000000000000000000000000000000000..e30f71160ab729596179ac99c014d7fdc04ea2b9 --- /dev/null +++ b/gui/theme.py @@ -0,0 +1,120 @@ +""" +GUI Launcher Theme Manager + +Handles light/dark mode switching with persistence. +""" + +from typing import Callable, List, Literal + +import customtkinter as ctk + +# Type alias for appearance modes +AppearanceMode = Literal["dark", "light", "system"] + +# Module-level state +_current_mode: AppearanceMode = "dark" +_on_change_callbacks: List[Callable[[AppearanceMode], None]] = [] + + +def get_appearance_mode() -> AppearanceMode: + """Get the current appearance mode.""" + return _current_mode + + +def set_appearance_mode(mode: AppearanceMode) -> None: + """ + Set the appearance mode and apply it. + + Args: + mode: One of "dark", "light", or "system" + """ + global _current_mode + + if mode not in ("dark", "light", "system"): + mode = "dark" # Default fallback + + _current_mode = mode + ctk.set_appearance_mode(mode) + + # Notify listeners + for callback in _on_change_callbacks: + try: + callback(mode) + except Exception: + pass + + +def toggle_appearance_mode() -> AppearanceMode: + """ + Toggle between dark and light mode. + + Returns: + The new appearance mode after toggle + """ + if _current_mode == "dark": + set_appearance_mode("light") + else: + set_appearance_mode("dark") + + return _current_mode + + +def is_dark_mode() -> bool: + """Check if currently in dark mode.""" + if _current_mode == "system": + # Check actual system setting + return ctk.get_appearance_mode().lower() == "dark" + return _current_mode == "dark" + + +def on_theme_change(callback: Callable[[AppearanceMode], None]) -> None: + """ + Register a callback to be called when theme changes. + + Args: + callback: Function that takes the new mode as argument + """ + if callback not in _on_change_callbacks: + _on_change_callbacks.append(callback) + + +def remove_theme_callback(callback: Callable[[AppearanceMode], None]) -> None: + """Remove a previously registered callback.""" + if callback in _on_change_callbacks: + _on_change_callbacks.remove(callback) + + +def get_mode_display_name(mode: AppearanceMode) -> str: + """ + Get a user-friendly display name for the mode. + + Args: + mode: The appearance mode + + Returns: + Display name with emoji + """ + names = { + "dark": "🌙 Dark", + "light": "☀️ Light", + "system": "💻 System", + } + return names.get(mode, mode) + + +def get_available_modes() -> List[AppearanceMode]: + """Get list of available appearance modes.""" + return ["dark", "light", "system"] + + +# Initialize with CustomTkinter's default +def init_theme(mode: AppearanceMode = "dark") -> None: + """ + Initialize the theme system. + + Should be called once at application startup. + + Args: + mode: Initial appearance mode + """ + set_appearance_mode(mode) diff --git a/gui/tray.py b/gui/tray.py new file mode 100644 index 0000000000000000000000000000000000000000..44e6ffd767a0b5737aea792689b064562cef4b17 --- /dev/null +++ b/gui/tray.py @@ -0,0 +1,197 @@ +""" +GUI Launcher System Tray + +Cross-platform system tray support with AppIndicator3 (Linux/GNOME) and pystray fallback. +""" + +import threading +from typing import TYPE_CHECKING, Optional + +from .config import get_current_color + +if TYPE_CHECKING: + from .app import GUILauncher + + +class TrayIcon: + """Cross-platform system tray support""" + + # Track if warning has already been shown (class-level to show only once) + _warning_shown = False + + def __init__(self, app: "GUILauncher"): + self.app = app + self.indicator = None + self.supported = False + self.backend: Optional[str] = None # "appindicator" or "pystray" + + def create_icon(self) -> None: + """Create tray icon - try AppIndicator3 first, then pystray""" + # Try GNOME AppIndicator3 first (for Wayland) + if self._try_appindicator(): + return + + # Then try pystray (for X11) + if self._try_pystray(): + return + + # Only show warning once per session + if not TrayIcon._warning_shown: + TrayIcon._warning_shown = True + # Silent - the GUI still works without system tray + + def _try_appindicator(self) -> bool: + """Try to create tray with AppIndicator3""" + try: + import gi + + gi.require_version("Gtk", "3.0") + gi.require_version("AppIndicator3", "0.1") + from gi.repository import AppIndicator3, GLib, Gtk + + # Create menu + menu = Gtk.Menu() + + # Menu items + item_show = Gtk.MenuItem(label="📂 Show Window") + item_show.connect( + "activate", lambda w: GLib.idle_add(self.app._show_window) + ) + menu.append(item_show) + + menu.append(Gtk.SeparatorMenuItem()) + + item_start = Gtk.MenuItem(label="▶️ Start") + item_start.connect("activate", lambda w: GLib.idle_add(self.app._start)) + menu.append(item_start) + + item_stop = Gtk.MenuItem(label="⏹️ Stop") + item_stop.connect("activate", lambda w: GLib.idle_add(self.app._stop)) + menu.append(item_stop) + + menu.append(Gtk.SeparatorMenuItem()) + + item_test = Gtk.MenuItem(label="🔍 API Test") + item_test.connect("activate", lambda w: GLib.idle_add(self.app._api_test)) + menu.append(item_test) + + menu.append(Gtk.SeparatorMenuItem()) + + item_quit = Gtk.MenuItem(label="❌ Exit") + item_quit.connect( + "activate", lambda w: GLib.idle_add(self.app._close_completely) + ) + menu.append(item_quit) + + menu.show_all() + + # Create AppIndicator + self.indicator = AppIndicator3.Indicator.new( + "aistudio-proxy", + "network-server", + AppIndicator3.IndicatorCategory.APPLICATION_STATUS, + ) + self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE) + self.indicator.set_menu(menu) + self.indicator.set_title("AI Studio Proxy") + + # Run GTK main loop in separate thread + def gtk_main(): + try: + Gtk.main() + except Exception: + pass + + threading.Thread(target=gtk_main, daemon=True).start() + + self.supported = True + self.backend = "appindicator" + return True + + except Exception: + # Silently fail - AppIndicator3 not available + return False + + def _try_pystray(self) -> bool: + """Try to create tray with pystray""" + try: + import pystray + from PIL import Image, ImageDraw + + # Get current colors (as hex strings) + accent_hex = get_current_color("accent") + bg_hex = get_current_color("bg_dark") + + # Create a simple icon + size = 64 + image = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + + # Parse hex colors to RGB tuples + accent_rgb = tuple( + int(accent_hex.lstrip("#")[i : i + 2], 16) for i in (0, 2, 4) + ) + bg_rgb = tuple(int(bg_hex.lstrip("#")[i : i + 2], 16) for i in (0, 2, 4)) + draw.ellipse([4, 4, size - 4, size - 4], fill=accent_rgb) + draw.ellipse([16, 16, size - 16, size - 16], fill=bg_rgb) + + # Create menu + menu = pystray.Menu( + pystray.MenuItem("📂 Show Window", self._pystray_show), + pystray.Menu.SEPARATOR, + pystray.MenuItem("▶️ Start", self._pystray_start), + pystray.MenuItem("⏹️ Stop", self._pystray_stop), + pystray.Menu.SEPARATOR, + pystray.MenuItem("🔍 API Test", self._pystray_test), + pystray.Menu.SEPARATOR, + pystray.MenuItem("❌ Exit", self._pystray_quit), + ) + + self.indicator = pystray.Icon( + "AI Studio Proxy", image, "AI Studio Proxy", menu + ) + threading.Thread(target=self.indicator.run, daemon=True).start() + + self.supported = True + self.backend = "pystray" + return True + + except Exception: + # Silently fail - pystray not available + return False + + def _pystray_show(self, icon=None, item=None): + self.app.root.after(0, self.app._show_window) + + def _pystray_start(self, icon=None, item=None): + self.app.root.after(0, self.app._start) + + def _pystray_stop(self, icon=None, item=None): + self.app.root.after(0, self.app._stop) + + def _pystray_test(self, icon=None, item=None): + self.app.root.after(0, self.app._api_test) + + def _pystray_quit(self, icon=None, item=None): + self.app.root.after(0, self.app._close_completely) + + def update_status(self, running: bool) -> None: + """Update tray icon status""" + if not self.supported: + return + # Status update - can be extended later + + def stop(self) -> None: + """Stop tray icon""" + try: + if self.backend == "appindicator": + import gi + + gi.require_version("Gtk", "3.0") + from gi.repository import Gtk + + Gtk.main_quit() + elif self.backend == "pystray" and self.indicator: + self.indicator.stop() + except Exception: + pass diff --git a/gui/utils.py b/gui/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..bb6417576ff734f8f39c459def5aa097883da085 --- /dev/null +++ b/gui/utils.py @@ -0,0 +1,451 @@ +""" +GUI Launcher Utilities + +Helper functions and widgets for the CustomTkinter GUI. +""" + +import platform +import time +from typing import Any, Callable, List, Optional + +import customtkinter as ctk + +from .config import COLORS, DIMENSIONS, FONTS +from .i18n import get_text + +# ============================================================================= +# Mouse Wheel Scrolling Support +# ============================================================================= + + +def bind_mousewheel(widget: Any, target_scrollable: Optional[Any] = None) -> None: + """ + Bind mouse wheel events to a widget for scrolling. + + This enables scrolling with mouse wheel and trackpad gestures on all platforms. + + Args: + widget: The widget to bind events to + target_scrollable: The scrollable frame to scroll (if None, uses widget) + """ + target = target_scrollable or widget + + def _on_mousewheel(event): + """Handle mouse wheel on Windows/macOS.""" + if hasattr(target, "_parent_canvas"): + canvas = target._parent_canvas + if platform.system() == "Darwin": + # macOS - trackpad and mouse wheel + canvas.yview_scroll(int(-1 * event.delta), "units") + else: + # Windows + canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") + + def _on_mousewheel_linux(event): + """Handle mouse wheel on Linux.""" + if hasattr(target, "_parent_canvas"): + canvas = target._parent_canvas + if event.num == 4: + canvas.yview_scroll(-1, "units") + elif event.num == 5: + canvas.yview_scroll(1, "units") + + # Bind based on platform + if platform.system() == "Linux": + widget.bind("", _on_mousewheel_linux, add="+") + widget.bind("", _on_mousewheel_linux, add="+") + else: + widget.bind("", _on_mousewheel, add="+") + + # Also bind to children + _bind_children_mousewheel(widget, target) + + +def _bind_children_mousewheel(widget: Any, target: Any) -> None: + """Recursively bind mouse wheel events to all children.""" + for child in widget.winfo_children(): + if platform.system() == "Linux": + child.bind( + "", lambda e, t=target: _scroll_linux(e, t, -1), add="+" + ) + child.bind( + "", lambda e, t=target: _scroll_linux(e, t, 1), add="+" + ) + else: + child.bind("", lambda e, t=target: _scroll_other(e, t), add="+") + _bind_children_mousewheel(child, target) + + +def _scroll_linux(event, target, direction): + """Scroll on Linux.""" + if hasattr(target, "_parent_canvas"): + target._parent_canvas.yview_scroll(direction, "units") + + +def _scroll_other(event, target): + """Scroll on Windows/macOS.""" + if hasattr(target, "_parent_canvas"): + if platform.system() == "Darwin": + target._parent_canvas.yview_scroll(int(-1 * event.delta), "units") + else: + target._parent_canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") + + +class CTkTooltip: + """ + Modern tooltip widget for CustomTkinter. + + Shows a tooltip on hover with smooth appearance. + + Usage: + CTkTooltip(widget, "tooltip_key") + """ + + def __init__(self, widget: ctk.CTkBaseClass, text_key: str): + self.widget = widget + self.text_key = text_key + self.tooltip_window: Optional[ctk.CTkToplevel] = None + self._scheduled_id = None + + widget.bind("", self._schedule_show) + widget.bind("", self._hide) + widget.bind("", self._hide) + + def _schedule_show(self, event=None) -> None: + """Schedule tooltip to appear after delay.""" + self._cancel_schedule() + self._scheduled_id = self.widget.after(500, self._show) + + def _cancel_schedule(self) -> None: + """Cancel scheduled tooltip.""" + if self._scheduled_id: + self.widget.after_cancel(self._scheduled_id) + self._scheduled_id = None + + def _show(self) -> None: + """Show the tooltip.""" + if self.tooltip_window: + return + + x = self.widget.winfo_rootx() + 20 + y = self.widget.winfo_rooty() + self.widget.winfo_height() + 5 + + self.tooltip_window = ctk.CTkToplevel(self.widget) + self.tooltip_window.wm_overrideredirect(True) + self.tooltip_window.wm_geometry(f"+{x}+{y}") + + # Remove window decorations and make it float + self.tooltip_window.attributes("-topmost", True) + + # Tooltip frame with rounded corners + frame = ctk.CTkFrame( + self.tooltip_window, + fg_color=COLORS["bg_medium"], + corner_radius=DIMENSIONS["corner_radius_small"], + border_width=1, + border_color=COLORS["border"], + ) + frame.pack(fill="both", expand=True) + + label = ctk.CTkLabel( + frame, + text=get_text(self.text_key), + font=ctk.CTkFont(family=FONTS["family"], size=FONTS["size_small"]), + text_color=COLORS["text_primary"], + wraplength=300, + ) + label.pack(padx=10, pady=6) + + def _hide(self, event=None) -> None: + """Hide the tooltip.""" + self._cancel_schedule() + if self.tooltip_window: + self.tooltip_window.destroy() + self.tooltip_window = None + + +# Alias for backwards compatibility +Tooltip = CTkTooltip + + +class CTkScrollableList(ctk.CTkScrollableFrame): + """ + A scrollable list widget with selectable items. + + Replaces the old tk.Listbox with a modern CTk implementation. + + Usage: + sl = CTkScrollableList(parent) + sl.add_item("Item 1") + sl.add_item("Item 2", icon="📧") + sl.bind_select(callback) + """ + + def __init__(self, parent, height: int = 300, **kwargs): + super().__init__( + parent, + height=height, + fg_color=COLORS["bg_light"], + corner_radius=DIMENSIONS["corner_radius"], + border_width=1, + border_color=COLORS["border"], + **kwargs, + ) + + self.items: List[ctk.CTkButton] = [] + self.selected_index: Optional[int] = None + self._on_select: Optional[Callable] = None + self._on_double_click: Optional[Callable] = None + + # Enable mouse wheel scrolling + self._bind_scroll_events() + + def add_item(self, text: str, icon: str = "") -> None: + """Add an item to the list.""" + display_text = f"{icon} {text}" if icon else text + + item = ctk.CTkButton( + self, + text=display_text, + anchor="w", + height=36, + corner_radius=DIMENSIONS["corner_radius_small"], + fg_color="transparent", + hover_color=COLORS["bg_medium"], + text_color=COLORS["text_primary"], + font=ctk.CTkFont(family=FONTS["family"], size=FONTS["size_normal"]), + command=lambda idx=len(self.items): self._select(idx), + ) + item.pack(fill="x", padx=4, pady=2) + item.bind("", lambda e, idx=len(self.items): self._double_click(idx)) + # Bind scroll events to new item + self._bind_scroll_to_widget(item) + self.items.append(item) + + def _bind_scroll_events(self) -> None: + """Bind mouse wheel scroll events to this scrollable frame.""" + self._bind_scroll_to_widget(self) + + def _bind_scroll_to_widget(self, widget) -> None: + """Bind scroll events to a specific widget.""" + if platform.system() == "Linux": + widget.bind("", self._on_scroll_up, add="+") + widget.bind("", self._on_scroll_down, add="+") + else: + widget.bind("", self._on_mousewheel, add="+") + + def _on_mousewheel(self, event) -> None: + """Handle mouse wheel on Windows/macOS.""" + if hasattr(self, "_parent_canvas") and self._parent_canvas: + if platform.system() == "Darwin": + self._parent_canvas.yview_scroll(int(-1 * event.delta), "units") + else: + self._parent_canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") + + def _on_scroll_up(self, event) -> None: + """Handle scroll up on Linux.""" + if hasattr(self, "_parent_canvas") and self._parent_canvas: + self._parent_canvas.yview_scroll(-3, "units") + + def _on_scroll_down(self, event) -> None: + """Handle scroll down on Linux.""" + if hasattr(self, "_parent_canvas") and self._parent_canvas: + self._parent_canvas.yview_scroll(3, "units") + + def clear(self) -> None: + """Clear all items.""" + for item in self.items: + item.destroy() + self.items.clear() + self.selected_index = None + + def _select(self, index: int) -> None: + """Handle item selection.""" + # Deselect previous + if self.selected_index is not None and self.selected_index < len(self.items): + self.items[self.selected_index].configure( + fg_color="transparent", + text_color=COLORS["text_primary"], + ) + + # Select new + self.selected_index = index + if index < len(self.items): + self.items[index].configure( + fg_color=COLORS["accent"], + text_color=COLORS["text_on_color"], # White text on colored background + ) + + if self._on_select: + self._on_select(index) + + def _double_click(self, index: int) -> None: + """Handle double click.""" + self._select(index) + if self._on_double_click: + self._on_double_click(index) + + def bind_select(self, callback: Callable[[int], None]) -> None: + """Bind selection callback.""" + self._on_select = callback + + def bind_double_click(self, callback: Callable[[int], None]) -> None: + """Bind double-click callback.""" + self._on_double_click = callback + + def get_selected(self) -> Optional[str]: + """Get selected item text.""" + if self.selected_index is not None and self.selected_index < len(self.items): + text = self.items[self.selected_index].cget("text") + # Remove icon prefix if present + if " " in text: + return text.split(" ", 1)[1] + return text + return None + + def get_selected_index(self) -> Optional[int]: + """Get selected index.""" + return self.selected_index + + def select(self, index: int) -> None: + """Select item by index.""" + if 0 <= index < len(self.items): + self._select(index) + + def get_items(self) -> List[str]: + """Get all item texts (without icons).""" + result = [] + for item in self.items: + text = item.cget("text") + if " " in text: + result.append(text.split(" ", 1)[1]) + else: + result.append(text) + return result + + +# Backwards compatibility alias +ScrollableListbox = CTkScrollableList + + +class CTkStatusBar(ctk.CTkFrame): + """ + Modern status bar widget for the bottom of the window. + + Features: + - Status text + - Port display + - Uptime counter + + Usage: + sb = CTkStatusBar(root) + sb.set_status("Ready") + sb.set_port(2048) + sb.start_uptime() + """ + + def __init__(self, parent): + super().__init__( + parent, + height=32, + fg_color=COLORS["bg_medium"], + corner_radius=0, + ) + + # Prevent frame from shrinking + self.pack_propagate(False) + + # Left - status text + self.status_label = ctk.CTkLabel( + self, + text=get_text("statusbar_ready"), + font=ctk.CTkFont(family=FONTS["family"], size=FONTS["size_small"]), + text_color=COLORS["text_secondary"], + ) + self.status_label.pack(side="left", padx=15) + + # Right - port info + self.port_label = ctk.CTkLabel( + self, + text="", + font=ctk.CTkFont(family=FONTS["family"], size=FONTS["size_small"]), + text_color=COLORS["text_secondary"], + ) + self.port_label.pack(side="right", padx=15) + + # Uptime (next to port) + self.uptime_label = ctk.CTkLabel( + self, + text="", + font=ctk.CTkFont(family=FONTS["family_mono"], size=FONTS["size_small"]), + text_color=COLORS["success"], + ) + self.uptime_label.pack(side="right", padx=(0, 20)) + + # Uptime tracking + self._start_time: Optional[float] = None + self._uptime_job = None + + def set_status(self, status: str) -> None: + """Set the status text.""" + self.status_label.configure(text=status) + + def set_port(self, port: int) -> None: + """Set the port display.""" + self.port_label.configure(text=f"{get_text('statusbar_port')} {port}") + + def start_uptime(self) -> None: + """Start the uptime counter.""" + self._start_time = time.time() + self._update_uptime() + + def stop_uptime(self) -> None: + """Stop the uptime counter.""" + if self._uptime_job: + self.after_cancel(self._uptime_job) + self._uptime_job = None + self._start_time = None + self.uptime_label.configure(text="") + + def _update_uptime(self) -> None: + """Update the uptime display.""" + if self._start_time: + elapsed = int(time.time() - self._start_time) + self.uptime_label.configure( + text=f"{get_text('statusbar_uptime')} {format_uptime(elapsed)}" + ) + self._uptime_job = self.after(1000, self._update_uptime) + + +# Backwards compatibility alias +StatusBar = CTkStatusBar + + +# ============================================================================= +# Pure Utility Functions +# ============================================================================= + + +def validate_port(port_str: str) -> bool: + """Validate that a string is a valid port number (1-65535).""" + try: + port = int(port_str) + return 1 <= port <= 65535 + except ValueError: + return False + + +def format_uptime(seconds: int) -> str: + """Format seconds into HH:MM:SS or MM:SS.""" + hours, remainder = divmod(seconds, 3600) + minutes, secs = divmod(remainder, 60) + if hours > 0: + return f"{hours:02d}:{minutes:02d}:{secs:02d}" + return f"{minutes:02d}:{secs:02d}" + + +def copy_to_clipboard(root, text: str) -> None: + """Copy text to system clipboard.""" + root.clipboard_clear() + root.clipboard_append(text) + root.update() # Required for clipboard to persist diff --git a/gui/widgets.py b/gui/widgets.py new file mode 100644 index 0000000000000000000000000000000000000000..158762311c0e1a29a350f332dc246a9185b20500 --- /dev/null +++ b/gui/widgets.py @@ -0,0 +1,555 @@ +""" +GUI Launcher Custom Widgets + +Extended widgets for the GUI including collapsible frames and setting editors. +""" + +import platform +from typing import Any, Callable, Dict, List, Optional + +import customtkinter as ctk + +from .config import COLORS, DIMENSIONS, FONTS + + +class CTkCollapsibleFrame(ctk.CTkFrame): + """ + A collapsible frame widget that can be expanded/collapsed by clicking the header. + + The header shows a title with an expand/collapse indicator, and the content + area can contain any widgets. + """ + + def __init__( + self, + master: Any, + title: str, + expanded: bool = False, + on_toggle: Optional[Callable[[bool], None]] = None, + **kwargs, + ): + """ + Initialize the collapsible frame. + + Args: + master: Parent widget + title: Title text for the header + expanded: Initial expanded state + on_toggle: Callback when expanded state changes + **kwargs: Additional CTkFrame arguments + """ + # Set default frame styling + kwargs.setdefault("fg_color", COLORS["bg_medium"]) + kwargs.setdefault("corner_radius", DIMENSIONS["corner_radius"]) + + super().__init__(master, **kwargs) + + self._title = title + self._expanded = expanded + self._on_toggle = on_toggle + self._content_widgets: List[Any] = [] + + # Configure grid + self.grid_columnconfigure(0, weight=1) + + # Create header + self._create_header() + + # Create content container + self._content_frame = ctk.CTkFrame( + self, + fg_color="transparent", + ) + + # Initial state + if self._expanded: + self._content_frame.grid( + row=1, column=0, sticky="ew", padx=10, pady=(0, 10) + ) + + self._update_header_text() + + def _create_header(self) -> None: + """Create the clickable header.""" + self._header_frame = ctk.CTkFrame( + self, + fg_color="transparent", + cursor="hand2", + ) + self._header_frame.grid(row=0, column=0, sticky="ew", padx=10, pady=10) + self._header_frame.grid_columnconfigure(1, weight=1) + + # Expand/collapse indicator and title + self._header_label = ctk.CTkLabel( + self._header_frame, + text="", + font=ctk.CTkFont( + family=FONTS["family"], size=FONTS["size_large"], weight="bold" + ), + text_color=COLORS["accent"], + cursor="hand2", + ) + self._header_label.grid(row=0, column=0, sticky="w") + + # Bind click events + self._header_frame.bind("", self._toggle) + self._header_label.bind("", self._toggle) + + def _update_header_text(self) -> None: + """Update header text based on expanded state.""" + indicator = "▼" if self._expanded else "▶" + self._header_label.configure(text=f"{indicator} {self._title}") + + def _toggle(self, event=None) -> None: + """Toggle expanded/collapsed state.""" + self._expanded = not self._expanded + self._update_header_text() + + if self._expanded: + self._content_frame.grid( + row=1, column=0, sticky="ew", padx=10, pady=(0, 10) + ) + else: + self._content_frame.grid_forget() + + if self._on_toggle: + self._on_toggle(self._expanded) + + def expand(self) -> None: + """Expand the frame.""" + if not self._expanded: + self._toggle() + + def collapse(self) -> None: + """Collapse the frame.""" + if self._expanded: + self._toggle() + + def is_expanded(self) -> bool: + """Check if frame is expanded.""" + return self._expanded + + def set_title(self, title: str) -> None: + """Update the title text.""" + self._title = title + self._update_header_text() + + def get_content_frame(self) -> ctk.CTkFrame: + """Get the content frame to add widgets to.""" + return self._content_frame + + +class CTkSettingRow(ctk.CTkFrame): + """ + A single setting row with label and appropriate input widget. + + Supports: + - bool: CTkSwitch + - int/float: CTkEntry with validation + - choice: CTkComboBox + - string: CTkEntry + """ + + def __init__( + self, + master: Any, + key: str, + label: str, + value: Any, + type_hint: str, + tooltip: Optional[str] = None, + on_change: Optional[Callable[[str, Any], None]] = None, + **kwargs, + ): + """ + Initialize a setting row. + + Args: + master: Parent widget + key: Setting key (env variable name) + label: Display label + value: Current value + type_hint: Type hint (bool, int, float, choice:a,b,c, or string) + tooltip: Optional tooltip text + on_change: Callback when value changes (receives key and new value) + """ + kwargs.setdefault("fg_color", "transparent") + super().__init__(master, **kwargs) + + self._key = key + self._type_hint = type_hint + self._on_change = on_change + self._value_var: Any = None + + # Configure grid + self.grid_columnconfigure(0, weight=0, minsize=280) + self.grid_columnconfigure(1, weight=1) + + # Create label + self._label = ctk.CTkLabel( + self, + text=label, + font=ctk.CTkFont(size=FONTS["size_normal"]), + text_color=COLORS["text_primary"], + anchor="w", + ) + self._label.grid(row=0, column=0, sticky="w", padx=(0, 10), pady=5) + + # Create input widget based on type + self._create_input(value) + + # Add tooltip if provided + if tooltip: + from .utils import CTkTooltip + + CTkTooltip(self._label, tooltip) + if hasattr(self, "_input_widget"): + CTkTooltip(self._input_widget, tooltip) + + def _create_input(self, value: Any) -> None: + """Create the appropriate input widget.""" + if self._type_hint == "bool": + self._value_var = ctk.BooleanVar(value=bool(value)) + self._input_widget = ctk.CTkSwitch( + self, + text="", + variable=self._value_var, + command=self._on_value_change, + fg_color=COLORS["bg_light"], + progress_color=COLORS["accent"], + button_color=COLORS["text_primary"], + button_hover_color=COLORS["accent_hover"], + ) + self._input_widget.grid(row=0, column=1, sticky="w", pady=5) + + elif self._type_hint == "int": + self._value_var = ctk.StringVar(value=str(value)) + self._value_var.trace_add("write", lambda *args: self._on_value_change()) + self._input_widget = ctk.CTkEntry( + self, + textvariable=self._value_var, + width=150, + height=32, + fg_color=COLORS["bg_light"], + border_color=COLORS["border"], + text_color=COLORS["text_primary"], + ) + self._input_widget.grid(row=0, column=1, sticky="w", pady=5) + + elif self._type_hint == "float": + self._value_var = ctk.StringVar(value=str(value)) + self._value_var.trace_add("write", lambda *args: self._on_value_change()) + self._input_widget = ctk.CTkEntry( + self, + textvariable=self._value_var, + width=150, + height=32, + fg_color=COLORS["bg_light"], + border_color=COLORS["border"], + text_color=COLORS["text_primary"], + ) + self._input_widget.grid(row=0, column=1, sticky="w", pady=5) + + elif self._type_hint.startswith("choice:"): + choices = self._type_hint.replace("choice:", "").split(",") + self._value_var = ctk.StringVar(value=str(value)) + self._input_widget = ctk.CTkComboBox( + self, + values=choices, + variable=self._value_var, + command=lambda v: self._on_value_change(), + width=180, + height=32, + fg_color=COLORS["bg_light"], + border_color=COLORS["border"], + button_color=COLORS["accent"], + button_hover_color=COLORS["accent_hover"], + dropdown_fg_color=COLORS["bg_medium"], + dropdown_hover_color=COLORS["bg_light"], + text_color=COLORS["text_primary"], + dropdown_text_color=COLORS["text_primary"], + ) + self._input_widget.grid(row=0, column=1, sticky="w", pady=5) + + else: + # Default: string entry + self._value_var = ctk.StringVar(value=str(value) if value else "") + self._value_var.trace_add("write", lambda *args: self._on_value_change()) + self._input_widget = ctk.CTkEntry( + self, + textvariable=self._value_var, + width=250, + height=32, + fg_color=COLORS["bg_light"], + border_color=COLORS["border"], + text_color=COLORS["text_primary"], + ) + self._input_widget.grid(row=0, column=1, sticky="w", pady=5) + + def _on_value_change(self) -> None: + """Handle value change.""" + if self._on_change: + self._on_change(self._key, self.get_value()) + + def get_value(self) -> Any: + """Get the current value with proper type conversion.""" + if self._type_hint == "bool": + return self._value_var.get() + elif self._type_hint == "int": + try: + return int(self._value_var.get()) + except ValueError: + return 0 + elif self._type_hint == "float": + try: + return float(self._value_var.get()) + except ValueError: + return 0.0 + else: + return self._value_var.get() + + def set_value(self, value: Any) -> None: + """Set the value.""" + if self._type_hint == "bool": + self._value_var.set(bool(value)) + else: + self._value_var.set(str(value)) + + def get_key(self) -> str: + """Get the setting key.""" + return self._key + + def set_modified_indicator(self, modified: bool) -> None: + """Show/hide modified indicator on the label.""" + base_label = self._label.cget("text").replace(" *", "") + if modified: + self._label.configure(text=f"{base_label} *", text_color=COLORS["warning"]) + else: + self._label.configure(text=base_label, text_color=COLORS["text_primary"]) + + +class CTkEnvSettingsPanel(ctk.CTkScrollableFrame): + """ + A scrollable panel containing all environment settings organized by category. + + Features: + - Collapsible category sections + - Tracks modified values + - Provides save/reload/reset functionality + """ + + def __init__( + self, + master: Any, + env_manager: Any, + on_save: Optional[Callable[[], None]] = None, + on_change: Optional[Callable[[bool], None]] = None, + **kwargs, + ): + """ + Initialize the settings panel. + + Args: + master: Parent widget + env_manager: EnvManager instance + on_save: Callback when settings are saved + on_change: Callback when dirty state changes (receives is_dirty) + """ + # Set default colors for proper theming + kwargs.setdefault("fg_color", COLORS["bg_dark"]) + kwargs.setdefault("scrollbar_fg_color", COLORS["bg_medium"]) + kwargs.setdefault("scrollbar_button_color", COLORS["border"]) + kwargs.setdefault("scrollbar_button_hover_color", COLORS["accent"]) + super().__init__(master, **kwargs) + + self._env_manager = env_manager + self._on_save = on_save + self._on_change = on_change + self._setting_rows: Dict[str, CTkSettingRow] = {} + self._category_frames: Dict[str, CTkCollapsibleFrame] = {} + self._is_dirty = False + + # Build the UI + self._build_settings_ui() + + # Enable mouse wheel scrolling + self._bind_scroll_events() + + def _build_settings_ui(self) -> None: + """Build the settings UI with collapsible categories.""" + from .i18n import get_text + + # Get category order + category_order = [ + "server", + "logging", + "auth", + "cookie", + "browser", + "api", + "function_calling", + "timeouts", + "misc", + ] + + for cat_key in category_order: + keys = self._env_manager.get_category_keys(cat_key) + if not keys: + continue + + # Create collapsible frame for category + cat_title = get_text(f"cat_{cat_key}") + frame = CTkCollapsibleFrame( + self, + title=cat_title, + expanded=False, + ) + frame.pack(fill="x", pady=(0, 10)) + self._category_frames[cat_key] = frame + + # Get content frame and add settings + content = frame.get_content_frame() + content.grid_columnconfigure(0, weight=1) + + for idx, key in enumerate(sorted(keys)): + schema = self._env_manager.get_schema_info(key) + if not schema: + continue + + default_val, type_hint, description, _ = schema + current_val = self._env_manager.get(key) + + row = CTkSettingRow( + content, + key=key, + label=description, + value=current_val, + type_hint=type_hint, + on_change=self._on_setting_change, + ) + row.grid(row=idx, column=0, sticky="ew", padx=5, pady=2) + self._setting_rows[key] = row + + def _on_setting_change(self, key: str, value: Any) -> None: + """Handle individual setting change.""" + self._env_manager.set(key, value) + + # Update modified indicator + schema = self._env_manager.get_schema_info(key) + if schema: + default_val = schema[0] + is_modified = value != default_val + if key in self._setting_rows: + self._setting_rows[key].set_modified_indicator(is_modified) + + # Check overall dirty state + new_dirty = self._env_manager.is_dirty() + if new_dirty != self._is_dirty: + self._is_dirty = new_dirty + if self._on_change: + self._on_change(new_dirty) + + def save(self) -> bool: + """Save all settings to .env file.""" + success = self._env_manager.save() + if success: + self._is_dirty = False + # Clear all modified indicators + for row in self._setting_rows.values(): + row.set_modified_indicator(False) + if self._on_change: + self._on_change(False) + if self._on_save: + self._on_save() + return success + + def reload(self) -> None: + """Reload settings from .env file.""" + self._env_manager.load() + + # Update all setting rows + for key, row in self._setting_rows.items(): + value = self._env_manager.get(key) + row.set_value(value) + row.set_modified_indicator(False) + + self._is_dirty = False + if self._on_change: + self._on_change(False) + + def reset_to_defaults(self) -> None: + """Reset all settings to default values.""" + self._env_manager.reset_to_defaults() + + # Update all setting rows + for key, row in self._setting_rows.items(): + schema = self._env_manager.get_schema_info(key) + if schema: + row.set_value(schema[0]) + row.set_modified_indicator(False) + + self._is_dirty = True + if self._on_change: + self._on_change(True) + + def is_dirty(self) -> bool: + """Check if there are unsaved changes.""" + return self._is_dirty + + def expand_all(self) -> None: + """Expand all category frames.""" + for frame in self._category_frames.values(): + frame.expand() + + def collapse_all(self) -> None: + """Collapse all category frames.""" + for frame in self._category_frames.values(): + frame.collapse() + + def get_modified_keys(self) -> List[str]: + """Get list of modified setting keys.""" + return self._env_manager.get_modified_keys() + + def _bind_scroll_events(self) -> None: + """Bind mouse wheel scroll events for cross-platform compatibility.""" + self._bind_scroll_to_widget(self) + # Also bind to all category frames + for frame in self._category_frames.values(): + self._bind_scroll_to_widget(frame) + self._bind_scroll_to_widget(frame.get_content_frame()) + + def _bind_scroll_to_widget(self, widget) -> None: + """Bind scroll events to a specific widget and its children.""" + if platform.system() == "Linux": + widget.bind("", self._on_scroll_up, add="+") + widget.bind("", self._on_scroll_down, add="+") + else: + widget.bind("", self._on_mousewheel, add="+") + + # Recursively bind to children + for child in widget.winfo_children(): + self._bind_scroll_to_widget(child) + + def _on_mousewheel(self, event) -> Optional[str]: + """Handle mouse wheel on Windows/macOS.""" + if hasattr(self, "_parent_canvas") and self._parent_canvas: + if platform.system() == "Darwin": + self._parent_canvas.yview_scroll(int(-1 * event.delta), "units") + else: + self._parent_canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") + return "break" # Stop event propagation to parent scrollable frames + return None + + def _on_scroll_up(self, event) -> Optional[str]: + """Handle scroll up on Linux.""" + if hasattr(self, "_parent_canvas") and self._parent_canvas: + self._parent_canvas.yview_scroll(-3, "units") + return "break" # Stop event propagation to parent scrollable frames + return None + + def _on_scroll_down(self, event) -> Optional[str]: + """Handle scroll down on Linux.""" + if hasattr(self, "_parent_canvas") and self._parent_canvas: + self._parent_canvas.yview_scroll(3, "units") + return "break" # Stop event propagation to parent scrollable frames + return None diff --git a/launch_camoufox.py b/launch_camoufox.py new file mode 100644 index 0000000000000000000000000000000000000000..fe2dc3f9febdca903f0974175900b1d1fbe02240 --- /dev/null +++ b/launch_camoufox.py @@ -0,0 +1,1913 @@ +#!/usr/bin/env python3 +# launch_camoufox.py +import argparse +import asyncio +import atexit +import json +import logging +import logging.handlers +import os +import platform +import queue +import re +import select +import shutil +import signal +import socket +import subprocess + +#!/usr/bin/env python3 +# launch_camoufox.py +import sys +import threading +import time +import traceback + +# --- New imports --- +from dotenv import load_dotenv + +# Load .env file early to ensure subsequent modules get correct env vars +load_dotenv() + +import uvicorn + +from server import app # Import FastAPI app object from server.py + +# ----------------- + +# Try importing launch_server (for internal launch mode, simulating Camoufox behavior) +try: + import camoufox.server + import camoufox.utils + from camoufox import ( + DefaultAddons, + ) # Assuming DefaultAddons contains AntiFingerprint + from camoufox.server import launch_server + + # --- Monkeypatch Fix Start --- + # Fix "proxy: expected object, got null" error in camoufox. + # The launch_server function fails if 'proxy' is explicitly None in the config because + # camoufox.utils.launch_options returns 'proxy': None, which becomes null in JSON, + # causing the Node.js launcher to throw "expected object, got null". + _original_launch_options = camoufox.utils.launch_options + + def _patched_launch_options(*args, **kwargs): + # Call original to get the full config dict (which includes defaults like proxy=None) + opts = _original_launch_options(*args, **kwargs) + # Remove 'proxy' key if it is None, so it doesn't get sent to the JS launcher + if "proxy" in opts and opts["proxy"] is None: + del opts["proxy"] + return opts + + # Replace the function in camoufox.server module so launch_server uses our wrapper + camoufox.server.launch_options = _patched_launch_options + # --- Monkeypatch Fix End --- + +except ImportError: + if "--internal-launch" in sys.argv or any( + arg.startswith("--internal-") for arg in sys.argv + ): # Check for internal args broadly + print( + "❌ Fatal Error: Internal launch mode requires 'camoufox.server.launch_server' and 'camoufox.DefaultAddons' but failed to import.", + file=sys.stderr, + ) + print( + " This usually means the 'camoufox' package is not installed correctly or not in PYTHONPATH.", + file=sys.stderr, + ) + sys.exit(1) + else: + launch_server = None + DefaultAddons = None + +# --- Configuration Constants --- +PYTHON_EXECUTABLE = sys.executable +ENDPOINT_CAPTURE_TIMEOUT = int( + os.environ.get("ENDPOINT_CAPTURE_TIMEOUT", "45") +) # Seconds (from dev) +DEFAULT_SERVER_PORT = int( + os.environ.get("DEFAULT_FASTAPI_PORT", "2048") +) # FastAPI server port +DEFAULT_CAMOUFOX_PORT = int( + os.environ.get("DEFAULT_CAMOUFOX_PORT", "9222") +) # Camoufox debug port (if needed for internal launch) +DEFAULT_STREAM_PORT = int( + os.environ.get("STREAM_PORT", "3120") +) # Stream proxy server port +DEFAULT_HELPER_ENDPOINT = os.environ.get( + "GUI_DEFAULT_HELPER_ENDPOINT", "" +) # External Helper endpoint +DEFAULT_AUTH_SAVE_TIMEOUT = int( + os.environ.get("AUTH_SAVE_TIMEOUT", "30") +) # Auth save timeout +DEFAULT_SERVER_LOG_LEVEL = os.environ.get( + "SERVER_LOG_LEVEL", "INFO" +) # Server log level +AUTH_PROFILES_DIR = os.path.join(os.path.dirname(__file__), "auth_profiles") +ACTIVE_AUTH_DIR = os.path.join(AUTH_PROFILES_DIR, "active") +SAVED_AUTH_DIR = os.path.join(AUTH_PROFILES_DIR, "saved") +EMERGENCY_AUTH_DIR = os.path.join(AUTH_PROFILES_DIR, "emergency") +HTTP_PROXY = os.environ.get("HTTP_PROXY", "") +HTTPS_PROXY = os.environ.get("HTTPS_PROXY", "") +LOG_DIR = os.path.join(os.path.dirname(__file__), "logs") +LAUNCHER_LOG_FILE_PATH = os.path.join(LOG_DIR, "launch_app.log") + +# --- Global process handle --- +camoufox_proc = None + +# --- Logger instance --- +logger = logging.getLogger("CamoufoxLauncher") + +# --- WebSocket endpoint regex --- +ws_regex = re.compile(r"(ws://\S+)") + + +# --- Thread-safe output queue handler (_enqueue_output) (from dev - more robust error handling) --- +def _enqueue_output( + stream, stream_name, output_queue, process_pid_for_log="" +): + log_prefix = f"[ReadThread-{stream_name}-PID:{process_pid_for_log}]" + try: + for line_bytes in iter(stream.readline, b""): + if not line_bytes: + break + try: + line_str = line_bytes.decode("utf-8", errors="replace") + output_queue.put((stream_name, line_str)) + except Exception as decode_err: + logger.warning( + f"{log_prefix} Decode error: {decode_err}. Raw data (first 100 bytes): {line_bytes[:100]}" + ) + output_queue.put( + ( + stream_name, + f"[Decode Error: {decode_err}] {line_bytes[:100]}...\n", + ) + ) + except ValueError: + logger.debug(f"{log_prefix} ValueError (Stream might be closed).") + except Exception as e: + logger.error( + f"{log_prefix} Unexpected error reading stream: {e}", exc_info=True + ) + finally: + output_queue.put((stream_name, None)) + if hasattr(stream, "close") and not stream.closed: + try: + stream.close() + except Exception: + pass + logger.debug(f"{log_prefix} Thread exiting.") + + +# --- Setup launcher logging system (setup_launcher_logging) (from dev - clears log on start) --- +def setup_launcher_logging(log_level=logging.INFO): + os.makedirs(LOG_DIR, exist_ok=True) + file_log_formatter = logging.Formatter( + "%(asctime)s - %(levelname)s - [%(name)s:%(funcName)s:%(lineno)d] - %(message)s" + ) + console_log_formatter = logging.Formatter( + "%(asctime)s - %(levelname)s - %(message)s" + ) + if logger.hasHandlers(): + logger.handlers.clear() + logger.setLevel(log_level) + logger.propagate = False + if os.path.exists(LAUNCHER_LOG_FILE_PATH): + try: + os.remove(LAUNCHER_LOG_FILE_PATH) + except OSError: + pass + file_handler = logging.handlers.RotatingFileHandler( + LAUNCHER_LOG_FILE_PATH, + maxBytes=2 * 1024 * 1024, + backupCount=3, + encoding="utf-8", + mode="w", + ) + file_handler.setFormatter(file_log_formatter) + logger.addHandler(file_handler) + stream_handler = logging.StreamHandler(sys.stderr) + stream_handler.setFormatter(console_log_formatter) + logger.addHandler(stream_handler) + logger.info("=" * 30 + " Camoufox Launcher Logging Initialized " + "=" * 30) + logger.info(f"Log level set to: {logging.getLevelName(logger.getEffectiveLevel())}") + logger.info(f"Log file path: {LAUNCHER_LOG_FILE_PATH}") + + +# --- Ensure auth directories exist (ensure_auth_dirs_exist) --- +def ensure_auth_dirs_exist(): + logger.info("Checking and ensuring auth directories exist...") + try: + os.makedirs(ACTIVE_AUTH_DIR, exist_ok=True) + logger.info(f" ✓ Active auth directory ready: {ACTIVE_AUTH_DIR}") + os.makedirs(SAVED_AUTH_DIR, exist_ok=True) + logger.info(f" ✓ Saved auth directory ready: {SAVED_AUTH_DIR}") + os.makedirs(EMERGENCY_AUTH_DIR, exist_ok=True) + logger.info(f" ✓ Emergency auth directory ready: {EMERGENCY_AUTH_DIR}") + except Exception as e: + logger.error(f" ❌ Failed to create auth directories: {e}", exc_info=True) + sys.exit(1) + + +# --- Cleanup function (executed on exit) (from dev - more detailed logging and checks) --- +def cleanup(): + global camoufox_proc + logger.info("--- Starting cleanup routine (launch_camoufox.py) ---") + if camoufox_proc and camoufox_proc.poll() is None: + pid = camoufox_proc.pid + logger.info(f"Terminating Camoufox internal subprocess (PID: {pid})...") + try: + if ( + sys.platform != "win32" + and hasattr(os, "getpgid") + and hasattr(os, "killpg") + ): + try: + pgid = os.getpgid(pid) + logger.info( + f" Sending SIGTERM to Camoufox process group (PGID: {pgid})..." + ) + os.killpg(pgid, signal.SIGTERM) + except ProcessLookupError: + logger.info( + f" Camoufox process group (PID: {pid}) not found, attempting direct termination..." + ) + camoufox_proc.terminate() + else: + if sys.platform == "win32": + logger.info( + f"🔥 [ID-02] Windows Force-Kill Strategy: Using immediate /F /T for process tree (PID: {pid})" + ) + # [ID-02] Enhanced Windows Force-Kill: Immediate /F /T without grace period + result = subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(pid)], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + logger.info( + " ✅ Successfully force-killed Camoufox process tree via taskkill." + ) + else: + logger.warning( + f" ⚠️ Taskkill /F /T returned code {result.returncode}: {result.stderr.strip()}" + ) + # Fallback: try regular terminate + camoufox_proc.terminate() + else: + logger.info(f" Sending SIGTERM to Camoufox (PID: {pid})...") + camoufox_proc.terminate() + camoufox_proc.wait(timeout=5) + logger.info( + f" ✓ Camoufox (PID: {pid}) successfully terminated via SIGTERM." + ) + except subprocess.TimeoutExpired: + logger.warning( + f" ⚠️ Camoufox (PID: {pid}) SIGTERM timed out. Sending SIGKILL to force terminate..." + ) + if ( + sys.platform != "win32" + and hasattr(os, "getpgid") + and hasattr(os, "killpg") + ): + try: + pgid = os.getpgid(pid) + logger.info( + f" Sending SIGKILL to Camoufox process group (PGID: {pgid})..." + ) + os.killpg(pgid, signal.SIGKILL) + except ProcessLookupError: + logger.info( + f" Camoufox process group (PID: {pid}) not found during SIGKILL, attempting direct force kill..." + ) + camoufox_proc.kill() + else: + if sys.platform == "win32": + logger.info( + f" 🔥 [ID-02] Fallback: Force killing Camoufox process tree (PID: {pid})" + ) + # [ID-02] Enhanced fallback force-kill with better error handling + result = subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(pid)], + capture_output=True, + text=True, + timeout=3, + ) + if result.returncode == 0: + logger.info( + " ✅ Fallback: Successfully force-killed Camoufox process tree." + ) + else: + logger.warning( + f" ⚠️ Fallback taskkill failed (code {result.returncode}): {result.stderr.strip()}" + ) + else: + camoufox_proc.kill() + try: + camoufox_proc.wait(timeout=2) + logger.info( + f" ✓ Camoufox (PID: {pid}) successfully terminated via SIGKILL." + ) + except Exception as e_kill: + logger.error( + f" ❌ Error waiting for Camoufox (PID: {pid}) SIGKILL completion: {e_kill}" + ) + except Exception as e_term: + logger.error( + f" ❌ Error terminating Camoufox (PID: {pid}): {e_term}", exc_info=True + ) + finally: + if ( + hasattr(camoufox_proc, "stdout") + and camoufox_proc.stdout + and not camoufox_proc.stdout.closed + ): + camoufox_proc.stdout.close() + if ( + hasattr(camoufox_proc, "stderr") + and camoufox_proc.stderr + and not camoufox_proc.stderr.closed + ): + camoufox_proc.stderr.close() + camoufox_proc = None + elif camoufox_proc: + logger.info( + f"Camoufox internal subprocess (PID: {camoufox_proc.pid if hasattr(camoufox_proc, 'pid') else 'N/A'}) ended previously, exit code: {camoufox_proc.poll()}." + ) + camoufox_proc = None + else: + logger.info("Camoufox internal subprocess not running or already cleaned up.") + logger.info("--- Cleanup routine finished (launch_camoufox.py) ---") + + +atexit.register(cleanup) + + +def signal_handler(sig, frame): + from config.global_state import GlobalState + + logger.info( + f"Received signal {signal.Signals(sig).name} ({sig}). Setting IS_SHUTTING_DOWN event..." + ) + GlobalState.IS_SHUTTING_DOWN.set() + logger.info("Initiating exit procedure (Force Exit)...") + + # [FIX-ZOMBIE] Run cleanup explicitly because os._exit skips atexit + try: + cleanup() + except Exception as e: + logger.error(f"Error during cleanup in signal handler: {e}") + + logger.info("Exiting with os._exit(0)") + os._exit(0) + + +signal.signal(signal.SIGINT, signal_handler) +signal.signal(signal.SIGTERM, signal_handler) + + +# --- Check dependencies (check_dependencies) (from dev - more comprehensive) --- +def check_dependencies(): + logger.info("--- Step 1: Check Dependencies ---") + required_modules = {} + if launch_server is not None and DefaultAddons is not None: + required_modules["camoufox"] = "camoufox (for server and addons)" + elif launch_server is not None: + required_modules["camoufox_server"] = "camoufox.server" + logger.warning( + " ⚠️ 'camoufox.server' imported, but 'camoufox.DefaultAddons' not imported. Addon exclusion features might be limited." + ) + missing_py_modules = [] + dependencies_ok = True + if required_modules: + logger.info("Checking Python modules:") + for module_name, install_package_name in required_modules.items(): + try: + __import__(module_name) + logger.info(f" ✓ Module '{module_name}' found.") + except ImportError: + logger.error( + f" ❌ Module '{module_name}' (package: '{install_package_name}') not found." + ) + missing_py_modules.append(install_package_name) + dependencies_ok = False + else: + # Check if internal launch mode, if so, camoufox must be importable + is_any_internal_arg = any(arg.startswith("--internal-") for arg in sys.argv) + if is_any_internal_arg and (launch_server is None or DefaultAddons is None): + logger.error( + " ❌ Internal launch mode (--internal-*) requires 'camoufox' package, but import failed." + ) + dependencies_ok = False + elif not is_any_internal_arg: + logger.info( + "Internal launch mode not requested and camoufox.server not imported, skipping 'camoufox' Python package check." + ) + + try: + from server import app as server_app_check + + if server_app_check: + logger.info(" ✓ Successfully imported 'app' object from 'server.py'.") + except ImportError as e_import_server: + logger.error( + f" ❌ Failed to import 'app' object from 'server.py': {e_import_server}" + ) + logger.error(" Please ensure 'server.py' exists and has no import errors.") + dependencies_ok = False + + if not dependencies_ok: + logger.error("-------------------------------------------------") + logger.error("❌ Dependency check failed!") + if missing_py_modules: + logger.error( + f" Missing Python libraries: {', '.join(missing_py_modules)}" + ) + logger.error( + f" Please try installing via pip: pip install {' '.join(missing_py_modules)}" + ) + logger.error("-------------------------------------------------") + sys.exit(1) + else: + logger.info("✅ All launcher dependency checks passed.") + + +# --- Port check and cleanup functions (from dev - more robust) --- +def is_port_in_use(port: int, host: str = "0.0.0.0") -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind((host, port)) + return False + except OSError: + return True + except Exception as e: + logger.warning(f"Unknown error checking port {port} (host {host}): {e}") + return True + + +def find_pids_on_port(port: int) -> list[int]: + pids = [] + system_platform = platform.system() + command = "" + try: + if system_platform == "Linux" or system_platform == "Darwin": + command = f"lsof -ti :{port} -sTCP:LISTEN" + process = subprocess.Popen( + command, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + close_fds=True, + ) + stdout, stderr = process.communicate(timeout=5) + if process.returncode == 0 and stdout: + pids = [int(pid) for pid in stdout.strip().split("\n") if pid.isdigit()] + # Check for localized "command not found" messages (e.g., "未找到命令" for Chinese systems) + elif process.returncode != 0 and ( + "command not found" in stderr.lower() or "未找到命令" in stderr + ): + logger.error("Command 'lsof' not found. Please ensure it is installed.") + elif process.returncode not in [0, 1]: # lsof returns 1 when not found + logger.warning( + f"Failed to execute lsof command (return code {process.returncode}): {stderr.strip()}" + ) + elif system_platform == "Windows": + command = f'netstat -ano -p TCP | findstr "LISTENING" | findstr ":{port} "' + process = subprocess.Popen( + command, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + stdout, stderr = process.communicate(timeout=10) + if process.returncode == 0 and stdout: + for line in stdout.strip().split("\n"): + parts = line.split() + if ( + len(parts) >= 4 + and parts[0].upper() == "TCP" + and f":{port}" in parts[1] + ): + if parts[-1].isdigit(): + pids.append(int(parts[-1])) + pids = list(set(pids)) # Remove duplicates + elif process.returncode not in [0, 1]: # findstr returns 1 when not found + logger.warning( + f"Failed to execute netstat/findstr command (return code {process.returncode}): {stderr.strip()}" + ) + else: + logger.warning( + f"Unsupported OS '{system_platform}' for finding processes on port." + ) + except FileNotFoundError: + cmd_name = command.split()[0] if command else "Related tool" + logger.error(f"Command '{cmd_name}' not found.") + except subprocess.TimeoutExpired: + logger.error(f"Command '{command}' timed out.") + except Exception as e: + logger.error(f"Error finding processes on port {port}: {e}", exc_info=True) + return pids + + +def kill_process_interactive(pid: int) -> bool: + system_platform = platform.system() + success = False + logger.info(f" Attempting to terminate process PID: {pid}...") + try: + if system_platform == "Linux" or system_platform == "Darwin": + result_term = subprocess.run( + f"kill {pid}", + shell=True, + capture_output=True, + text=True, + timeout=3, + check=False, + ) + if result_term.returncode == 0: + logger.info(f" ✓ PID {pid} sent SIGTERM signal.") + success = True + else: + logger.warning( + f" PID {pid} SIGTERM failed: {result_term.stderr.strip() or result_term.stdout.strip()}. Attempting SIGKILL..." + ) + result_kill = subprocess.run( + f"kill -9 {pid}", + shell=True, + capture_output=True, + text=True, + timeout=3, + check=False, + ) + if result_kill.returncode == 0: + logger.info(f" ✓ PID {pid} sent SIGKILL signal.") + success = True + else: + logger.error( + f" ✗ PID {pid} SIGKILL failed: {result_kill.stderr.strip() or result_kill.stdout.strip()}." + ) + elif system_platform == "Windows": + command_desc = f"taskkill /PID {pid} /T /F" + result = subprocess.run( + command_desc, + shell=True, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + output = result.stdout.strip() + error_output = result.stderr.strip() + # Check for localized "Success" messages (e.g., "成功" for Chinese systems) + if result.returncode == 0 and ( + "SUCCESS" in output.upper() or "成功" in output + ): + logger.info(f" ✓ PID {pid} terminated via taskkill /F.") + success = True + # Check for localized "Not Found" messages (e.g., "找不到" for Chinese systems) + elif ( + "could not find process" in error_output.lower() + or "找不到" in error_output + ): # Process might have exited itself + logger.info( + f" PID {pid} not found during taskkill (might have exited)." + ) + success = True # Considered success as target is port availability + else: + logger.error( + f" ✗ PID {pid} taskkill /F failed: {(error_output + ' ' + output).strip()}." + ) + else: + logger.warning( + f" Unsupported OS '{system_platform}' for process termination." + ) + except Exception as e: + logger.error(f" Unexpected error terminating PID {pid}: {e}", exc_info=True) + return success + + +# --- Input function with timeout (from dev - more robust Windows implementation) --- +def input_with_timeout(prompt_message: str, timeout_seconds: int = 30) -> str: + print(prompt_message, end="", flush=True) + if sys.platform == "win32": + user_input_container = [None] + + def get_input_in_thread(): + try: + user_input_container[0] = sys.stdin.readline().strip() + except Exception: + user_input_container[0] = "" # Return empty string on error + + input_thread = threading.Thread(target=get_input_in_thread, daemon=True) + input_thread.start() + input_thread.join(timeout=timeout_seconds) + if input_thread.is_alive(): + print("\nInput timed out. Using default value.", flush=True) + return "" + return user_input_container[0] if user_input_container[0] is not None else "" + else: # Linux/macOS + readable_fds, _, _ = select.select([sys.stdin], [], [], timeout_seconds) + if readable_fds: + return sys.stdin.readline().strip() + else: + print("\nInput timed out. Using default value.", flush=True) + return "" + + +def get_proxy_from_gsettings(): + """ + Retrieves the proxy settings from GSettings on Linux systems. + Returns a proxy string like "http://host:port" or None. + """ + + def _run_gsettings_command(command_parts: list[str]) -> str | None: + """Helper function to run gsettings command and return cleaned string output.""" + try: + process_result = subprocess.run( + command_parts, + capture_output=True, + text=True, + check=False, # Do not raise CalledProcessError for non-zero exit codes + timeout=1, # Timeout for the subprocess call + ) + if process_result.returncode == 0: + value = process_result.stdout.strip() + if value.startswith("'") and value.endswith( + "'" + ): # Remove surrounding single quotes + value = value[1:-1] + + # If after stripping quotes, value is empty, or it's a gsettings "empty" representation + if not value or value == "''" or value == "@as []" or value == "[]": + return None + return value + else: + return None + except subprocess.TimeoutExpired: + return None + except Exception: # Broad exception as per pseudocode + return None + + proxy_mode = _run_gsettings_command( + ["gsettings", "get", "org.gnome.system.proxy", "mode"] + ) + + if proxy_mode == "manual": + # Try HTTP proxy first + http_host = _run_gsettings_command( + ["gsettings", "get", "org.gnome.system.proxy.http", "host"] + ) + http_port_str = _run_gsettings_command( + ["gsettings", "get", "org.gnome.system.proxy.http", "port"] + ) + + if http_host and http_port_str: + try: + http_port = int(http_port_str) + if http_port > 0: + return f"http://{http_host}:{http_port}" + except ValueError: + pass # Continue to HTTPS + + # Try HTTPS proxy if HTTP not found or invalid + https_host = _run_gsettings_command( + ["gsettings", "get", "org.gnome.system.proxy.https", "host"] + ) + https_port_str = _run_gsettings_command( + ["gsettings", "get", "org.gnome.system.proxy.https", "port"] + ) + + if https_host and https_port_str: + try: + https_port = int(https_port_str) + if https_port > 0: + # Note: Even for HTTPS proxy settings, the scheme for Playwright/requests is usually http:// + return f"http://{https_host}:{https_port}" + except ValueError: + pass + + return None + + +def determine_proxy_configuration(internal_camoufox_proxy_arg=None): + """ + Unified proxy configuration determination function + Order of priority: Command line args > Env vars > System settings + + Args: + internal_camoufox_proxy_arg: --internal-camoufox-proxy command line argument value + + Returns: + dict: Dictionary containing proxy configuration info + { + 'camoufox_proxy': str or None, # Proxy used by Camoufox browser + 'stream_proxy': str or None, # Upstream proxy used by stream proxy service + 'source': str # Proxy source description + } + """ + result = {"camoufox_proxy": None, "stream_proxy": None, "source": "No Proxy"} + + # 1. Prefer command line arguments + if internal_camoufox_proxy_arg is not None: + if internal_camoufox_proxy_arg.strip(): # Non-empty string + result["camoufox_proxy"] = internal_camoufox_proxy_arg.strip() + result["stream_proxy"] = internal_camoufox_proxy_arg.strip() + result["source"] = ( + f"Command line arg --internal-camoufox-proxy: {internal_camoufox_proxy_arg.strip()}" + ) + else: # Empty string, explicitly disable proxy + result["source"] = ( + "Command line arg --internal-camoufox-proxy='' (explicitly disabled)" + ) + return result + + # 2. Try env var UNIFIED_PROXY_CONFIG (priority over HTTP/HTTPS_PROXY) + unified_proxy = os.environ.get("UNIFIED_PROXY_CONFIG") + if unified_proxy: + result["camoufox_proxy"] = unified_proxy + result["stream_proxy"] = unified_proxy + result["source"] = f"Env var UNIFIED_PROXY_CONFIG: {unified_proxy}" + return result + + # 3. Try env var HTTP_PROXY + http_proxy = os.environ.get("HTTP_PROXY") + if http_proxy: + result["camoufox_proxy"] = http_proxy + result["stream_proxy"] = http_proxy + result["source"] = f"Env var HTTP_PROXY: {http_proxy}" + return result + + # 4. Try env var HTTPS_PROXY + https_proxy = os.environ.get("HTTPS_PROXY") + if https_proxy: + result["camoufox_proxy"] = https_proxy + result["stream_proxy"] = https_proxy + result["source"] = f"Env var HTTPS_PROXY: {https_proxy}" + return result + + # 5. Try system proxy settings (Linux only) + if sys.platform.startswith("linux"): + gsettings_proxy = get_proxy_from_gsettings() + if gsettings_proxy: + result["camoufox_proxy"] = gsettings_proxy + result["stream_proxy"] = gsettings_proxy + result["source"] = f"gsettings system proxy: {gsettings_proxy}" + return result + + return result + + +# --- Main Execution Logic --- +if __name__ == "__main__": + # Check if internal launch call; if so, do not configure launcher logging + is_internal_call = any(arg.startswith("--internal-") for arg in sys.argv) + if not is_internal_call: + setup_launcher_logging(log_level=logging.INFO) + + parser = argparse.ArgumentParser( + description="Launcher for Camoufox browser simulation and FastAPI proxy server.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + # Internal arguments (from dev) + parser.add_argument( + "--internal-launch-mode", + type=str, + choices=["debug", "headless", "virtual_headless"], + help=argparse.SUPPRESS, + ) + parser.add_argument( + "--internal-auth-file", type=str, default=None, help=argparse.SUPPRESS + ) + parser.add_argument( + "--internal-camoufox-port", + type=int, + default=DEFAULT_CAMOUFOX_PORT, + help=argparse.SUPPRESS, + ) + parser.add_argument( + "--internal-camoufox-proxy", type=str, default=None, help=argparse.SUPPRESS + ) + parser.add_argument( + "--internal-camoufox-os", type=str, default="random", help=argparse.SUPPRESS + ) + + # User visible arguments (merged from dev and helper) + parser.add_argument( + "--server-port", + type=int, + default=DEFAULT_SERVER_PORT, + help=f"Port number for FastAPI server (Default: {DEFAULT_SERVER_PORT})", + ) + parser.add_argument( + "--stream-port", + type=int, + default=DEFAULT_STREAM_PORT, # Read default from .env + help=( + f"Port for stream proxy server. " + f"Provide --stream-port=0 to disable. Default: {DEFAULT_STREAM_PORT}" + ), + ) + parser.add_argument( + "--helper", + type=str, + default=DEFAULT_HELPER_ENDPOINT, # Use default value + help=( + f"Helper server getStreamResponse endpoint (e.g., http://127.0.0.1:3121/getStreamResponse). " + f"Provide empty string (e.g., --helper='') to disable. Default: {DEFAULT_HELPER_ENDPOINT}" + ), + ) + parser.add_argument( + "--camoufox-debug-port", # from dev + type=int, + default=DEFAULT_CAMOUFOX_PORT, + help=f"Debug port number for internal Camoufox instance (Default: {DEFAULT_CAMOUFOX_PORT})", + ) + mode_selection_group = ( + parser.add_mutually_exclusive_group() + ) # from dev (more options) + mode_selection_group.add_argument( + "--debug", + action="store_true", + help="Start debug mode (browser UI visible, allows interactive auth)", + ) + mode_selection_group.add_argument( + "--headless", + action="store_true", + help="Start headless mode (no browser UI, requires saved auth file)", + ) + mode_selection_group.add_argument( + "--virtual-display", + action="store_true", + help="Start headless mode with virtual display (Xvfb, Linux only)", + ) # from dev + + # --camoufox-os argument removed, will be auto-detected and set by script + parser.add_argument( # from dev + "--active-auth-json", + type=str, + default=None, + help="[Headless/Debug Optional] Path to active auth JSON file (in auth_profiles/active/ or saved/, or absolute path). " + "If not provided, headless mode uses latest in active/, debug mode prompts or uses none.", + ) + parser.add_argument( # from dev + "--auto-save-auth", + action="store_true", + help="[Debug Mode] Automatically prompt to save new auth state after successful login if no auth file was loaded.", + ) + parser.add_argument( + "--save-auth-as", + type=str, + default=None, + help="[Debug Mode] Specify filename for saving new auth file (without .json suffix).", + ) + parser.add_argument( # from dev + "--auth-save-timeout", + type=int, + default=DEFAULT_AUTH_SAVE_TIMEOUT, + help=f"[Debug Mode] Timeout (seconds) for auto-save auth or filename input. Default: {DEFAULT_AUTH_SAVE_TIMEOUT}", + ) + parser.add_argument( + "--exit-on-auth-save", + action="store_true", + help="[Debug Mode] Automatically close launcher and all processes after successful auth save via UI.", + ) + parser.add_argument( + "--auto-auth-rotation-on-startup", + type=str, + default=os.environ.get("AUTO_AUTH_ROTATION_ON_STARTUP", "false"), + help="Enable auto-rotation to saved/emergency profiles on startup if active profile is missing (true/false).", + ) + # Logging related arguments (from dev) + parser.add_argument( + "--server-log-level", + type=str, + default=DEFAULT_SERVER_LOG_LEVEL, + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + help=f"Log level for server.py. Default: {DEFAULT_SERVER_LOG_LEVEL}", + ) + parser.add_argument( + "--server-redirect-print", + action="store_true", + help="Redirect print output in server.py to its logging system. Default is not to redirect so input() prompts in debug mode are visible.", + ) + parser.add_argument( + "--debug-logs", + action="store_true", + help="Enable DEBUG level detailed logs inside server.py (env DEBUG_LOGS_ENABLED).", + ) + parser.add_argument( + "--trace-logs", + action="store_true", + help="Enable TRACE level more detailed logs inside server.py (env TRACE_LOGS_ENABLED).", + ) + + args = parser.parse_args() + + # Mark if --server-redirect-print was explicitly provided via CLI + args.server_redirect_print_from_cli = "--server-redirect-print" in sys.argv + + # --- Auto-detect current system and set Camoufox OS simulation --- + # This variable will be used for internal Camoufox launch and HOST_OS_FOR_SHORTCUT + current_system_for_camoufox = platform.system() + if current_system_for_camoufox == "Linux": + simulated_os_for_camoufox = "linux" + elif current_system_for_camoufox == "Windows": + simulated_os_for_camoufox = "windows" + elif current_system_for_camoufox == "Darwin": # macOS + simulated_os_for_camoufox = "macos" + else: + simulated_os_for_camoufox = "linux" # Default fallback for unknown systems + logger.warning( + f"Unrecognized system '{current_system_for_camoufox}'. Camoufox OS simulation defaulting to: {simulated_os_for_camoufox}" + ) + logger.info( + f"Based on system '{current_system_for_camoufox}', Camoufox OS simulation auto-set to: {simulated_os_for_camoufox}" + ) + + # --- Handle internal Camoufox launch logic (if script called as subprocess) (from dev) --- + if args.internal_launch_mode: + if not launch_server or not DefaultAddons: + print( + "❌ Fatal Error (--internal-launch-mode): camoufox.server.launch_server or camoufox.DefaultAddons unavailable. Script cannot proceed.", + file=sys.stderr, + ) + sys.exit(1) + + internal_mode_arg = args.internal_launch_mode + auth_file = args.internal_auth_file + camoufox_port_internal = args.internal_camoufox_port + # Use unified proxy configuration determination logic + proxy_config = determine_proxy_configuration(args.internal_camoufox_proxy) + actual_proxy_to_use = proxy_config["camoufox_proxy"] + print( + f"--- [Internal Camoufox Launch] Proxy Config: {proxy_config['source']} ---", + flush=True, + ) + + camoufox_proxy_internal = actual_proxy_to_use # Update variable for later use + camoufox_os_internal = args.internal_camoufox_os + + print( + f"--- [Internal Camoufox Launch] Mode: {internal_mode_arg}, Auth File: {os.path.basename(auth_file) if auth_file else 'None'}, " + f"Camoufox Port: {camoufox_port_internal}, Proxy: {camoufox_proxy_internal or 'None'}, Sim OS: {camoufox_os_internal} ---", + flush=True, + ) + print( + "--- [Internal Camoufox Launch] Calling camoufox.server.launch_server ... ---", + flush=True, + ) + + try: + launch_args_for_internal_camoufox = { + "port": camoufox_port_internal, + "addons": [], + # "proxy": camoufox_proxy_internal, # Removed + "exclude_addons": [ + DefaultAddons.UBO + ], # Assuming DefaultAddons.UBO exists + "window": (1440, 900), + } + + # Correct way to add proxy + if camoufox_proxy_internal: # If proxy string exists and is not empty + launch_args_for_internal_camoufox["proxy"] = { + "server": camoufox_proxy_internal + } + # If camoufox_proxy_internal is None or empty, "proxy" key won't be added. + if auth_file: + launch_args_for_internal_camoufox["storage_state"] = auth_file + + if "," in camoufox_os_internal: + camoufox_os_list_internal = [ + s.strip().lower() for s in camoufox_os_internal.split(",") + ] + valid_os_values = ["windows", "macos", "linux"] + if not all(val in valid_os_values for val in camoufox_os_list_internal): + print( + f"❌ Internal Camoufox Launch Error: Invalid values in camoufox_os_internal list: {camoufox_os_list_internal}", + file=sys.stderr, + ) + sys.exit(1) + launch_args_for_internal_camoufox["os"] = camoufox_os_list_internal + elif camoufox_os_internal.lower() in ["windows", "macos", "linux"]: + launch_args_for_internal_camoufox["os"] = camoufox_os_internal.lower() + elif camoufox_os_internal.lower() != "random": + print( + f"❌ Internal Camoufox Launch Error: Invalid camoufox_os_internal value: '{camoufox_os_internal}'", + file=sys.stderr, + ) + sys.exit(1) + + print( + f" Args passed to launch_server: {launch_args_for_internal_camoufox}", + flush=True, + ) + + if internal_mode_arg == "headless": + launch_server(headless=True, **launch_args_for_internal_camoufox) + elif internal_mode_arg == "virtual_headless": + launch_server(headless="virtual", **launch_args_for_internal_camoufox) + elif internal_mode_arg == "debug": + launch_server(headless=False, **launch_args_for_internal_camoufox) + + print( + f"--- [Internal Camoufox Launch] camoufox.server.launch_server ({internal_mode_arg} mode) call finished/blocked. Script will wait for it to end. ---", + flush=True, + ) + except Exception as e_internal_launch_final: + print( + f"❌ Error (--internal-launch-mode): Exception executing camoufox.server.launch_server: {e_internal_launch_final}", + file=sys.stderr, + flush=True, + ) + traceback.print_exc(file=sys.stderr) + sys.exit(1) + sys.exit(0) + + # --- Main Launcher Logic --- + logger.info("🚀 Camoufox Launcher Started 🚀") + logger.info("=================================================") + ensure_auth_dirs_exist() + check_dependencies() + logger.info("=================================================") + + final_launch_mode = None # from dev + if args.debug: + final_launch_mode = "debug" + elif args.headless: + final_launch_mode = "headless" + elif args.virtual_display: # from dev + final_launch_mode = "virtual_headless" + if platform.system() != "Linux": + logger.warning( + "⚠️ --virtual-display mode is mainly for Linux. On non-Linux systems, behavior may be identical to standard headless or cause internal errors." + ) + else: + # Read LAUNCH_MODE from .env as default + env_launch_mode = os.environ.get("LAUNCH_MODE", "").lower() + default_mode_from_env = None + default_interactive_choice = "1" # Default choice: headless + + # Map LAUNCH_MODE from .env to interactive choices + if env_launch_mode == "headless": + default_mode_from_env = "headless" + default_interactive_choice = "1" + elif env_launch_mode == "debug" or env_launch_mode == "normal": + default_mode_from_env = "debug" + default_interactive_choice = "2" + elif ( + env_launch_mode == "virtual_display" + or env_launch_mode == "virtual_headless" + ): + default_mode_from_env = "virtual_headless" + default_interactive_choice = "3" if platform.system() == "Linux" else "1" + + logger.info("--- Select Launch Mode (not specified via args) ---") + if env_launch_mode and default_mode_from_env: + logger.info( + f" Read default launch mode from .env: {env_launch_mode} -> {default_mode_from_env}" + ) + + prompt_options_text = "[1] Headless, [2] Debug" + valid_choices = {"1": "headless", "2": "debug"} + + if platform.system() == "Linux": # from dev + prompt_options_text += ", [3] Headless (Virtual Display Xvfb)" + valid_choices["3"] = "virtual_headless" + + # Build prompt showing current default + default_mode_name = valid_choices.get(default_interactive_choice, "headless") + user_mode_choice = ( + input_with_timeout( + f" Enter launch mode ({prompt_options_text}; Default: {default_interactive_choice} {default_mode_name} mode, 15s timeout): ", + 15, + ) + or default_interactive_choice + ) + + if user_mode_choice in valid_choices: + final_launch_mode = valid_choices[user_mode_choice] + else: + final_launch_mode = ( + default_mode_from_env or "headless" + ) # Use .env default or fallback to headless + logger.info( + f"Invalid input '{user_mode_choice}' or timeout, using default mode: {final_launch_mode} mode" + ) + logger.info( + f"Final selected launch mode: {final_launch_mode.replace('_', ' ')} mode" + ) + logger.info("-------------------------------------------------") + + effective_active_auth_json_path = None # Initialize early + + # --- Interactive Auth File Creation Logic --- + # Skip this prompt if --save-auth-as is already provided (e.g., from GUI launcher) + if final_launch_mode == "debug" and not args.active_auth_json and not args.save_auth_as: + create_new_auth_choice = ( + input_with_timeout( + " Create and save new auth file? (y/n; Default: n, 15s timeout): ", 15 + ) + .strip() + .lower() + ) + if create_new_auth_choice == "y": + new_auth_filename = "" + while not new_auth_filename: + new_auth_filename_input = input_with_timeout( + " Enter filename to save (no .json suffix, alphanumeric/-/_): ", + args.auth_save_timeout, + ).strip() + # Simple validation + if re.match(r"^[a-zA-Z0-9_-]+$", new_auth_filename_input): + new_auth_filename = new_auth_filename_input + elif new_auth_filename_input == "": + logger.info( + "Input empty or timeout, cancelled creating new auth file." + ) + break + else: + print(" Filename contains invalid characters, please retry.") + + if new_auth_filename: + args.auto_save_auth = True + args.auto_save_auth_from_cli = True + args.save_auth_as = new_auth_filename + logger.info( + f" Okay, will auto-save auth file as: {new_auth_filename}.json after successful login" + ) + # In this mode, should not load any existing auth files + if effective_active_auth_json_path: + logger.info( + " Cleared previously loaded auth file settings as new auth file will be created." + ) + effective_active_auth_json_path = None + else: + logger.info(" Okay, will not create new auth file.") + + if ( + final_launch_mode == "virtual_headless" and platform.system() == "Linux" + ): # from dev + logger.info("--- Check Xvfb (Virtual Display) Dependency ---") + if not shutil.which("Xvfb"): + logger.error( + " ❌ Xvfb not found. Virtual display mode requires Xvfb. Please install (e.g., sudo apt-get install xvfb) and retry." + ) + sys.exit(1) + logger.info(" ✓ Xvfb found.") + + server_target_port = args.server_port + logger.info( + f"--- Step 2: Check if FastAPI server target port ({server_target_port}) is in use ---" + ) + port_is_available = False + uvicorn_bind_host = "0.0.0.0" # from dev (was 127.0.0.1 in helper) + if is_port_in_use(server_target_port, host=uvicorn_bind_host): + logger.warning( + f" ❌ Port {server_target_port} (host {uvicorn_bind_host}) currently in use." + ) + pids_on_port = find_pids_on_port(server_target_port) + if pids_on_port: + logger.warning( + f" Identified PIDs potentially using port {server_target_port}: {pids_on_port}" + ) + if final_launch_mode == "debug": + sys.stderr.flush() + # Using input_with_timeout for consistency, though timeout might not be strictly needed here + choice = ( + input_with_timeout( + " Attempt to terminate these processes? (y/n, n continues and may fail, 15s timeout): ", + 15, + ) + .strip() + .lower() + ) + if choice == "y": + logger.info(" User selected to attempt termination...") + all_killed = all( + kill_process_interactive(pid) for pid in pids_on_port + ) + time.sleep(2) + if not is_port_in_use(server_target_port, host=uvicorn_bind_host): + logger.info( + f" ✅ Port {server_target_port} (host {uvicorn_bind_host}) is now available." + ) + port_is_available = True + else: + logger.error( + f" ❌ Port {server_target_port} (host {uvicorn_bind_host}) still in use after termination attempt." + ) + else: + logger.info( + " User selected not to auto-terminate or timed out. Continuing server start attempt." + ) + else: + logger.error( + " Headless mode will not attempt auto-termination of port-hogging processes. Server start may fail." + ) + else: + logger.warning( + f" Could not auto-identify processes using port {server_target_port}. Server start may fail." + ) + + if not port_is_available: + logger.warning( + f"--- Port {server_target_port} might still be in use. Continuing, server will handle binding. ---" + ) + else: + logger.info( + f" ✅ Port {server_target_port} (host {uvicorn_bind_host}) is currently available." + ) + port_is_available = True + + logger.info("--- Step 3: Prepare and start Camoufox internal process ---") + captured_ws_endpoint = None + # effective_active_auth_json_path = None # from dev # Initialized early + + if args.active_auth_json: + logger.info( + f" Attempting to use path from --active-auth-json: '{args.active_auth_json}'" + ) + candidate_path = os.path.expanduser(args.active_auth_json) + + # Attempt to resolve path: + # 1. As absolute path + if ( + os.path.isabs(candidate_path) + and os.path.exists(candidate_path) + and os.path.isfile(candidate_path) + ): + effective_active_auth_json_path = candidate_path + else: + # 2. As path relative to CWD + path_rel_to_cwd = os.path.abspath(candidate_path) + if os.path.exists(path_rel_to_cwd) and os.path.isfile(path_rel_to_cwd): + effective_active_auth_json_path = path_rel_to_cwd + else: + # 3. As path relative to script directory + path_rel_to_script = os.path.join( + os.path.dirname(__file__), candidate_path + ) + if os.path.exists(path_rel_to_script) and os.path.isfile( + path_rel_to_script + ): + effective_active_auth_json_path = path_rel_to_script + # 4. If just a filename, check in ACTIVE_AUTH_DIR then SAVED_AUTH_DIR + elif os.path.sep not in candidate_path: # This is a simple filename + path_in_active = os.path.join(ACTIVE_AUTH_DIR, candidate_path) + if os.path.exists(path_in_active) and os.path.isfile( + path_in_active + ): + effective_active_auth_json_path = path_in_active + else: + path_in_saved = os.path.join(SAVED_AUTH_DIR, candidate_path) + if os.path.exists(path_in_saved) and os.path.isfile( + path_in_saved + ): + effective_active_auth_json_path = path_in_saved + + if effective_active_auth_json_path: + logger.info( + f" Using resolved auth file from --active-auth-json: {effective_active_auth_json_path}" + ) + else: + logger.error( + f"❌ Specified auth file (--active-auth-json='{args.active_auth_json}') not found or not a file." + ) + sys.exit(1) + else: + # --active-auth-json not provided. + if final_launch_mode == "debug": + # For debug mode, scan dirs and prompt user, don't auto-use files + logger.info( + " Debug Mode: Scanning directories to prompt user selection from available auth files..." + ) + else: + # For headless mode, check default auth file in active/ dir + logger.info( + f" --active-auth-json not provided. Checking default auth file in '{ACTIVE_AUTH_DIR}'..." + ) + try: + if os.path.exists(ACTIVE_AUTH_DIR): + active_json_files = sorted( + [ + f + for f in os.listdir(ACTIVE_AUTH_DIR) + if f.lower().endswith(".json") + and os.path.isfile(os.path.join(ACTIVE_AUTH_DIR, f)) + ] + ) + if active_json_files: + effective_active_auth_json_path = os.path.join( + ACTIVE_AUTH_DIR, active_json_files[0] + ) + logger.info( + f" Using first alphabetic JSON file in '{ACTIVE_AUTH_DIR}': {os.path.basename(effective_active_auth_json_path)}" + ) + else: + logger.info( + f" Directory '{ACTIVE_AUTH_DIR}' empty or contains no JSON files." + ) + else: + logger.info(f" Directory '{ACTIVE_AUTH_DIR}' does not exist.") + except Exception as e_scan_active: + logger.warning( + f" Error scanning '{ACTIVE_AUTH_DIR}': {e_scan_active}", + exc_info=True, + ) + + # Handle debug mode user selection logic + if final_launch_mode == "debug" and not args.auto_save_auth: + # For debug mode, scan all directories and prompt user + available_profiles = [] + # Scan ACTIVE_AUTH_DIR first, then SAVED_AUTH_DIR + logger.info( + "[DIAGNOSTIC] Scanning for profiles in: active, saved, emergency." + ) + for profile_dir_path_str, dir_label in [ + (ACTIVE_AUTH_DIR, "active"), + (SAVED_AUTH_DIR, "saved"), + (EMERGENCY_AUTH_DIR, "emergency"), + ]: + if os.path.exists(profile_dir_path_str): + try: + # Sort filenames in each directory + filenames = sorted( + [ + f + for f in os.listdir(profile_dir_path_str) + if f.lower().endswith(".json") + and os.path.isfile( + os.path.join(profile_dir_path_str, f) + ) + ] + ) + for filename in filenames: + full_path = os.path.join(profile_dir_path_str, filename) + available_profiles.append( + {"name": f"{dir_label}/{filename}", "path": full_path} + ) + except OSError as e: + logger.warning( + f" ⚠️ Warning: Cannot read directory '{profile_dir_path_str}': {e}" + ) + + if available_profiles: + # Sort available profile list for consistent display + available_profiles.sort(key=lambda x: x["name"]) + print( + "-" * 60 + "\n Found the following available auth files:", + flush=True, + ) + for i, profile in enumerate(available_profiles): + print(f" {i + 1}: {profile['name']}", flush=True) + print( + " N: Do not load any file (Use browser current state)\n" + + "-" * 60, + flush=True, + ) + choice = input_with_timeout( + f" Select auth file number to load (Enter N or Enter to skip, {args.auth_save_timeout}s timeout): ", + args.auth_save_timeout, + ) + if choice.strip().lower() not in ["n", ""]: + try: + choice_index = int(choice.strip()) - 1 + if 0 <= choice_index < len(available_profiles): + selected_profile = available_profiles[choice_index] + effective_active_auth_json_path = selected_profile["path"] + logger.info( + f" Selected to load auth file: {selected_profile['name']}" + ) + print( + f" Selected loading: {selected_profile['name']}", + flush=True, + ) + else: + logger.info( + " Invalid selection number or timeout. Will not load auth file." + ) + print( + " Invalid selection number or timeout. Will not load auth file.", + flush=True, + ) + except ValueError: + logger.info(" Invalid input. Will not load auth file.") + print(" Invalid input. Will not load auth file.", flush=True) + else: + logger.info(" Okay, no auth file loaded or timeout.") + print(" Okay, no auth file loaded or timeout.", flush=True) + print("-" * 60, flush=True) + else: + logger.info(" No auth files found. Using browser current state.") + print( + " No auth files found. Using browser current state.", flush=True + ) + elif not effective_active_auth_json_path and not args.auto_save_auth: + # Check for backup profiles in saved or emergency before failing, BUT ONLY if auto-rotation is enabled. + auto_rotation_enabled = ( + str(args.auto_auth_rotation_on_startup).lower() == "true" + ) + + if not auto_rotation_enabled: + logger.error( + f" ❌ {final_launch_mode} Mode Error: No active profile found in '{ACTIVE_AUTH_DIR}' and AUTO_AUTH_ROTATION_ON_STARTUP is disabled." + ) + logger.error( + f" Please ensure a profile exists in '{ACTIVE_AUTH_DIR}' or enable auto-rotation." + ) + sys.exit(1) + + # If auto-rotation IS enabled, verify we actually have backups to rotate TO. + has_backups = False + for backup_dir in [SAVED_AUTH_DIR, EMERGENCY_AUTH_DIR]: + if os.path.exists(backup_dir): + try: + if any( + f.lower().endswith(".json") for f in os.listdir(backup_dir) + ): + has_backups = True + break + except Exception: + pass + + if has_backups: + logger.info( + " ⚠️ No active profile selected, but profiles exist in saved/emergency and auto-rotation is enabled. Allowing startup (runtime rotation will select one)." + ) + else: + # For headless mode, error if --active-auth-json not provided and active/ is empty AND no backups + logger.error( + f" ❌ {final_launch_mode} Mode Error: --active-auth-json not provided, active/ is empty, and no backup profiles found in saved/emergency." + ) + sys.exit(1) + + # Build Camoufox internal launch command (from dev) + camoufox_internal_cmd_args = [ + PYTHON_EXECUTABLE, + "-u", + __file__, + "--internal-launch-mode", + final_launch_mode, + ] + if effective_active_auth_json_path: + camoufox_internal_cmd_args.extend( + ["--internal-auth-file", effective_active_auth_json_path] + ) + + camoufox_internal_cmd_args.extend( + ["--internal-camoufox-os", simulated_os_for_camoufox] + ) + camoufox_internal_cmd_args.extend( + ["--internal-camoufox-port", str(args.camoufox_debug_port)] + ) + + # Fix: Pass proxy args to internal Camoufox process + if args.internal_camoufox_proxy is not None: + camoufox_internal_cmd_args.extend( + ["--internal-camoufox-proxy", args.internal_camoufox_proxy] + ) + + camoufox_popen_kwargs = { + "stdout": subprocess.PIPE, + "stderr": subprocess.PIPE, + "env": os.environ.copy(), + } + camoufox_popen_kwargs["env"]["PYTHONIOENCODING"] = "utf-8" + if sys.platform != "win32" and final_launch_mode != "debug": + camoufox_popen_kwargs["start_new_session"] = True + elif sys.platform == "win32" and ( + final_launch_mode == "headless" or final_launch_mode == "virtual_headless" + ): + camoufox_popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW + + try: + logger.info( + f" Executing Camoufox internal launch command: {' '.join(camoufox_internal_cmd_args)}" + ) + camoufox_proc = subprocess.Popen( + camoufox_internal_cmd_args, **camoufox_popen_kwargs + ) + logger.info( + f" Camoufox internal process started (PID: {camoufox_proc.pid}). Waiting for WebSocket endpoint output (max {ENDPOINT_CAPTURE_TIMEOUT}s)..." + ) + + camoufox_output_q = queue.Queue() + camoufox_stdout_reader = threading.Thread( + target=_enqueue_output, + args=(camoufox_proc.stdout, "stdout", camoufox_output_q, camoufox_proc.pid), + daemon=True, + ) + camoufox_stderr_reader = threading.Thread( + target=_enqueue_output, + args=(camoufox_proc.stderr, "stderr", camoufox_output_q, camoufox_proc.pid), + daemon=True, + ) + camoufox_stdout_reader.start() + camoufox_stderr_reader.start() + + ws_capture_start_time = time.time() + camoufox_ended_streams_count = 0 + while time.time() - ws_capture_start_time < ENDPOINT_CAPTURE_TIMEOUT: + if camoufox_proc.poll() is not None: + logger.error( + f" Camoufox internal process (PID: {camoufox_proc.pid}) exited unexpectedly while waiting for WebSocket endpoint, exit code: {camoufox_proc.poll()}." + ) + break + try: + stream_name, line_from_camoufox = camoufox_output_q.get(timeout=0.2) + if line_from_camoufox is None: + camoufox_ended_streams_count += 1 + logger.debug( + f" [InternalCamoufox-{stream_name}-PID:{camoufox_proc.pid}] Output stream closed (EOF)." + ) + if camoufox_ended_streams_count >= 2: + logger.info( + f" All output streams of Camoufox internal process (PID: {camoufox_proc.pid}) closed." + ) + break + continue + + log_line_content = f"[InternalCamoufox-{stream_name}-PID:{camoufox_proc.pid}]: {line_from_camoufox.rstrip()}" + if ( + stream_name == "stderr" + or "ERROR" in line_from_camoufox.upper() + or "❌" in line_from_camoufox + ): + logger.warning(log_line_content) + else: + logger.info(log_line_content) + + ws_match = ws_regex.search(line_from_camoufox) + if ws_match: + captured_ws_endpoint = ws_match.group(1) + logger.info( + f" ✅ Successfully captured WebSocket endpoint from Camoufox internal process: {captured_ws_endpoint[:40]}..." + ) + break + except queue.Empty: + continue + + if camoufox_stdout_reader.is_alive(): + camoufox_stdout_reader.join(timeout=1.0) + if camoufox_stderr_reader.is_alive(): + camoufox_stderr_reader.join(timeout=1.0) + + if not captured_ws_endpoint and ( + camoufox_proc and camoufox_proc.poll() is None + ): + logger.error( + f" ❌ Failed to capture WebSocket endpoint from Camoufox internal process (PID: {camoufox_proc.pid}) within {ENDPOINT_CAPTURE_TIMEOUT} seconds." + ) + logger.error( + " Camoufox internal process still running but didn't output expected WebSocket endpoint. Check its logs." + ) + cleanup() + sys.exit(1) + elif not captured_ws_endpoint and ( + camoufox_proc and camoufox_proc.poll() is not None + ): + logger.error( + " ❌ Camoufox internal process exited, and failed to capture WebSocket endpoint." + ) + sys.exit(1) + elif not captured_ws_endpoint: + logger.error(" ❌ Failed to capture WebSocket endpoint.") + sys.exit(1) + + except Exception as e_launch_camoufox_internal: + logger.critical( + f" ❌ Fatal error launching internal Camoufox or capturing WebSocket endpoint: {e_launch_camoufox_internal}", + exc_info=True, + ) + cleanup() + sys.exit(1) + + # --- Helper mode logic (New implementation) --- + if ( + args.helper + ): # If args.helper is not empty (helper enabled by default or user specified) + logger.info(f" Helper mode enabled, endpoint: {args.helper}") + os.environ["HELPER_ENDPOINT"] = args.helper # Set endpoint env var + + if effective_active_auth_json_path: + logger.info( + f" Attempting to extract SAPISID from auth file '{os.path.basename(effective_active_auth_json_path)}'..." + ) + sapisid = "" + try: + with open( + effective_active_auth_json_path, "r", encoding="utf-8" + ) as file: + auth_file_data = json.load(file) + if "cookies" in auth_file_data and isinstance( + auth_file_data["cookies"], list + ): + for cookie in auth_file_data["cookies"]: + if ( + isinstance(cookie, dict) + and cookie.get("name") == "SAPISID" + and cookie.get("domain") == ".google.com" + ): + sapisid = cookie.get("value", "") + break + except (FileNotFoundError, json.JSONDecodeError, KeyError, TypeError) as e: + logger.warning( + f" ⚠️ Failed to load or parse SAPISID from auth file '{os.path.basename(effective_active_auth_json_path)}': {e}" + ) + except Exception as e_sapisid_extraction: + logger.warning( + f" ⚠️ Unknown error extracting SAPISID: {e_sapisid_extraction}" + ) + + if sapisid: + logger.info( + " ✅ Successfully loaded SAPISID. Will set HELPER_SAPISID env var." + ) + os.environ["HELPER_SAPISID"] = sapisid + else: + logger.warning( + f" ⚠️ Valid SAPISID not found in auth file '{os.path.basename(effective_active_auth_json_path)}'. HELPER_SAPISID will not be set." + ) + if "HELPER_SAPISID" in os.environ: # Clean up just in case + del os.environ["HELPER_SAPISID"] + else: # args.helper has value (Helper mode enabled), but no auth file + logger.warning( + " ⚠️ Helper mode enabled but no valid auth file to extract SAPISID. HELPER_SAPISID will not be set." + ) + if "HELPER_SAPISID" in os.environ: # Clean up + del os.environ["HELPER_SAPISID"] + else: # args.helper is empty (user disabled helper via --helper='') + logger.info(" Helper mode disabled via --helper=''.") + # Clean up related env vars + if "HELPER_ENDPOINT" in os.environ: + del os.environ["HELPER_ENDPOINT"] + if "HELPER_SAPISID" in os.environ: + del os.environ["HELPER_SAPISID"] + + # --- Step 4: Set env vars and prepare to start FastAPI/Uvicorn server (from dev) --- + logger.info( + "--- Step 4: Set env vars and prepare to start FastAPI/Uvicorn server ---" + ) + + if captured_ws_endpoint: + os.environ["CAMOUFOX_WS_ENDPOINT"] = captured_ws_endpoint + else: + logger.error( + " Critical Logic Error: WebSocket endpoint not captured but program continuing." + ) + sys.exit(1) + + os.environ["LAUNCH_MODE"] = final_launch_mode + os.environ["SERVER_LOG_LEVEL"] = args.server_log_level.upper() + + # Fix: If command-line argument is not provided, keep the original value from environment variable + # This respects the .env file configuration + if ( + hasattr(args, "server_redirect_print_from_cli") + and args.server_redirect_print_from_cli + ): + os.environ["SERVER_REDIRECT_PRINT"] = str(args.server_redirect_print).lower() + # Otherwise keep existing environment variable value (loaded from .env) + + if hasattr(args, "debug_logs_from_cli") and args.debug_logs_from_cli: + os.environ["DEBUG_LOGS_ENABLED"] = str(args.debug_logs).lower() + # Otherwise keep existing environment variable value (loaded from .env) + + if hasattr(args, "trace_logs_from_cli") and args.trace_logs_from_cli: + os.environ["TRACE_LOGS_ENABLED"] = str(args.trace_logs).lower() + # Otherwise keep existing environment variable value (loaded from .env) + + if effective_active_auth_json_path: + os.environ["ACTIVE_AUTH_JSON_PATH"] = effective_active_auth_json_path + + # For AUTO_SAVE_AUTH, only override if in debug mode and explicitly specified via command line + if ( + final_launch_mode == "debug" + and hasattr(args, "auto_save_auth_from_cli") + and args.auto_save_auth_from_cli + ): + os.environ["AUTO_SAVE_AUTH"] = str(args.auto_save_auth).lower() + # Also set AUTO_SAVE_AUTH if --save-auth-as is provided (e.g., from GUI launcher) + elif final_launch_mode == "debug" and args.save_auth_as: + os.environ["AUTO_SAVE_AUTH"] = "true" + # Otherwise keep existing environment variable value (loaded from .env) + if args.save_auth_as: + os.environ["SAVE_AUTH_FILENAME"] = args.save_auth_as + os.environ["AUTH_SAVE_TIMEOUT"] = str(args.auth_save_timeout) + os.environ["SERVER_PORT_INFO"] = str(args.server_port) + os.environ["STREAM_PORT"] = str(args.stream_port) + + # Set unified proxy configuration env var + proxy_config = determine_proxy_configuration(args.internal_camoufox_proxy) + if proxy_config["stream_proxy"]: + os.environ["UNIFIED_PROXY_CONFIG"] = proxy_config["stream_proxy"] + logger.info(f" Setting unified proxy config: {proxy_config['source']}") + elif "UNIFIED_PROXY_CONFIG" in os.environ: + del os.environ["UNIFIED_PROXY_CONFIG"] + + host_os_for_shortcut_env = None + camoufox_os_param_lower = simulated_os_for_camoufox.lower() + if camoufox_os_param_lower == "macos": + host_os_for_shortcut_env = "Darwin" + elif camoufox_os_param_lower == "windows": + host_os_for_shortcut_env = "Windows" + elif camoufox_os_param_lower == "linux": + host_os_for_shortcut_env = "Linux" + if host_os_for_shortcut_env: + os.environ["HOST_OS_FOR_SHORTCUT"] = host_os_for_shortcut_env + elif "HOST_OS_FOR_SHORTCUT" in os.environ: + del os.environ["HOST_OS_FOR_SHORTCUT"] + + logger.info(" Environment variables set for server.app:") + env_keys_to_log = [ + "CAMOUFOX_WS_ENDPOINT", + "LAUNCH_MODE", + "SERVER_LOG_LEVEL", + "SERVER_REDIRECT_PRINT", + "DEBUG_LOGS_ENABLED", + "TRACE_LOGS_ENABLED", + "ACTIVE_AUTH_JSON_PATH", + "AUTO_SAVE_AUTH", + "SAVE_AUTH_FILENAME", + "AUTH_SAVE_TIMEOUT", + "SERVER_PORT_INFO", + "HOST_OS_FOR_SHORTCUT", + "HELPER_ENDPOINT", + "HELPER_SAPISID", + "STREAM_PORT", + "UNIFIED_PROXY_CONFIG", # Added unified proxy config + ] + for key in env_keys_to_log: + if key in os.environ: + val_to_log = os.environ[key] + if key == "CAMOUFOX_WS_ENDPOINT" and len(val_to_log) > 40: + val_to_log = val_to_log[:40] + "..." + if key == "ACTIVE_AUTH_JSON_PATH": + val_to_log = os.path.basename(val_to_log) + logger.info(f" {key}={val_to_log}") + else: + logger.info(f" {key}= (Not Set)") + + # --- Step 5: Start FastAPI/Uvicorn server (from dev) --- + logger.info( + f"--- Step 5: Start Integrated FastAPI Server (Listening on: {args.server_port}) ---" + ) + + if not args.exit_on_auth_save: + try: + # [ID-03] Enhanced Uvicorn Signal Handling with async task cancellation + from config.global_state import GlobalState + + # Create custom server config to control signal handling + server_config = uvicorn.Config( + app, + host="0.0.0.0", + port=args.server_port, + log_config=None, + access_log=False, + ) + + # [ID-03] Custom Server to prevent Uvicorn from overriding signal handlers + class CustomUvicornServer(uvicorn.Server): + def install_signal_handlers(self): + # We handle signals ourselves + pass + + server = CustomUvicornServer(server_config) + + # Install custom signal handlers that cancel asyncio tasks + def install_custom_signal_handlers(): + """Install custom signal handlers for immediate asyncio task cancellation""" + import signal + + def signal_handler(signum, frame): + logger.info( + f"[ID-03] 🚨 Received signal {signum}. Setting shutdown event and cancelling tasks..." + ) + GlobalState.IS_SHUTTING_DOWN.set() + + # Cancel all asyncio tasks immediately + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + # Get all tasks except the current one + tasks = [ + t + for t in asyncio.all_tasks(loop) + if not t.done() and t is not asyncio.current_task() + ] + logger.info( + f"[ID-03] Cancelling {len(tasks)} asyncio tasks..." + ) + for task in tasks: + task.cancel() + + # Give tasks a brief moment to acknowledge cancellation + async def wait_for_cancellation(): + try: + await asyncio.wait_for( + asyncio.gather(*tasks, return_exceptions=True), + timeout=3.0, + ) + except asyncio.TimeoutError: + logger.warning( + "[ID-03] Timeout waiting for tasks to cancel." + ) + + loop.create_task(wait_for_cancellation()) + except Exception as e: + logger.warning(f"[ID-03] Error cancelling asyncio tasks: {e}") + + # Force server to exit + server.should_exit = True + logger.info("[ID-03] Uvicorn server exit requested.") + + # Install handlers for SIGINT and SIGTERM + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + logger.info( + "[ID-03] Custom signal handlers installed for immediate shutdown." + ) + + # Install our custom handlers + install_custom_signal_handlers() + + # Run server with enhanced shutdown handling + server.run() + logger.info("Uvicorn server stopped.") + except SystemExit as e_sysexit: + logger.info(f"Uvicorn or subsystem exited via sys.exit({e_sysexit.code}).") + except Exception as e_uvicorn: + logger.critical( + f"❌ Fatal error running Uvicorn: {e_uvicorn}", exc_info=True + ) + sys.exit(1) + else: + logger.info( + " --exit-on-auth-save enabled. Server will auto-close after auth save." + ) + + server_config = uvicorn.Config( + app, host="0.0.0.0", port=args.server_port, log_config=None + ) + server = uvicorn.Server(server_config) + + stop_watcher = threading.Event() + + def watch_for_saved_auth_and_shutdown(): + os.makedirs(SAVED_AUTH_DIR, exist_ok=True) + initial_files = set(os.listdir(SAVED_AUTH_DIR)) + logger.info(f"Started monitoring auth save directory: {SAVED_AUTH_DIR}") + + while not stop_watcher.is_set(): + try: + current_files = set(os.listdir(SAVED_AUTH_DIR)) + new_files = current_files - initial_files + if new_files: + sleep_time = ( + float(os.getenv("POLLING_INTERVAL_STREAM", 500)) / 1000 + ) + logger.info( + f"Detected new saved auth files: {', '.join(new_files)}. Triggering shutdown in {sleep_time} seconds..." + ) + time.sleep(sleep_time) + server.should_exit = True + logger.info("Shutdown signal sent to Uvicorn server.") + break + initial_files = current_files + except Exception as e: + logger.error(f"Error monitoring auth directory: {e}", exc_info=True) + + if stop_watcher.wait(1): + break + logger.info("Auth file monitor thread stopped.") + + watcher_thread = threading.Thread(target=watch_for_saved_auth_and_shutdown) + + try: + watcher_thread.start() + server.run() + logger.info("Uvicorn server stopped.") + except (KeyboardInterrupt, SystemExit) as e: + event_name = ( + "KeyboardInterrupt" + if isinstance(e, KeyboardInterrupt) + else f"SystemExit({getattr(e, 'code', '')})" + ) + logger.info(f"Received {event_name}, shutting down...") + except Exception as e_uvicorn: + logger.critical( + f"❌ Fatal error running Uvicorn: {e_uvicorn}", exc_info=True + ) + sys.exit(1) + finally: + stop_watcher.set() + if watcher_thread.is_alive(): + watcher_thread.join() + + logger.info("🚀 Camoufox Launcher Main Logic Finished 🚀") diff --git a/launcher/__init__.py b/launcher/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1de828ab5434707280dc697a6b6f98e85612ebeb --- /dev/null +++ b/launcher/__init__.py @@ -0,0 +1 @@ +# launcher package diff --git a/launcher/checks.py b/launcher/checks.py new file mode 100644 index 0000000000000000000000000000000000000000..c9cd02188b2533027f19e11b341f379a7a482dbb --- /dev/null +++ b/launcher/checks.py @@ -0,0 +1,84 @@ +import logging +import os +import sys +from typing import Dict, List, Optional + +from launcher.config import ACTIVE_AUTH_DIR, SAVED_AUTH_DIR + +logger = logging.getLogger("CamoufoxLauncher") + + +def ensure_auth_dirs_exist() -> None: + try: + os.makedirs(ACTIVE_AUTH_DIR, exist_ok=True) + logger.debug(f"Active auth directory ready: {ACTIVE_AUTH_DIR}") + os.makedirs(SAVED_AUTH_DIR, exist_ok=True) + logger.debug(f"Saved auth directory ready: {SAVED_AUTH_DIR}") + except Exception as e: + logger.error(f"Failed to create auth directories: {e}", exc_info=True) + sys.exit(1) + + +def check_dependencies( + launch_server: Optional[bool], DefaultAddons: Optional[bool] +) -> None: + logger.debug("[Init] Step 1: Checking dependencies...") + required_modules: Dict[str, str] = {} + if launch_server is not None and DefaultAddons is not None: + required_modules["camoufox"] = "camoufox (for server and addons)" + elif launch_server is not None: + required_modules["camoufox_server"] = "camoufox.server" + logger.warning( + " 'camoufox.server' imported, but 'camoufox.DefaultAddons' not imported. Addon exclusion functionality may be limited." + ) + missing_py_modules: List[str] = [] + dependencies_ok = True + if required_modules: + for module_name, install_package_name in required_modules.items(): + try: + __import__(module_name) + logger.debug(f"Module '{module_name}' found.") + except ImportError: + logger.error( + f" Module '{module_name}' (package: '{install_package_name}') not found." + ) + missing_py_modules.append(install_package_name) + dependencies_ok = False + else: + # Check if internal launch mode, if so camoufox must be importable + is_any_internal_arg = any(arg.startswith("--internal-") for arg in sys.argv) + if is_any_internal_arg and (launch_server is None or DefaultAddons is None): + logger.error( + " Internal launch mode (--internal-*) requires 'camoufox' package, but failed to import." + ) + dependencies_ok = False + elif not is_any_internal_arg: + logger.debug( + "Internal launch mode not requested and camoufox.server not imported, skipping 'camoufox' Python package check." + ) + + try: + from server import app as server_app_check + + if server_app_check: + logger.debug("[Init] Successfully imported app object from server.py") + except ImportError as e_import_server: + logger.error( + f" Cannot import 'app' object from 'server.py': {e_import_server}", + exc_info=True, + ) + logger.error("Please ensure 'server.py' file exists and has no import errors.") + dependencies_ok = False + + if not dependencies_ok: + logger.error("-------------------------------------------------") + logger.error("Dependency check failed!") + if missing_py_modules: + logger.error(f"Missing Python libraries: {', '.join(missing_py_modules)}") + logger.error( + f" Please try installing with pip: pip install {' '.join(missing_py_modules)}" + ) + logger.error("-------------------------------------------------") + sys.exit(1) + else: + logger.debug("[Init] All dependency checks passed") diff --git a/launcher/config.py b/launcher/config.py new file mode 100644 index 0000000000000000000000000000000000000000..e78dea3624d0933a98f11ed951b730a1f3b3a907 --- /dev/null +++ b/launcher/config.py @@ -0,0 +1,256 @@ +import argparse +import os +import re +import sys +from typing import Dict, Optional + +from launcher.utils import get_proxy_from_gsettings + +# --- Configuration Constants --- +PYTHON_EXECUTABLE = sys.executable +ENDPOINT_CAPTURE_TIMEOUT = int(os.environ.get("ENDPOINT_CAPTURE_TIMEOUT", "45")) +DEFAULT_SERVER_PORT = int(os.environ.get("DEFAULT_FASTAPI_PORT", "2048")) +DEFAULT_CAMOUFOX_PORT = int(os.environ.get("DEFAULT_CAMOUFOX_PORT", "9222")) +DEFAULT_STREAM_PORT = int(os.environ.get("STREAM_PORT", "3120")) +DEFAULT_HELPER_ENDPOINT = os.environ.get("GUI_DEFAULT_HELPER_ENDPOINT", "") +DEFAULT_AUTH_SAVE_TIMEOUT = int(os.environ.get("AUTH_SAVE_TIMEOUT", "30")) +DEFAULT_SERVER_LOG_LEVEL = os.environ.get("SERVER_LOG_LEVEL", "INFO") +AUTH_PROFILES_DIR = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "auth_profiles" +) +ACTIVE_AUTH_DIR = os.path.join(AUTH_PROFILES_DIR, "active") +SAVED_AUTH_DIR = os.path.join(AUTH_PROFILES_DIR, "saved") +LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logs") +LAUNCHER_LOG_FILE_PATH = os.path.join(LOG_DIR, "launch_app.log") +DIRECT_LAUNCH = os.environ.get("DIRECT_LAUNCH", "").lower() in ("true", "1", "yes") + +ws_regex = re.compile(r"(ws://\S+)") + + +def determine_proxy_configuration( + internal_camoufox_proxy_arg: Optional[str] = None, +) -> Dict[str, Optional[str]]: + """ + Unified proxy configuration determination function. + Priority: Command-line arguments > Environment variables > System settings. + """ + result = {"camoufox_proxy": None, "stream_proxy": None, "source": "No Proxy"} + + if internal_camoufox_proxy_arg is not None: + if internal_camoufox_proxy_arg.strip(): + result["camoufox_proxy"] = internal_camoufox_proxy_arg.strip() + result["stream_proxy"] = internal_camoufox_proxy_arg.strip() + result["source"] = ( + f"CLI argument --internal-camoufox-proxy: {internal_camoufox_proxy_arg.strip()}" + ) + else: + result["source"] = ( + "CLI argument --internal-camoufox-proxy='' (Explicitly disabled)" + ) + return result + + unified_proxy = os.environ.get("UNIFIED_PROXY_CONFIG") + if unified_proxy: + result["camoufox_proxy"] = unified_proxy + result["stream_proxy"] = unified_proxy + result["source"] = f"Environment variable UNIFIED_PROXY_CONFIG: {unified_proxy}" + return result + + http_proxy = os.environ.get("HTTP_PROXY") + if http_proxy: + result["camoufox_proxy"] = http_proxy + result["stream_proxy"] = http_proxy + result["source"] = f"Environment variable HTTP_PROXY: {http_proxy}" + return result + + https_proxy = os.environ.get("HTTPS_PROXY") + if https_proxy: + result["camoufox_proxy"] = https_proxy + result["stream_proxy"] = https_proxy + result["source"] = f"Environment variable HTTPS_PROXY: {https_proxy}" + return result + + if sys.platform.startswith("linux"): + gsettings_proxy = get_proxy_from_gsettings() + if gsettings_proxy: + result["camoufox_proxy"] = gsettings_proxy + result["stream_proxy"] = gsettings_proxy + result["source"] = f"gsettings System Proxy: {gsettings_proxy}" + return result + + return result + + +def parse_args() -> argparse.Namespace: + # Resolve defaults at call time to avoid module-import-time env pinning + # (e.g., test sessions that override STREAM_PORT globally). + runtime_default_server_port = int( + os.environ.get("DEFAULT_FASTAPI_PORT", str(DEFAULT_SERVER_PORT)) + ) + runtime_default_stream_port = int( + os.environ.get("STREAM_PORT", str(DEFAULT_STREAM_PORT)) + ) + runtime_default_camoufox_port = int( + os.environ.get("DEFAULT_CAMOUFOX_PORT", str(DEFAULT_CAMOUFOX_PORT)) + ) + runtime_default_helper_endpoint = os.environ.get( + "GUI_DEFAULT_HELPER_ENDPOINT", DEFAULT_HELPER_ENDPOINT + ) + runtime_default_auth_save_timeout = int( + os.environ.get("AUTH_SAVE_TIMEOUT", str(DEFAULT_AUTH_SAVE_TIMEOUT)) + ) + runtime_default_server_log_level = os.environ.get( + "SERVER_LOG_LEVEL", DEFAULT_SERVER_LOG_LEVEL + ) + + parser = argparse.ArgumentParser( + description="Launcher for Camoufox browser simulation and FastAPI proxy server.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + # Internal parameters + parser.add_argument( + "--internal-launch-mode", + type=str, + choices=["debug", "headless", "virtual_headless"], + help=argparse.SUPPRESS, + ) + parser.add_argument( + "--internal-auth-file", type=str, default=None, help=argparse.SUPPRESS + ) + parser.add_argument( + "--internal-camoufox-port", + type=int, + default=runtime_default_camoufox_port, + help=argparse.SUPPRESS, + ) + parser.add_argument( + "--internal-camoufox-proxy", type=str, default=None, help=argparse.SUPPRESS + ) + parser.add_argument( + "--internal-camoufox-os", type=str, default="random", help=argparse.SUPPRESS + ) + + # User-visible parameters + parser.add_argument( + "--server-port", + type=int, + default=runtime_default_server_port, + help=f"FastAPI server listening port (Default: {runtime_default_server_port})", + ) + parser.add_argument( + "--stream-port", + type=int, + default=runtime_default_stream_port, + help=f"Streaming proxy server port. Provide 0 to disable. Default: {runtime_default_stream_port}", + ) + parser.add_argument( + "--helper", + type=str, + default=runtime_default_helper_endpoint, + help=f"Helper server getStreamResponse endpoint. Provide empty string to disable. Default: {runtime_default_helper_endpoint}", + ) + parser.add_argument( + "--camoufox-debug-port", + type=int, + default=runtime_default_camoufox_port, + help=f"Internal Camoufox instance debugging port (Default: {runtime_default_camoufox_port})", + ) + + mode_selection_group = parser.add_mutually_exclusive_group() + mode_selection_group.add_argument( + "--debug", + action="store_true", + help="Start in debug mode (browser visible, interactive auth allowed)", + ) + mode_selection_group.add_argument( + "--headless", + action="store_true", + help="Start in headless mode (no browser UI, requires pre-saved auth)", + ) + mode_selection_group.add_argument( + "--virtual-display", + action="store_true", + help="Start in headless mode with virtual display (Xvfb, Linux only)", + ) + + parser.add_argument( + "--active-auth-json", + type=str, + default=None, + help="[Optional] Path to active authentication JSON file.", + ) + + debug_logs_default = os.environ.get("DEBUG_LOGS_ENABLED", "false").lower() == "true" + trace_logs_default = os.environ.get("TRACE_LOGS_ENABLED", "false").lower() == "true" + auto_save_auth_default = os.environ.get("AUTO_SAVE_AUTH", "false").lower() == "true" + + parser.add_argument( + "--auto-save-auth", + action="store_true", + help="[Debug Mode] Automatically prompt and save new auth state after successful login.", + ) + parser.add_argument( + "--save-auth-as", + type=str, + default=None, + help="[Debug Mode] Filename for the new auth file (without .json extension).", + ) + parser.add_argument( + "--auth-save-timeout", + type=int, + default=runtime_default_auth_save_timeout, + help=f"[Debug Mode] Timeout in seconds to wait for auth save. Default: {runtime_default_auth_save_timeout}", + ) + parser.add_argument( + "--exit-on-auth-save", + action="store_true", + help="[Debug Mode] Automatically close the launcher and processes after saving new auth file.", + ) + + parser.add_argument( + "--server-log-level", + type=str, + default=runtime_default_server_log_level, + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + help=f"Log level for server.py. Default: {runtime_default_server_log_level}", + ) + parser.add_argument( + "--server-redirect-print", + action="store_true", + help="Redirect server.py print output to its logging system.", + ) + parser.add_argument( + "--debug-logs", + action="store_true", + default=debug_logs_default, + help="Enable internal DEBUG logs for server.py (DEBUG_LOGS_ENABLED env).", + ) + parser.add_argument( + "--trace-logs", + action="store_true", + default=trace_logs_default, + help="Enable internal TRACE logs for server.py (TRACE_LOGS_ENABLED env).", + ) + parser.add_argument( + "--skip-frontend-build", + action="store_true", + help="Skip frontend build check (for environments without Node.js/npm).", + ) + + args = parser.parse_args() + + # Mark which arguments were explicitly set via CLI + args.debug_logs_from_cli = "--debug-logs" in sys.argv + args.trace_logs_from_cli = "--trace-logs" in sys.argv + args.auto_save_auth_from_cli = "--auto-save-auth" in sys.argv + args.server_redirect_print_from_cli = "--server-redirect-print" in sys.argv + + # Use environment variables if not set via CLI + if not args.debug_logs_from_cli: + args.debug_logs = debug_logs_default + if not args.trace_logs_from_cli: + args.trace_logs = trace_logs_default + if not args.auto_save_auth_from_cli: + args.auto_save_auth = auto_save_auth_default + + return args diff --git a/launcher/frontend_build.py b/launcher/frontend_build.py new file mode 100644 index 0000000000000000000000000000000000000000..4dc4ba0d50ebef9ccaa16d97a4d4cde0d5c01a0f --- /dev/null +++ b/launcher/frontend_build.py @@ -0,0 +1,169 @@ +""" +Frontend build utilities for the launcher. + +Provides automatic detection and rebuild of stale frontend assets. +""" + +import logging +import shutil +import subprocess +from pathlib import Path + +logger = logging.getLogger("CamoufoxLauncher") + +# Paths relative to project root +_PROJECT_ROOT = Path(__file__).parent.parent +_FRONTEND_DIR = _PROJECT_ROOT / "static" / "frontend" +_FRONTEND_SRC = _FRONTEND_DIR / "src" +_FRONTEND_DIST = _FRONTEND_DIR / "dist" + + +def _get_latest_mtime( + directory: Path, extensions: tuple[str, ...] = (".ts", ".tsx", ".css") +) -> float: + """Get the latest modification time of source files in a directory.""" + latest_mtime = 0.0 + if not directory.exists(): + return latest_mtime + + for file_path in directory.rglob("*"): + if file_path.is_file() and file_path.suffix in extensions: + try: + mtime = file_path.stat().st_mtime + if mtime > latest_mtime: + latest_mtime = mtime + except OSError: + pass + return latest_mtime + + +def _get_dist_mtime() -> float: + """Get the modification time of the dist/index.html.""" + index_html = _FRONTEND_DIST / "index.html" + if index_html.exists(): + try: + return index_html.stat().st_mtime + except OSError: + pass + return 0.0 + + +def is_frontend_stale() -> bool: + """Check if frontend needs to be rebuilt.""" + if not _FRONTEND_DIST.exists(): + return True + + src_mtime = _get_latest_mtime(_FRONTEND_SRC) + dist_mtime = _get_dist_mtime() + + if src_mtime == 0.0: + # No source files found, don't rebuild + return False + + return src_mtime > dist_mtime + + +def check_npm_available() -> bool: + """Check if npm is available.""" + return shutil.which("npm") is not None + + +def rebuild_frontend() -> bool: + """ + Rebuild the frontend. + + Returns: + True if build succeeded, False otherwise. + """ + if not _FRONTEND_DIR.exists(): + logger.warning(f"Frontend directory not found: {_FRONTEND_DIR}") + return False + + if not check_npm_available(): + logger.warning("npm not installed, skipping frontend rebuild") + return False + + # Check if node_modules exists + if not (_FRONTEND_DIR / "node_modules").exists(): + logger.info("[Build] Installing frontend dependencies...") + try: + result = subprocess.run( + ["npm", "install"], + cwd=str(_FRONTEND_DIR), + capture_output=True, + text=True, + timeout=120, + ) + if result.returncode != 0: + logger.error(f"npm install failed: {result.stderr}") + return False + except subprocess.TimeoutExpired: + logger.error("npm install timed out") + return False + except Exception as e: + logger.error(f"npm install error: {e}") + return False + + logger.info("[Build] Building frontend...") + try: + result = subprocess.run( + ["npm", "run", "build"], + cwd=str(_FRONTEND_DIR), + capture_output=True, + text=True, + timeout=60, + ) + if result.returncode == 0: + logger.info("[Build] Frontend build succeeded") + return True + else: + # TypeScript errors go to stdout, other errors to stderr + error_output = result.stdout.strip() or result.stderr.strip() + if error_output: + # Truncate long error messages for readability + if len(error_output) > 500: + error_output = error_output[:500] + "\n... (truncated)" + logger.error(f"Frontend build failed:\n{error_output}") + else: + logger.error(f"Frontend build failed (exit code: {result.returncode})") + return False + except subprocess.TimeoutExpired: + logger.error("Frontend build timed out") + return False + except Exception as e: + logger.error(f"Frontend build error: {e}") + return False + + +def ensure_frontend_built(skip_build: bool = False) -> None: + """ + Ensure frontend is built and up-to-date. + + Automatically rebuilds if source files are newer than dist. + + Args: + skip_build: If True, skip all build checks. + Can also be skipped by setting environment variable SKIP_FRONTEND_BUILD=1. + """ + import os + + # Check for skip flag from argument or environment + if skip_build or os.environ.get("SKIP_FRONTEND_BUILD", "").lower() in ( + "1", + "true", + "yes", + ): + logger.info("[Build] Skipping frontend build check (SKIP_FRONTEND_BUILD)") + return + + if not _FRONTEND_SRC.exists(): + logger.debug( + "[Build] Frontend source directory not found, skipping build check" + ) + return + + if is_frontend_stale(): + logger.info("[Build] Detected frontend source file updates, rebuilding...") + rebuild_frontend() + else: + logger.info("[Build] Frontend is up-to-date") diff --git a/launcher/internal.py b/launcher/internal.py new file mode 100644 index 0000000000000000000000000000000000000000..645086618050a86abbdf33a9c364fc4e5a338fc7 --- /dev/null +++ b/launcher/internal.py @@ -0,0 +1,94 @@ +import os +import sys +import traceback + +from launcher.config import determine_proxy_configuration + + +def run_internal_camoufox(args, launch_server, DefaultAddons): + if not launch_server or not DefaultAddons: + print( + "Fatal Error (--internal-launch-mode): camoufox.server.launch_server or camoufox.DefaultAddons not available.", + file=sys.stderr, + ) + sys.exit(1) + + internal_mode_arg = args.internal_launch_mode + auth_file = args.internal_auth_file + camoufox_port_internal = args.internal_camoufox_port + + proxy_config = determine_proxy_configuration(args.internal_camoufox_proxy) + actual_proxy_to_use = proxy_config["camoufox_proxy"] + print(f"--- [Internal Camoufox Start] Proxy Config: {proxy_config['source']} ---", flush=True) + + camoufox_proxy_internal = actual_proxy_to_use + camoufox_os_internal = args.internal_camoufox_os + + print( + f"--- [Internal Camoufox Start] Mode: {internal_mode_arg}, Auth: {os.path.basename(auth_file) if auth_file else 'None'}, " + f"Port: {camoufox_port_internal}, Proxy: {camoufox_proxy_internal or 'None'}, OS: {camoufox_os_internal} ---", + flush=True, + ) + + try: + launch_args_for_internal_camoufox = { + "port": camoufox_port_internal, + "addons": [], + "exclude_addons": [DefaultAddons.UBO], + "window": (1440, 900), + } + + if camoufox_proxy_internal: + launch_args_for_internal_camoufox["proxy"] = { + "server": camoufox_proxy_internal + } + + if auth_file: + launch_args_for_internal_camoufox["storage_state"] = auth_file + + if "," in camoufox_os_internal: + camoufox_os_list_internal = [ + s.strip().lower() for s in camoufox_os_internal.split(",") + ] + valid_os_values = ["windows", "macos", "linux"] + if not all(val in valid_os_values for val in camoufox_os_list_internal): + print( + f"Internal Camoufox Error: Invalid OS values: {camoufox_os_list_internal}", + file=sys.stderr, + ) + sys.exit(1) + launch_args_for_internal_camoufox["os"] = camoufox_os_list_internal + elif camoufox_os_internal.lower() in ["windows", "macos", "linux"]: + launch_args_for_internal_camoufox["os"] = camoufox_os_internal.lower() + elif camoufox_os_internal.lower() != "random": + print( + f"Internal Camoufox Error: Invalid OS value: '{camoufox_os_internal}'", + file=sys.stderr, + ) + sys.exit(1) + + print( + f"Parameters passed to launch_server: {launch_args_for_internal_camoufox}", + flush=True, + ) + + if internal_mode_arg == "headless": + launch_server(headless=True, **launch_args_for_internal_camoufox) + elif internal_mode_arg == "virtual_headless": + launch_server(headless="virtual", **launch_args_for_internal_camoufox) + elif internal_mode_arg == "debug": + launch_server(headless=False, **launch_args_for_internal_camoufox) + + print( + f"--- [Internal Camoufox Start] launch_server ({internal_mode_arg} mode) call completed/blocked. ---", + flush=True, + ) + except Exception as e_internal_launch_final: + print( + f"Error (--internal-launch-mode): Exception in launch_server: {e_internal_launch_final}", + file=sys.stderr, + flush=True, + ) + traceback.print_exc(file=sys.stderr) + sys.exit(1) + sys.exit(0) diff --git a/launcher/logging_setup.py b/launcher/logging_setup.py new file mode 100644 index 0000000000000000000000000000000000000000..2afdb8434f399f3d4852f3966403cdccc4c02b72 --- /dev/null +++ b/launcher/logging_setup.py @@ -0,0 +1,56 @@ +import logging +import logging.handlers +import os +import sys + +from launcher.config import LAUNCHER_LOG_FILE_PATH, LOG_DIR +from logging_utils import GridFormatter, PlainGridFormatter, set_source + +logger = logging.getLogger("CamoufoxLauncher") + + +def setup_launcher_logging(log_level: int = logging.INFO) -> None: + """ + Set up launcher logging system (using GridFormatter) + + Args: + log_level: Log level + """ + os.makedirs(LOG_DIR, exist_ok=True) + + # Set source to LAUNCHER + set_source("LAUNCHER") + + # Use PlainGridFormatter for file logging + file_log_formatter = PlainGridFormatter() + + # Use GridFormatter for console (colored output) + console_log_formatter = GridFormatter(show_tree=True, colorize=True) + + if logger.hasHandlers(): + logger.handlers.clear() + logger.setLevel(log_level) + logger.propagate = False + + if os.path.exists(LAUNCHER_LOG_FILE_PATH): + try: + os.remove(LAUNCHER_LOG_FILE_PATH) + except OSError: + pass + + file_handler = logging.handlers.RotatingFileHandler( + LAUNCHER_LOG_FILE_PATH, + maxBytes=10 * 1024 * 1024, # 10MB + backupCount=5, + encoding="utf-8", + mode="w", + ) + file_handler.setFormatter(file_log_formatter) + logger.addHandler(file_handler) + + stream_handler = logging.StreamHandler(sys.stderr) + stream_handler.setFormatter(console_log_formatter) + logger.addHandler(stream_handler) + + logger.info(f"Log level set to: {logging.getLevelName(logger.getEffectiveLevel())}") + logger.debug(f"Log file path: {LAUNCHER_LOG_FILE_PATH}") diff --git a/launcher/process.py b/launcher/process.py new file mode 100644 index 0000000000000000000000000000000000000000..0ba52275e6699f737712600beaf9baf637893f4f --- /dev/null +++ b/launcher/process.py @@ -0,0 +1,322 @@ +import logging +import os +import queue +import signal +import subprocess +import sys +import threading +import time +from typing import Optional + +from launcher.config import ENDPOINT_CAPTURE_TIMEOUT, PYTHON_EXECUTABLE, ws_regex + +logger = logging.getLogger("CamoufoxLauncher") + + +def _enqueue_output( + stream, stream_name, output_queue, process_pid_for_log="" +): + log_prefix = f"[ReadThread-{stream_name}-PID:{process_pid_for_log}]" + try: + for line_bytes in iter(stream.readline, b""): + if not line_bytes: + break + try: + line_str = line_bytes.decode("utf-8", errors="replace") + output_queue.put((stream_name, line_str)) + except Exception as decode_err: + logger.warning( + f"{log_prefix} Decode error: {decode_err}. Raw data (first 100 bytes): {line_bytes[:100]}" + ) + output_queue.put( + ( + stream_name, + f"[Decode error: {decode_err}] {line_bytes[:100]}...\n", + ) + ) + except ValueError: + logger.debug(f"{log_prefix} ValueError (stream may be closed).") + except Exception as e: + logger.error( + f"{log_prefix} Unexpected error reading stream: {e}", exc_info=True + ) + finally: + output_queue.put((stream_name, None)) + if hasattr(stream, "close") and not stream.closed: + try: + stream.close() + except Exception: + pass + logger.debug(f"{log_prefix} Thread exiting.") + + +def build_launch_command( + final_launch_mode: str, + effective_active_auth_json_path: Optional[str], + simulated_os_for_camoufox: str, + camoufox_debug_port: int, + internal_camoufox_proxy: Optional[str], +) -> list[str]: + """ + Build the command-line arguments for launching the internal Camoufox process. + + This is a pure function (no I/O) that can be easily unit tested. + + Args: + final_launch_mode: The launch mode (headless, virtual_headless, debug) + effective_active_auth_json_path: Path to auth file, or None + simulated_os_for_camoufox: OS to simulate (linux, windows, macos) + camoufox_debug_port: Debug port for Camoufox + internal_camoufox_proxy: Proxy configuration, or None + + Returns: + List of command-line arguments for subprocess.Popen + """ + cmd = [ + PYTHON_EXECUTABLE, + "-u", + sys.argv[0], + "--internal-launch-mode", + final_launch_mode, + ] + + if effective_active_auth_json_path: + cmd.extend(["--internal-auth-file", effective_active_auth_json_path]) + + cmd.extend(["--internal-camoufox-os", simulated_os_for_camoufox]) + cmd.extend(["--internal-camoufox-port", str(camoufox_debug_port)]) + + if internal_camoufox_proxy is not None: + cmd.extend(["--internal-camoufox-proxy", internal_camoufox_proxy]) + + return cmd + + +class CamoufoxProcessManager: + def __init__(self): + self.camoufox_proc = None + self.captured_ws_endpoint = None + + def start( + self, + final_launch_mode, + effective_active_auth_json_path, + simulated_os_for_camoufox, + args, + ): + # Build Camoufox internal launch command (from dev) + camoufox_internal_cmd_args = build_launch_command( + final_launch_mode, + effective_active_auth_json_path, + simulated_os_for_camoufox, + args.camoufox_debug_port, + args.internal_camoufox_proxy, + ) + + camoufox_popen_kwargs = { + "stdout": subprocess.PIPE, + "stderr": subprocess.PIPE, + "env": os.environ.copy(), + } + camoufox_popen_kwargs["env"]["PYTHONIOENCODING"] = "utf-8" + if sys.platform != "win32" and final_launch_mode != "debug": + camoufox_popen_kwargs["start_new_session"] = True + elif sys.platform == "win32" and ( + final_launch_mode == "headless" or final_launch_mode == "virtual_headless" + ): + camoufox_popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW + + try: + logger.debug( + f"Executing Camoufox internal launch command: {' '.join(camoufox_internal_cmd_args)}" + ) + self.camoufox_proc = subprocess.Popen( + camoufox_internal_cmd_args, **camoufox_popen_kwargs + ) + logger.info( + f"Camoufox internal process started (PID: {self.camoufox_proc.pid}). Waiting for WebSocket endpoint output (max {ENDPOINT_CAPTURE_TIMEOUT} seconds)..." + ) + + camoufox_output_q = queue.Queue() + camoufox_stdout_reader = threading.Thread( + target=_enqueue_output, + args=( + self.camoufox_proc.stdout, + "stdout", + camoufox_output_q, + self.camoufox_proc.pid, + ), + daemon=True, + ) + camoufox_stderr_reader = threading.Thread( + target=_enqueue_output, + args=( + self.camoufox_proc.stderr, + "stderr", + camoufox_output_q, + self.camoufox_proc.pid, + ), + daemon=True, + ) + camoufox_stdout_reader.start() + camoufox_stderr_reader.start() + + ws_capture_start_time = time.time() + camoufox_ended_streams_count = 0 + while time.time() - ws_capture_start_time < ENDPOINT_CAPTURE_TIMEOUT: + if self.camoufox_proc.poll() is not None: + logger.error( + f" Camoufox internal process (PID: {self.camoufox_proc.pid}) unexpectedly exited while waiting for WebSocket endpoint, exit code: {self.camoufox_proc.poll()}." + ) + break + try: + stream_name, line_from_camoufox = camoufox_output_q.get(timeout=0.2) + if line_from_camoufox is None: + camoufox_ended_streams_count += 1 + logger.debug( + f" [InternalCamoufox-{stream_name}-PID:{self.camoufox_proc.pid}] Output stream closed (EOF)." + ) + if camoufox_ended_streams_count >= 2: + logger.info( + f" Camoufox internal process (PID: {self.camoufox_proc.pid}) all output streams closed." + ) + break + continue + + # Skip the ugly prefix, just log the content + log_content = line_from_camoufox.rstrip() + # Skip verbose startup messages (move to debug) + if ( + "[InternalCamoufoxStartup]" in log_content + or "passed to launch_server" in log_content + ): + logger.debug(f"(Camoufox) {log_content}") + elif ( + stream_name == "stderr" or "ERROR" in line_from_camoufox.upper() + ): + logger.info(f"(Camoufox) {log_content}") + else: + logger.debug(f"(Camoufox) {log_content}") + + ws_match = ws_regex.search(line_from_camoufox) + if ws_match: + self.captured_ws_endpoint = ws_match.group(1) + logger.debug( + f"Successfully captured WebSocket endpoint from Camoufox internal process: {self.captured_ws_endpoint[:40]}..." + ) + logger.info("[Core] WebSocket endpoint obtained successfully") + break + except queue.Empty: + continue + + if camoufox_stdout_reader.is_alive(): + camoufox_stdout_reader.join(timeout=1.0) + if camoufox_stderr_reader.is_alive(): + camoufox_stderr_reader.join(timeout=1.0) + + if not self.captured_ws_endpoint and ( + self.camoufox_proc and self.camoufox_proc.poll() is None + ): + logger.error( + f" Failed to capture WebSocket endpoint from Camoufox internal process (PID: {self.camoufox_proc.pid}) within {ENDPOINT_CAPTURE_TIMEOUT} seconds." + ) + logger.error( + " Camoufox internal process is still running but did not output expected WebSocket endpoint. Please check its logs or behavior." + ) + self.cleanup() + sys.exit(1) + elif not self.captured_ws_endpoint and ( + self.camoufox_proc and self.camoufox_proc.poll() is not None + ): + logger.error( + "Camoufox internal process exited and failed to capture WebSocket endpoint." + ) + sys.exit(1) + elif not self.captured_ws_endpoint: + logger.error("Failed to capture WebSocket endpoint.") + sys.exit(1) + + except Exception as e_launch_camoufox_internal: + logger.critical( + f" Fatal error launching Camoufox internally or capturing its WebSocket endpoint: {e_launch_camoufox_internal}", + exc_info=True, + ) + self.cleanup() + sys.exit(1) + + return self.captured_ws_endpoint + + def cleanup(self): + logger.info("--- Starting cleanup procedure (CamoufoxProcessManager) ---") + if self.camoufox_proc and self.camoufox_proc.poll() is None: + pid = self.camoufox_proc.pid + logger.info(f"Terminating Camoufox process tree (PID: {pid})...") + try: + if sys.platform == "win32": + # Windows: Force terminate directly, don't try graceful shutdown (headless browsers often hang) + try: + subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(pid)], + capture_output=True, + text=True, + check=False, + timeout=5, + ) + logger.info("Process tree successfully terminated.") + except subprocess.TimeoutExpired: + logger.warning( + "taskkill timed out, process may have terminated." + ) + except Exception as e: + logger.warning(f"taskkill execution exception: {e}") + elif hasattr(os, "getpgid") and hasattr(os, "killpg"): + # Unix: Try SIGTERM, then SIGKILL on timeout + try: + pgid = os.getpgid(pid) + os.killpg(pgid, signal.SIGTERM) + self.camoufox_proc.wait(timeout=5) + logger.info( + f"Process group (PGID: {pgid}) successfully terminated via SIGTERM." + ) + except subprocess.TimeoutExpired: + logger.warning("SIGTERM timed out, sending SIGKILL...") + try: + os.killpg(os.getpgid(pid), signal.SIGKILL) + self.camoufox_proc.wait(timeout=2) + logger.info( + "Process group successfully terminated via SIGKILL." + ) + except Exception: + pass + except ProcessLookupError: + logger.info("Process group not found, may have already exited.") + else: + # Fallback: Terminate process directly + self.camoufox_proc.terminate() + try: + self.camoufox_proc.wait(timeout=5) + logger.info("Process successfully terminated.") + except subprocess.TimeoutExpired: + self.camoufox_proc.kill() + logger.info("Process forcefully terminated.") + except Exception as e_term: + logger.warning(f"Error terminating process: {e_term}") + finally: + # Clean up streams + for stream in [self.camoufox_proc.stdout, self.camoufox_proc.stderr]: + if stream and not stream.closed: + try: + stream.close() + except Exception: + pass + self.camoufox_proc = None + elif self.camoufox_proc: + logger.info( + f"Camoufox internal subprocess previously exited on its own, exit code: {self.camoufox_proc.poll()}." + ) + self.camoufox_proc = None + else: + logger.info( + "Camoufox internal subprocess not running or already cleaned up." + ) + logger.info("--- Cleanup procedure completed (CamoufoxProcessManager) ---") diff --git a/launcher/runner.py b/launcher/runner.py new file mode 100644 index 0000000000000000000000000000000000000000..5060b610d9ce411fa6789b9d1e9bb8e759da5d4c --- /dev/null +++ b/launcher/runner.py @@ -0,0 +1,501 @@ +import atexit +import json +import logging +import os +import platform +import re +import shutil +import signal +import sys +import threading +import time +from typing import Optional + +import uvicorn + +from launcher.checks import check_dependencies, ensure_auth_dirs_exist +from launcher.config import ( + ACTIVE_AUTH_DIR, + DIRECT_LAUNCH, + SAVED_AUTH_DIR, + determine_proxy_configuration, + parse_args, +) +from launcher.internal import run_internal_camoufox +from launcher.logging_setup import setup_launcher_logging +from launcher.process import CamoufoxProcessManager +from launcher.utils import ( + find_pids_on_port, + input_with_timeout, + is_port_in_use, + kill_process_interactive, +) + +# Try to import launch_server (used for internal launch mode) +try: + from camoufox import DefaultAddons + from camoufox.server import launch_server +except Exception: + launch_server = None + DefaultAddons = None + +# Import server app +try: + from server import app +except ImportError: + app = None + +logger = logging.getLogger("CamoufoxLauncher") + + +class Launcher: # pragma: no cover + def __init__(self): + self.args = parse_args() + self.camoufox_manager = CamoufoxProcessManager() + atexit.register(self.camoufox_manager.cleanup) + self.final_launch_mode: str = "headless" + self.effective_active_auth_json_path: Optional[str] = None + self.simulated_os_for_camoufox = "linux" + + def run(self): + is_internal_call = self.args.internal_launch_mode is not None + + if is_internal_call: + if self.args.internal_launch_mode: + run_internal_camoufox(self.args, launch_server, DefaultAddons) + return + + log_level_str = os.environ.get("SERVER_LOG_LEVEL", "INFO").upper() + log_level = getattr(logging, log_level_str, logging.INFO) + setup_launcher_logging(log_level=log_level) + logger.info("[System] Launcher started") + ensure_auth_dirs_exist() + check_dependencies(launch_server is not None, DefaultAddons is not None) + + # Automatically check and rebuild frontend if needed + from launcher.frontend_build import ensure_frontend_built + + ensure_frontend_built(skip_build=self.args.skip_frontend_build) + + self._determine_launch_mode() + + if not DIRECT_LAUNCH: + self._handle_auth_file_selection() + self._check_xvfb() + self._check_server_port() + + logger.debug("[Init] Step 3: Preparing and starting Camoufox...") + self._resolve_auth_file_path() + + current_system_for_camoufox = platform.system() + if current_system_for_camoufox == "Linux": + self.simulated_os_for_camoufox = "linux" + elif current_system_for_camoufox == "Windows": + self.simulated_os_for_camoufox = "windows" + elif current_system_for_camoufox == "Darwin": + self.simulated_os_for_camoufox = "macos" + else: + logger.warning( + f"Unrecognized system '{current_system_for_camoufox}'. Defaulting Camoufox OS simulation to: {self.simulated_os_for_camoufox}" + ) + + mode_str = self.final_launch_mode.replace("_", " ").capitalize() + auth_name = ( + os.path.basename(self.effective_active_auth_json_path) + if self.effective_active_auth_json_path + else "None" + ) + logger.info( + f"[System] Configuration ready | Port: {self.args.server_port} | Mode: {mode_str} | Auth: {auth_name}" + ) + + captured_ws_endpoint = self.camoufox_manager.start( + self.final_launch_mode, + self.effective_active_auth_json_path, + self.simulated_os_for_camoufox, + self.args, + ) + + self._setup_helper_mode() + self._setup_environment_variables(captured_ws_endpoint) + self._start_server() + + logger.info("Launcher main logic completed") + + def _determine_launch_mode(self): + if self.args.debug: + self.final_launch_mode = "debug" + elif self.args.headless: + self.final_launch_mode = "headless" + elif self.args.virtual_display: + self.final_launch_mode = "virtual_headless" + if platform.system() != "Linux": + logger.warning( + "--virtual-display is designed for Linux. Behavior on other systems may be unpredictable." + ) + else: + env_launch_mode = os.environ.get("LAUNCH_MODE", "").lower() + default_mode_from_env = None + default_interactive_choice = "1" + + if env_launch_mode == "headless": + default_mode_from_env = "headless" + default_interactive_choice = "1" + elif env_launch_mode == "debug" or env_launch_mode == "normal": + default_mode_from_env = "debug" + default_interactive_choice = "2" + elif env_launch_mode in ("virtual_display", "virtual_headless"): + default_mode_from_env = "virtual_headless" + default_interactive_choice = ( + "3" if platform.system() == "Linux" else "1" + ) + + if DIRECT_LAUNCH: + self.final_launch_mode = default_mode_from_env or "headless" + return + + logger.info("--- Select Launch Mode (Not specified via CLI) ---") + if env_launch_mode and default_mode_from_env: + logger.info( + f" Default mode from .env: {env_launch_mode} -> {default_mode_from_env}" + ) + + prompt_options_text = "[1] Headless, [2] Debug" + valid_choices = {"1": "headless", "2": "debug"} + + if platform.system() == "Linux": + prompt_options_text += ", [3] Headless (Virtual Display Xvfb)" + valid_choices["3"] = "virtual_headless" + + default_mode_name = valid_choices.get( + default_interactive_choice, "headless" + ) + user_mode_choice = ( + input_with_timeout( + f" Enter launch mode ({prompt_options_text}; Default: {default_interactive_choice} {default_mode_name} mode, 15s timeout): ", + 15, + ) + or default_interactive_choice + ) + + if user_mode_choice in valid_choices: + self.final_launch_mode = valid_choices[user_mode_choice] + else: + self.final_launch_mode = default_mode_from_env or "headless" + logger.info( + f"Invalid input '{user_mode_choice}', using default: {self.final_launch_mode}" + ) + logger.debug(f"Selected launch mode: {self.final_launch_mode}") + + def _handle_auth_file_selection(self): + if self.final_launch_mode == "debug" and not self.args.active_auth_json: + create_new_auth_choice = ( + input_with_timeout( + " Create and save new auth file? (y/n; Default: n, 15s timeout): ", + 15, + ) + .strip() + .lower() + ) + if create_new_auth_choice == "y": + new_auth_filename = "" + while not new_auth_filename: + new_auth_filename_input = input_with_timeout( + " Enter filename to save (without .json, alphanumeric/_-): ", + self.args.auth_save_timeout, + ).strip() + if re.match(r"^[a-zA-Z0-9_-]+$", new_auth_filename_input): + new_auth_filename = new_auth_filename_input + elif new_auth_filename_input == "": + logger.info("Empty input, cancelling new auth creation.") + break + else: + print("Invalid characters, try again.") + + if new_auth_filename: + self.args.auto_save_auth = True + self.args.auto_save_auth_from_cli = True + self.args.save_auth_as = new_auth_filename + logger.info(f"Auth will be saved as: {new_auth_filename}.json") + if self.effective_active_auth_json_path: + self.effective_active_auth_json_path = None + else: + logger.info("New auth file will not be created.") + + def _check_xvfb(self): + if ( + self.final_launch_mode == "virtual_headless" + and platform.system() == "Linux" + ): + if not shutil.which("Xvfb"): + logger.error("Xvfb not found. Required for virtual display mode.") + sys.exit(1) + + def _check_server_port(self): + server_target_port = self.args.server_port + logger.info(f"--- Step 2: Checking if port {server_target_port} is in use ---") + port_is_available = False + uvicorn_bind_host = "0.0.0.0" + if is_port_in_use(server_target_port, host=uvicorn_bind_host): + logger.warning(f"Port {server_target_port} is currently in use.") + pids_on_port = find_pids_on_port(server_target_port) + if pids_on_port: + logger.warning(f"PIDs using port {server_target_port}: {pids_on_port}") + if self.final_launch_mode == "debug": + choice = ( + input_with_timeout( + " Try to kill these processes? (y/n, 15s timeout): ", + 15, + ) + .strip() + .lower() + ) + if choice == "y": + all(kill_process_interactive(pid) for pid in pids_on_port) + time.sleep(2) + if not is_port_in_use( + server_target_port, host=uvicorn_bind_host + ): + logger.info("Port is now available.") + port_is_available = True + else: + logger.error("Port still in use after kill attempt.") + else: + logger.error("Headless mode: will not auto-kill processes.") + if not port_is_available: + logger.warning("Continuing anyway, Uvicorn will handle binding.") + else: + logger.debug(f"[System] Port {server_target_port} is available") + + def _resolve_auth_file_path(self): + if self.args.active_auth_json: + candidate_path = os.path.expanduser(self.args.active_auth_json) + if os.path.isabs(candidate_path) and os.path.exists(candidate_path): + self.effective_active_auth_json_path = candidate_path + else: + path_rel_to_cwd = os.path.abspath(candidate_path) + if os.path.exists(path_rel_to_cwd): + self.effective_active_auth_json_path = path_rel_to_cwd + else: + path_rel_to_script = os.path.join( + os.path.dirname(os.path.dirname(__file__)), candidate_path + ) + if os.path.exists(path_rel_to_script): + self.effective_active_auth_json_path = path_rel_to_script + elif os.path.sep not in candidate_path: + for d in [ACTIVE_AUTH_DIR, SAVED_AUTH_DIR]: + p = os.path.join(d, candidate_path) + if os.path.exists(p): + self.effective_active_auth_json_path = p + break + + if self.effective_active_auth_json_path: + logger.info(f"Using auth file: {self.effective_active_auth_json_path}") + else: + logger.error(f"Auth file '{self.args.active_auth_json}' not found.") + sys.exit(1) + else: + if self.final_launch_mode != "debug": + try: + if os.path.exists(ACTIVE_AUTH_DIR): + active_json_files = sorted( + [ + f + for f in os.listdir(ACTIVE_AUTH_DIR) + if f.lower().endswith(".json") + ] + ) + if active_json_files: + self.effective_active_auth_json_path = os.path.join( + ACTIVE_AUTH_DIR, active_json_files[0] + ) + except Exception as e: + logger.warning(f"Error scanning active auth dir: {e}") + + if self.final_launch_mode == "debug" and not self.args.auto_save_auth: + available_profiles = [] + for d, label in [ + (ACTIVE_AUTH_DIR, "active"), + (SAVED_AUTH_DIR, "saved"), + ]: + if os.path.exists(d): + try: + for f in sorted(os.listdir(d)): + if f.lower().endswith(".json"): + available_profiles.append( + { + "name": f"{label}/{f}", + "path": os.path.join(d, f), + } + ) + except OSError: + pass + + if available_profiles: + available_profiles.sort(key=lambda x: x["name"]) + if DIRECT_LAUNCH: + self.effective_active_auth_json_path = available_profiles[0][ + "path" + ] + else: + print("-" * 60 + "\nAvailable auth files:") + for i, p in enumerate(available_profiles): + print(f"{i + 1}: {p['name']}") + print("N: None (use current browser state)\n" + "-" * 60) + choice = input_with_timeout( + "Select number (N/Enter for none, 30s): ", 30 + ) + if choice.strip().lower() not in ["n", ""]: + try: + idx = int(choice.strip()) - 1 + if 0 <= idx < len(available_profiles): + self.effective_active_auth_json_path = ( + available_profiles[idx]["path"] + ) + logger.info( + f"Selected: {available_profiles[idx]['name']}" + ) + except ValueError: + pass + else: + logger.info("No auth files found.") + elif ( + not self.effective_active_auth_json_path + and not self.args.auto_save_auth + ): + logger.error( + f"Headless mode error: no auth file found in {ACTIVE_AUTH_DIR}." + ) + sys.exit(1) + + def _setup_helper_mode(self): + if self.args.helper: + logger.info(f"Helper mode enabled, endpoint: {self.args.helper}") + os.environ["HELPER_ENDPOINT"] = self.args.helper + if self.effective_active_auth_json_path: + try: + with open( + self.effective_active_auth_json_path, "r", encoding="utf-8" + ) as f: + data = json.load(f) + for cookie in data.get("cookies", []): + if ( + cookie.get("name") == "SAPISID" + and cookie.get("domain") == ".google.com" + ): + os.environ["HELPER_SAPISID"] = cookie.get("value", "") + break + except Exception as e: + logger.warning(f"Failed to extract SAPISID: {e}") + + def _setup_environment_variables(self, captured_ws_endpoint): + if not captured_ws_endpoint: + logger.error("Critical error: WebSocket endpoint not captured.") + sys.exit(1) + + os.environ["CAMOUFOX_WS_ENDPOINT"] = captured_ws_endpoint + os.environ["LAUNCH_MODE"] = self.final_launch_mode + os.environ["SERVER_LOG_LEVEL"] = self.args.server_log_level.upper() + + # Only set these env vars if CLI flags were explicitly provided + # Otherwise, preserve existing env var values (from .env files) + if ( + hasattr(self.args, "server_redirect_print_from_cli") + and self.args.server_redirect_print_from_cli + ): + os.environ["SERVER_REDIRECT_PRINT"] = str( + self.args.server_redirect_print + ).lower() + if hasattr(self.args, "debug_logs_from_cli") and self.args.debug_logs_from_cli: + os.environ["DEBUG_LOGS_ENABLED"] = str(self.args.debug_logs).lower() + if hasattr(self.args, "trace_logs_from_cli") and self.args.trace_logs_from_cli: + os.environ["TRACE_LOGS_ENABLED"] = str(self.args.trace_logs).lower() + if self.effective_active_auth_json_path: + os.environ["ACTIVE_AUTH_JSON_PATH"] = self.effective_active_auth_json_path + if ( + self.final_launch_mode == "debug" + and hasattr(self.args, "auto_save_auth_from_cli") + and self.args.auto_save_auth_from_cli + ): + os.environ["AUTO_SAVE_AUTH"] = str(self.args.auto_save_auth).lower() + + if self.args.save_auth_as: + os.environ["SAVE_AUTH_FILENAME"] = self.args.save_auth_as + os.environ["AUTH_SAVE_TIMEOUT"] = str(self.args.auth_save_timeout) + os.environ["SERVER_PORT_INFO"] = str(self.args.server_port) + os.environ["STREAM_PORT"] = str(self.args.stream_port) + + proxy_config = determine_proxy_configuration(self.args.internal_camoufox_proxy) + if proxy_config["stream_proxy"]: + os.environ["UNIFIED_PROXY_CONFIG"] = proxy_config["stream_proxy"] + logger.info(f"Unified proxy set: {proxy_config['source']}") + + camoufox_os = self.simulated_os_for_camoufox.lower() + host_os_map = {"macos": "Darwin", "windows": "Windows", "linux": "Linux"} + if camoufox_os in host_os_map: + os.environ["HOST_OS_FOR_SHORTCUT"] = host_os_map[camoufox_os] + + def _start_server(self): + logger.info( + f"--- Step 5: Starting integrated FastAPI server on port {self.args.server_port} ---" + ) + if app is None: + logger.error("FastAPI app not found.") + sys.exit(1) + + if not self.args.exit_on_auth_save: + try: + uvicorn.run( + app, host="0.0.0.0", port=self.args.server_port, log_config=None + ) + except Exception as e: + logger.critical(f"Uvicorn error: {e}", exc_info=True) + sys.exit(1) + else: + server_config = uvicorn.Config( + app, host="0.0.0.0", port=self.args.server_port, log_config=None + ) + server = uvicorn.Server(server_config) + stop_watcher = threading.Event() + + def watch_for_saved_auth_and_shutdown(): + os.makedirs(SAVED_AUTH_DIR, exist_ok=True) + initial_files = set(os.listdir(SAVED_AUTH_DIR)) + while not stop_watcher.is_set(): + try: + new_files = set(os.listdir(SAVED_AUTH_DIR)) - initial_files + if new_files: + logger.info( + f"New auth file detected: {', '.join(new_files)}. Shutting down in 3s..." + ) + time.sleep(3) + server.should_exit = True + break + except Exception: + pass + if stop_watcher.wait(1): + break + + watcher_thread = threading.Thread(target=watch_for_saved_auth_and_shutdown) + try: + watcher_thread.start() + server.run() + except Exception as e: + logger.critical(f"Uvicorn error: {e}", exc_info=True) + sys.exit(1) + finally: + stop_watcher.set() + if watcher_thread.is_alive(): + watcher_thread.join() + + +def signal_handler(sig, frame): + sys.exit(0) + + +signal.signal(signal.SIGINT, signal_handler) +signal.signal(signal.SIGTERM, signal_handler) + + +def cleanup(): + pass diff --git a/launcher/utils.py b/launcher/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..a08992bba3084a33e3616eac583f4c844c64bbd4 --- /dev/null +++ b/launcher/utils.py @@ -0,0 +1,270 @@ +import logging +import platform +import select +import socket +import subprocess +import sys +import threading +from typing import List, Optional + +logger = logging.getLogger("CamoufoxLauncher") + + +def is_port_in_use(port: int, host: str = "0.0.0.0") -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind((host, port)) + return False + except OSError: + return True + except Exception as e: + logger.warning(f"Unknown error checking port {port} (host {host}): {e}") + return True + + +def find_pids_on_port(port: int) -> List[int]: + pids: List[int] = [] + system_platform = platform.system() + command = "" + try: + if system_platform == "Linux" or system_platform == "Darwin": + command = f"lsof -ti :{port} -sTCP:LISTEN" + process = subprocess.Popen( + command, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + close_fds=True, + ) + stdout, stderr = process.communicate(timeout=5) + if process.returncode == 0 and stdout: + pids = [int(pid) for pid in stdout.strip().split("\n") if pid.isdigit()] + elif process.returncode != 0 and ( + "command not found" in stderr.lower() or "未找到命令" in stderr + ): + logger.error("Command 'lsof' not found. Please ensure it is installed.") + elif process.returncode not in [0, 1]: # lsof returns 1 when not found + logger.warning( + f"Failed to execute lsof command (return code {process.returncode}): {stderr.strip()}" + ) + elif system_platform == "Windows": + command = f'netstat -ano -p TCP | findstr "LISTENING" | findstr ":{port} "' + process = subprocess.Popen( + command, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + stdout, stderr = process.communicate(timeout=10) + if process.returncode == 0 and stdout: + for line in stdout.strip().split("\n"): + parts = line.split() + if ( + len(parts) >= 4 + and parts[0].upper() == "TCP" + and f":{port}" in parts[1] + ): + if parts[-1].isdigit(): + pids.append(int(parts[-1])) + pids = list(set(pids)) # Deduplicate + elif process.returncode not in [0, 1]: # findstr returns 1 when not found + logger.warning( + f"Failed to execute netstat/findstr command (return code {process.returncode}): {stderr.strip()}" + ) + else: + logger.warning( + f"Unsupported operating system '{system_platform}' for finding processes on port." + ) + except FileNotFoundError: + cmd_name = command.split()[0] if command else "required tool" + logger.error(f"Command '{cmd_name}' not found.") + except subprocess.TimeoutExpired: + logger.error(f"Command '{command}' timed out.") + except Exception as e: + logger.error(f"Error finding processes on port {port}: {e}", exc_info=True) + return pids + + +def kill_process_interactive(pid: int) -> bool: + system_platform = platform.system() + success = False + logger.info(f"Attempting to terminate process PID: {pid}...") + try: + if system_platform == "Linux" or system_platform == "Darwin": + result_term = subprocess.run( + f"kill {pid}", + shell=True, + capture_output=True, + text=True, + timeout=3, + check=False, + ) + if result_term.returncode == 0: + logger.info(f"SIGTERM signal sent to PID {pid}.") + success = True + else: + logger.warning( + f" PID {pid} SIGTERM failed: {result_term.stderr.strip() or result_term.stdout.strip()}. Trying SIGKILL..." + ) + result_kill = subprocess.run( + f"kill -9 {pid}", + shell=True, + capture_output=True, + text=True, + timeout=3, + check=False, + ) + if result_kill.returncode == 0: + logger.info(f"SIGKILL signal sent to PID {pid}.") + success = True + else: + logger.error( + f" ✗ PID {pid} SIGKILL failed: {result_kill.stderr.strip() or result_kill.stdout.strip()}." + ) + elif system_platform == "Windows": + command_desc = f"taskkill /PID {pid} /T /F" + result = subprocess.run( + command_desc, + shell=True, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + output = result.stdout.strip() + error_output = result.stderr.strip() + if result.returncode == 0 and ( + "SUCCESS" in output.upper() or "成功" in output + ): + logger.info(f"PID {pid} terminated via taskkill /F.") + success = True + elif ( + "could not find process" in error_output.lower() + or "找不到" in error_output + ): # Process may have already exited + logger.info( + f"PID {pid} not found when executing taskkill (may have exited)." + ) + success = True # Treat as success since goal is port availability + else: + # Count errors rather than outputting each one + combined = (error_output + " " + output).strip() + error_count = combined.count("ERROR:") + if error_count > 0: + logger.warning( + f" PID {pid} taskkill /F: (suppressed {error_count} error messages)" + ) + else: + logger.warning(f"PID {pid} taskkill /F returned non-zero status") + else: + logger.warning( + f"Unsupported operating system '{system_platform}' for terminating processes." + ) + except Exception as e: + logger.error(f"Unexpected error terminating PID {pid}: {e}", exc_info=True) + return success + + +def input_with_timeout(prompt_message: str, timeout_seconds: int = 30) -> str: + print(prompt_message, end="", flush=True) + if sys.platform == "win32": + user_input_container: List[Optional[str]] = [None] + + def get_input_in_thread(): + try: + user_input_container[0] = sys.stdin.readline().strip() + except Exception: + user_input_container[0] = "" # Return empty string on error + + input_thread = threading.Thread(target=get_input_in_thread, daemon=True) + input_thread.start() + input_thread.join(timeout=timeout_seconds) + if input_thread.is_alive(): + print("\nInput timed out. Using default value.", flush=True) + return "" + return user_input_container[0] if user_input_container[0] is not None else "" + else: # Linux/macOS + readable_fds, _, _ = select.select([sys.stdin], [], [], timeout_seconds) + if readable_fds: + return sys.stdin.readline().strip() + else: + print("\nInput timed out. Using default value.", flush=True) + return "" + + +def get_proxy_from_gsettings() -> Optional[str]: + """ + Retrieves the proxy settings from GSettings on Linux systems. + Returns a proxy string like "http://host:port" or None. + """ + + def _run_gsettings_command(command_parts: List[str]) -> Optional[str]: + """Helper function to run gsettings command and return cleaned string output.""" + try: + process_result = subprocess.run( + command_parts, + capture_output=True, + text=True, + check=False, # Do not raise CalledProcessError for non-zero exit codes + timeout=1, # Timeout for the subprocess call + ) + if process_result.returncode == 0: + value = process_result.stdout.strip() + if value.startswith("'") and value.endswith( + "'" + ): # Remove surrounding single quotes + value = value[1:-1] + + # If after stripping quotes, value is empty, or it's a gsettings "empty" representation + if not value or value == "''" or value == "@as []" or value == "[]": + return None + return value + else: + return None + except subprocess.TimeoutExpired: + return None + except Exception: # Broad exception as per pseudocode + return None + + proxy_mode = _run_gsettings_command( + ["gsettings", "get", "org.gnome.system.proxy", "mode"] + ) + + if proxy_mode == "manual": + # Try HTTP proxy first + http_host = _run_gsettings_command( + ["gsettings", "get", "org.gnome.system.proxy.http", "host"] + ) + http_port_str = _run_gsettings_command( + ["gsettings", "get", "org.gnome.system.proxy.http", "port"] + ) + + if http_host and http_port_str: + try: + http_port = int(http_port_str) + if http_port > 0: + return f"http://{http_host}:{http_port}" + except ValueError: + pass # Continue to HTTPS + + # Try HTTPS proxy if HTTP not found or invalid + https_host = _run_gsettings_command( + ["gsettings", "get", "org.gnome.system.proxy.https", "host"] + ) + https_port_str = _run_gsettings_command( + ["gsettings", "get", "org.gnome.system.proxy.https", "port"] + ) + + if https_host and https_port_str: + try: + https_port = int(https_port_str) + if https_port > 0: + # Note: Even for HTTPS proxy settings, the scheme for Playwright/requests is usually http:// + return f"http://{https_host}:{https_port}" + except ValueError: + pass + + return None diff --git a/logging_utils/__init__.py b/logging_utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..085082f6e7fa6cd6b3b27dc899f8a40813f33d19 --- /dev/null +++ b/logging_utils/__init__.py @@ -0,0 +1,80 @@ +# Logging utilities +# Grid logging system v2.0 +from .core.error_handler import ( + install_asyncio_handler_on_loop, + log_error, + setup_global_exception_handlers, +) +from .grid_logger import ( + # Source mapping + SOURCE_MAP, + # Classes + AbortErrorFilter, + BrowserNoiseFilter, + BurstBuffer, + Colors, + Columns, + GridFormatter, + # JSON formatter for structured logging + JSONFormatter, + PlainGridFormatter, + ProgressLine, + SemanticHighlighter, + # Utility functions + flush_burst_buffer, + format_object, + get_request_id, + get_source, + # Context managers + log_context, + log_object, + normalize_source, + request_context, + # Context variables + request_id_var, + set_request_id, + set_source, + setup_grid_logging, + source_var, +) +from .setup import restore_original_streams, setup_server_logging + +__all__ = [ + # Legacy setup + "setup_server_logging", + "restore_original_streams", + # Grid logger + "setup_grid_logging", + "GridFormatter", + "PlainGridFormatter", + "AbortErrorFilter", + "BrowserNoiseFilter", + "Colors", + "Columns", + "SemanticHighlighter", + "ProgressLine", + "BurstBuffer", + # Context managers + "log_context", + "request_context", + # Context variables + "request_id_var", + "source_var", + # Source mapping + "SOURCE_MAP", + "normalize_source", + # Utility functions + "set_source", + "set_request_id", + "get_source", + "get_request_id", + "format_object", + "log_object", + "flush_burst_buffer", + # JSON formatter + "JSONFormatter", + # Error handling utilities + "log_error", + "setup_global_exception_handlers", + "install_asyncio_handler_on_loop", +] diff --git a/logging_utils/core/constants.py b/logging_utils/core/constants.py new file mode 100644 index 0000000000000000000000000000000000000000..802a8f3e74cbb23e4565a192f079b2375a817d3f --- /dev/null +++ b/logging_utils/core/constants.py @@ -0,0 +1,172 @@ +""" +Logging Constants +""" + +import sys +from typing import Dict + +from colorama import Fore, Style +from colorama import init as colorama_init + +# ============================================================================= +# Initialize colorama for Windows compatibility +# ============================================================================= +colorama_init(autoreset=False) + +# Enable Windows 10+ ANSI support +if sys.platform == "win32": + try: + import ctypes + + kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] + kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) + except Exception: + pass # Graceful fallback + + +# ============================================================================= +# Source Mapping (Normalize to 5-letter codes) +# ============================================================================= + +SOURCE_MAP: Dict[str, str] = { + # API/Server sources + "api": "API ", + "server": "SERVR", + "system": "SYS ", + "sys": "SYS ", + # Worker/Queue sources + "worker": "WORKR", + "workr": "WORKR", + "processo": "WORKR", + "processor": "WORKR", + "queue": "QUEUE", + "queue_worker": "WORKR", + # Proxy/Stream sources + "proxy": "PROXY", + "proxy_server": "PROXY", + "proxyserver": "PROXY", + "stream": "STRM ", + "inter": "INTER", + "http_interceptor": "INTER", + "interceptor": "INTER", + # Browser sources + "browser": "BROWR", + "browr": "BROWR", + "page": "PAGE ", + "ui": "UI ", + # Launcher sources + "launcher": "LNCHR", + "lnchr": "LNCHR", + "camoufoxlauncher": "LNCHR", + # Auth sources + "auth": "AUTH ", + # Config sources + "config": "CONFG", + # Network sources + "net": "NET ", + "network": "NET ", + # Model management + "model": "MODEL", + # Debug + "debug": "DEBUG", +} + + +# ============================================================================= +# Column Configuration +# ============================================================================= + + +class Columns: + """Fixed column widths for grid alignment.""" + + TIME = 12 # HH:MM:SS.mmm + LEVEL = 3 # INF, WRN, ERR, DBG, CRT + SOURCE = 5 # Fixed 5-letter source code + ID = 7 # Request ID (truncated/padded) + TREE_INDENT = 3 # Each tree level width + + +# ============================================================================= +# Color Definitions +# ============================================================================= + + +class Colors: + """Centralized color definitions for consistent theming.""" + + # Reset + RESET = Style.RESET_ALL + + # Time column - dim/subtle (dark grey) + TIME = Style.DIM + Fore.WHITE + + # Level colors (high contrast) + LEVELS: Dict[str, str] = { + "DEBUG": Style.DIM + Fore.CYAN, + "INFO": Fore.WHITE, + "WARNING": Fore.YELLOW + Style.BRIGHT, + "ERROR": Fore.RED + Style.BRIGHT, + "CRITICAL": Fore.RED + Style.BRIGHT, + } + + # Level abbreviations + LEVEL_ABBREV: Dict[str, str] = { + "DEBUG": "DBG", + "INFO": "INF", + "WARNING": "WRN", + "ERROR": "ERR", + "CRITICAL": "CRT", + } + + # Source colors (distinct, pastel-ish) + SOURCES: Dict[str, str] = { + "API ": Fore.LIGHTBLUE_EX, + "SERVR": Fore.MAGENTA, + "SYS ": Style.DIM + Fore.WHITE, + "WORKR": Fore.LIGHTYELLOW_EX, + "QUEUE": Fore.YELLOW, + "PROXY": Fore.BLUE, + "STRM ": Fore.GREEN, + "INTER": Fore.LIGHTCYAN_EX, + "BROWR": Fore.CYAN, + "PAGE ": Fore.CYAN, + "UI ": Fore.LIGHTMAGENTA_EX, + "LNCHR": Fore.LIGHTGREEN_EX, + "AUTH ": Fore.LIGHTRED_EX, + "CONFG": Style.DIM + Fore.WHITE, + "NET ": Fore.LIGHTCYAN_EX, + "MODEL": Fore.LIGHTMAGENTA_EX, + "DEBUG": Style.DIM + Fore.CYAN, + } + + # Request ID - dim + REQUEST_ID = Style.DIM + Fore.WHITE + + # Tree structure - dim + TREE = Style.DIM + Fore.WHITE + + # Message - default white + MESSAGE = Fore.WHITE + + # Semantic highlighting (updated for new scheme) + STRING = Fore.CYAN # Strings/IDs: Cyan + NUMBER = Fore.MAGENTA # Numbers: Magenta + BOOLEAN_TRUE = Fore.GREEN # True: Green + BOOLEAN_FALSE = Fore.RED # False: Red + BOOLEAN_NONE = Style.DIM + Fore.WHITE # None: Dim + URL = Style.DIM + Fore.BLUE # URLs: Faint Blue + KEY = Fore.LIGHTBLUE_EX # Keys in key:value + TAG = Style.BRIGHT + Fore.WHITE # Tags like [UI] + + # Key phrases (Bold) + PHRASE_ERROR = Style.BRIGHT + Fore.RED + PHRASE_FAILED = Style.BRIGHT + Fore.RED + PHRASE_SUCCESS = Style.BRIGHT + Fore.GREEN + PHRASE_WARNING = Style.BRIGHT + Fore.YELLOW + + # Burst count indicator + BURST_COUNT = Fore.YELLOW + + # Separator (unused now) + SEPARATOR = Style.DIM + Fore.WHITE diff --git a/logging_utils/core/context.py b/logging_utils/core/context.py new file mode 100644 index 0000000000000000000000000000000000000000..903b58e3af928261d26429f536ff639c54bbcbfc --- /dev/null +++ b/logging_utils/core/context.py @@ -0,0 +1,15 @@ +""" +Logging Context Variables +""" + +from contextvars import ContextVar + +# ============================================================================= +# Context Variables (Thread-safe request tracking) +# ============================================================================= + +# Request ID for the current context (e.g., 'akvdate') +request_id_var: ContextVar[str] = ContextVar("request_id", default=" ") + +# Source identifier for the current context (e.g., 'SERVER', 'PROXY') +source_var: ContextVar[str] = ContextVar("source", default="SYS") diff --git a/logging_utils/core/error_handler.py b/logging_utils/core/error_handler.py new file mode 100644 index 0000000000000000000000000000000000000000..f55c7b160c12d17a383ff1473b9a3b9cd5dae183 --- /dev/null +++ b/logging_utils/core/error_handler.py @@ -0,0 +1,234 @@ +# -*- coding: utf-8 -*- +""" +Centralized Error Handling Utilities +===================================== +Provides consistent error logging, global exception handlers, and +optional error snapshot integration for robust debugging. +""" + +import asyncio +import logging +import threading +from typing import Optional + +from .context import request_id_var, source_var + + +def log_error( + logger: logging.Logger, + message: str, + exception: Optional[BaseException] = None, + *, + save_snapshot: bool = False, + req_id: str = "", + exc_info: bool = True, +) -> None: + """ + Centralized error logging with automatic stack trace capture. + + This function ensures consistent error logging across the codebase: + - Always includes exc_info for full stack traces + - Optionally saves error snapshots for debugging + - Integrates with request context for tracing + + Args: + logger: Logger instance to use + message: Error message to log + exception: Optional exception object (used for snapshot) + save_snapshot: Whether to save an error snapshot (screenshot, DOM, etc.) + req_id: Optional request ID override (uses context var if empty) + exc_info: Whether to include exception info (default True) + + Usage: + try: + risky_operation() + except Exception as e: + log_error(logger, f"Operation failed: {e}", e, save_snapshot=True) + """ + # Get request ID from context if not provided + if not req_id: + try: + req_id = request_id_var.get() + except LookupError: + req_id = "unknown" + + # Log with exc_info for full stack trace + logger.error(message, exc_info=exc_info) + + # Save error snapshot if requested + if save_snapshot: + try: + # Lazy import to avoid circular dependencies + from browser_utils.debug_utils import save_error_snapshot_enhanced + + # Generate error name from message (first 30 chars, sanitized) + error_name = ( + message[:30].replace(" ", "_").replace(":", "").replace("/", "_") + ) + # This is async, but we're in sync context - schedule it + try: + loop = asyncio.get_running_loop() + loop.create_task( + save_error_snapshot_enhanced( + error_name=error_name, + error_exception=exception + if isinstance(exception, Exception) + else None, + error_stage="log_error", + ) + ) + except RuntimeError: + # No running event loop - can't save async snapshot + logger.debug( + "[Snapshot] Cannot save error snapshot: no running event loop" + ) + except ImportError: + # debug_utils not available (e.g., in tests or standalone mode) + pass + except Exception as snapshot_err: + # Don't let snapshot failures break the main error handling + logger.debug(f"[Snapshot] Failed to save error snapshot: {snapshot_err}") + + +def _asyncio_exception_handler(loop: asyncio.AbstractEventLoop, context: dict) -> None: + """ + Global asyncio exception handler for uncaught exceptions in tasks. + + This catches exceptions that would otherwise be silently ignored when: + - A Task raises an exception but is never awaited + - A callback raises an exception + + Args: + loop: The event loop that caught the exception + context: Exception context dict with 'message', 'exception', etc. + """ + # Skip logging during Python shutdown to avoid ImportError crashes + import sys + + if sys.meta_path is None or sys.modules is None: + return + + # Also check if logging module is still available + try: + import logging as _check + + del _check + except (ImportError, TypeError): + return + + logger = logging.getLogger("AIStudioProxyServer") + + # Extract exception info + exception = context.get("exception") + message = context.get("message", "Unhandled exception in asyncio task") + + # Get additional context + task = context.get("task") + future = context.get("future") + + # Build detailed error message + error_parts = [f"[ASYNCIO EXCEPTION] {message}"] + + if task is not None: + error_parts.append( + f"Task: {task.get_name() if hasattr(task, 'get_name') else repr(task)}" + ) + + if future is not None and future is not task: + error_parts.append(f"Future: {repr(future)}") + + # Get source from context if available + try: + source = source_var.get() + error_parts.append(f"Source: {source}") + except LookupError: + pass + + try: + req_id = request_id_var.get() + if req_id.strip(): + error_parts.append(f"Request ID: {req_id}") + except LookupError: + pass + + full_message = " | ".join(error_parts) + + if exception is not None: + # Log with full traceback + logger.error( + full_message, exc_info=(type(exception), exception, exception.__traceback__) + ) + else: + logger.error(full_message) + + +def _threading_exception_handler(args: threading.ExceptHookArgs) -> None: + """ + Global threading exception handler for uncaught exceptions in threads. + + Args: + args: ExceptHookArgs containing exc_type, exc_value, exc_traceback, thread + """ + logger = logging.getLogger("AIStudioProxyServer") + + thread_name = args.thread.name if args.thread else "unknown" + + error_message = f"[THREAD EXCEPTION] Uncaught exception in thread '{thread_name}'" + + if args.exc_value is not None: + logger.error( + error_message, + exc_info=(args.exc_type, args.exc_value, args.exc_traceback), + ) + else: + logger.error(error_message) + + +def setup_global_exception_handlers( + *, install_asyncio: bool = True, install_threading: bool = True +) -> None: + """ + Install global exception handlers for asyncio and threading. + + This ensures that uncaught exceptions in background tasks and threads + are properly logged instead of being silently ignored. + + Should be called once during application startup. + + Args: + install_asyncio: Install asyncio exception handler + install_threading: Install threading exception handler + + Usage: + # In application startup + setup_global_exception_handlers() + """ + logger = logging.getLogger("AIStudioProxyServer") + + if install_asyncio: + try: + loop = asyncio.get_running_loop() + loop.set_exception_handler(_asyncio_exception_handler) + logger.debug("[Init] Global exception handlers installed (Asyncio)") + except RuntimeError: + # No running event loop yet - will be installed when loop starts + # This is common during module import + pass + + if install_threading: + # Python 3.8+ + if hasattr(threading, "excepthook"): + threading.excepthook = _threading_exception_handler + logger.debug("[Init] Global exception handlers installed (Threading)") + + +def install_asyncio_handler_on_loop(loop: asyncio.AbstractEventLoop) -> None: + """ + Install the asyncio exception handler on a specific event loop. + + Use this when you need to install the handler after the loop is created. + + Args: + loop: The event loop to install the handler on + """ + loop.set_exception_handler(_asyncio_exception_handler) diff --git a/logging_utils/core/logger.py b/logging_utils/core/logger.py new file mode 100644 index 0000000000000000000000000000000000000000..df6f0c0fb51ea00b1167f3f3b4534db40c170d70 --- /dev/null +++ b/logging_utils/core/logger.py @@ -0,0 +1,228 @@ +""" +Core Logger Module +""" + +import logging +import sys +from contextlib import contextmanager +from typing import Any, Generator, Optional + +from colorama import init as colorama_init + +from logging_utils.core.context import ( + request_id_var, + source_var, +) +from logging_utils.core.rendering import ( + GridFormatter, + _burst_buffer, + format_object, +) + +# ============================================================================= +# Logging Filters +# ============================================================================= + + +class BrowserNoiseFilter(logging.Filter): + """Filter out benign browser noise (AbortError, CORS, Google logging, SSL).""" + + # Patterns to filter out + NOISE_PATTERNS = [ + "AbortError: The operation was aborted", # Playwright navigation cancellation + "Cross-Origin Request Blocked", # CORS errors (usually harmless) + "play.google.com/log", # Google's internal logging endpoint + "APPLICATION_DATA_AFTER_CLOSE_NOTIFY", # SSL shutdown warning (harmless) + ] + + def filter(self, record: logging.LogRecord) -> bool: + """Return False to drop the log record, True to keep it.""" + message = record.getMessage() + for pattern in self.NOISE_PATTERNS: + if pattern in message: + return False + return True + + +# Legacy alias for backwards compatibility +AbortErrorFilter = BrowserNoiseFilter + + +# ============================================================================= +# Context Managers +# ============================================================================= + + +@contextmanager +def log_context( + name: str, + logger: Optional[logging.Logger] = None, + source: Optional[str] = None, + silent: bool = False, +) -> Generator[None, None, None]: + """ + Context manager for source switching (simplified - no tree tracking). + + Usage: + with log_context("Processing request", logger): + logger.info("Step 1") + + With silent=True, no header is logged: + with log_context("", logger, silent=True): + logger.info("Some work") + """ + if logger is None: + logger = logging.getLogger() + + # Handle optional source change + source_token = None + if source is not None: + source_token = source_var.set(source) + + # Log the context entry (unless silent) + if not silent and name: + logger.info(name) + + try: + yield + finally: + # Restore previous source + if source_token is not None: + source_var.reset(source_token) + + +@contextmanager +def request_context( + request_id: str, source: str = "WORKR" +) -> Generator[None, None, None]: + """ + Context manager for request lifecycle. + + Sets request ID and source for all logs within the context. + + Usage: + with request_context("akvdate", source="WORKR"): + logger.info("Processing...") + """ + # Set context variables + id_token = request_id_var.set(request_id) + source_token = source_var.set(source) + + try: + yield + finally: + # Reset context variables + request_id_var.reset(id_token) + source_var.reset(source_token) + + +# ============================================================================= +# Convenience Functions +# ============================================================================= + + +def set_source(source: str) -> None: + """Set the source identifier for subsequent logs.""" + source_var.set(source) + + +def set_request_id(request_id: str) -> None: + """Set the request ID for subsequent logs.""" + request_id_var.set(request_id) + + +def get_source() -> str: + """Get the current source identifier.""" + try: + return source_var.get() + except LookupError: + return "SYS" + + +def get_request_id() -> str: + """Get the current request ID.""" + try: + return request_id_var.get() + except LookupError: + return " " + + +def flush_burst_buffer() -> None: + """Flush any remaining burst-suppressed messages.""" + result = _burst_buffer.flush() + if result: + print(result) + + +def log_object( + logger: logging.Logger, obj: Any, label: str = "Data", level: int = logging.INFO +) -> None: + """ + Log an object with YAML-style formatting. + + Args: + logger: Logger instance to use + obj: Object to dump (dict, list, etc.) + label: Label for the data block + level: Logging level to use + """ + logger.log(level, f"{label}:") + formatted = format_object(obj, indent=1) + for line in formatted.split("\n"): + if line.strip(): + logger.log(level, line) + + +# ============================================================================= +# Logger Setup +# ============================================================================= + + +def setup_grid_logging( + level: int = logging.DEBUG, + show_tree: bool = True, + colorize: bool = True, + burst_suppression: bool = True, + logger_name: Optional[str] = None, +) -> logging.Logger: + """ + Configure the logging system with grid formatting. + + Args: + level: Logging level (default: DEBUG) + show_tree: Whether to show tree structure (default: True) + colorize: Whether to apply colors (default: True) + burst_suppression: Whether to suppress duplicate messages (default: True) + logger_name: Optional logger name (default: root logger) + + Returns: + Configured logger instance + """ + # Initialize colorama + colorama_init(autoreset=False) + + # Get logger + if logger_name: + logger = logging.getLogger(logger_name) + else: + logger = logging.getLogger() + + logger.setLevel(level) + + # Remove existing handlers to avoid duplicates + for handler in logger.handlers[:]: + logger.removeHandler(handler) + + # Create console handler + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(level) + + # Apply grid formatter + formatter = GridFormatter( + show_tree=show_tree, colorize=colorize, burst_suppression=burst_suppression + ) + console_handler.setFormatter(formatter) + + logger.addHandler(console_handler) + + return logger diff --git a/logging_utils/core/rendering.py b/logging_utils/core/rendering.py new file mode 100644 index 0000000000000000000000000000000000000000..b8971cb5bd477663c30df6b194f90a8d53536ca2 --- /dev/null +++ b/logging_utils/core/rendering.py @@ -0,0 +1,674 @@ +""" +Rendering Logic for Grid Logger +""" + +import logging +import re +import sys +import threading +import time +from datetime import datetime +from typing import Any, List, Optional, Tuple + +from colorama import Fore, Style + +from .constants import SOURCE_MAP, Colors, Columns +from .context import ( + request_id_var, + source_var, +) + + +def normalize_source(source: str) -> str: + """Normalize source name to fixed 5-letter code.""" + key = source.lower().replace(" ", "_").replace("-", "_") + if key in SOURCE_MAP: + return SOURCE_MAP[key] + # Try partial match + for map_key, map_val in SOURCE_MAP.items(): + if map_key in key or key in map_key: + return map_val + # Default: first 5 chars, uppercase, padded + return source[:5].upper().ljust(5) + + +# ============================================================================= +# Semantic Highlighter (Enhanced) +# ============================================================================= + + +class SemanticHighlighter: + """Applies semantic coloring to log message content.""" + + # Compiled regex patterns for efficiency + TAG_PATTERN = re.compile(r"^\[([A-Z]{2,10})\]\s*") + STRING_PATTERN = re.compile(r"'([^']*)'|\"([^\"]*)\"") + BOOLEAN_PATTERN = re.compile(r"\b(True|False|None)\b") + NUMBER_PATTERN = re.compile(r"\b(\d+\.?\d*(?:ms|s|kb|mb|gb|KB|MB|GB|%|px)?)\b") + HEX_PATTERN = re.compile(r"\b(0x[0-9a-fA-F]+)\b") + URL_PATTERN = re.compile( + r"(https?://[^\s]+|[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(?::\d+)?(?:/[^\s]*)?)" + ) + # Key phrases to bold + PHRASE_ERROR = re.compile(r"\b(Error|ERROR|error)\b") + PHRASE_FAILED = re.compile(r"\b(Failed|FAILED|failed|Failure|failure)\b") + PHRASE_SUCCESS = re.compile( + r"\b(Success|SUCCESS|success|Successful|successful|Complete|complete|Completed|completed)\b" + ) + PHRASE_WARNING = re.compile(r"\b(Warning|WARNING|warning)\b") + # Status phrases - Success patterns (Green) + PHRASE_MATCHES = re.compile(r"\((Matches page|Cached|Matches)\)") + # Status phrases - Action patterns (Yellow) + PHRASE_UPDATING = re.compile(r"\((Updating|Toggling|Loading)\.\.\.\)") + # Model IDs (gemini-*, claude-*, etc.) + MODEL_ID_PATTERN = re.compile( + r"\b(gemini-[\w.-]+|claude-[\w.-]+|gpt-[\w.-]+|veo-[\w.-]+)\b" + ) + # Request IDs (7-char alphanumeric) + REQ_ID_PATTERN = re.compile(r"\b([a-z0-9]{7})\b") + + @classmethod + def highlight(cls, text: str, colorize: bool = True) -> str: + """Apply semantic highlighting to text.""" + if not colorize: + return text + + result = text + + # Handle tags at the start (e.g., [UI], [NET], [SYS]) + tag_match = cls.TAG_PATTERN.match(result) + if tag_match: + full_tag = tag_match.group(0) + tag_name = tag_match.group(1) + # Use source color if available, otherwise use TAG color + normalized = normalize_source(tag_name) + tag_color = Colors.SOURCES.get(normalized, Colors.TAG) + colored_tag = f"{tag_color}[{tag_name}]{Colors.RESET} " + result = colored_tag + result[len(full_tag) :] + + # Highlight URLs first (before strings, to avoid conflict) + def replace_url(match: re.Match[str]) -> str: + return f"{Colors.URL}{Style.DIM}{match.group(1)}{Colors.RESET}" + + result = cls.URL_PATTERN.sub(replace_url, result) + + # Highlight model IDs + def replace_model_id(match: re.Match[str]) -> str: + return f"{Colors.STRING}{match.group(1)}{Colors.RESET}" + + result = cls.MODEL_ID_PATTERN.sub(replace_model_id, result) + + # Highlight quoted strings + def replace_string(match: re.Match[str]) -> str: + # Match group 1 is single-quote, group 2 is double-quote + content = match.group(1) if match.group(1) else match.group(2) + quote = "'" if match.group(1) else '"' + return f"{Colors.STRING}{quote}{content}{quote}{Colors.RESET}" + + result = cls.STRING_PATTERN.sub(replace_string, result) + + # Highlight booleans with distinct colors + def replace_boolean(match: re.Match[str]) -> str: + val = match.group(1) + if val == "True": + return f"{Colors.BOOLEAN_TRUE}{val}{Colors.RESET}" + elif val == "False": + return f"{Colors.BOOLEAN_FALSE}{val}{Colors.RESET}" + else: # None + return f"{Colors.BOOLEAN_NONE}{val}{Colors.RESET}" + + result = cls.BOOLEAN_PATTERN.sub(replace_boolean, result) + + # Highlight numbers (avoid matching inside ANSI codes) + def replace_number(match: re.Match[str]) -> str: + return f"{Colors.NUMBER}{match.group(1)}{Colors.RESET}" + + result = cls.NUMBER_PATTERN.sub(replace_number, result) + + # Highlight hex numbers + def replace_hex(match: re.Match[str]) -> str: + return f"{Colors.NUMBER}{match.group(1)}{Colors.RESET}" + + result = cls.HEX_PATTERN.sub(replace_hex, result) + + # Bold key phrases + def replace_error(match: re.Match[str]) -> str: + return f"{Colors.PHRASE_ERROR}{match.group(1)}{Colors.RESET}" + + result = cls.PHRASE_ERROR.sub(replace_error, result) + + def replace_failed(match: re.Match[str]) -> str: + return f"{Colors.PHRASE_FAILED}{match.group(1)}{Colors.RESET}" + + result = cls.PHRASE_FAILED.sub(replace_failed, result) + + def replace_success(match: re.Match[str]) -> str: + return f"{Colors.PHRASE_SUCCESS}{match.group(1)}{Colors.RESET}" + + result = cls.PHRASE_SUCCESS.sub(replace_success, result) + + def replace_warning(match: re.Match[str]) -> str: + return f"{Colors.PHRASE_WARNING}{match.group(1)}{Colors.RESET}" + + result = cls.PHRASE_WARNING.sub(replace_warning, result) + + # Highlight status phrases - Success (Green) + def replace_matches(match: re.Match[str]) -> str: + return f"{Colors.PHRASE_SUCCESS}{match.group(0)}{Colors.RESET}" + + result = cls.PHRASE_MATCHES.sub(replace_matches, result) + + # Highlight status phrases - Action (Yellow) + def replace_updating(match: re.Match[str]) -> str: + return f"{Colors.PHRASE_WARNING}{match.group(0)}{Colors.RESET}" + + result = cls.PHRASE_UPDATING.sub(replace_updating, result) + + return result + + +# ============================================================================= +# Burst Suppression State (Thread-safe) +# ============================================================================= + + +class BurstBuffer: + """ + Thread-safe buffer for burst suppression. + + Tracks duplicate log messages and suppresses them, appending + a count like (x3) when a different message arrives. + """ + + def __init__(self): + self._lock = threading.Lock() + self._last_key: Optional[str] = None + self._last_formatted: Optional[str] = None + self._count: int = 0 + + def process( + self, key: str, formatted_line: str + ) -> Tuple[Optional[str], Optional[str]]: + """ + Process a log entry for burst suppression. + + Args: + key: Unique key for the log (source + message) + formatted_line: The fully formatted log line + + Returns: + Tuple of (line_to_print_now, deferred_line_if_any) + - If same as last: returns (None, None) - suppressed + - If different: returns (last_with_count_if_needed, current_line) + """ + with self._lock: + if self._last_key is None: + # First message + self._last_key = key + self._last_formatted = formatted_line + self._count = 1 + return (formatted_line, None) + + if key == self._last_key: + # Duplicate - suppress and increment counter + self._count += 1 + return (None, None) + else: + # Different message - flush previous with count + prev_line = self._last_formatted + prev_count = self._count + + # Update to new message + self._last_key = key + self._last_formatted = formatted_line + self._count = 1 + + if prev_count > 1 and prev_line: + # Create a dim footnote line instead of appending count + # Extract timestamp/level/source/id columns from prev_line to align properly + # Format: " └─ (Repeated N times)" + indent = ( + " " * 31 + ) # Align with message column (timestamp + level + source + id + spaces) + count_line = f"{indent}{Colors.TREE}\u2514\u2500 {Colors.BURST_COUNT}(Repeated {prev_count} times){Colors.RESET}" + # Return previous line (without count), count line, and current line + return (f"{prev_line}\n{count_line}", formatted_line) + else: + # Previous was single, just print current + return (formatted_line, None) + + def flush(self) -> Optional[str]: + """Flush any remaining buffered message.""" + with self._lock: + if self._last_formatted and self._count > 1: + # Create footnote line for repeated message + indent = " " * 31 + count_line = f"{indent}{Colors.TREE}\u2514\u2500 {Colors.BURST_COUNT}(Repeated {self._count} times){Colors.RESET}" + result = f"{self._last_formatted}\n{count_line}" + self._last_key = None + self._last_formatted = None + self._count = 0 + return result + # Don't return anything if count <= 1 (no repetition to report) + self._last_key = None + self._last_formatted = None + self._count = 0 + return None + + +# Global burst buffer instance +_burst_buffer = BurstBuffer() + + +# ============================================================================= +# Grid Formatter +# ============================================================================= + + +class GridFormatter(logging.Formatter): + """ + Fixed-width grid formatter with semantic highlighting and burst suppression. + + Format: TIME | LVL | SOURCE | ID | MESSAGE + + Example output: + 22:47:51.690 INF API y74ebn9 Received /v1/chat/completions request + 22:47:51.692 INF WORKR y74ebn9 Processing request logic... + 22:47:51.695 INF PROXY Sniff HTTPS requests (x5) + """ + + def __init__( + self, + colorize: bool = True, + burst_suppression: bool = True, + show_tree: bool = True, # Deprecated, kept for compatibility + ): + super().__init__() + self.colorize = colorize + self.burst_suppression = burst_suppression + + def format(self, record: logging.LogRecord) -> str: + """Format log record into grid layout.""" + # Skip during Python shutdown to avoid ImportError + if sys.meta_path is None: + return record.getMessage() + + # Extract context variables with defaults + try: + req_id = request_id_var.get() + except LookupError: + req_id = " " + + try: + source = source_var.get() + except LookupError: + source = "SYS" + + # Normalize source to 5-letter code + source_normalized = normalize_source(source) + + # Column 1: Time (HH:MM:SS.mmm) - no date + now = datetime.now() + timestamp = now.strftime("%H:%M:%S.") + f"{int(now.microsecond / 1000):03d}" + if self.colorize: + time_col = f"{Colors.TIME}{timestamp}{Colors.RESET}" + else: + time_col = timestamp + + # Column 2: Level (3 chars) + level_abbrev = Colors.LEVEL_ABBREV.get( + record.levelname, record.levelname[:3].upper() + ) + if self.colorize: + level_color = Colors.LEVELS.get(record.levelname, Fore.WHITE) + level_col = f"{level_color}{level_abbrev}{Colors.RESET}" + else: + level_col = level_abbrev + + # Column 3: Source (fixed 5-char width) + if self.colorize: + source_color = Colors.SOURCES.get(source_normalized, Fore.WHITE) + source_col = f"{source_color}{source_normalized}{Colors.RESET}" + else: + source_col = source_normalized + + # Column 4: Request ID (fixed 7-char width) + id_display = req_id[: Columns.ID].ljust(Columns.ID) + if self.colorize: + id_col = f"{Colors.REQUEST_ID}{id_display}{Colors.RESET}" + else: + id_col = id_display + + # Column 6: Message with semantic highlighting + message = record.getMessage() + + # Skip separator lines (dashed lines) + if message.strip().startswith("---") or message.strip().startswith("==="): + # Skip separator lines entirely + return "" + + if self.colorize: + message = SemanticHighlighter.highlight(message) + + # Combine all columns + line = f"{time_col} {level_col} {source_col} {id_col} {message}" + + # Apply burst suppression + if self.burst_suppression: + # Create key from source + original message (pre-highlight) + burst_key = f"{source_normalized}:{record.getMessage()}" + prev_line, current_line = _burst_buffer.process(burst_key, line) + + if prev_line is None and current_line is None: + # Suppressed - return empty string (won't be printed) + return "" + elif current_line is None: + # Just the previous (with count if needed) + return prev_line or "" + elif prev_line: + # Both lines - print previous then current + return f"{prev_line}\n{current_line}" + else: + # Just current + return current_line + + return line + + +# ============================================================================= +# Plain Formatter (for file/WebSocket - no ANSI codes) +# ============================================================================= + + +class PlainGridFormatter(logging.Formatter): + """Plain-text grid formatter for file/WebSocket logging (no ANSI codes).""" + + def format(self, record: logging.LogRecord) -> str: + """Format log record into plain text grid layout.""" + try: + req_id = request_id_var.get() + except LookupError: + req_id = " " + + try: + source = source_var.get() + except LookupError: + source = "SYS" + + source_normalized = normalize_source(source) + + now = datetime.now() + timestamp = now.strftime("%H:%M:%S.") + f"{int(now.microsecond / 1000):03d}" + + level_abbrev = Colors.LEVEL_ABBREV.get( + record.levelname, record.levelname[:3].upper() + ) + + id_display = req_id[: Columns.ID].ljust(Columns.ID) + message = record.getMessage() + + # Skip separator lines + if message.strip().startswith("---") or message.strip().startswith("==="): + return "" + + return f"{timestamp} {level_abbrev} {source_normalized} {id_display} {message}" + + +# ============================================================================= +# Object Dumper (YAML-style) +# ============================================================================= + + +def format_object(obj: Any, indent: int = 0, colorize: bool = True) -> str: + """ + Format an object in YAML-style for clean logging. + + Args: + obj: The object to format (dict, list, or primitive) + indent: Current indentation level + colorize: Whether to apply colors + + Returns: + Formatted string representation + """ + lines: List[str] = [] + prefix = " " * indent + + if isinstance(obj, dict): + for key, value in obj.items(): # pyright: ignore[reportUnknownVariableType] + key_str = ( + f"{Colors.KEY}{key}{Colors.RESET}" if colorize else str(key) # pyright: ignore[reportUnknownArgumentType] + ) + if isinstance(value, (dict, list)) and value: + lines.append(f"{prefix}{key_str}:") + lines.append(format_object(value, indent + 1, colorize)) + else: + formatted_value = _format_value(value, colorize) + lines.append(f"{prefix}{key_str}: {formatted_value}") + elif isinstance(obj, list): + for item in obj: # pyright: ignore[reportUnknownVariableType] + if isinstance(item, (dict, list)) and item: + lines.append(f"{prefix}-") + lines.append(format_object(item, indent + 1, colorize)) + else: + formatted_value = _format_value(item, colorize) + lines.append(f"{prefix}- {formatted_value}") + else: + lines.append(f"{prefix}{_format_value(obj, colorize)}") + + return "\n".join(lines) + + +def _format_value(value: Any, colorize: bool = True) -> str: + """Format a single value with appropriate coloring.""" + if not colorize: + if isinstance(value, str): + return f"'{value}'" + return str(value) + + if isinstance(value, bool): + if value: + return f"{Colors.BOOLEAN_TRUE}{value}{Colors.RESET}" + else: + return f"{Colors.BOOLEAN_FALSE}{value}{Colors.RESET}" + elif isinstance(value, (int, float)): + return f"{Colors.NUMBER}{value}{Colors.RESET}" + elif isinstance(value, str): + # Truncate long strings + if len(value) > 50: + truncated = value[:47] + "..." + return f"{Colors.STRING}'{truncated}'{Colors.RESET}" + return f"{Colors.STRING}'{value}'{Colors.RESET}" + elif value is None: + return f"{Colors.BOOLEAN_NONE}None{Colors.RESET}" + else: + return f"{Colors.STRING}{repr(value)}{Colors.RESET}" + + +# ============================================================================= +# Progress Indicator +# ============================================================================= + + +class ProgressLine: + """ + Progress indicator that updates on the same line. + + Uses carriage return (\\r) to overwrite the previous output + instead of creating new log lines. + + Usage: + progress = ProgressLine("Waiting for stream") + for i in range(100): + progress.update(i + 1, 100, f"chunks: {i + 1}") + time.sleep(0.05) + progress.finish("Complete") + """ + + def __init__(self, message: str, source: Optional[str] = None): + """ + Initialize progress indicator. + + Args: + message: Base message to display + source: Optional source override (uses context var if None) + """ + self.message = message + self.source = source + self.last_update = 0.0 + self._started = False + self._min_interval = 0.05 # Minimum time between updates (50ms) + + def update(self, current: int, total: int, extra: str = "") -> None: + """ + Update progress on the same line. + + Args: + current: Current progress value + total: Total value for 100% + extra: Additional info to display after progress bar + """ + now = time.time() + + # Rate limit updates to avoid flickering + if now - self.last_update < self._min_interval and current < total: + return + + self.last_update = now + self._started = True + + # Get context + try: + req_id = request_id_var.get() + except LookupError: + req_id = " " + + source = self.source + if source is None: + try: + source = source_var.get() + except LookupError: + source = "SYS" + + source_normalized = normalize_source(source) + + # Build timestamp + timestamp = ( + datetime.now().strftime("%H:%M:%S.") + f"{int(now * 1000) % 1000:03d}" + ) + + # Calculate progress + percentage = (current / total * 100) if total > 0 else 0 + bar_width = 20 + filled = int(bar_width * current / total) if total > 0 else 0 + bar = "#" * filled + "-" * (bar_width - filled) + + extra_text = f" {extra}" if extra else "" + + # Build the line with colors + source_color = Colors.SOURCES.get(source_normalized, Fore.WHITE) + line = ( + f"\r{Colors.TIME}{timestamp}{Colors.RESET} " + f"{Colors.LEVELS['INFO']}INF{Colors.RESET} " + f"{source_color}{source_normalized}{Colors.RESET} " + f"{Colors.REQUEST_ID}{req_id:<{Columns.ID}}{Colors.RESET} " + f"{Colors.MESSAGE}{self.message} [{bar}] " + f"{Colors.NUMBER}{current}{Colors.RESET}/{Colors.NUMBER}{total}{Colors.RESET} " + f"({Colors.NUMBER}{percentage:.0f}%{Colors.RESET}){extra_text}" + ) + + # Clear to end of line and print + sys.stdout.write(f"{line}\033[K") + sys.stdout.flush() + + def finish(self, message: Optional[str] = None) -> None: + """ + Complete the progress and move to new line. + + Args: + message: Optional completion message + """ + if self._started: + if message: + sys.stdout.write(f" - {Colors.STRING}{message}{Colors.RESET}") + sys.stdout.write("\n") + sys.stdout.flush() + + +# ============================================================================= +# JSON Formatter (Structured Logging) +# ============================================================================= + + +class JSONFormatter(logging.Formatter): + """ + JSON formatter for structured logging. + + Outputs logs as JSON lines for easy parsing by log aggregation tools + like ELK, Datadog, CloudWatch, etc. + + Enable via JSON_LOGS=true environment variable. + + Format: + {"timestamp": "2024-12-15T15:30:00.123Z", "level": "ERROR", "source": "API", + "request_id": "abc1234", "message": "...", "exception": "..."} + """ + + def format(self, record: logging.LogRecord) -> str: + """Format log record as JSON.""" + import json + from datetime import timezone + + # Get context variables + try: + req_id = request_id_var.get() + except LookupError: + req_id = "" + + try: + source = source_var.get() + except LookupError: + source = "SYS" + + source_normalized = normalize_source(source) + + # Build timestamp in ISO 8601 format with milliseconds + now = datetime.now(timezone.utc) + timestamp = ( + now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{int(now.microsecond / 1000):03d}Z" + ) + + # Base log entry + log_entry: dict[str, Any] = { + "timestamp": timestamp, + "level": record.levelname, + "source": source_normalized.strip(), + "message": record.getMessage(), + } + + # Add request ID if present + if req_id and req_id.strip(): + log_entry["request_id"] = req_id.strip() + + # Add logger name + if record.name: + log_entry["logger"] = record.name + + # Add exception info if present + if record.exc_info and record.exc_info[1] is not None: + import traceback as tb + + log_entry["exception"] = { + "type": type(record.exc_info[1]).__name__, + "message": str(record.exc_info[1]), + "traceback": "".join(tb.format_exception(*record.exc_info)), + } + + # Add extra fields from record + # Common extra fields that might be useful + for key in ("funcName", "lineno", "pathname"): + value = getattr(record, key, None) + if value: + log_entry[key] = value + + return json.dumps(log_entry, ensure_ascii=False, default=str) diff --git a/logging_utils/fc_debug/__init__.py b/logging_utils/fc_debug/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..fa71de0f8581e53adfadcba3fcb450c944eda146 --- /dev/null +++ b/logging_utils/fc_debug/__init__.py @@ -0,0 +1,56 @@ +""" +FC Debug Logging Module. + +Provides modular, configurable debug logging for Function Calling components. + +Usage: + from logging_utils.fc_debug import get_fc_logger, FCModule + + fc_logger = get_fc_logger() + + # Log to specific modules + fc_logger.debug(FCModule.CACHE, "Cache hit", req_id="abc123") + fc_logger.info(FCModule.UI, "Toggle enabled", req_id="abc123") + + # Use convenience methods + fc_logger.log_cache_hit(req_id, digest, age_seconds) + fc_logger.log_ui_action(req_id, "click", "toggle_button", elapsed_ms=45.2) + + # Check if module is enabled (for performance-sensitive logging) + if fc_logger.is_enabled(FCModule.SCHEMA): + fc_logger.debug(FCModule.SCHEMA, "Converting tools", payload=tools) + +Environment Variables: + FC_DEBUG_ENABLED=true # Master switch + FC_DEBUG_CACHE=true # Enable cache module + FC_DEBUG_UI=true # Enable UI module + FC_DEBUG_LEVEL_CACHE=DEBUG # Set log level for cache + FC_DEBUG_COMBINED_LOG=true # Also write to fc_combined.log +""" + +from .config import FCDebugConfig +from .formatters import FCDebugFormatter +from .handlers import create_rotating_file_handler, ensure_log_directory +from .logger import FunctionCallingDebugLogger, ModuleLogger, get_fc_logger +from .modules import FCModule +from .truncation import TruncationConfig, summarize_tools, truncate_payload + +__all__ = [ + # Main logger + "FunctionCallingDebugLogger", + "get_fc_logger", + "ModuleLogger", + # Modules + "FCModule", + # Configuration + "FCDebugConfig", + "TruncationConfig", + # Formatters + "FCDebugFormatter", + # Handlers + "create_rotating_file_handler", + "ensure_log_directory", + # Truncation utilities + "truncate_payload", + "summarize_tools", +] diff --git a/logging_utils/fc_debug/config.py b/logging_utils/fc_debug/config.py new file mode 100644 index 0000000000000000000000000000000000000000..8e2e76deaa85f152d24dbe30e355eca9c7ed935c --- /dev/null +++ b/logging_utils/fc_debug/config.py @@ -0,0 +1,88 @@ +""" +FC Debug Configuration. + +Loads configuration from environment variables for FC debug logging. +Supports master switch, per-module enable/disable, and per-module log levels. +""" + +import logging +import os +from dataclasses import dataclass, field +from typing import Dict + +from .modules import FCModule + + +@dataclass +class FCDebugConfig: + """Configuration for FC debug logging.""" + + master_enabled: bool = False + module_enabled: Dict[FCModule, bool] = field(default_factory=dict) + module_levels: Dict[FCModule, int] = field(default_factory=dict) + log_max_bytes: int = 5 * 1024 * 1024 # 5MB + log_backup_count: int = 3 + combined_log_enabled: bool = False + + @classmethod + def from_env(cls) -> "FCDebugConfig": + """Load configuration from environment variables.""" + # Master switch: FUNCTION_CALLING_DEBUG is the primary control + fc_debug_env = os.environ.get("FUNCTION_CALLING_DEBUG", "false").lower() + legacy_master = fc_debug_env in ("true", "1", "yes") + + master = legacy_master + # Allow FC_DEBUG_ENABLED as an alias for granular control + if not master: + master = os.environ.get("FC_DEBUG_ENABLED", "false").lower() in ( + "true", + "1", + "yes", + ) + + # Per-module enabled + module_enabled: Dict[FCModule, bool] = {} + for module in FCModule: + env_val = os.environ.get(module.env_enabled_key, "").lower() + if env_val: + module_enabled[module] = env_val in ("true", "1", "yes") + elif module == FCModule.ORCHESTRATOR and legacy_master: + # Legacy mode: FUNCTION_CALLING_DEBUG=true enables ORCHESTRATOR by default + module_enabled[module] = True + else: + # Default to False to ensure granular control + module_enabled[module] = False + + # Per-module levels + module_levels: Dict[FCModule, int] = {} + for module in FCModule: + level_str = os.environ.get(module.env_level_key, "DEBUG").upper() + module_levels[module] = getattr(logging, level_str, logging.DEBUG) + + # Rotation settings + max_bytes = int(os.environ.get("FC_DEBUG_LOG_MAX_BYTES", str(5 * 1024 * 1024))) + backup_count = int(os.environ.get("FC_DEBUG_LOG_BACKUP_COUNT", "3")) + + # Combined log + combined = os.environ.get("FC_DEBUG_COMBINED_LOG", "false").lower() in ( + "true", + "1", + "yes", + ) + + return cls( + master_enabled=master, + module_enabled=module_enabled, + module_levels=module_levels, + log_max_bytes=max_bytes, + log_backup_count=backup_count, + combined_log_enabled=combined, + ) + + def is_module_enabled(self, module: FCModule) -> bool: + """Check if a module is enabled.""" + return self.master_enabled and self.module_enabled.get(module, False) + + def get_module_level(self, module: FCModule) -> int: + """Get the log level for a module.""" + return self.module_levels.get(module, logging.DEBUG) diff --git a/logging_utils/fc_debug/formatters.py b/logging_utils/fc_debug/formatters.py new file mode 100644 index 0000000000000000000000000000000000000000..2224e075e78666037ec6f35bd3164eb37160e003 --- /dev/null +++ b/logging_utils/fc_debug/formatters.py @@ -0,0 +1,50 @@ +""" +FC Debug Log Formatters. + +Provides consistent formatting for FC debug logs with timestamps, +log levels, and proper structure. +""" + +import logging +from datetime import datetime +from zoneinfo import ZoneInfo + + +class FCDebugFormatter(logging.Formatter): + """ + Formatter for FC debug logs. + + Format: YYYY-MM-DD HH:MM:SS.mmm | LEVEL | message + + Uses America/Chicago timezone for consistency with existing Grid Logger. + """ + + def __init__(self) -> None: + super().__init__() + try: + self._tz = ZoneInfo("America/Chicago") + except Exception: + # Fallback to UTC if timezone not available + self._tz = ZoneInfo("UTC") + + def format(self, record: logging.LogRecord) -> str: + """Format the log record.""" + # Timestamp with milliseconds + dt = datetime.fromtimestamp(record.created, tz=self._tz) + timestamp = dt.strftime("%Y-%m-%d %H:%M:%S") + f".{int(record.msecs):03d}" + + # Level name padded to 7 chars + level = record.levelname.ljust(7) + + # Message + message = record.getMessage() + + # Base format + formatted = f"{timestamp} | {level} | {message}" + + # Add exception info if present + if record.exc_info: + exc_text = self.formatException(record.exc_info) + formatted += f"\n{exc_text}" + + return formatted diff --git a/logging_utils/fc_debug/handlers.py b/logging_utils/fc_debug/handlers.py new file mode 100644 index 0000000000000000000000000000000000000000..df2d9b8a96d580c67a124144a7953303447d6abf --- /dev/null +++ b/logging_utils/fc_debug/handlers.py @@ -0,0 +1,59 @@ +""" +FC Debug File Handlers. + +Factory functions for creating RotatingFileHandler instances +for each FC debug module. +""" + +import logging +import logging.handlers +from pathlib import Path +from typing import Optional + +from .formatters import FCDebugFormatter + + +def create_rotating_file_handler( + log_path: Path, + max_bytes: int = 5 * 1024 * 1024, + backup_count: int = 3, + level: int = logging.DEBUG, + formatter: Optional[logging.Formatter] = None, +) -> logging.handlers.RotatingFileHandler: + """ + Create a rotating file handler for FC debug logs. + + Args: + log_path: Path to the log file + max_bytes: Maximum file size before rotation (default 5MB) + backup_count: Number of backup files to keep (default 3) + level: Log level for the handler + formatter: Custom formatter (defaults to FCDebugFormatter) + + Returns: + Configured RotatingFileHandler + """ + handler = logging.handlers.RotatingFileHandler( + log_path, + maxBytes=max_bytes, + backupCount=backup_count, + encoding="utf-8", + ) + + if formatter is None: + formatter = FCDebugFormatter() + + handler.setFormatter(formatter) + handler.setLevel(level) + + return handler + + +def ensure_log_directory(log_dir: Path) -> None: + """ + Ensure the log directory exists. + + Args: + log_dir: Path to the log directory + """ + log_dir.mkdir(parents=True, exist_ok=True) diff --git a/logging_utils/fc_debug/logger.py b/logging_utils/fc_debug/logger.py new file mode 100644 index 0000000000000000000000000000000000000000..20668cb053581cd065e05ee07e2a40258ab1701e --- /dev/null +++ b/logging_utils/fc_debug/logger.py @@ -0,0 +1,384 @@ +""" +Function Calling Debug Logger. + +Centralized singleton logger for all Function Calling components. +Provides per-module logging with separate log files, configurable +log levels, payload truncation, and request ID correlation. +""" + +import logging +import logging.handlers +from dataclasses import dataclass +from pathlib import Path +from threading import Lock +from typing import Any, Dict, Optional + +from .config import FCDebugConfig +from .handlers import create_rotating_file_handler, ensure_log_directory +from .modules import FCModule +from .truncation import TruncationConfig, truncate_payload + + +@dataclass +class ModuleLogger: + """Wrapper for a module-specific logger with configuration.""" + + module: FCModule + logger: logging.Logger + enabled: bool + level: int + file_handler: Optional[logging.handlers.RotatingFileHandler] = None + + +class FunctionCallingDebugLogger: + """ + Centralized debug logger for Function Calling components. + + Thread-safe singleton that manages per-module loggers with: + - Independent enable/disable per module + - Separate log files per module + - Configurable log levels + - Payload truncation + - Request ID correlation + + Usage: + fc_logger = FunctionCallingDebugLogger.get_instance() + + # Log with specific module + fc_logger.debug(FCModule.CACHE, "Cache hit", req_id="abc123") + fc_logger.info(FCModule.UI, "Opening dialog", req_id="abc123") + + # Log with payload truncation + fc_logger.debug( + FCModule.SCHEMA, + "Converting tools", + req_id="abc123", + payload={"tools": large_tool_list} + ) + """ + + _instance: Optional["FunctionCallingDebugLogger"] = None + _lock: Lock = Lock() + + def __init__(self) -> None: + """Initialize the logger. Use get_instance() instead.""" + self._config: Optional[FCDebugConfig] = None + self._truncation: Optional[TruncationConfig] = None + self._module_loggers: Dict[FCModule, ModuleLogger] = {} + self._combined_handler: Optional[logging.handlers.RotatingFileHandler] = None + self._initialized = False + + @classmethod + def get_instance(cls) -> "FunctionCallingDebugLogger": + """Get the singleton instance, creating if needed.""" + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = cls() + cls._instance._initialize() + return cls._instance + + @classmethod + def reset_instance(cls) -> None: + """Reset the singleton instance (for testing).""" + with cls._lock: + if cls._instance is not None: + cls._instance._cleanup() + cls._instance = None + + def _initialize(self) -> None: + """Initialize all module loggers based on configuration.""" + if self._initialized: + return + + try: + self._config = FCDebugConfig.from_env() + self._truncation = TruncationConfig.from_env() + except Exception: + # Graceful degradation: use defaults if config loading fails + self._config = FCDebugConfig() + self._truncation = TruncationConfig() + + # Create log directory + log_dir = Path("logs/fc_debug") + ensure_log_directory(log_dir) + + # Initialize combined handler if enabled + if self._config.combined_log_enabled: + self._combined_handler = create_rotating_file_handler( + log_dir / "fc_combined.log", + max_bytes=self._config.log_max_bytes, + backup_count=self._config.log_backup_count, + ) + + # Initialize per-module loggers + for module in FCModule: + self._module_loggers[module] = self._create_module_logger(module, log_dir) + + self._initialized = True + + def _create_module_logger( + self, + module: FCModule, + log_dir: Path, + ) -> ModuleLogger: + """Create a logger for a specific module.""" + assert self._config is not None + + # Get module-specific config + enabled = self._config.is_module_enabled(module) + level = self._config.get_module_level(module) + + # Create logger with unique name + logger_name = f"AIStudioProxyServer.FC.{module.name}" + logger = logging.getLogger(logger_name) + logger.setLevel(level if enabled else logging.CRITICAL + 1) + logger.propagate = False # Don't propagate to root logger + + # Clear existing handlers + logger.handlers.clear() + + file_handler = None + if enabled and self._config.master_enabled: + # Create file handler + file_handler = create_rotating_file_handler( + log_dir / module.log_filename, + max_bytes=self._config.log_max_bytes, + backup_count=self._config.log_backup_count, + level=level, + ) + logger.addHandler(file_handler) + + # Add to combined log if enabled + if self._combined_handler: + logger.addHandler(self._combined_handler) + + return ModuleLogger( + module=module, + logger=logger, + enabled=enabled and self._config.master_enabled, + level=level, + file_handler=file_handler, + ) + + def _cleanup(self) -> None: + """Clean up all handlers.""" + for module_logger in self._module_loggers.values(): + if module_logger.file_handler: + module_logger.file_handler.close() + module_logger.logger.removeHandler(module_logger.file_handler) + + if self._combined_handler: + self._combined_handler.close() + + self._module_loggers.clear() + self._initialized = False + + # ========================================================================= + # Public Logging Methods + # ========================================================================= + + def is_enabled(self, module: FCModule) -> bool: + """Check if a module is enabled for logging.""" + if module not in self._module_loggers: + return False + return self._module_loggers[module].enabled + + def debug( + self, + module: FCModule, + message: str, + req_id: str = "", + payload: Optional[Any] = None, + **kwargs: Any, + ) -> None: + """Log a DEBUG message for a module.""" + self._log(module, logging.DEBUG, message, req_id, payload, **kwargs) + + def info( + self, + module: FCModule, + message: str, + req_id: str = "", + payload: Optional[Any] = None, + **kwargs: Any, + ) -> None: + """Log an INFO message for a module.""" + self._log(module, logging.INFO, message, req_id, payload, **kwargs) + + def warning( + self, + module: FCModule, + message: str, + req_id: str = "", + payload: Optional[Any] = None, + **kwargs: Any, + ) -> None: + """Log a WARNING message for a module.""" + self._log(module, logging.WARNING, message, req_id, payload, **kwargs) + + def error( + self, + module: FCModule, + message: str, + req_id: str = "", + payload: Optional[Any] = None, + exc_info: bool = False, + **kwargs: Any, + ) -> None: + """Log an ERROR message for a module.""" + self._log( + module, logging.ERROR, message, req_id, payload, exc_info=exc_info, **kwargs + ) + + def _log( + self, + module: FCModule, + level: int, + message: str, + req_id: str = "", + payload: Optional[Any] = None, + exc_info: bool = False, + **kwargs: Any, + ) -> None: + """Internal logging method.""" + if module not in self._module_loggers: + return + + module_logger = self._module_loggers[module] + if not module_logger.enabled: + return + + # Build the full message + prefix = module.prefix + req_prefix = f"[{req_id}] " if req_id else "" + + # Handle payload truncation + payload_str = "" + if payload is not None: + payload_str = self._format_payload(payload, module) + + full_message = f"{req_prefix}{prefix} {message}" + if payload_str: + full_message += f"\n{payload_str}" + + # Log it + module_logger.logger.log(level, full_message, exc_info=exc_info) + + def _format_payload(self, payload: Any, module: FCModule) -> str: + """Format and optionally truncate a payload for logging.""" + if self._truncation is None or not self._truncation.enabled: + return str(payload) + + # Determine max length based on payload type + max_length = self._truncation.get_max_length(payload, module) + return truncate_payload(payload, max_length) + + # ========================================================================= + # Convenience Methods for Specific Modules + # ========================================================================= + + def log_cache_hit(self, req_id: str, digest: str, age_seconds: float) -> None: + """Log a cache hit event.""" + self.debug( + FCModule.CACHE, + f"HIT - digest={digest[:8]}..., age={age_seconds:.1f}s", + req_id=req_id, + ) + + def log_cache_miss(self, req_id: str, reason: str) -> None: + """Log a cache miss event.""" + self.debug( + FCModule.CACHE, + f"MISS - reason={reason}", + req_id=req_id, + ) + + def log_ui_action( + self, + req_id: str, + action: str, + element: str, + elapsed_ms: Optional[float] = None, + ) -> None: + """Log a UI action.""" + timing = f" ({elapsed_ms:.0f}ms)" if elapsed_ms else "" + self.debug( + FCModule.UI, + f"{action} {element}{timing}", + req_id=req_id, + ) + + def log_wire_parse( + self, + req_id: str, + func_name: str, + params: Dict[str, Any], + success: bool = True, + ) -> None: + """Log wire format parsing.""" + status = "parsed" if success else "FAILED" + self.debug( + FCModule.WIRE, + f"Function '{func_name}' {status}", + req_id=req_id, + payload=params if params else None, + ) + + def log_dom_extraction( + self, + req_id: str, + call_count: int, + strategy: str, + ) -> None: + """Log DOM-based function call extraction.""" + self.debug( + FCModule.DOM, + f"Extracted {call_count} call(s) via {strategy}", + req_id=req_id, + ) + + def log_schema_conversion( + self, + req_id: str, + tool_count: int, + elapsed_ms: float, + ) -> None: + """Log schema conversion.""" + self.info( + FCModule.SCHEMA, + f"Converted {tool_count} tools in {elapsed_ms:.2f}ms", + req_id=req_id, + ) + + def log_response_format( + self, + req_id: str, + call_count: int, + finish_reason: str, + ) -> None: + """Log response formatting.""" + self.debug( + FCModule.RESPONSE, + f"Formatted {call_count} tool calls, finish_reason={finish_reason}", + req_id=req_id, + ) + + def log_mode_selection( + self, + req_id: str, + mode: str, + reason: str, + ) -> None: + """Log mode selection decision.""" + self.info( + FCModule.ORCHESTRATOR, + f"Mode={mode}, reason={reason}", + req_id=req_id, + ) + + +# Global convenience function +def get_fc_logger() -> FunctionCallingDebugLogger: + """Get the FC debug logger instance.""" + return FunctionCallingDebugLogger.get_instance() diff --git a/logging_utils/fc_debug/modules.py b/logging_utils/fc_debug/modules.py new file mode 100644 index 0000000000000000000000000000000000000000..68f89cec659331b12c954ffc1990d5ac61192fab --- /dev/null +++ b/logging_utils/fc_debug/modules.py @@ -0,0 +1,46 @@ +""" +FC Debug Module Definitions. + +Defines the FCModule enum representing different function calling components +that can be independently logged and configured. +""" + +from enum import Enum + + +class FCModule(Enum): + """Function Calling debug logging modules.""" + + ORCHESTRATOR = "fc_orchestrator" # Mode selection, fallback logic, high-level flow + UI = "fc_ui" # Browser UI automation (toggle, dialog, paste) + CACHE = "fc_cache" # Cache hits/misses/invalidation + WIRE = "fc_wire" # Wire format parsing from network + DOM = "fc_dom" # DOM-based function call extraction + SCHEMA = "fc_schema" # Schema conversion and validation + RESPONSE = "fc_response" # Response formatting for OpenAI compatibility + + @property + def prefix(self) -> str: + """Get the log prefix for this module.""" + # Use shorter prefixes for some modules + prefix_map = { + "ORCHESTRATOR": "ORCH", + "RESPONSE": "RESP", + } + name = prefix_map.get(self.name, self.name) + return f"[FC:{name}]" + + @property + def env_enabled_key(self) -> str: + """Get the environment variable key for enabling this module.""" + return f"FC_DEBUG_{self.name.upper()}" + + @property + def env_level_key(self) -> str: + """Get the environment variable key for log level.""" + return f"FC_DEBUG_LEVEL_{self.name.upper()}" + + @property + def log_filename(self) -> str: + """Get the log filename for this module.""" + return f"{self.value}.log" diff --git a/logging_utils/fc_debug/truncation.py b/logging_utils/fc_debug/truncation.py new file mode 100644 index 0000000000000000000000000000000000000000..b2926bd5a1f197483ac787d366a3925ac3f4aa15 --- /dev/null +++ b/logging_utils/fc_debug/truncation.py @@ -0,0 +1,127 @@ +""" +FC Debug Payload Truncation Utilities. + +Provides intelligent truncation of large payloads (tool definitions, +function arguments, responses) for readable logging without excessive output. +""" + +import json +import os +from dataclasses import dataclass +from typing import Any, List + +from .modules import FCModule + + +@dataclass +class TruncationConfig: + """Configuration for payload truncation.""" + + enabled: bool = True + max_tool_definition: int = 500 + max_arguments: int = 1000 + max_response: int = 2000 + max_default: int = 500 + + @classmethod + def from_env(cls) -> "TruncationConfig": + """Load truncation config from environment.""" + return cls( + enabled=os.environ.get("FC_DEBUG_TRUNCATE_ENABLED", "true").lower() + in ("true", "1", "yes"), + max_tool_definition=int( + os.environ.get("FC_DEBUG_TRUNCATE_MAX_TOOL_DEF", "500") + ), + max_arguments=int(os.environ.get("FC_DEBUG_TRUNCATE_MAX_ARGS", "1000")), + max_response=int(os.environ.get("FC_DEBUG_TRUNCATE_MAX_RESPONSE", "2000")), + ) + + def get_max_length(self, payload: Any, module: FCModule) -> int: + """Get the max length for a payload based on module and content type.""" + # Module-specific defaults + if module == FCModule.SCHEMA: + return self.max_tool_definition + elif module in (FCModule.WIRE, FCModule.DOM): + return self.max_arguments + elif module == FCModule.RESPONSE: + return self.max_response + return self.max_default + + +def truncate_payload(payload: Any, max_length: int) -> str: + """ + Truncate a payload for logging. + + Handles dicts, lists, and strings intelligently: + - Shows structure summary for truncated objects + - Preserves enough context to be useful + + Args: + payload: The payload to truncate + max_length: Maximum allowed length + + Returns: + Truncated string representation + """ + try: + if isinstance(payload, str): + if len(payload) <= max_length: + return payload + return f"{payload[:max_length]}... [truncated, total={len(payload)}]" + + if isinstance(payload, (dict, list)): + json_str = json.dumps(payload, indent=2, default=str) + if len(json_str) <= max_length: + return json_str + + # Show truncated with summary + truncated = json_str[:max_length] + + # Add summary + if isinstance(payload, dict): + keys = list(payload.keys())[:5] + summary = f"{{...}} [keys={keys}, truncated={len(json_str)}]" + else: + summary = f"[...] [length={len(payload)}, truncated={len(json_str)}]" + + return f"{truncated}\n... {summary}" + + # For other types, convert to string and truncate + str_val = str(payload) + if len(str_val) <= max_length: + return str_val + return f"{str_val[:max_length]}... [truncated]" + + except Exception as e: + return f"[Error formatting payload: {e}]" + + +def summarize_tools(tools: List[Any]) -> str: + """ + Create a summary of tool definitions without full schemas. + + Args: + tools: List of tool definition dicts + + Returns: + Concise summary string + """ + if not tools: + return "[]" + + summaries = [] + for tool in tools[:10]: # Max 10 tools in summary + if isinstance(tool, dict): + func = tool.get("function", tool) + name = func.get("name", "unknown") + params = func.get("parameters", {}) + param_count = ( + len(params.get("properties", {})) if isinstance(params, dict) else 0 + ) + summaries.append(f"{name}({param_count} params)") + + result = ", ".join(summaries) + if len(tools) > 10: + result += f", ... +{len(tools) - 10} more" + + return f"[{result}]" diff --git a/logging_utils/grid_logger.py b/logging_utils/grid_logger.py new file mode 100644 index 0000000000000000000000000000000000000000..96900bd3fe0875317830b42e4c824296d7a0c131 --- /dev/null +++ b/logging_utils/grid_logger.py @@ -0,0 +1,193 @@ +""" +Grid-Based Professional Logging System v2.0 +============================================ +A verbose but clean logging system with: +- Fixed-width grid layout for perfect column alignment +- Semantic syntax highlighting for values and tokens +- Burst suppression (deduplication) for repeated messages +- Progress indicators that update on the same line +- Thread-safe context variables for request tracking + +Author: AI Studio Proxy API +License: MIT +(Refactored: logic moved to logging_utils.core submodules) +""" + +from logging_utils.core.constants import ( + SOURCE_MAP, + Colors, + Columns, +) +from logging_utils.core.context import ( + request_id_var, + source_var, +) +from logging_utils.core.logger import ( + AbortErrorFilter, + BrowserNoiseFilter, + flush_burst_buffer, + get_request_id, + get_source, + log_context, + log_object, + request_context, + set_request_id, + set_source, + setup_grid_logging, +) +from logging_utils.core.rendering import ( + BurstBuffer, + GridFormatter, + JSONFormatter, + PlainGridFormatter, + ProgressLine, + SemanticHighlighter, + _burst_buffer, + _format_value, + format_object, + normalize_source, +) + +__all__ = [ + "Colors", + "Columns", + "SOURCE_MAP", + "request_id_var", + "source_var", + "SemanticHighlighter", + "BurstBuffer", + "_burst_buffer", + "_format_value", + "GridFormatter", + "JSONFormatter", + "PlainGridFormatter", + "format_object", + "log_object", + "ProgressLine", + "set_source", + "set_request_id", + "get_source", + "get_request_id", + "flush_burst_buffer", + "setup_grid_logging", + "BrowserNoiseFilter", + "AbortErrorFilter", + "log_context", + "request_context", + "normalize_source", +] + + +if __name__ == "__main__": + import logging + import random + import string + + # Setup logging + logger = setup_grid_logging(level=logging.DEBUG) + + print() + print("=" * 70) + print(" GRID LOGGING SYSTEM v2.0 - DEMONSTRATION") + print("=" * 70) + print() + + # Generate a random request ID (like your existing system) + req_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=7)) + + # ========================================================================= + # Test 1: Basic logging with source switching + # ========================================================================= + + set_request_id(req_id) + set_source("API") + + logger.info("Received /v1/chat/completions request (Stream=True)") + + set_source("WORKR") + logger.info("Processing request logic") + logger.info("Dequeued request from queue") + + # Use log_context for source switching + with log_context("UI State Validation", logger): + logger.info("Temperature matches (0.0). No update needed.") + logger.info("Max tokens: 8192, Top-P: 0.95") + + with log_context("Model Switching", logger, source="BROWR"): + logger.info("Current model: 'gemini-1.5-flash'") + logger.info("Target model: 'gemini-2.0-flash-exp'") + logger.info("Model switch completed in 1.2s") + + # ========================================================================= + # Test 2: Burst suppression + # ========================================================================= + + print() + print("-" * 70) + print(" BURST SUPPRESSION DEMO") + print("-" * 70) + print() + + set_source("PROXY") + set_request_id(" ") + + # Simulate repeated messages + for _ in range(5): + logger.info("Sniff HTTPS requests to: aistudio.google.com:443") + + logger.info("Different message - this triggers flush") + + for _ in range(3): + logger.error("[UPSTREAM ERROR] 429 Too Many Requests") + + logger.warning("Another different message") + + # ========================================================================= + # Test 3: Semantic highlighting + # ========================================================================= + + print() + print("-" * 70) + print(" SEMANTIC HIGHLIGHTING DEMO") + print("-" * 70) + print() + + set_source("SYS") + + logger.info("Processing True and False values with None") + logger.info("Temperature: 0.95, max_tokens: 2048, top_p: 0.9") + logger.info("Loaded model 'gemini-2.0-flash-exp' successfully") + logger.info("URL: https://aistudio.google.com/prompts") + logger.warning("Warning: Rate limit approaching") + logger.error("Error: Connection failed after 3 retries") + logger.info("Success: Request completed in 150ms") + + # ========================================================================= + # Test 4: Object dumping + # ========================================================================= + + print() + print("-" * 70) + print(" OBJECT DUMP DEMO") + print("-" * 70) + print() + + data = { + "model": "gemini-2.0-flash-exp", + "temperature": 0.7, + "stream": True, + "messages": [ + {"role": "user", "content": "Hello!"}, + {"role": "assistant", "content": "Hi there!"}, + ], + } + log_object(logger, data, "Request Parameters") + + print() + print("=" * 70) + print(" DEMONSTRATION COMPLETE") + print("=" * 70) + print() + + # Flush any remaining burst buffer + flush_burst_buffer() diff --git a/logging_utils/setup.py b/logging_utils/setup.py new file mode 100644 index 0000000000000000000000000000000000000000..e7f83585b7f6d7317e60cea82a9ccbe62df35e47 --- /dev/null +++ b/logging_utils/setup.py @@ -0,0 +1,219 @@ +import logging +import logging.handlers +import os +import sys +from datetime import datetime +from typing import Any, Optional, Tuple +from zoneinfo import ZoneInfo + +from config import ( + ACTIVE_AUTH_DIR, + APP_LOG_FILE_PATH, + JSON_LOGS_ENABLED, + LOG_DIR, + LOG_FILE_BACKUP_COUNT, + LOG_FILE_MAX_BYTES, + SAVED_AUTH_DIR, +) +from models import StreamToLogger, WebSocketConnectionManager, WebSocketLogHandler + +from .core.error_handler import setup_global_exception_handlers +from .grid_logger import ( + GridFormatter, + JSONFormatter, + PlainGridFormatter, +) + + +class ColoredFormatter(logging.Formatter): + """Cross-platform colored formatter using ANSI codes (legacy, kept for compatibility).""" + + COLORS = { + "DEBUG": "\033[36m", + "INFO": "\033[0m", + "WARNING": "\033[93m", + "ERROR": "\033[91m", + "CRITICAL": "\033[41m\033[97m", + } + RESET = "\033[0m" + + def __init__( + self, fmt: Any = None, datefmt: Any = None, use_color: bool = True + ) -> None: + super().__init__(fmt, datefmt) + self.use_color = use_color + + if use_color and sys.platform == "win32": + try: + import ctypes + + kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] + kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) + except Exception: + self.use_color = False + + def formatTime(self, record: logging.LogRecord, datefmt: Any = None) -> str: + dt = datetime.fromtimestamp(record.created, tz=ZoneInfo("America/Chicago")) + if datefmt: + s = dt.strftime(datefmt) + else: + s = dt.strftime("%Y-%m-%d %H:%M:%S") + s = "%s,%03d" % (s, record.msecs) + return s + + def format(self, record: logging.LogRecord) -> str: + if self.use_color and record.levelname in self.COLORS: + original_levelname = record.levelname + record.levelname = ( + f"{self.COLORS[record.levelname]}{record.levelname}{self.RESET}" + ) + result = super().format(record) + record.levelname = original_levelname + return result + return super().format(record) + + +def setup_server_logging( + logger_instance: logging.Logger, + log_ws_manager: Optional[WebSocketConnectionManager], + log_level_name: str = "INFO", + redirect_print_str: str = "false", +) -> Tuple[object, object]: + """ + Setup server logging system + + Args: + logger_instance: Main logger instance + log_ws_manager: WebSocket connection manager + log_level_name: Log level name + redirect_print_str: Whether to redirect print output + + Returns: + Tuple[object, object]: Original stdout and stderr streams + """ + log_level = getattr(logging, log_level_name.upper(), logging.INFO) + redirect_print = redirect_print_str.lower() in ("true", "1", "yes") + + # Create necessary directories + os.makedirs(LOG_DIR, exist_ok=True) + os.makedirs(ACTIVE_AUTH_DIR, exist_ok=True) + os.makedirs(SAVED_AUTH_DIR, exist_ok=True) + + # Clear existing handlers + if logger_instance.hasHandlers(): + logger_instance.handlers.clear() + logger_instance.setLevel(log_level) + logger_instance.propagate = False + + # Remove old log file + if os.path.exists(APP_LOG_FILE_PATH): + try: + os.remove(APP_LOG_FILE_PATH) + except OSError as e: + print( + f"Warning (setup_server_logging): Failed to remove old app.log file '{APP_LOG_FILE_PATH}': {e}. Will rely on mode='w' for truncation.", + file=sys.__stderr__, + ) + + # Use JSONFormatter for file logging if JSON_LOGS_ENABLED, otherwise PlainGridFormatter + if JSON_LOGS_ENABLED: + file_log_formatter = JSONFormatter() + else: + file_log_formatter = PlainGridFormatter() + + file_handler = logging.handlers.RotatingFileHandler( + APP_LOG_FILE_PATH, + maxBytes=LOG_FILE_MAX_BYTES, + backupCount=LOG_FILE_BACKUP_COUNT, + encoding="utf-8", + mode="w", + ) + file_handler.setFormatter(file_log_formatter) + file_handler.setLevel(log_level) + logger_instance.addHandler(file_handler) + + # Add WebSocket handler + if log_ws_manager is None: + print( + "Critical Warning (setup_server_logging): log_ws_manager not initialized! WebSocket logging will be unavailable.", + file=sys.__stderr__, + ) + else: + ws_handler = WebSocketLogHandler(log_ws_manager) + ws_handler.setLevel(log_level) + ws_handler.setFormatter(PlainGridFormatter()) + logger_instance.addHandler(ws_handler) + + # Add console handler (using GridFormatter with color) + console_grid_formatter = GridFormatter(show_tree=True, colorize=True) + console_handler = logging.StreamHandler(sys.stderr) + console_handler.setFormatter(console_grid_formatter) + console_handler.setLevel(log_level) + logger_instance.addHandler(console_handler) + + # Add AbortError filter (benign errors from Playwright navigation cancellations) + from logging_utils import AbortErrorFilter + + logger_instance.addFilter(AbortErrorFilter()) + + # Save original streams + original_stdout = sys.stdout + original_stderr = sys.stderr + + # Redirect print output (if needed) + if redirect_print: + print( + "--- Note: server.py is redirecting its print output to the logging system (File, WebSocket, and Console logger) ---", + file=original_stderr, + ) + stdout_redirect_logger = logging.getLogger("AIStudioProxyServer.stdout") + stdout_redirect_logger.setLevel(logging.INFO) + stdout_redirect_logger.propagate = True + sys.stdout = StreamToLogger(stdout_redirect_logger, logging.INFO) + stderr_redirect_logger = logging.getLogger("AIStudioProxyServer.stderr") + stderr_redirect_logger.setLevel(logging.ERROR) + stderr_redirect_logger.propagate = True + sys.stderr = StreamToLogger(stderr_redirect_logger, logging.ERROR) + else: + print( + "--- server.py print output is NOT redirected to logging system (using original stdout/stderr) ---", + file=original_stderr, + ) + + # Configure third-party library log levels + logging.getLogger("uvicorn").setLevel(logging.WARNING) + logging.getLogger("uvicorn.error").setLevel(logging.INFO) + logging.getLogger("uvicorn.access").setLevel(logging.WARNING) + logging.getLogger("websockets").setLevel(logging.WARNING) + logging.getLogger("playwright").setLevel(logging.WARNING) + logging.getLogger("asyncio").setLevel(logging.ERROR) + + # Log initialization info + logger_instance.info( + "=" * 5 + + " AIStudioProxyServer Logging System Initialized in lifespan " + + "=" * 5 + ) + logger_instance.info(f"Log level set to: {logging.getLevelName(log_level)}") + logger_instance.debug(f"Log file path: {APP_LOG_FILE_PATH}") + logger_instance.info("Console log handler added.") + logger_instance.info( + f"Print Redirection (controlled by SERVER_REDIRECT_PRINT env var): {'Enabled' if redirect_print else 'Disabled'}" + ) + + # Install global exception handlers + setup_global_exception_handlers() + + return original_stdout, original_stderr + + +def restore_original_streams(original_stdout: object, original_stderr: object) -> None: + """ + Restore original stdout and stderr streams + + Args: + original_stdout: Original stdout stream + original_stderr: Original stderr stream + """ + sys.stdout = original_stdout + sys.stderr = original_stderr diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..65b10246f974f097bf8baae278015682e0c61071 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,37 @@ +# Chat related models +from .chat import ( + ChatCompletionRequest, + FunctionCall, + Message, + MessageContentItem, + ToolCall, +) + +# Exception classes +from .exceptions import ( + ClientDisconnectedError, + QuotaExceededError, + QuotaExceededRetry, + UpstreamError, +) + +# Logging utility classes +from .logging import StreamToLogger, WebSocketConnectionManager, WebSocketLogHandler + +__all__ = [ + # Chat models + "FunctionCall", + "ToolCall", + "MessageContentItem", + "Message", + "ChatCompletionRequest", + # Exceptions + "ClientDisconnectedError", + "QuotaExceededError", + "QuotaExceededRetry", + "UpstreamError", + # Logging tools + "StreamToLogger", + "WebSocketConnectionManager", + "WebSocketLogHandler", +] diff --git a/models/chat.py b/models/chat.py new file mode 100644 index 0000000000000000000000000000000000000000..fb3c673b5a2849a2e9c4834111472f79807eb14a --- /dev/null +++ b/models/chat.py @@ -0,0 +1,89 @@ +from typing import Any, Dict, List, Optional, Union + +from pydantic import BaseModel + +from config import MODEL_NAME + + +class FunctionCall(BaseModel): + name: str + arguments: str + + +class ToolCall(BaseModel): + id: str + type: str = "function" + function: FunctionCall + + +class ImageURL(BaseModel): + url: str + # OpenAI compatible: detail can be 'auto' | 'low' | 'high' + detail: Optional[str] = None + + +class AudioInput(BaseModel): + # Allow either url or data + url: Optional[str] = None + data: Optional[str] = None # Base64 or data:URL + format: Optional[str] = None # e.g., 'wav', 'mp3' + mime_type: Optional[str] = None # e.g., 'audio/wav' + + +class VideoInput(BaseModel): + url: Optional[str] = None + data: Optional[str] = None + format: Optional[str] = None + mime_type: Optional[str] = None + + +class URLRef(BaseModel): + url: str + + +class MessageContentItem(BaseModel): + type: str + text: Optional[str] = None + image_url: Optional[ImageURL] = None + # Added support for input_image (OpenAI compatible) + input_image: Optional[ImageURL] = None + # Extended support for general file_url/media_url and direct url field, maintaining OpenAI style + file_url: Optional[URLRef] = None + media_url: Optional[URLRef] = None + url: Optional[str] = None + # Extended support for input_audio/input_video + input_audio: Optional[AudioInput] = None + input_video: Optional[VideoInput] = None + + +class Message(BaseModel): + role: str + content: Union[str, List[MessageContentItem], None] = None + name: Optional[str] = None + tool_calls: Optional[List[ToolCall]] = None + tool_call_id: Optional[str] = None + # Compatible with third-party clients passing attachments at message level (non-standard but common) + attachments: Optional[List[Any]] = None + images: Optional[List[Any]] = None + files: Optional[List[Any]] = None + media: Optional[List[Any]] = None + + +class ChatCompletionRequest(BaseModel): + messages: List[Message] + model: Optional[str] = MODEL_NAME + stream: Optional[bool] = False + temperature: Optional[float] = None + max_output_tokens: Optional[int] = None + stop: Optional[Union[str, List[str]]] = None + top_p: Optional[float] = None + reasoning_effort: Optional[Union[str, int]] = None + tools: Optional[List[Dict[str, Any]]] = None + tool_choice: Optional[Union[str, Dict[str, Any]]] = None + seed: Optional[int] = None + response_format: Optional[Union[str, Dict[str, Any]]] = None + # Compatible with third-party clients' top-level attachments field (non-standard OpenAI, but common) + attachments: Optional[List[Any]] = None + # MCP per-request endpoint (optional), used for tool calling fallback to MCP service + mcp_endpoint: Optional[str] = None + parallel_tool_calls: Optional[bool] = True diff --git a/models/exceptions.py b/models/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..511f67492c1ff23847c508701d87b65da6737980 --- /dev/null +++ b/models/exceptions.py @@ -0,0 +1,176 @@ +import time +from typing import Any, List, Optional + +from fastapi import HTTPException + + +class AIStudioProxyError(Exception): + """Base exception for AIStudio Proxy errors.""" + def __init__( + self, + message: str, + req_id: Optional[str] = None, + http_status: int = 500, + retry_after: Optional[int] = None, + **kwargs: Any + ): + self.message = message + self.req_id = req_id + self.http_status = http_status + self.retry_after = retry_after + self.timestamp = time.time() + self.context = kwargs + + # Format message with req_id if present for string representation + super().__init__(self.__str__()) + + def __str__(self) -> str: + if self.req_id: + return f"[{self.req_id}] {self.message}" + return self.message + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} message='{self.message}' req_id='{self.req_id}' http_status={self.http_status} context={self.context}>" + + def to_http_exception(self) -> HTTPException: + headers = {} + if self.retry_after is not None: + headers["Retry-After"] = str(self.retry_after) + + return HTTPException( + status_code=self.http_status, + detail=str(self), + headers=headers if headers else None + ) + +# Browser Errors +class BrowserError(AIStudioProxyError): + def __init__(self, message: str, http_status: int = 503, retry_after: int = 30, **kwargs): + super().__init__(message, http_status=http_status, retry_after=retry_after, **kwargs) + +class BrowserInitError(BrowserError): + pass + +class PageNotReadyError(BrowserError): + pass + +class BrowserCrashedError(BrowserError): + def __init__(self, message: str = "Browser crashed unexpectedly", **kwargs): + super().__init__(message, **kwargs) + +class SelectorNotFoundError(BrowserError): + def __init__(self, selector: str, message: str = "Selector not found", **kwargs): + super().__init__(f"{message}: {selector}", selector=selector, **kwargs) + +# Model Errors +class ModelError(AIStudioProxyError): + def __init__(self, message: str, http_status: int = 422, **kwargs): + super().__init__(message, http_status=http_status, **kwargs) + +class InvalidModelError(ModelError): + def __init__(self, model_id: str, available_models: Optional[List[str]] = None, message: str = "Invalid model", **kwargs): + msg = f"{message}: {model_id}" + if available_models: + msg += f" (Available: {', '.join(available_models)})" + super().__init__(msg, model_id=model_id, available_models=available_models, **kwargs) + +class ModelSwitchError(ModelError): + def __init__(self, target_model: str, current_model: str, message: str = "Failed to switch model", **kwargs): + super().__init__(f"{message} from {current_model} to {target_model}", target_model=target_model, current_model=current_model, **kwargs) + +class ModelListError(ModelError): + pass + +# Client Errors +class ClientDisconnectedError(Exception): + """Client disconnected exception (kept simple for backward compatibility but with extra fields).""" + def __init__(self, message: str = "Client disconnected", stage: str = "", req_id: Optional[str] = None, http_status: int = 499): + self.message = message + self.stage = stage + self.req_id = req_id + self.http_status = http_status + super().__init__(message) + +# Validation Errors +class ValidationError(AIStudioProxyError): + def __init__(self, message: str, http_status: int = 400, **kwargs): + super().__init__(message, http_status=http_status, **kwargs) + +class MissingParameterError(ValidationError): + def __init__(self, parameter: str, message: str = "Missing parameter", **kwargs): + super().__init__(f"{message}: {parameter}", parameter=parameter, **kwargs) + +class InvalidParameterError(ValidationError): + def __init__(self, parameter: str, value: Any, reason: str, message: str = "Invalid parameter", **kwargs): + super().__init__(f"{message} {parameter}={value}: {reason}", parameter=parameter, value=value, reason=reason, **kwargs) + +# Stream Errors +class StreamError(AIStudioProxyError): + def __init__(self, message: str, http_status: int = 502, **kwargs): + super().__init__(message, http_status=http_status, **kwargs) + +class ProxyConnectionError(StreamError): + def __init__(self, proxy_url: str, message: str = "Failed to connect to proxy", **kwargs): + super().__init__(f"{message}: {proxy_url}", proxy_url=proxy_url, **kwargs) + +class StreamTimeoutError(StreamError): + def __init__(self, timeout_seconds: float, message: str = "Stream timed out", **kwargs): + super().__init__(f"{message} after {timeout_seconds}s", timeout_seconds=timeout_seconds, **kwargs) + +# Resource Errors +class ResourceError(AIStudioProxyError): + def __init__(self, message: str, http_status: int = 503, retry_after: int = 60, **kwargs): + super().__init__(message, http_status=http_status, retry_after=retry_after, **kwargs) + +class QueueFullError(ResourceError): + def __init__(self, queue_size: int, message: str = "Queue full", **kwargs): + super().__init__(f"{message} (size: {queue_size})", queue_size=queue_size, **kwargs) + +# Upstream Errors +class UpstreamError(AIStudioProxyError): + def __init__(self, message: str, http_status: int = 502, retry_after: int = 10, **kwargs): + super().__init__(message, http_status=http_status, retry_after=retry_after, **kwargs) + +class AIStudioError(UpstreamError): + def __init__(self, error_message: str, status_code: int, message: str = "AI Studio error", **kwargs): + super().__init__(f"{message}: {error_message} (Status: {status_code})", ai_studio_status=status_code, error_message=error_message, **kwargs) + +class QuotaExceededError(UpstreamError): + def __init__(self, message: str = "Quota exceeded", retry_after: int = 3600, **kwargs): + super().__init__(message, retry_after=retry_after, **kwargs) + +class EmptyResponseError(UpstreamError): + def __init__(self, message: str = "Received empty response", **kwargs): + super().__init__(message, **kwargs) + +class QuotaExceededRetry(Exception): + pass + +# Timeout Errors +class TimeoutError(AIStudioProxyError): + def __init__(self, message: str, http_status: int = 504, **kwargs): + super().__init__(message, http_status=http_status, **kwargs) + +class ResponseTimeoutError(TimeoutError): + def __init__(self, timeout_seconds: float, message: str = "Response timed out", **kwargs): + super().__init__(f"{message} after {timeout_seconds}s", timeout_seconds=timeout_seconds, **kwargs) + +class ProcessingTimeoutError(TimeoutError): + def __init__(self, timeout_seconds: Optional[float] = None, message: str = "Processing timeout", **kwargs): + msg = message + if timeout_seconds: + msg += f" after {timeout_seconds}s" + super().__init__(msg, timeout_seconds=timeout_seconds, **kwargs) + +# Configuration Errors +class ConfigurationError(AIStudioProxyError): + def __init__(self, message: str, http_status: int = 500, **kwargs): + super().__init__(message, http_status=http_status, **kwargs) + +class MissingConfigError(ConfigurationError): + def __init__(self, config_key: str, message: str = "Missing configuration", **kwargs): + super().__init__(f"{message}: {config_key}", config_key=config_key, **kwargs) + +class InvalidConfigError(ConfigurationError): + def __init__(self, config_key: str, value: Any, reason: str, message: str = "Invalid configuration", **kwargs): + super().__init__(f"{message} {config_key}={value}: {reason}", config_key=config_key, value=value, reason=reason, **kwargs) diff --git a/models/logging.py b/models/logging.py new file mode 100644 index 0000000000000000000000000000000000000000..a7553498e4a1e3741bca4c98104ff8e2bd1a7b69 --- /dev/null +++ b/models/logging.py @@ -0,0 +1,117 @@ +import asyncio +import datetime +import json +import logging +import sys +from typing import Dict, List + +from fastapi import WebSocket, WebSocketDisconnect + + +class StreamToLogger: + def __init__(self, logger_instance: logging.Logger, log_level: int = logging.INFO): + self.logger = logger_instance + self.log_level = log_level + self.linebuf = "" + + def write(self, buf: str): + try: + temp_linebuf = self.linebuf + buf + self.linebuf = "" + for line in temp_linebuf.splitlines(True): + if line.endswith(("\n", "\r")): + self.logger.log(self.log_level, line.rstrip()) + else: + self.linebuf += line + except Exception as e: + print(f"StreamToLogger Error: {e}", file=sys.__stderr__) + + def flush(self): + try: + if self.linebuf != "": + self.logger.log(self.log_level, self.linebuf.rstrip()) + self.linebuf = "" + except Exception as e: + print(f"StreamToLogger Flush Error: {e}", file=sys.__stderr__) + + def isatty(self): + return False + + +class WebSocketConnectionManager: + def __init__(self): + self.active_connections: Dict[str, WebSocket] = {} + + async def connect(self, client_id: str, websocket: WebSocket): + await websocket.accept() + self.active_connections[client_id] = websocket + logger = logging.getLogger("AIStudioProxyServer") + logger.info(f"WebSocket logging client connected: {client_id}") + try: + await websocket.send_text( + json.dumps( + { + "type": "connection_status", + "status": "connected", + "message": "Connected to real-time log stream.", + "timestamp": datetime.datetime.now().isoformat(), + } + ) + ) + except asyncio.CancelledError: + raise + except Exception as e: + logger.warning(f"Failed to send welcome message to WebSocket client {client_id}: {e}") + + def disconnect(self, client_id: str): + if client_id in self.active_connections: + del self.active_connections[client_id] + logger = logging.getLogger("AIStudioProxyServer") + logger.info(f"WebSocket logging client disconnected: {client_id}") + + async def broadcast(self, message: str): + if not self.active_connections: + return + disconnected_clients: List[str] = [] + active_conns_copy = list(self.active_connections.items()) + logger = logging.getLogger("AIStudioProxyServer") + for client_id, connection in active_conns_copy: + try: + await connection.send_text(message) + except WebSocketDisconnect: + logger.info(f"[WS Broadcast] Client {client_id} disconnected during broadcast.") + disconnected_clients.append(client_id) + except RuntimeError as e: + if "Connection is closed" in str(e): + logger.info(f"[WS Broadcast] Connection for client {client_id} is closed.") + disconnected_clients.append(client_id) + else: + logger.error(f"Runtime error broadcasting to WebSocket {client_id}: {e}") + disconnected_clients.append(client_id) + except asyncio.CancelledError: + raise + except Exception as e: + logger.error(f"Unknown error broadcasting to WebSocket {client_id}: {e}") + disconnected_clients.append(client_id) + if disconnected_clients: + for client_id_to_remove in disconnected_clients: + self.disconnect(client_id_to_remove) + + +class WebSocketLogHandler(logging.Handler): + def __init__(self, manager: WebSocketConnectionManager): + super().__init__() + self.manager = manager + self.formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") + + def emit(self, record: logging.LogRecord): + if self.manager and self.manager.active_connections: + try: + log_entry_str = self.format(record) + try: + current_loop = asyncio.get_running_loop() + current_loop.create_task(self.manager.broadcast(log_entry_str)) + except RuntimeError: + pass + except Exception as e: + print(f"WebSocketLogHandler Error: Failed to broadcast log - {e}", file=sys.__stderr__) diff --git a/monkeytype_config.py b/monkeytype_config.py new file mode 100644 index 0000000000000000000000000000000000000000..91c9b863b57258c0fc2f2ae57436af29420e08d9 --- /dev/null +++ b/monkeytype_config.py @@ -0,0 +1,82 @@ +"""MonkeyType configuration for AIStudioProxyAPI. + +This config: +- Filters to only trace project modules (exclude tests, external libs) +- Enables TypedDict generation for config dicts +- Applies type rewriters to clean up messy unions +- Increases query limit for comprehensive coverage +""" + +from monkeytype.config import DefaultConfig +from monkeytype.typing import ( + ChainedRewriter, + RemoveEmptyContainers, + RewriteConfigDict, + RewriteLargeUnion, +) + + +class AIStudioProxyConfig(DefaultConfig): + """Custom MonkeyType configuration for this project.""" + + def code_filter(self): + """Only trace project modules, exclude tests and external libraries.""" + + def should_trace(code): + # Normalize Windows path separators + filename = code.co_filename.replace("\\", "/") + + # Project modules to trace + project_modules = [ + "api_utils", + "browser_utils", + "stream", + "config", + "models", + "launcher", + "logging_utils", + ] + + # Check if file is in any project module + for module in project_modules: + if f"/{module}/" in filename or filename.endswith(f"/{module}.py"): + # Exclude test files + if "/tests/" not in filename and "/test_" not in filename: + return True + + return False + + return should_trace + + def type_rewriter(self): + """Clean up generated types with chained rewriters.""" + return ChainedRewriter( + [ + RemoveEmptyContainers(), # Union[List[Any], List[int]] -> List[int] + RewriteConfigDict(), # Union[Dict[K,V1], Dict[K,V2]] -> Dict[K, Union[V1,V2]] + RewriteLargeUnion( + max_union_len=3 + ), # Large unions -> Any (strict: max 3 elements) + ] + ) + + def max_typed_dict_size(self) -> int: + """Enable TypedDict generation for dictionaries. + + Since 19.11.2, TypedDict generation is disabled by default. + This enables it for dicts with up to 50 keys, which is critical + for config.settings and similar modules. + """ + return 50 + + def query_limit(self) -> int: + """Increase query limit for comprehensive type inference. + + Default is 2000. We increase to 5000 to capture more traces + and improve type accuracy, especially for polymorphic functions. + """ + return 5000 + + +# MonkeyType will automatically find and use this CONFIG instance +CONFIG = AIStudioProxyConfig() diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000000000000000000000000000000000000..63f74fc56b236690aceb88a32026edf2126e7e81 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,3518 @@ +# This file is automatically @generated by Poetry 2.3.0 and should not be changed by hand. + +[[package]] +name = "aiohttp" +version = "3.9.5" +description = "Async http client/server framework (asyncio)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fcde4c397f673fdec23e6b05ebf8d4751314fa7c24f93334bf1f1364c1c69ac7"}, + {file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d6b3f1fabe465e819aed2c421a6743d8debbde79b6a8600739300630a01bf2c"}, + {file = "aiohttp-3.9.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6ae79c1bc12c34082d92bf9422764f799aee4746fd7a392db46b7fd357d4a17a"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d3ebb9e1316ec74277d19c5f482f98cc65a73ccd5430540d6d11682cd857430"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84dabd95154f43a2ea80deffec9cb44d2e301e38a0c9d331cc4aa0166fe28ae3"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a02fbeca6f63cb1f0475c799679057fc9268b77075ab7cf3f1c600e81dd46b"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c26959ca7b75ff768e2776d8055bf9582a6267e24556bb7f7bd29e677932be72"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:714d4e5231fed4ba2762ed489b4aec07b2b9953cf4ee31e9871caac895a839c0"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7a6a8354f1b62e15d48e04350f13e726fa08b62c3d7b8401c0a1314f02e3558"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c413016880e03e69d166efb5a1a95d40f83d5a3a648d16486592c49ffb76d0db"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:ff84aeb864e0fac81f676be9f4685f0527b660f1efdc40dcede3c251ef1e867f"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:ad7f2919d7dac062f24d6f5fe95d401597fbb015a25771f85e692d043c9d7832"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:702e2c7c187c1a498a4e2b03155d52658fdd6fda882d3d7fbb891a5cf108bb10"}, + {file = "aiohttp-3.9.5-cp310-cp310-win32.whl", hash = "sha256:67c3119f5ddc7261d47163ed86d760ddf0e625cd6246b4ed852e82159617b5fb"}, + {file = "aiohttp-3.9.5-cp310-cp310-win_amd64.whl", hash = "sha256:471f0ef53ccedec9995287f02caf0c068732f026455f07db3f01a46e49d76bbb"}, + {file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e0ae53e33ee7476dd3d1132f932eeb39bf6125083820049d06edcdca4381f342"}, + {file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c088c4d70d21f8ca5c0b8b5403fe84a7bc8e024161febdd4ef04575ef35d474d"}, + {file = "aiohttp-3.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:639d0042b7670222f33b0028de6b4e2fad6451462ce7df2af8aee37dcac55424"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f26383adb94da5e7fb388d441bf09c61e5e35f455a3217bfd790c6b6bc64b2ee"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66331d00fb28dc90aa606d9a54304af76b335ae204d1836f65797d6fe27f1ca2"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ff550491f5492ab5ed3533e76b8567f4b37bd2995e780a1f46bca2024223233"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f22eb3a6c1080d862befa0a89c380b4dafce29dc6cd56083f630073d102eb595"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a81b1143d42b66ffc40a441379387076243ef7b51019204fd3ec36b9f69e77d6"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f64fd07515dad67f24b6ea4a66ae2876c01031de91c93075b8093f07c0a2d93d"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:93e22add827447d2e26d67c9ac0161756007f152fdc5210277d00a85f6c92323"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:55b39c8684a46e56ef8c8d24faf02de4a2b2ac60d26cee93bc595651ff545de9"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4715a9b778f4293b9f8ae7a0a7cef9829f02ff8d6277a39d7f40565c737d3771"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:afc52b8d969eff14e069a710057d15ab9ac17cd4b6753042c407dcea0e40bf75"}, + {file = "aiohttp-3.9.5-cp311-cp311-win32.whl", hash = "sha256:b3df71da99c98534be076196791adca8819761f0bf6e08e07fd7da25127150d6"}, + {file = "aiohttp-3.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:88e311d98cc0bf45b62fc46c66753a83445f5ab20038bcc1b8a1cc05666f428a"}, + {file = "aiohttp-3.9.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:c7a4b7a6cf5b6eb11e109a9755fd4fda7d57395f8c575e166d363b9fc3ec4678"}, + {file = "aiohttp-3.9.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:0a158704edf0abcac8ac371fbb54044f3270bdbc93e254a82b6c82be1ef08f3c"}, + {file = "aiohttp-3.9.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d153f652a687a8e95ad367a86a61e8d53d528b0530ef382ec5aaf533140ed00f"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82a6a97d9771cb48ae16979c3a3a9a18b600a8505b1115cfe354dfb2054468b4"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60cdbd56f4cad9f69c35eaac0fbbdf1f77b0ff9456cebd4902f3dd1cf096464c"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8676e8fd73141ded15ea586de0b7cda1542960a7b9ad89b2b06428e97125d4fa"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da00da442a0e31f1c69d26d224e1efd3a1ca5bcbf210978a2ca7426dfcae9f58"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18f634d540dd099c262e9f887c8bbacc959847cfe5da7a0e2e1cf3f14dbf2daf"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:320e8618eda64e19d11bdb3bd04ccc0a816c17eaecb7e4945d01deee2a22f95f"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:2faa61a904b83142747fc6a6d7ad8fccff898c849123030f8e75d5d967fd4a81"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:8c64a6dc3fe5db7b1b4d2b5cb84c4f677768bdc340611eca673afb7cf416ef5a"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:393c7aba2b55559ef7ab791c94b44f7482a07bf7640d17b341b79081f5e5cd1a"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c671dc117c2c21a1ca10c116cfcd6e3e44da7fcde37bf83b2be485ab377b25da"}, + {file = "aiohttp-3.9.5-cp312-cp312-win32.whl", hash = "sha256:5a7ee16aab26e76add4afc45e8f8206c95d1d75540f1039b84a03c3b3800dd59"}, + {file = "aiohttp-3.9.5-cp312-cp312-win_amd64.whl", hash = "sha256:5ca51eadbd67045396bc92a4345d1790b7301c14d1848feaac1d6a6c9289e888"}, + {file = "aiohttp-3.9.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:694d828b5c41255e54bc2dddb51a9f5150b4eefa9886e38b52605a05d96566e8"}, + {file = "aiohttp-3.9.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0605cc2c0088fcaae79f01c913a38611ad09ba68ff482402d3410bf59039bfb8"}, + {file = "aiohttp-3.9.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4558e5012ee03d2638c681e156461d37b7a113fe13970d438d95d10173d25f78"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbc053ac75ccc63dc3a3cc547b98c7258ec35a215a92bd9f983e0aac95d3d5b"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4109adee842b90671f1b689901b948f347325045c15f46b39797ae1bf17019de"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6ea1a5b409a85477fd8e5ee6ad8f0e40bf2844c270955e09360418cfd09abac"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3c2890ca8c59ee683fd09adf32321a40fe1cf164e3387799efb2acebf090c11"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3916c8692dbd9d55c523374a3b8213e628424d19116ac4308e434dbf6d95bbdd"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8d1964eb7617907c792ca00b341b5ec3e01ae8c280825deadbbd678447b127e1"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d5ab8e1f6bee051a4bf6195e38a5c13e5e161cb7bad83d8854524798bd9fcd6e"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:52c27110f3862a1afbcb2af4281fc9fdc40327fa286c4625dfee247c3ba90156"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:7f64cbd44443e80094309875d4f9c71d0401e966d191c3d469cde4642bc2e031"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8b4f72fbb66279624bfe83fd5eb6aea0022dad8eec62b71e7bf63ee1caadeafe"}, + {file = "aiohttp-3.9.5-cp38-cp38-win32.whl", hash = "sha256:6380c039ec52866c06d69b5c7aad5478b24ed11696f0e72f6b807cfb261453da"}, + {file = "aiohttp-3.9.5-cp38-cp38-win_amd64.whl", hash = "sha256:da22dab31d7180f8c3ac7c7635f3bcd53808f374f6aa333fe0b0b9e14b01f91a"}, + {file = "aiohttp-3.9.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1732102949ff6087589408d76cd6dea656b93c896b011ecafff418c9661dc4ed"}, + {file = "aiohttp-3.9.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c6021d296318cb6f9414b48e6a439a7f5d1f665464da507e8ff640848ee2a58a"}, + {file = "aiohttp-3.9.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:239f975589a944eeb1bad26b8b140a59a3a320067fb3cd10b75c3092405a1372"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b7b30258348082826d274504fbc7c849959f1989d86c29bc355107accec6cfb"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd2adf5c87ff6d8b277814a28a535b59e20bfea40a101db6b3bdca7e9926bc24"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9a3d838441bebcf5cf442700e3963f58b5c33f015341f9ea86dcd7d503c07e2"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e3a1ae66e3d0c17cf65c08968a5ee3180c5a95920ec2731f53343fac9bad106"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c69e77370cce2d6df5d12b4e12bdcca60c47ba13d1cbbc8645dd005a20b738b"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf56238f4bbf49dab8c2dc2e6b1b68502b1e88d335bea59b3f5b9f4c001475"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d1469f228cd9ffddd396d9948b8c9cd8022b6d1bf1e40c6f25b0fb90b4f893ed"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:45731330e754f5811c314901cebdf19dd776a44b31927fa4b4dbecab9e457b0c"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:3fcb4046d2904378e3aeea1df51f697b0467f2aac55d232c87ba162709478c46"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8cf142aa6c1a751fcb364158fd710b8a9be874b81889c2bd13aa8893197455e2"}, + {file = "aiohttp-3.9.5-cp39-cp39-win32.whl", hash = "sha256:7b179eea70833c8dee51ec42f3b4097bd6370892fa93f510f76762105568cf09"}, + {file = "aiohttp-3.9.5-cp39-cp39-win_amd64.whl", hash = "sha256:38d80498e2e169bc61418ff36170e0aad0cd268da8b38a17c4cf29d254a8b3f1"}, + {file = "aiohttp-3.9.5.tar.gz", hash = "sha256:edea7d15772ceeb29db4aff55e482d4bcfb6ae160ce144f2682de02f6d693551"}, +] + +[package.dependencies] +aiosignal = ">=1.1.2" +async-timeout = {version = ">=4.0,<5.0", markers = "python_version < \"3.11\""} +attrs = ">=17.3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +yarl = ">=1.0,<2.0" + +[package.extras] +speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns ; sys_platform == \"linux\" or sys_platform == \"darwin\"", "brotlicffi ; platform_python_implementation != \"CPython\""] + +[[package]] +name = "aiosignal" +version = "1.4.0" +description = "aiosignal: a list of registered asynchronous callbacks" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, + {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, +] + +[package.dependencies] +frozenlist = ">=1.1.0" +typing-extensions = {version = ">=4.2", markers = "python_version < \"3.13\""} + +[[package]] +name = "aiosocks" +version = "0.2.6" +description = "SOCKS proxy client for asyncio and aiohttp" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "aiosocks-0.2.6.tar.gz", hash = "sha256:94dfb2c3ff2fc646c88629e29872599cc93d9137c2eace68dc89079e6a221277"}, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "anyio" +version = "4.11.0" +description = "High-level concurrency and networking framework on top of asyncio or Trio" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"}, + {file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +trio = ["trio (>=0.31.0)"] + +[[package]] +name = "async-timeout" +version = "4.0.3" +description = "Timeout context manager for asyncio programs" +optional = false +python-versions = ">=3.7" +groups = ["main"] +markers = "python_version < \"3.11\"" +files = [ + {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, + {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, +] + +[[package]] +name = "attrs" +version = "25.4.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"}, + {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, +] + +[[package]] +name = "blinker" +version = "1.9.0" +description = "Fast, simple object-to-object and broadcast signaling" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"}, + {file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"}, +] + +[[package]] +name = "browserforge" +version = "1.2.3" +description = "Intelligent browser header & fingerprint generator" +optional = false +python-versions = "<4.0,>=3.8" +groups = ["main"] +files = [ + {file = "browserforge-1.2.3-py3-none-any.whl", hash = "sha256:a6c71ed4688b2f1b0bee757ca82ddad0007cbba68a71eca66ca607dde382f132"}, + {file = "browserforge-1.2.3.tar.gz", hash = "sha256:d5bec6dffd4748b30fbac9f9c1ef33b26c01a23185240bf90011843e174b7ecc"}, +] + +[package.dependencies] +click = "*" +typing_extensions = {version = "*", markers = "python_version < \"3.10\""} + +[package.extras] +all = ["orjson"] + +[[package]] +name = "camoufox" +version = "0.4.11" +description = "Wrapper around Playwright to help launch Camoufox" +optional = false +python-versions = "<4.0,>=3.8" +groups = ["main"] +files = [ + {file = "camoufox-0.4.11-py3-none-any.whl", hash = "sha256:83864d434d159a7566990aa6524429a8d1a859cbf84d2f64ef4a9f29e7d2e5ff"}, + {file = "camoufox-0.4.11.tar.gz", hash = "sha256:0a2c9d24ac5070c104e7c2b125c0a3937f70efa416084ef88afe94c32a72eebe"}, +] + +[package.dependencies] +browserforge = ">=1.2.1,<2.0.0" +click = "*" +geoip2 = {version = "*", optional = true, markers = "extra == \"geoip\""} +language-tags = "*" +lxml = "*" +numpy = "*" +orjson = "*" +platformdirs = "*" +playwright = "*" +pysocks = "*" +pyyaml = "*" +requests = "*" +screeninfo = "*" +tqdm = "*" +typing_extensions = "*" +ua_parser = "*" + +[package.extras] +geoip = ["geoip2"] + +[[package]] +name = "certifi" +version = "2025.11.12" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"}, + {file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"}, +] + +[[package]] +name = "cffi" +version = "2.0.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, + {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, + {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, +] + +[[package]] +name = "click" +version = "8.1.8" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, + {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] +markers = {dev = "sys_platform == \"win32\""} + +[[package]] +name = "coverage" +version = "7.10.7" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a"}, + {file = "coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13"}, + {file = "coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b"}, + {file = "coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807"}, + {file = "coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59"}, + {file = "coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61"}, + {file = "coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14"}, + {file = "coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2"}, + {file = "coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a"}, + {file = "coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417"}, + {file = "coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1"}, + {file = "coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256"}, + {file = "coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba"}, + {file = "coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf"}, + {file = "coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d"}, + {file = "coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f"}, + {file = "coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698"}, + {file = "coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843"}, + {file = "coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546"}, + {file = "coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c"}, + {file = "coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2"}, + {file = "coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a"}, + {file = "coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb"}, + {file = "coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb"}, + {file = "coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520"}, + {file = "coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd"}, + {file = "coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2"}, + {file = "coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681"}, + {file = "coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880"}, + {file = "coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63"}, + {file = "coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399"}, + {file = "coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235"}, + {file = "coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d"}, + {file = "coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a"}, + {file = "coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3"}, + {file = "coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f"}, + {file = "coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431"}, + {file = "coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07"}, + {file = "coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260"}, + {file = "coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] + +[[package]] +name = "cryptography" +version = "42.0.5" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "cryptography-42.0.5-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:a30596bae9403a342c978fb47d9b0ee277699fa53bbafad14706af51fe543d16"}, + {file = "cryptography-42.0.5-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b7ffe927ee6531c78f81aa17e684e2ff617daeba7f189f911065b2ea2d526dec"}, + {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2424ff4c4ac7f6b8177b53c17ed5d8fa74ae5955656867f5a8affaca36a27abb"}, + {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:329906dcc7b20ff3cad13c069a78124ed8247adcac44b10bea1130e36caae0b4"}, + {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:b03c2ae5d2f0fc05f9a2c0c997e1bc18c8229f392234e8a0194f202169ccd278"}, + {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8837fe1d6ac4a8052a9a8ddab256bc006242696f03368a4009be7ee3075cdb7"}, + {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:0270572b8bd2c833c3981724b8ee9747b3ec96f699a9665470018594301439ee"}, + {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:b8cac287fafc4ad485b8a9b67d0ee80c66bf3574f655d3b97ef2e1082360faf1"}, + {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:16a48c23a62a2f4a285699dba2e4ff2d1cff3115b9df052cdd976a18856d8e3d"}, + {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2bce03af1ce5a5567ab89bd90d11e7bbdff56b8af3acbbec1faded8f44cb06da"}, + {file = "cryptography-42.0.5-cp37-abi3-win32.whl", hash = "sha256:b6cd2203306b63e41acdf39aa93b86fb566049aeb6dc489b70e34bcd07adca74"}, + {file = "cryptography-42.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:98d8dc6d012b82287f2c3d26ce1d2dd130ec200c8679b6213b3c73c08b2b7940"}, + {file = "cryptography-42.0.5-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:5e6275c09d2badf57aea3afa80d975444f4be8d3bc58f7f80d2a484c6f9485c8"}, + {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4985a790f921508f36f81831817cbc03b102d643b5fcb81cd33df3fa291a1a1"}, + {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7cde5f38e614f55e28d831754e8a3bacf9ace5d1566235e39d91b35502d6936e"}, + {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7367d7b2eca6513681127ebad53b2582911d1736dc2ffc19f2c3ae49997496bc"}, + {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cd2030f6650c089aeb304cf093f3244d34745ce0cfcc39f20c6fbfe030102e2a"}, + {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a2913c5375154b6ef2e91c10b5720ea6e21007412f6437504ffea2109b5a33d7"}, + {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:c41fb5e6a5fe9ebcd58ca3abfeb51dffb5d83d6775405305bfa8715b76521922"}, + {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3eaafe47ec0d0ffcc9349e1708be2aaea4c6dd4978d76bf6eb0cb2c13636c6fc"}, + {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1b95b98b0d2af784078fa69f637135e3c317091b615cd0905f8b8a087e86fa30"}, + {file = "cryptography-42.0.5-cp39-abi3-win32.whl", hash = "sha256:1f71c10d1e88467126f0efd484bd44bca5e14c664ec2ede64c32f20875c0d413"}, + {file = "cryptography-42.0.5-cp39-abi3-win_amd64.whl", hash = "sha256:a011a644f6d7d03736214d38832e030d8268bcff4a41f728e6030325fea3e400"}, + {file = "cryptography-42.0.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9481ffe3cf013b71b2428b905c4f7a9a4f76ec03065b05ff499bb5682a8d9ad8"}, + {file = "cryptography-42.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ba334e6e4b1d92442b75ddacc615c5476d4ad55cc29b15d590cc6b86efa487e2"}, + {file = "cryptography-42.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:ba3e4a42397c25b7ff88cdec6e2a16c2be18720f317506ee25210f6d31925f9c"}, + {file = "cryptography-42.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:111a0d8553afcf8eb02a4fea6ca4f59d48ddb34497aa8706a6cf536f1a5ec576"}, + {file = "cryptography-42.0.5-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cd65d75953847815962c84a4654a84850b2bb4aed3f26fadcc1c13892e1e29f6"}, + {file = "cryptography-42.0.5-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:e807b3188f9eb0eaa7bbb579b462c5ace579f1cedb28107ce8b48a9f7ad3679e"}, + {file = "cryptography-42.0.5-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f12764b8fffc7a123f641d7d049d382b73f96a34117e0b637b80643169cec8ac"}, + {file = "cryptography-42.0.5-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:37dd623507659e08be98eec89323469e8c7b4c1407c85112634ae3dbdb926fdd"}, + {file = "cryptography-42.0.5.tar.gz", hash = "sha256:6fe07eec95dfd477eb9530aef5bead34fec819b3aaf6c5bd6d20565da607bfe1"}, +] + +[package.dependencies] +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] +nox = ["nox"] +pep8test = ["check-sdist", "click", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + +[[package]] +name = "customtkinter" +version = "5.2.2" +description = "Create modern looking GUIs with Python" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "customtkinter-5.2.2-py3-none-any.whl", hash = "sha256:14ad3e7cd3cb3b9eb642b9d4e8711ae80d3f79fb82545ad11258eeffb2e6b37c"}, + {file = "customtkinter-5.2.2.tar.gz", hash = "sha256:fd8db3bafa961c982ee6030dba80b4c2e25858630756b513986db19113d8d207"}, +] + +[package.dependencies] +darkdetect = "*" +packaging = "*" + +[[package]] +name = "cython" +version = "3.2.1" +description = "The Cython compiler for writing C extensions in the Python language." +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "sys_platform == \"darwin\"" +files = [ + {file = "cython-3.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f1d10b3731171a33563ba81fdcba39c229e45087269dfbe07a1c00e7dcb2537f"}, + {file = "cython-3.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92b814b6066d178a5057b557d372e2a03854e947e41cb9dec21db732fbd14c3c"}, + {file = "cython-3.2.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9fc6abd0532007827d8c6143b2bfedf80c7cb89a3c1c12f058336663489ed2e"}, + {file = "cython-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:14f1ed135347587cfddcd3c3219667cac4f0ea0b66aa1c4c0187d50a1b92c222"}, + {file = "cython-3.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3cb32c650e7f4476941d1f735cae75a2067d5e3279576273bb8802e8ea907222"}, + {file = "cython-3.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a2b306813d7f28aa0a2c3e4e63ada1427a8109917532df942cd5429db228252"}, + {file = "cython-3.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0959d9a36d4f004ce63acc1474b3c606745af98b65e8ae709efd0c10988e9d6b"}, + {file = "cython-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:60c62e734421365135cc2842013d883136054a26c617c001be494235edfc447a"}, + {file = "cython-3.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ea5097d97afd2ab14e98637b7033eba5146de29a5dedf89f5e946076396ab891"}, + {file = "cython-3.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4bf12de0475bb6a21e2336a4a04dc4a2b4dd0507a2a3c703e045f3484266605"}, + {file = "cython-3.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18c64a0f69a1b8164de70ec7efc72250c589fec21519170de21582300f6aaed9"}, + {file = "cython-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5ba14907d5826d8010e82306ce279a0d3650f5b50a4813c80836a17b2213c520"}, + {file = "cython-3.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b4e850fc7a2f72d19679dd083fe4d20bf66860fceabb4f3207112f240249d708"}, + {file = "cython-3.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d20ca4afe993f7dccad3aeddbf4c3536cb0fd3ad6dc7a225935a666a5655af2"}, + {file = "cython-3.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f5a54a757d01ca6a260b02ce5baf17d9db1c2253566ab5844ee4966ff2a69c19"}, + {file = "cython-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:1b81e56584727a328e00d91c164f8f0f2c59b02bf6857c3f000cd830fa571453"}, + {file = "cython-3.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7af6ad01c0fe1965d1d3badaeb6df53c1f37383ebae1ccb405b73f628f87713"}, + {file = "cython-3.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3ea7cd085b62acb67c0fbde5cd17a7d9e47992c965e81ec977cf9ea7c59cd65"}, + {file = "cython-3.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:986aea38fdf231e78d73745f83271c5654852c822dc5141a1d3fba64429a6aa6"}, + {file = "cython-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:4960e26cd34c1385f21646339f2e0361fcdd2ed3c01cdb50fe734add577ec56a"}, + {file = "cython-3.2.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3fa3f625f775f68998c387dd2a9f39314b14dfb728ade11af2b61dfe3121eafc"}, + {file = "cython-3.2.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b48dd7f76809bde103ae9029d52b036af1f81a41b6997cec028864df9eae5ab3"}, + {file = "cython-3.2.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c58a496e8365a6094f2c4981938d08a8937f7a18e3a38b75f0a2652058b3669"}, + {file = "cython-3.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:19124aeb9f86a4d293a6e79e7a971c4fb5b7bd16757432729e580c2f505e2efd"}, + {file = "cython-3.2.1-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:4e9167316bf6ecfea33dcca62f074605648fb93cc053ef46b5deb3e5d12fc0d3"}, + {file = "cython-3.2.1-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3095df6cd470064742f428c937bed7200c5123b9e19ee04aa09ec61281e565a3"}, + {file = "cython-3.2.1-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:db3f53b2d9afb206075a2605f1150aa019f0733c7795a38eccc6119c2e9c3f7b"}, + {file = "cython-3.2.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0fc5e7687ac8f8e2b2fb95648f43e9e074ebaa72fd5cb3d8e20e5f1e8b8e02d9"}, + {file = "cython-3.2.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:bbb3bc152bc0de82b031c8d355418fa4890a92424209d59366c2c0bc9e6cf53c"}, + {file = "cython-3.2.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:a2022bc48ad0c2c0e0485bf0b54902913a3d81086b7d435f4437620c667799f6"}, + {file = "cython-3.2.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:99fdd4ffc2dcb513f4be9ce71c6fedd895b96b1f814655b6bbab196df497b090"}, + {file = "cython-3.2.1-cp39-abi3-win32.whl", hash = "sha256:06071f85bd5ce040464d43b2f9f287742a79f905e81b709fe904567230f1ed51"}, + {file = "cython-3.2.1-cp39-abi3-win_arm64.whl", hash = "sha256:e87c131d59480aee1ebac622b64f287c0e1d665ad1a1b7d498ac48accdb36c6b"}, + {file = "cython-3.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:eb3c59a137aead5aecdda87922aa96ff5541147b5dc84850955717b539753014"}, + {file = "cython-3.2.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e2d11f770259884b337481df78547727e92d042afbbe29e0f21f7246f1f9252"}, + {file = "cython-3.2.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7ccb0e16071fac3f115c083b2c5c25fa6c519c20c662b6c20f2c26fe6d98877"}, + {file = "cython-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:c0ced1c077412b38594c1e3b97ef8db5432e51bf35046678cfe5828e00252c96"}, + {file = "cython-3.2.1-py3-none-any.whl", hash = "sha256:cd72c46e7bffe8250c52d400e72c8d5d3086437b6aeec5b0eca99ccd337f5834"}, + {file = "cython-3.2.1.tar.gz", hash = "sha256:2be1e4d0cbdf7f4cd4d9b8284a034e1989b59fd060f6bd4d24bf3729394d2ed8"}, +] + +[[package]] +name = "darkdetect" +version = "0.8.0" +description = "Detect OS Dark Mode from Python" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "darkdetect-0.8.0-py3-none-any.whl", hash = "sha256:a7509ccf517eaad92b31c214f593dbcf138ea8a43b2935406bbd565e15527a85"}, + {file = "darkdetect-0.8.0.tar.gz", hash = "sha256:b5428e1170263eb5dea44c25dc3895edd75e6f52300986353cd63533fe7df8b1"}, +] + +[package.extras] +macos-listener = ["pyobjc-framework-Cocoa ; platform_system == \"Darwin\""] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +markers = "python_version < \"3.11\"" +files = [ + {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, + {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "execnet" +version = "2.1.2" +description = "execnet: rapid multi-Python deployment" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec"}, + {file = "execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd"}, +] + +[package.extras] +testing = ["hatch", "pre-commit", "pytest", "tox"] + +[[package]] +name = "fastapi" +version = "0.115.12" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d"}, + {file = "fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681"}, +] + +[package.dependencies] +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +starlette = ">=0.40.0,<0.47.0" +typing-extensions = ">=4.8.0" + +[package.extras] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] + +[[package]] +name = "flask" +version = "3.0.3" +description = "A simple framework for building complex web applications." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "flask-3.0.3-py3-none-any.whl", hash = "sha256:34e815dfaa43340d1d15a5c3a02b8476004037eb4840b34910c6e21679d288f3"}, + {file = "flask-3.0.3.tar.gz", hash = "sha256:ceb27b0af3823ea2737928a4d99d125a06175b8512c445cbd9a9ce200ef76842"}, +] + +[package.dependencies] +blinker = ">=1.6.2" +click = ">=8.1.3" +importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""} +itsdangerous = ">=2.1.2" +Jinja2 = ">=3.1.2" +Werkzeug = ">=3.0.0" + +[package.extras] +async = ["asgiref (>=3.2)"] +dotenv = ["python-dotenv"] + +[[package]] +name = "frozenlist" +version = "1.8.0" +description = "A list-like structure which implements collections.abc.MutableSequence" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011"}, + {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565"}, + {file = "frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7"}, + {file = "frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a"}, + {file = "frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6"}, + {file = "frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967"}, + {file = "frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25"}, + {file = "frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b"}, + {file = "frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa"}, + {file = "frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf"}, + {file = "frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746"}, + {file = "frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed"}, + {file = "frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496"}, + {file = "frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231"}, + {file = "frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7"}, + {file = "frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806"}, + {file = "frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0"}, + {file = "frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda"}, + {file = "frozenlist-1.8.0-cp39-cp39-win32.whl", hash = "sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087"}, + {file = "frozenlist-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a"}, + {file = "frozenlist-1.8.0-cp39-cp39-win_arm64.whl", hash = "sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103"}, + {file = "frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d"}, + {file = "frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad"}, +] + +[[package]] +name = "geoip2" +version = "5.1.0" +description = "MaxMind GeoIP2 API" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "geoip2-5.1.0-py3-none-any.whl", hash = "sha256:445a058995ad5bb3e665ae716413298d4383b1fb38d372ad59b9b405f6b0ca19"}, + {file = "geoip2-5.1.0.tar.gz", hash = "sha256:ee3f87f0ce9325eb6484fe18cbd9771a03d0a2bad1dd156fa3584fafa562d39a"}, +] + +[package.dependencies] +aiohttp = ">=3.6.2,<4.0.0" +maxminddb = ">=2.7.0,<3.0.0" +requests = ">=2.24.0,<3.0.0" + +[[package]] +name = "greenlet" +version = "3.2.4" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d"}, + {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5"}, + {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f"}, + {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f47617f698838ba98f4ff4189aef02e7343952df3a615f847bb575c3feb177a7"}, + {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af41be48a4f60429d5cad9d22175217805098a9ef7c40bfef44f7669fb9d74d8"}, + {file = "greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c"}, + {file = "greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8"}, + {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52"}, + {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa"}, + {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c"}, + {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5"}, + {file = "greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9"}, + {file = "greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0"}, + {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0"}, + {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f"}, + {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0"}, + {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d"}, + {file = "greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02"}, + {file = "greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671"}, + {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b"}, + {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae"}, + {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b"}, + {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929"}, + {file = "greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b"}, + {file = "greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337"}, + {file = "greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269"}, + {file = "greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681"}, + {file = "greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01"}, + {file = "greenlet-3.2.4-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:18d9260df2b5fbf41ae5139e1be4e796d99655f023a636cd0e11e6406cca7d58"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:671df96c1f23c4a0d4077a325483c1503c96a1b7d9db26592ae770daa41233d4"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:16458c245a38991aa19676900d48bd1a6f2ce3e16595051a4db9d012154e8433"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df"}, + {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594"}, + {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98"}, + {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:28a3c6b7cd72a96f61b0e4b2a36f681025b60ae4779cc73c1535eb5f29560b10"}, + {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:52206cd642670b0b320a1fd1cbfd95bca0e043179c1d8a045f2c6109dfe973be"}, + {file = "greenlet-3.2.4-cp39-cp39-win32.whl", hash = "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b"}, + {file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"}, + {file = "greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d"}, +] + +[package.extras] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil", "setuptools"] + +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "idna" +version = "3.11" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "importlib-metadata" +version = "8.7.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version == \"3.9\"" +files = [ + {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, + {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, +] + +[package.dependencies] +zipp = ">=3.20" + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +perf = ["ipython"] +test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["pytest-mypy"] + +[[package]] +name = "iniconfig" +version = "2.1.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +description = "Safely pass data to untrusted environments and back." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, + {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "language-tags" +version = "1.2.0" +description = "This project is a Python version of the language-tags Javascript project." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "language_tags-1.2.0-py3-none-any.whl", hash = "sha256:d815604622242fdfbbfd747b40c31213617fd03734a267f2e39ee4bd73c88722"}, + {file = "language_tags-1.2.0.tar.gz", hash = "sha256:e934acba3e3dc85f867703eca421847a9ab7b7679b11b5d5cfd096febbf8bde6"}, +] + +[[package]] +name = "libcst" +version = "1.8.6" +description = "A concrete syntax tree with AST-like properties for Python 3.0 through 3.14 programs." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "libcst-1.8.6-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a20c5182af04332cc94d8520792befda06d73daf2865e6dddc5161c72ea92cb9"}, + {file = "libcst-1.8.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:36473e47cb199b7e6531d653ee6ffed057de1d179301e6c67f651f3af0b499d6"}, + {file = "libcst-1.8.6-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:06fc56335a45d61b7c1b856bfab4587b84cfe31e9d6368f60bb3c9129d900f58"}, + {file = "libcst-1.8.6-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6b23d14a7fc0addd9795795763af26b185deb7c456b1e7cc4d5228e69dab5ce8"}, + {file = "libcst-1.8.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:16cfe0cfca5fd840e1fb2c30afb628b023d3085b30c3484a79b61eae9d6fe7ba"}, + {file = "libcst-1.8.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:455f49a93aea4070132c30ebb6c07c2dea0ba6c1fde5ffde59fc45dbb9cfbe4b"}, + {file = "libcst-1.8.6-cp310-cp310-win_amd64.whl", hash = "sha256:72cca15800ffc00ba25788e4626189fe0bc5fe2a0c1cb4294bce2e4df21cc073"}, + {file = "libcst-1.8.6-cp310-cp310-win_arm64.whl", hash = "sha256:6cad63e3a26556b020b634d25a8703b605c0e0b491426b3e6b9e12ed20f09100"}, + {file = "libcst-1.8.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3649a813660fbffd7bc24d3f810b1f75ac98bd40d9d6f56d1f0ee38579021073"}, + {file = "libcst-1.8.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0cbe17067055829607c5ba4afa46bfa4d0dd554c0b5a583546e690b7367a29b6"}, + {file = "libcst-1.8.6-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:59a7e388c57d21d63722018978a8ddba7b176e3a99bd34b9b84a576ed53f2978"}, + {file = "libcst-1.8.6-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:b6c1248cc62952a3a005792b10cdef2a4e130847be9c74f33a7d617486f7e532"}, + {file = "libcst-1.8.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6421a930b028c5ef4a943b32a5a78b7f1bf15138214525a2088f11acbb7d3d64"}, + {file = "libcst-1.8.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6d8b67874f2188399a71a71731e1ba2d1a2c3173b7565d1cc7ffb32e8fbaba5b"}, + {file = "libcst-1.8.6-cp311-cp311-win_amd64.whl", hash = "sha256:b0d8c364c44ae343937f474b2e492c1040df96d94530377c2f9263fb77096e4f"}, + {file = "libcst-1.8.6-cp311-cp311-win_arm64.whl", hash = "sha256:5dcaaebc835dfe5755bc85f9b186fb7e2895dda78e805e577fef1011d51d5a5c"}, + {file = "libcst-1.8.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0c13d5bd3d8414a129e9dccaf0e5785108a4441e9b266e1e5e9d1f82d1b943c9"}, + {file = "libcst-1.8.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1472eeafd67cdb22544e59cf3bfc25d23dc94058a68cf41f6654ff4fcb92e09"}, + {file = "libcst-1.8.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:089c58e75cb142ec33738a1a4ea7760a28b40c078ab2fd26b270dac7d2633a4d"}, + {file = "libcst-1.8.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c9d7aeafb1b07d25a964b148c0dda9451efb47bbbf67756e16eeae65004b0eb5"}, + {file = "libcst-1.8.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:207481197afd328aa91d02670c15b48d0256e676ce1ad4bafb6dc2b593cc58f1"}, + {file = "libcst-1.8.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:375965f34cc6f09f5f809244d3ff9bd4f6cb6699f571121cebce53622e7e0b86"}, + {file = "libcst-1.8.6-cp312-cp312-win_amd64.whl", hash = "sha256:da95b38693b989eaa8d32e452e8261cfa77fe5babfef1d8d2ac25af8c4aa7e6d"}, + {file = "libcst-1.8.6-cp312-cp312-win_arm64.whl", hash = "sha256:bff00e1c766658adbd09a175267f8b2f7616e5ee70ce45db3d7c4ce6d9f6bec7"}, + {file = "libcst-1.8.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7445479ebe7d1aff0ee094ab5a1c7718e1ad78d33e3241e1a1ec65dcdbc22ffb"}, + {file = "libcst-1.8.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fc3fef8a2c983e7abf5d633e1884c5dd6fa0dcb8f6e32035abd3d3803a3a196"}, + {file = "libcst-1.8.6-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1a3a5e4ee870907aa85a4076c914ae69066715a2741b821d9bf16f9579de1105"}, + {file = "libcst-1.8.6-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6609291c41f7ad0bac570bfca5af8fea1f4a27987d30a1fa8b67fe5e67e6c78d"}, + {file = "libcst-1.8.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25eaeae6567091443b5374b4c7d33a33636a2d58f5eda02135e96fc6c8807786"}, + {file = "libcst-1.8.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04030ea4d39d69a65873b1d4d877def1c3951a7ada1824242539e399b8763d30"}, + {file = "libcst-1.8.6-cp313-cp313-win_amd64.whl", hash = "sha256:8066f1b70f21a2961e96bedf48649f27dfd5ea68be5cd1bed3742b047f14acde"}, + {file = "libcst-1.8.6-cp313-cp313-win_arm64.whl", hash = "sha256:c188d06b583900e662cd791a3f962a8c96d3dfc9b36ea315be39e0a4c4792ebf"}, + {file = "libcst-1.8.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c41c76e034a1094afed7057023b1d8967f968782433f7299cd170eaa01ec033e"}, + {file = "libcst-1.8.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5432e785322aba3170352f6e72b32bea58d28abd141ac37cc9b0bf6b7c778f58"}, + {file = "libcst-1.8.6-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:85b7025795b796dea5284d290ff69de5089fc8e989b25d6f6f15b6800be7167f"}, + {file = "libcst-1.8.6-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:536567441182a62fb706e7aa954aca034827b19746832205953b2c725d254a93"}, + {file = "libcst-1.8.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f04d3672bde1704f383a19e8f8331521abdbc1ed13abb349325a02ac56e5012"}, + {file = "libcst-1.8.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f04febcd70e1e67917be7de513c8d4749d2e09206798558d7fe632134426ea4"}, + {file = "libcst-1.8.6-cp313-cp313t-win_amd64.whl", hash = "sha256:1dc3b897c8b0f7323412da3f4ad12b16b909150efc42238e19cbf19b561cc330"}, + {file = "libcst-1.8.6-cp313-cp313t-win_arm64.whl", hash = "sha256:44f38139fa95e488db0f8976f9c7ca39a64d6bc09f2eceef260aa1f6da6a2e42"}, + {file = "libcst-1.8.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b188e626ce61de5ad1f95161b8557beb39253de4ec74fc9b1f25593324a0279c"}, + {file = "libcst-1.8.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:87e74f7d7dfcba9efa91127081e22331d7c42515f0a0ac6e81d4cf2c3ed14661"}, + {file = "libcst-1.8.6-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:3a926a4b42015ee24ddfc8ae940c97bd99483d286b315b3ce82f3bafd9f53474"}, + {file = "libcst-1.8.6-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:3f4fbb7f569e69fd9e89d9d9caa57ca42c577c28ed05062f96a8c207594e75b8"}, + {file = "libcst-1.8.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:08bd63a8ce674be431260649e70fca1d43f1554f1591eac657f403ff8ef82c7a"}, + {file = "libcst-1.8.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e00e275d4ba95d4963431ea3e409aa407566a74ee2bf309a402f84fc744abe47"}, + {file = "libcst-1.8.6-cp314-cp314-win_amd64.whl", hash = "sha256:fea5c7fa26556eedf277d4f72779c5ede45ac3018650721edd77fd37ccd4a2d4"}, + {file = "libcst-1.8.6-cp314-cp314-win_arm64.whl", hash = "sha256:bb9b4077bdf8857b2483879cbbf70f1073bc255b057ec5aac8a70d901bb838e9"}, + {file = "libcst-1.8.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:55ec021a296960c92e5a33b8d93e8ad4182b0eab657021f45262510a58223de1"}, + {file = "libcst-1.8.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ba9ab2b012fbd53b36cafd8f4440a6b60e7e487cd8b87428e57336b7f38409a4"}, + {file = "libcst-1.8.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c0a0cc80aebd8aa15609dd4d330611cbc05e9b4216bcaeabba7189f99ef07c28"}, + {file = "libcst-1.8.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:42a4f68121e2e9c29f49c97f6154e8527cd31021809cc4a941c7270aa64f41aa"}, + {file = "libcst-1.8.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a434c521fadaf9680788b50d5c21f4048fa85ed19d7d70bd40549fbaeeecab1"}, + {file = "libcst-1.8.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6a65f844d813ab4ef351443badffa0ae358f98821561d19e18b3190f59e71996"}, + {file = "libcst-1.8.6-cp314-cp314t-win_amd64.whl", hash = "sha256:bdb14bc4d4d83a57062fed2c5da93ecb426ff65b0dc02ddf3481040f5f074a82"}, + {file = "libcst-1.8.6-cp314-cp314t-win_arm64.whl", hash = "sha256:819c8081e2948635cab60c603e1bbdceccdfe19104a242530ad38a36222cb88f"}, + {file = "libcst-1.8.6-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:cb2679ef532f9fa5be5c5a283b6357cb6e9888a8dd889c4bb2b01845a29d8c0b"}, + {file = "libcst-1.8.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:203ec2a83f259baf686b9526268cd23d048d38be5589594ef143aee50a4faf7e"}, + {file = "libcst-1.8.6-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:6366ab2107425bf934b0c83311177f2a371bfc757ee8c6ad4a602d7cbcc2f363"}, + {file = "libcst-1.8.6-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:6aa11df6c58812f731172b593fcb485d7ba09ccc3b52fea6c7f26a43377dc748"}, + {file = "libcst-1.8.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:351ab879c2fd20d9cb2844ed1ea3e617ed72854d3d1e2b0880ede9c3eea43ba8"}, + {file = "libcst-1.8.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:98fa1ca321c81fb1f02e5c43f956ca543968cc1a30b264fd8e0a2e1b0b0bf106"}, + {file = "libcst-1.8.6-cp39-cp39-win_amd64.whl", hash = "sha256:25fc7a1303cad7639ad45ec38c06789b4540b7258e9a108924aaa2c132af4aca"}, + {file = "libcst-1.8.6-cp39-cp39-win_arm64.whl", hash = "sha256:4d7bbdd35f3abdfb5ac5d1a674923572dab892b126a58da81ff2726102d6ec2e"}, + {file = "libcst-1.8.6.tar.gz", hash = "sha256:f729c37c9317126da9475bdd06a7208eb52fcbd180a6341648b45a56b4ba708b"}, +] + +[package.dependencies] +pyyaml = [ + {version = ">=5.2", markers = "python_version < \"3.13\""}, + {version = ">=6.0.3", markers = "python_version >= \"3.14\""}, +] +pyyaml-ft = {version = ">=8.0.0", markers = "python_version == \"3.13\""} +typing-extensions = {version = "*", markers = "python_version < \"3.10\""} + +[[package]] +name = "lxml" +version = "6.0.2" +description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "lxml-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e77dd455b9a16bbd2a5036a63ddbd479c19572af81b624e79ef422f929eef388"}, + {file = "lxml-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d444858b9f07cefff6455b983aea9a67f7462ba1f6cbe4a21e8bf6791bf2153"}, + {file = "lxml-6.0.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f952dacaa552f3bb8834908dddd500ba7d508e6ea6eb8c52eb2d28f48ca06a31"}, + {file = "lxml-6.0.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71695772df6acea9f3c0e59e44ba8ac50c4f125217e84aab21074a1a55e7e5c9"}, + {file = "lxml-6.0.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17f68764f35fd78d7c4cc4ef209a184c38b65440378013d24b8aecd327c3e0c8"}, + {file = "lxml-6.0.2-cp310-cp310-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:058027e261afed589eddcfe530fcc6f3402d7fd7e89bfd0532df82ebc1563dba"}, + {file = "lxml-6.0.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8ffaeec5dfea5881d4c9d8913a32d10cfe3923495386106e4a24d45300ef79c"}, + {file = "lxml-6.0.2-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:f2e3b1a6bb38de0bc713edd4d612969dd250ca8b724be8d460001a387507021c"}, + {file = "lxml-6.0.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d6690ec5ec1cce0385cb20896b16be35247ac8c2046e493d03232f1c2414d321"}, + {file = "lxml-6.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2a50c3c1d11cad0ebebbac357a97b26aa79d2bcaf46f256551152aa85d3a4d1"}, + {file = "lxml-6.0.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3efe1b21c7801ffa29a1112fab3b0f643628c30472d507f39544fd48e9549e34"}, + {file = "lxml-6.0.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:59c45e125140b2c4b33920d21d83681940ca29f0b83f8629ea1a2196dc8cfe6a"}, + {file = "lxml-6.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:452b899faa64f1805943ec1c0c9ebeaece01a1af83e130b69cdefeda180bb42c"}, + {file = "lxml-6.0.2-cp310-cp310-win32.whl", hash = "sha256:1e786a464c191ca43b133906c6903a7e4d56bef376b75d97ccbb8ec5cf1f0a4b"}, + {file = "lxml-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:dacf3c64ef3f7440e3167aa4b49aa9e0fb99e0aa4f9ff03795640bf94531bcb0"}, + {file = "lxml-6.0.2-cp310-cp310-win_arm64.whl", hash = "sha256:45f93e6f75123f88d7f0cfd90f2d05f441b808562bf0bc01070a00f53f5028b5"}, + {file = "lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607"}, + {file = "lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938"}, + {file = "lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d"}, + {file = "lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438"}, + {file = "lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964"}, + {file = "lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d"}, + {file = "lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7"}, + {file = "lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178"}, + {file = "lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553"}, + {file = "lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb"}, + {file = "lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a"}, + {file = "lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c"}, + {file = "lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7"}, + {file = "lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46"}, + {file = "lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078"}, + {file = "lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285"}, + {file = "lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456"}, + {file = "lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092"}, + {file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f"}, + {file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8"}, + {file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f"}, + {file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6"}, + {file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322"}, + {file = "lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849"}, + {file = "lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f"}, + {file = "lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6"}, + {file = "lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77"}, + {file = "lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a"}, + {file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679"}, + {file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659"}, + {file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484"}, + {file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2"}, + {file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314"}, + {file = "lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2"}, + {file = "lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7"}, + {file = "lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf"}, + {file = "lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe"}, + {file = "lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9"}, + {file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917"}, + {file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f"}, + {file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8"}, + {file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a"}, + {file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c"}, + {file = "lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b"}, + {file = "lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed"}, + {file = "lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8"}, + {file = "lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d"}, + {file = "lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9"}, + {file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e"}, + {file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d"}, + {file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec"}, + {file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272"}, + {file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f"}, + {file = "lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312"}, + {file = "lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca"}, + {file = "lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c"}, + {file = "lxml-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a656ca105115f6b766bba324f23a67914d9c728dafec57638e2b92a9dcd76c62"}, + {file = "lxml-6.0.2-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c54d83a2188a10ebdba573f16bd97135d06c9ef60c3dc495315c7a28c80a263f"}, + {file = "lxml-6.0.2-cp38-cp38-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:1ea99340b3c729beea786f78c38f60f4795622f36e305d9c9be402201efdc3b7"}, + {file = "lxml-6.0.2-cp38-cp38-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:af85529ae8d2a453feee4c780d9406a5e3b17cee0dd75c18bd31adcd584debc3"}, + {file = "lxml-6.0.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:fe659f6b5d10fb5a17f00a50eb903eb277a71ee35df4615db573c069bcf967ac"}, + {file = "lxml-6.0.2-cp38-cp38-win32.whl", hash = "sha256:5921d924aa5468c939d95c9814fa9f9b5935a6ff4e679e26aaf2951f74043512"}, + {file = "lxml-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:0aa7070978f893954008ab73bb9e3c24a7c56c054e00566a21b553dc18105fca"}, + {file = "lxml-6.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2c8458c2cdd29589a8367c09c8f030f1d202be673f0ca224ec18590b3b9fb694"}, + {file = "lxml-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3fee0851639d06276e6b387f1c190eb9d7f06f7f53514e966b26bae46481ec90"}, + {file = "lxml-6.0.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b2142a376b40b6736dfc214fd2902409e9e3857eff554fed2d3c60f097e62a62"}, + {file = "lxml-6.0.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6b5b39cc7e2998f968f05309e666103b53e2edd01df8dc51b90d734c0825444"}, + {file = "lxml-6.0.2-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4aec24d6b72ee457ec665344a29acb2d35937d5192faebe429ea02633151aad"}, + {file = "lxml-6.0.2-cp39-cp39-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:b42f4d86b451c2f9d06ffb4f8bbc776e04df3ba070b9fe2657804b1b40277c48"}, + {file = "lxml-6.0.2-cp39-cp39-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cdaefac66e8b8f30e37a9b4768a391e1f8a16a7526d5bc77a7928408ef68e93"}, + {file = "lxml-6.0.2-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:b738f7e648735714bbb82bdfd030203360cfeab7f6e8a34772b3c8c8b820568c"}, + {file = "lxml-6.0.2-cp39-cp39-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daf42de090d59db025af61ce6bdb2521f0f102ea0e6ea310f13c17610a97da4c"}, + {file = "lxml-6.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:66328dabea70b5ba7e53d94aa774b733cf66686535f3bc9250a7aab53a91caaf"}, + {file = "lxml-6.0.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:e237b807d68a61fc3b1e845407e27e5eb8ef69bc93fe8505337c1acb4ee300b6"}, + {file = "lxml-6.0.2-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:ac02dc29fd397608f8eb15ac1610ae2f2f0154b03f631e6d724d9e2ad4ee2c84"}, + {file = "lxml-6.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:817ef43a0c0b4a77bd166dc9a09a555394105ff3374777ad41f453526e37f9cb"}, + {file = "lxml-6.0.2-cp39-cp39-win32.whl", hash = "sha256:bc532422ff26b304cfb62b328826bd995c96154ffd2bac4544f37dbb95ecaa8f"}, + {file = "lxml-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:995e783eb0374c120f528f807443ad5a83a656a8624c467ea73781fc5f8a8304"}, + {file = "lxml-6.0.2-cp39-cp39-win_arm64.whl", hash = "sha256:08b9d5e803c2e4725ae9e8559ee880e5328ed61aa0935244e0515d7d9dbec0aa"}, + {file = "lxml-6.0.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e748d4cf8fef2526bb2a589a417eba0c8674e29ffcb570ce2ceca44f1e567bf6"}, + {file = "lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4ddb1049fa0579d0cbd00503ad8c58b9ab34d1254c77bc6a5576d96ec7853dba"}, + {file = "lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb233f9c95f83707dae461b12b720c1af9c28c2d19208e1be03387222151daf5"}, + {file = "lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc456d04db0515ce3320d714a1eac7a97774ff0849e7718b492d957da4631dd4"}, + {file = "lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2613e67de13d619fd283d58bda40bff0ee07739f624ffee8b13b631abf33083d"}, + {file = "lxml-6.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:24a8e756c982c001ca8d59e87c80c4d9dcd4d9b44a4cbeb8d9be4482c514d41d"}, + {file = "lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700"}, + {file = "lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee"}, + {file = "lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f"}, + {file = "lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9"}, + {file = "lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a"}, + {file = "lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e"}, + {file = "lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62"}, +] + +[package.extras] +cssselect = ["cssselect (>=0.7)"] +html-clean = ["lxml_html_clean"] +html5 = ["html5lib"] +htmlsoup = ["BeautifulSoup4"] + +[[package]] +name = "markupsafe" +version = "3.0.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, + {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, + {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, + {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, + {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, + {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, + {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, + {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, +] + +[[package]] +name = "maxminddb" +version = "2.8.2" +description = "Reader for the MaxMind DB format" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "maxminddb-2.8.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3db07d41644fbb712f31d8837feb3109a8b73f42f7ef1be32b3eb84af96f062b"}, + {file = "maxminddb-2.8.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cb7797d3cf35160f5ed54e12e7bddb12ec011e838bedc9201f7c2987ea284a3c"}, + {file = "maxminddb-2.8.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:08df1edfb85bd2e30e8f7a2c512be15c5c169492e5972afd3ddab7c498b5aad2"}, + {file = "maxminddb-2.8.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c671d56b95543a28ec05628fa139d9db9f43f53f09f466b6b2d0dae09adddb"}, + {file = "maxminddb-2.8.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3f7453048c0f20750a77091eb38443abf1e30f6d6e41de3b8358ea6e7cd73730"}, + {file = "maxminddb-2.8.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:990b7993503e77e44baed17f2c7cd1006112f54bd132af354ef4640c6d83a68b"}, + {file = "maxminddb-2.8.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:027a8bc9e622532196cb84f14f8b18d555b0937a3e0a6e95805db215f98c451b"}, + {file = "maxminddb-2.8.2-cp310-cp310-win32.whl", hash = "sha256:883e17e942631a3b99747a4dc8d55c3e20ac2e342696e828a961d9dcd1811cbb"}, + {file = "maxminddb-2.8.2-cp310-cp310-win_amd64.whl", hash = "sha256:472d6c61c5c1994989fbdefc7a17adec245330f3e9a11021b9460c5b9f27bcd1"}, + {file = "maxminddb-2.8.2-cp310-cp310-win_arm64.whl", hash = "sha256:67828addad0cb0ef21fd37549db58a16f219cc1e9c6243b089a726dfe8dfcd34"}, + {file = "maxminddb-2.8.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7c6d18662c285bb5dfa3b8f2b222c5f77d2521f1d9260a025d8c8b8ec87916f4"}, + {file = "maxminddb-2.8.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4fd06457cee79e465e72cf21a46c78d5a8574dfeed98b54c106f14f47d237009"}, + {file = "maxminddb-2.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:711beeb8fda0169c379e77758499f4b7feb56a89327e894fff57bf35d9fe35d5"}, + {file = "maxminddb-2.8.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc0eaef5f5a371484542503d70b979e14dd2efded78a19029e78c4e016d7d694"}, + {file = "maxminddb-2.8.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a38f213e887c273ba14f563980f15b620bf600576d3ba530dd12416004dcd33"}, + {file = "maxminddb-2.8.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a3fbf0d36cb3fad3743cd2c522855577209c533a782c7176b4d54550928f6935"}, + {file = "maxminddb-2.8.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b516e113564228ed1965a2454bba901a85984aef599b61e98ce743ce94c22a07"}, + {file = "maxminddb-2.8.2-cp311-cp311-win32.whl", hash = "sha256:c7fc5b3ea6b9a664712544738f14da256981031d0a951e590508a79f4d4a37d1"}, + {file = "maxminddb-2.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:590399b8c6b41aaf42385da412bb0c0690c3db2720fb3a6e7d6967aecc4342ad"}, + {file = "maxminddb-2.8.2-cp311-cp311-win_arm64.whl", hash = "sha256:f63d07b6a6d402548f153e0cc31fd21ddd7825a457d4da6205fef6b9211361d8"}, + {file = "maxminddb-2.8.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bcfb9bc5e31875dd6c1e2de9d748ce403ca5d5d4bc6167973bb0b1bd294bf8d7"}, + {file = "maxminddb-2.8.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e12bec7f672af46e2177e7c1cd5d330eb969f0dc42f672e250b3d5d72e61778d"}, + {file = "maxminddb-2.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b23103a754ff1e795d6e107ae23bf9b3360bce9e9bff08c58e388dc2f3fd85ad"}, + {file = "maxminddb-2.8.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c4a10cb799ed3449d063883df962b76b55fdfe0756dfa82eed9765d95e8fd6e"}, + {file = "maxminddb-2.8.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6315977c0512cb7d982bc2eb869355a168f12ef6d2bd5a4f2c93148bc3c03fdc"}, + {file = "maxminddb-2.8.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b24594f04d03855687b8166ee2c7b788f1e1836b4c5fef2e55fc19327f507ac"}, + {file = "maxminddb-2.8.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b07b72d9297179c74344aaecad48c88dfdea4422e16721b5955015800d865da2"}, + {file = "maxminddb-2.8.2-cp312-cp312-win32.whl", hash = "sha256:51d9717354ee7aa02d52c15115fec2d29bb33f31d6c9f5a8a5aaa2c25dc66e63"}, + {file = "maxminddb-2.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:18132ccd77ad68863b9022451655cbe1e8fc3c973bafcad66a252eff2732a5c1"}, + {file = "maxminddb-2.8.2-cp312-cp312-win_arm64.whl", hash = "sha256:59934eb00274f8b7860927f470a2b9b049842f91e2524a24ade99e16755320f2"}, + {file = "maxminddb-2.8.2-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b32a8b61e0dae09c80f41dcd6dc4a442a3cc94b7874a18931daecfea274f640c"}, + {file = "maxminddb-2.8.2-cp313-cp313-android_21_x86_64.whl", hash = "sha256:5f12674cee687cd41c9be1c9ab806bd6a777864e762d5f34ec57c0afa9a21411"}, + {file = "maxminddb-2.8.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:995a506a02f70a33ba5ee9f73ce737ef8cdb219bfca3177db79622ebc5624057"}, + {file = "maxminddb-2.8.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5ef9b7f106a1e9ee08f47cd98f7ae80fa40fc0fd40d97cf0d011266738847b52"}, + {file = "maxminddb-2.8.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:adeceeb591755b36a0dc544b92f6d80fc5c112519f5ed8211c34d2ad796bfac0"}, + {file = "maxminddb-2.8.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5c8df08cbdafaa04f7d36a0506e342e4cd679587b56b0fad065b4777e94c8065"}, + {file = "maxminddb-2.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3e982112e239925c2d8739f834c71539947e54747e56e66c6d960ac356432f32"}, + {file = "maxminddb-2.8.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5ef30c32af0107e6b0b9d53f9ae949cf74ddb6882025054bd7500a7b1eb02ec0"}, + {file = "maxminddb-2.8.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685df893f44606dcb1353b31762b18a2a9537015f1b9e7c0bb3ae74c9fbced32"}, + {file = "maxminddb-2.8.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3dc27c443cf27b35d4d77ff90fbc6caf1c4e28cffd967775b11cf993af5b9d1"}, + {file = "maxminddb-2.8.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:742e857b4411ae3d59c555c2aa96856f72437374cf668c3bed18647092584af6"}, + {file = "maxminddb-2.8.2-cp313-cp313-win32.whl", hash = "sha256:1fba9c16f5e492eee16362e8204aaec30241167a3466874ca9b0521dec32d63e"}, + {file = "maxminddb-2.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:cfbfee615d2566124cb6232401d89f15609f5297eb4f022f1f6a14205c091df6"}, + {file = "maxminddb-2.8.2-cp313-cp313-win_arm64.whl", hash = "sha256:2ade954d94087039fc45de99eeae0e2f0480d69a767abd417bd0742bf5d177ab"}, + {file = "maxminddb-2.8.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7d5db6d4f8caaf7b753a0f6782765ea5352409ef6d430196b0dc7c61c0a8c72b"}, + {file = "maxminddb-2.8.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:bda6015f617b4ec6f1a49ae74b1a36c10d997602d3e9141514ef11983e6ddf8d"}, + {file = "maxminddb-2.8.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:4e32f5608af05bc0b6cee91edd0698f6a310ae9dd0f3cebfb524a6b444c003a2"}, + {file = "maxminddb-2.8.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:5abf18c51f3a3e5590ea77d43bff159a9f88cec1f95a7e3fc2a39a21fc8f9e7c"}, + {file = "maxminddb-2.8.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3c8d57063ff2c6d0690e5d907a10b5b6ba64e0ab5e6d8661b6075fbda854e97d"}, + {file = "maxminddb-2.8.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73d603c7202e1338bdbb3ead8a3db4f74825e419ecc8733ef8a76c14366800d2"}, + {file = "maxminddb-2.8.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:acca37ed0372efa01251da32db1a5d81189369449bc4b943d3087ebc9e30e814"}, + {file = "maxminddb-2.8.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1e1e3ef04a686cf7d893a8274ddc0081bd40121ac4923b67e8caa902094ac111"}, + {file = "maxminddb-2.8.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6657615038d8fe106acccd2bf4fe073d07f72886ee893725c74649687635a1a"}, + {file = "maxminddb-2.8.2-cp314-cp314-win32.whl", hash = "sha256:af058500ab3448b709c43f1aefd3d9f7c5f1773af07611d589502ea78bf2b9dc"}, + {file = "maxminddb-2.8.2-cp314-cp314-win_amd64.whl", hash = "sha256:b5982d1b53b50b96a9afcf4f7f49db0a842501f9cf58c4c16c0d62c1b0d22840"}, + {file = "maxminddb-2.8.2-cp314-cp314-win_arm64.whl", hash = "sha256:48c9f7e182c6e970a412c02e7438c2a66197c0664d0c7da81b951bff86519dd5"}, + {file = "maxminddb-2.8.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:b40ed2ec586a5a479d08bd39838fbfbdff84d7deb57089317f312609f1357384"}, + {file = "maxminddb-2.8.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1ba4036f823a8e6418af0d69734fb176e3d1edd0432e218f3be8362564b53ea5"}, + {file = "maxminddb-2.8.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:96531e18bddff9639061ee543417f941a2fd41efc7b1699e1e18aba4157b0b03"}, + {file = "maxminddb-2.8.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bb77ad5c585d6255001d701eafc4758e2d28953ba47510d9f54cc2a9e469c6b6"}, + {file = "maxminddb-2.8.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bfd950af416ef4133bc04b059f29ac4d4b356927fa4a500048220d65ec4c6ac"}, + {file = "maxminddb-2.8.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bf73612f8fbfa9181ba62fa88fb3d732bdc775017bdb3725e24cdd1a0da92d4"}, + {file = "maxminddb-2.8.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:74361fbddb0566970af38cff0a6256ec3f445cb5031da486d0cee6f19ccb9e2e"}, + {file = "maxminddb-2.8.2-cp314-cp314t-win32.whl", hash = "sha256:6bfb41c3a560a60fc20d0d87cb400003974fbb833b44571250476c2d9cb4d407"}, + {file = "maxminddb-2.8.2-cp314-cp314t-win_amd64.whl", hash = "sha256:ec6bba1b1f0fd0846aac5b0af1f84804c67702e873aa9d79c9965794a635ada8"}, + {file = "maxminddb-2.8.2-cp314-cp314t-win_arm64.whl", hash = "sha256:929a00528db82ffa5aa928a9cd1a972e8f93c36243609c25574dfd920c21533b"}, + {file = "maxminddb-2.8.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9b27485e54eee7c251846cfc3b3277b1fdbdae6b6bbc26015c360de7ce78ae33"}, + {file = "maxminddb-2.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c335db4abdd79e3846deb2aa72374284eae78bb2622a82a29c5fd7dd42741a11"}, + {file = "maxminddb-2.8.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c6ff6b84327bb4521068ab6e62f6b537641d106b1acabbdc6436ab7a74ce1328"}, + {file = "maxminddb-2.8.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7dccb69b63aac9b9b7c5f251e9abc0c945c9bd1681869ca72b7e6f512009b541"}, + {file = "maxminddb-2.8.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9efa8a04f546f3c91a235256d61f2985f0a45bb1ec3559bbb551906c015d9464"}, + {file = "maxminddb-2.8.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5853b9f1fb4fc2b394b6ddce33a0be6711b80c8df86498a6e9e90057f0e7276f"}, + {file = "maxminddb-2.8.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0d39044f19696a3bca319539c8cd159c3c5af99d1ee381da6e4b273b6a27c728"}, + {file = "maxminddb-2.8.2-cp39-cp39-win32.whl", hash = "sha256:56a84983debc7b8d9874c9c739106b860f9d4f120b0179085ffb500704c31266"}, + {file = "maxminddb-2.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:2f754550d51c25233853cdcbae1ee384a2af9e3e422b54b992bd4cef6332f894"}, + {file = "maxminddb-2.8.2-cp39-cp39-win_arm64.whl", hash = "sha256:1c319d257fa3e8225ec2eece0043687ad64bf3968de9432187376eb97c2ac6da"}, + {file = "maxminddb-2.8.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ed8d6742e66b119e66a658307bba5da32ba3f7e4e99a35a770dcf924e51326a5"}, + {file = "maxminddb-2.8.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:464b6e4269b9feea12c63eb1561038fac5f1b449a14b78be250ad081b560ff3c"}, + {file = "maxminddb-2.8.2-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:833247b194d86bc62e16d36169336daebba777414821fd0003b1ecfc6bb3f1a7"}, + {file = "maxminddb-2.8.2-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d8d30c6038bdc7ad0458598e4b8c54f19cb052853ac84a0be8902c7af3a009f"}, + {file = "maxminddb-2.8.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f6da4d844f176b7a662446107dd09b987759126c2d8c266918fe7f0186d41538"}, + {file = "maxminddb-2.8.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:28205d215b426c31c35ecc2e71f6ee22ebf12a9a7560ed1efec3709e343d720b"}, + {file = "maxminddb-2.8.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:88b7be82d81a4de2ea40e9bd1f39074ac2d127268a328ad524500c3c210eced1"}, + {file = "maxminddb-2.8.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9a37c151ccdff7ae0be86eff1c464db02237e428f079300b3efc07277762334"}, + {file = "maxminddb-2.8.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ff2045eadfad106824ff4fe2045e7f8ca737405e3201a9adfa646e2e6cdfad7"}, + {file = "maxminddb-2.8.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:869add1b2c9c48008e13c8db204b681a82cbe815c5f58ab8267205b522c852c0"}, + {file = "maxminddb-2.8.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8d85e20807ee11494fce001cffdb1364729e154041739813fb261f866865522c"}, + {file = "maxminddb-2.8.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:622fde1542a4753a39253d138438e1f543edb8455fd70a8f4afbe0a0bc04fe1e"}, + {file = "maxminddb-2.8.2-pp39-pypy39_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79492896ec7f6e029c2aa92c4cc10ad0347a03b866025bd26a6f415982a833de"}, + {file = "maxminddb-2.8.2-pp39-pypy39_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd42526b902755d383108bf2ba38fb9a946ec369faeead3cbe8ffc034a0462e0"}, + {file = "maxminddb-2.8.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:40e113e56ae90d3410bbfc20f5510308c29aa6815964f59859aff4187d21db8c"}, + {file = "maxminddb-2.8.2.tar.gz", hash = "sha256:26a8e536228d8cc28c5b8f574a571a2704befce3b368ceca593a76d56b6590f9"}, +] + +[[package]] +name = "monkeytype" +version = "23.3.0" +description = "Generating type annotations from sampled production types" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "MonkeyType-23.3.0-py3-none-any.whl", hash = "sha256:38ce8ad6568190f54c334b9fe835608af29b40a33ad448ecae749ae8790cdbf9"}, + {file = "MonkeyType-23.3.0.tar.gz", hash = "sha256:f2595db34d57cdddbde5a990117a50a22f373dbb917a2a0fa91ffbe07dfe0313"}, +] + +[package.dependencies] +libcst = ">=0.4.4" +mypy-extensions = "*" + +[package.extras] +dev = ["black", "flake8", "importlib-metadata", "isort", "libcst", "mypy", "pytest", "sphinx", "tox", "twine"] + +[[package]] +name = "multidict" +version = "6.7.0" +description = "multidict implementation" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "multidict-6.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9f474ad5acda359c8758c8accc22032c6abe6dc87a8be2440d097785e27a9349"}, + {file = "multidict-6.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a9db5a870f780220e931d0002bbfd88fb53aceb6293251e2c839415c1b20e"}, + {file = "multidict-6.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03ca744319864e92721195fa28c7a3b2bc7b686246b35e4078c1e4d0eb5466d3"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f0e77e3c0008bc9316e662624535b88d360c3a5d3f81e15cf12c139a75250046"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08325c9e5367aa379a3496aa9a022fe8837ff22e00b94db256d3a1378c76ab32"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e2862408c99f84aa571ab462d25236ef9cb12a602ea959ba9c9009a54902fc73"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d72a9a2d885f5c208b0cb91ff2ed43636bb7e345ec839ff64708e04f69a13cc"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:478cc36476687bac1514d651cbbaa94b86b0732fb6855c60c673794c7dd2da62"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6843b28b0364dc605f21481c90fadb5f60d9123b442eb8a726bb74feef588a84"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23bfeee5316266e5ee2d625df2d2c602b829435fc3a235c2ba2131495706e4a0"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:680878b9f3d45c31e1f730eef731f9b0bc1da456155688c6745ee84eb818e90e"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:eb866162ef2f45063acc7a53a88ef6fe8bf121d45c30ea3c9cd87ce7e191a8d4"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:df0e3bf7993bdbeca5ac25aa859cf40d39019e015c9c91809ba7093967f7a648"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:661709cdcd919a2ece2234f9bae7174e5220c80b034585d7d8a755632d3e2111"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:096f52730c3fb8ed419db2d44391932b63891b2c5ed14850a7e215c0ba9ade36"}, + {file = "multidict-6.7.0-cp310-cp310-win32.whl", hash = "sha256:afa8a2978ec65d2336305550535c9c4ff50ee527914328c8677b3973ade52b85"}, + {file = "multidict-6.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:b15b3afff74f707b9275d5ba6a91ae8f6429c3ffb29bbfd216b0b375a56f13d7"}, + {file = "multidict-6.7.0-cp310-cp310-win_arm64.whl", hash = "sha256:4b73189894398d59131a66ff157837b1fafea9974be486d036bb3d32331fdbf0"}, + {file = "multidict-6.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4d409aa42a94c0b3fa617708ef5276dfe81012ba6753a0370fcc9d0195d0a1fc"}, + {file = "multidict-6.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14c9e076eede3b54c636f8ce1c9c252b5f057c62131211f0ceeec273810c9721"}, + {file = "multidict-6.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c09703000a9d0fa3c3404b27041e574cc7f4df4c6563873246d0e11812a94b6"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a265acbb7bb33a3a2d626afbe756371dce0279e7b17f4f4eda406459c2b5ff1c"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51cb455de290ae462593e5b1cb1118c5c22ea7f0d3620d9940bf695cea5a4bd7"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:db99677b4457c7a5c5a949353e125ba72d62b35f74e26da141530fbb012218a7"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f470f68adc395e0183b92a2f4689264d1ea4b40504a24d9882c27375e6662bb9"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0db4956f82723cc1c270de9c6e799b4c341d327762ec78ef82bb962f79cc07d8"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e56d780c238f9e1ae66a22d2adf8d16f485381878250db8d496623cd38b22bd"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d14baca2ee12c1a64740d4531356ba50b82543017f3ad6de0deb943c5979abb"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:295a92a76188917c7f99cda95858c822f9e4aae5824246bba9b6b44004ddd0a6"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39f1719f57adbb767ef592a50ae5ebb794220d1188f9ca93de471336401c34d2"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0a13fb8e748dfc94749f622de065dd5c1def7e0d2216dba72b1d8069a389c6ff"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e3aa16de190d29a0ea1b48253c57d99a68492c8dd8948638073ab9e74dc9410b"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a048ce45dcdaaf1defb76b2e684f997fb5abf74437b6cb7b22ddad934a964e34"}, + {file = "multidict-6.7.0-cp311-cp311-win32.whl", hash = "sha256:a90af66facec4cebe4181b9e62a68be65e45ac9b52b67de9eec118701856e7ff"}, + {file = "multidict-6.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:95b5ffa4349df2887518bb839409bcf22caa72d82beec453216802f475b23c81"}, + {file = "multidict-6.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:329aa225b085b6f004a4955271a7ba9f1087e39dcb7e65f6284a988264a63912"}, + {file = "multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184"}, + {file = "multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45"}, + {file = "multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8"}, + {file = "multidict-6.7.0-cp312-cp312-win32.whl", hash = "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4"}, + {file = "multidict-6.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b"}, + {file = "multidict-6.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec"}, + {file = "multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6"}, + {file = "multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159"}, + {file = "multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288"}, + {file = "multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17"}, + {file = "multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390"}, + {file = "multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e"}, + {file = "multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00"}, + {file = "multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb"}, + {file = "multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6"}, + {file = "multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d"}, + {file = "multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6"}, + {file = "multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792"}, + {file = "multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842"}, + {file = "multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b"}, + {file = "multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f"}, + {file = "multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885"}, + {file = "multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c"}, + {file = "multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000"}, + {file = "multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63"}, + {file = "multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718"}, + {file = "multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0"}, + {file = "multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13"}, + {file = "multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd"}, + {file = "multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827"}, + {file = "multidict-6.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:363eb68a0a59bd2303216d2346e6c441ba10d36d1f9969fcb6f1ba700de7bb5c"}, + {file = "multidict-6.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d874eb056410ca05fed180b6642e680373688efafc7f077b2a2f61811e873a40"}, + {file = "multidict-6.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b55d5497b51afdfde55925e04a022f1de14d4f4f25cdfd4f5d9b0aa96166851"}, + {file = "multidict-6.7.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f8e5c0031b90ca9ce555e2e8fd5c3b02a25f14989cbc310701823832c99eb687"}, + {file = "multidict-6.7.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cf41880c991716f3c7cec48e2f19ae4045fc9db5fc9cff27347ada24d710bb5"}, + {file = "multidict-6.7.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cfc12a8630a29d601f48d47787bd7eb730e475e83edb5d6c5084317463373eb"}, + {file = "multidict-6.7.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3996b50c3237c4aec17459217c1e7bbdead9a22a0fcd3c365564fbd16439dde6"}, + {file = "multidict-6.7.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7f5170993a0dd3ab871c74f45c0a21a4e2c37a2f2b01b5f722a2ad9c6650469e"}, + {file = "multidict-6.7.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ec81878ddf0e98817def1e77d4f50dae5ef5b0e4fe796fae3bd674304172416e"}, + {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9281bf5b34f59afbc6b1e477a372e9526b66ca446f4bf62592839c195a718b32"}, + {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:68af405971779d8b37198726f2b6fe3955db846fee42db7a4286fc542203934c"}, + {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3ba3ef510467abb0667421a286dc906e30eb08569365f5cdb131d7aff7c2dd84"}, + {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b61189b29081a20c7e4e0b49b44d5d44bb0dc92be3c6d06a11cc043f81bf9329"}, + {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fb287618b9c7aa3bf8d825f02d9201b2f13078a5ed3b293c8f4d953917d84d5e"}, + {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:521f33e377ff64b96c4c556b81c55d0cfffb96a11c194fd0c3f1e56f3d8dd5a4"}, + {file = "multidict-6.7.0-cp39-cp39-win32.whl", hash = "sha256:ce8fdc2dca699f8dbf055a61d73eaa10482569ad20ee3c36ef9641f69afa8c91"}, + {file = "multidict-6.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:7e73299c99939f089dd9b2120a04a516b95cdf8c1cd2b18c53ebf0de80b1f18f"}, + {file = "multidict-6.7.0-cp39-cp39-win_arm64.whl", hash = "sha256:6bdce131e14b04fd34a809b6380dbfd826065c3e2fe8a50dbae659fa0c390546"}, + {file = "multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3"}, + {file = "multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""} + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +files = [ + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, +] + +[[package]] +name = "numpy" +version = "2.0.2" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece"}, + {file = "numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04"}, + {file = "numpy-2.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66"}, + {file = "numpy-2.0.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b"}, + {file = "numpy-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd"}, + {file = "numpy-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318"}, + {file = "numpy-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8"}, + {file = "numpy-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326"}, + {file = "numpy-2.0.2-cp310-cp310-win32.whl", hash = "sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97"}, + {file = "numpy-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131"}, + {file = "numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448"}, + {file = "numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195"}, + {file = "numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57"}, + {file = "numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a"}, + {file = "numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669"}, + {file = "numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951"}, + {file = "numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9"}, + {file = "numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15"}, + {file = "numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4"}, + {file = "numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc"}, + {file = "numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b"}, + {file = "numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e"}, + {file = "numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c"}, + {file = "numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c"}, + {file = "numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692"}, + {file = "numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a"}, + {file = "numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c"}, + {file = "numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded"}, + {file = "numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5"}, + {file = "numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a"}, + {file = "numpy-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c"}, + {file = "numpy-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd"}, + {file = "numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b"}, + {file = "numpy-2.0.2-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729"}, + {file = "numpy-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1"}, + {file = "numpy-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd"}, + {file = "numpy-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d"}, + {file = "numpy-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d"}, + {file = "numpy-2.0.2-cp39-cp39-win32.whl", hash = "sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa"}, + {file = "numpy-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73"}, + {file = "numpy-2.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8"}, + {file = "numpy-2.0.2-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4"}, + {file = "numpy-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c"}, + {file = "numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385"}, + {file = "numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78"}, +] + +[[package]] +name = "orjson" +version = "3.11.4" +description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "orjson-3.11.4-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e3aa2118a3ece0d25489cbe48498de8a5d580e42e8d9979f65bf47900a15aba1"}, + {file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a69ab657a4e6733133a3dca82768f2f8b884043714e8d2b9ba9f52b6efef5c44"}, + {file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3740bffd9816fc0326ddc406098a3a8f387e42223f5f455f2a02a9f834ead80c"}, + {file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65fd2f5730b1bf7f350c6dc896173d3460d235c4be007af73986d7cd9a2acd23"}, + {file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fdc3ae730541086158d549c97852e2eea6820665d4faf0f41bf99df41bc11ea"}, + {file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e10b4d65901da88845516ce9f7f9736f9638d19a1d483b3883dc0182e6e5edba"}, + {file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb6a03a678085f64b97f9d4a9ae69376ce91a3a9e9b56a82b1580d8e1d501aff"}, + {file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2c82e4f0b1c712477317434761fbc28b044c838b6b1240d895607441412371ac"}, + {file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:d58c166a18f44cc9e2bad03a327dc2d1a3d2e85b847133cfbafd6bfc6719bd79"}, + {file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:94f206766bf1ea30e1382e4890f763bd1eefddc580e08fec1ccdc20ddd95c827"}, + {file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:41bf25fb39a34cf8edb4398818523277ee7096689db352036a9e8437f2f3ee6b"}, + {file = "orjson-3.11.4-cp310-cp310-win32.whl", hash = "sha256:fa9627eba4e82f99ca6d29bc967f09aba446ee2b5a1ea728949ede73d313f5d3"}, + {file = "orjson-3.11.4-cp310-cp310-win_amd64.whl", hash = "sha256:23ef7abc7fca96632d8174ac115e668c1e931b8fe4dde586e92a500bf1914dcc"}, + {file = "orjson-3.11.4-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:5e59d23cd93ada23ec59a96f215139753fbfe3a4d989549bcb390f8c00370b39"}, + {file = "orjson-3.11.4-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:5c3aedecfc1beb988c27c79d52ebefab93b6c3921dbec361167e6559aba2d36d"}, + {file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da9e5301f1c2caa2a9a4a303480d79c9ad73560b2e7761de742ab39fe59d9175"}, + {file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8873812c164a90a79f65368f8f96817e59e35d0cc02786a5356f0e2abed78040"}, + {file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5d7feb0741ebb15204e748f26c9638e6665a5fa93c37a2c73d64f1669b0ddc63"}, + {file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ee5487fefee21e6910da4c2ee9eef005bee568a0879834df86f888d2ffbdd9"}, + {file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d40d46f348c0321df01507f92b95a377240c4ec31985225a6668f10e2676f9a"}, + {file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95713e5fc8af84d8edc75b785d2386f653b63d62b16d681687746734b4dfc0be"}, + {file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ad73ede24f9083614d6c4ca9a85fe70e33be7bf047ec586ee2363bc7418fe4d7"}, + {file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:842289889de515421f3f224ef9c1f1efb199a32d76d8d2ca2706fa8afe749549"}, + {file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3b2427ed5791619851c52a1261b45c233930977e7de8cf36de05636c708fa905"}, + {file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3c36e524af1d29982e9b190573677ea02781456b2e537d5840e4538a5ec41907"}, + {file = "orjson-3.11.4-cp311-cp311-win32.whl", hash = "sha256:87255b88756eab4a68ec61837ca754e5d10fa8bc47dc57f75cedfeaec358d54c"}, + {file = "orjson-3.11.4-cp311-cp311-win_amd64.whl", hash = "sha256:e2d5d5d798aba9a0e1fede8d853fa899ce2cb930ec0857365f700dffc2c7af6a"}, + {file = "orjson-3.11.4-cp311-cp311-win_arm64.whl", hash = "sha256:6bb6bb41b14c95d4f2702bce9975fda4516f1db48e500102fc4d8119032ff045"}, + {file = "orjson-3.11.4-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:d4371de39319d05d3f482f372720b841c841b52f5385bd99c61ed69d55d9ab50"}, + {file = "orjson-3.11.4-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e41fd3b3cac850eaae78232f37325ed7d7436e11c471246b87b2cd294ec94853"}, + {file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600e0e9ca042878c7fdf189cf1b028fe2c1418cc9195f6cb9824eb6ed99cb938"}, + {file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7bbf9b333f1568ef5da42bc96e18bf30fd7f8d54e9ae066d711056add508e415"}, + {file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4806363144bb6e7297b8e95870e78d30a649fdc4e23fc84daa80c8ebd366ce44"}, + {file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad355e8308493f527d41154e9053b86a5be892b3b359a5c6d5d95cda23601cb2"}, + {file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a7517482667fb9f0ff1b2f16fe5829296ed7a655d04d68cd9711a4d8a4e708"}, + {file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97eb5942c7395a171cbfecc4ef6701fc3c403e762194683772df4c54cfbb2210"}, + {file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:149d95d5e018bdd822e3f38c103b1a7c91f88d38a88aada5c4e9b3a73a244241"}, + {file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:624f3951181eb46fc47dea3d221554e98784c823e7069edb5dbd0dc826ac909b"}, + {file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:03bfa548cf35e3f8b3a96c4e8e41f753c686ff3d8e182ce275b1751deddab58c"}, + {file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:525021896afef44a68148f6ed8a8bf8375553d6066c7f48537657f64823565b9"}, + {file = "orjson-3.11.4-cp312-cp312-win32.whl", hash = "sha256:b58430396687ce0f7d9eeb3dd47761ca7d8fda8e9eb92b3077a7a353a75efefa"}, + {file = "orjson-3.11.4-cp312-cp312-win_amd64.whl", hash = "sha256:c6dbf422894e1e3c80a177133c0dda260f81428f9de16d61041949f6a2e5c140"}, + {file = "orjson-3.11.4-cp312-cp312-win_arm64.whl", hash = "sha256:d38d2bc06d6415852224fcc9c0bfa834c25431e466dc319f0edd56cca81aa96e"}, + {file = "orjson-3.11.4-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:2d6737d0e616a6e053c8b4acc9eccea6b6cce078533666f32d140e4f85002534"}, + {file = "orjson-3.11.4-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:afb14052690aa328cc118a8e09f07c651d301a72e44920b887c519b313d892ff"}, + {file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38aa9e65c591febb1b0aed8da4d469eba239d434c218562df179885c94e1a3ad"}, + {file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f2cf4dfaf9163b0728d061bebc1e08631875c51cd30bf47cb9e3293bfbd7dcd5"}, + {file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89216ff3dfdde0e4070932e126320a1752c9d9a758d6a32ec54b3b9334991a6a"}, + {file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9daa26ca8e97fae0ce8aa5d80606ef8f7914e9b129b6b5df9104266f764ce436"}, + {file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c8b2769dc31883c44a9cd126560327767f848eb95f99c36c9932f51090bfce9"}, + {file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1469d254b9884f984026bd9b0fa5bbab477a4bfe558bba6848086f6d43eb5e73"}, + {file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:68e44722541983614e37117209a194e8c3ad07838ccb3127d96863c95ec7f1e0"}, + {file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8e7805fda9672c12be2f22ae124dcd7b03928d6c197544fe12174b86553f3196"}, + {file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:04b69c14615fb4434ab867bf6f38b2d649f6f300af30a6705397e895f7aec67a"}, + {file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:639c3735b8ae7f970066930e58cf0ed39a852d417c24acd4a25fc0b3da3c39a6"}, + {file = "orjson-3.11.4-cp313-cp313-win32.whl", hash = "sha256:6c13879c0d2964335491463302a6ca5ad98105fc5db3565499dcb80b1b4bd839"}, + {file = "orjson-3.11.4-cp313-cp313-win_amd64.whl", hash = "sha256:09bf242a4af98732db9f9a1ec57ca2604848e16f132e3f72edfd3c5c96de009a"}, + {file = "orjson-3.11.4-cp313-cp313-win_arm64.whl", hash = "sha256:a85f0adf63319d6c1ba06fb0dbf997fced64a01179cf17939a6caca662bf92de"}, + {file = "orjson-3.11.4-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:42d43a1f552be1a112af0b21c10a5f553983c2a0938d2bbb8ecd8bc9fb572803"}, + {file = "orjson-3.11.4-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:26a20f3fbc6c7ff2cb8e89c4c5897762c9d88cf37330c6a117312365d6781d54"}, + {file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e3f20be9048941c7ffa8fc523ccbd17f82e24df1549d1d1fe9317712d19938e"}, + {file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aac364c758dc87a52e68e349924d7e4ded348dedff553889e4d9f22f74785316"}, + {file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d5c54a6d76e3d741dcc3f2707f8eeb9ba2a791d3adbf18f900219b62942803b1"}, + {file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f28485bdca8617b79d44627f5fb04336897041dfd9fa66d383a49d09d86798bc"}, + {file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bfc2a484cad3585e4ba61985a6062a4c2ed5c7925db6d39f1fa267c9d166487f"}, + {file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e34dbd508cb91c54f9c9788923daca129fe5b55c5b4eebe713bf5ed3791280cf"}, + {file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b13c478fa413d4b4ee606ec8e11c3b2e52683a640b006bb586b3041c2ca5f606"}, + {file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:724ca721ecc8a831b319dcd72cfa370cc380db0bf94537f08f7edd0a7d4e1780"}, + {file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:977c393f2e44845ce1b540e19a786e9643221b3323dae190668a98672d43fb23"}, + {file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e539e382cf46edec157ad66b0b0872a90d829a6b71f17cb633d6c160a223155"}, + {file = "orjson-3.11.4-cp314-cp314-win32.whl", hash = "sha256:d63076d625babab9db5e7836118bdfa086e60f37d8a174194ae720161eb12394"}, + {file = "orjson-3.11.4-cp314-cp314-win_amd64.whl", hash = "sha256:0a54d6635fa3aaa438ae32e8570b9f0de36f3f6562c308d2a2a452e8b0592db1"}, + {file = "orjson-3.11.4-cp314-cp314-win_arm64.whl", hash = "sha256:78b999999039db3cf58f6d230f524f04f75f129ba3d1ca2ed121f8657e575d3d"}, + {file = "orjson-3.11.4-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:405261b0a8c62bcbd8e2931c26fdc08714faf7025f45531541e2b29e544b545b"}, + {file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:af02ff34059ee9199a3546f123a6ab4c86caf1708c79042caf0820dc290a6d4f"}, + {file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0b2eba969ea4203c177c7b38b36c69519e6067ee68c34dc37081fac74c796e10"}, + {file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0baa0ea43cfa5b008a28d3c07705cf3ada40e5d347f0f44994a64b1b7b4b5350"}, + {file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80fd082f5dcc0e94657c144f1b2a3a6479c44ad50be216cf0c244e567f5eae19"}, + {file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e3704d35e47d5bee811fb1cbd8599f0b4009b14d451c4c57be5a7e25eb89a13"}, + {file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:caa447f2b5356779d914658519c874cf3b7629e99e63391ed519c28c8aea4919"}, + {file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:bba5118143373a86f91dadb8df41d9457498226698ebdf8e11cbb54d5b0e802d"}, + {file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:622463ab81d19ef3e06868b576551587de8e4d518892d1afab71e0fbc1f9cffc"}, + {file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3e0a700c4b82144b72946b6629968df9762552ee1344bfdb767fecdd634fbd5a"}, + {file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6e18a5c15e764e5f3fc569b47872450b4bcea24f2a6354c0a0e95ad21045d5a9"}, + {file = "orjson-3.11.4-cp39-cp39-win32.whl", hash = "sha256:fb1c37c71cad991ef4d89c7a634b5ffb4447dbd7ae3ae13e8f5ee7f1775e7ab1"}, + {file = "orjson-3.11.4-cp39-cp39-win_amd64.whl", hash = "sha256:e2985ce8b8c42d00492d0ed79f2bd2b6460d00f2fa671dfde4bf2e02f49bf5c6"}, + {file = "orjson-3.11.4.tar.gz", hash = "sha256:39485f4ab4c9b30a3943cfe99e1a213c4776fb69e8abd68f66b83d5a0b0fdc6d"}, +] + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "pillow" +version = "10.4.0" +description = "Python Imaging Library (Fork)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e"}, + {file = "pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d"}, + {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856"}, + {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f"}, + {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b"}, + {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc"}, + {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e"}, + {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46"}, + {file = "pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984"}, + {file = "pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141"}, + {file = "pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1"}, + {file = "pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c"}, + {file = "pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be"}, + {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3"}, + {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6"}, + {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe"}, + {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319"}, + {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d"}, + {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696"}, + {file = "pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496"}, + {file = "pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91"}, + {file = "pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22"}, + {file = "pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94"}, + {file = "pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597"}, + {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80"}, + {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca"}, + {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef"}, + {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a"}, + {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b"}, + {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9"}, + {file = "pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42"}, + {file = "pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a"}, + {file = "pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9"}, + {file = "pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3"}, + {file = "pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb"}, + {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70"}, + {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be"}, + {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0"}, + {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc"}, + {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a"}, + {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309"}, + {file = "pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060"}, + {file = "pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea"}, + {file = "pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d"}, + {file = "pillow-10.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8d4d5063501b6dd4024b8ac2f04962d661222d120381272deea52e3fc52d3736"}, + {file = "pillow-10.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7c1ee6f42250df403c5f103cbd2768a28fe1a0ea1f0f03fe151c8741e1469c8b"}, + {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15e02e9bb4c21e39876698abf233c8c579127986f8207200bc8a8f6bb27acf2"}, + {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a8d4bade9952ea9a77d0c3e49cbd8b2890a399422258a77f357b9cc9be8d680"}, + {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:43efea75eb06b95d1631cb784aa40156177bf9dd5b4b03ff38979e048258bc6b"}, + {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:950be4d8ba92aca4b2bb0741285a46bfae3ca699ef913ec8416c1b78eadd64cd"}, + {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d7480af14364494365e89d6fddc510a13e5a2c3584cb19ef65415ca57252fb84"}, + {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:73664fe514b34c8f02452ffb73b7a92c6774e39a647087f83d67f010eb9a0cf0"}, + {file = "pillow-10.4.0-cp38-cp38-win32.whl", hash = "sha256:e88d5e6ad0d026fba7bdab8c3f225a69f063f116462c49892b0149e21b6c0a0e"}, + {file = "pillow-10.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:5161eef006d335e46895297f642341111945e2c1c899eb406882a6c61a4357ab"}, + {file = "pillow-10.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0ae24a547e8b711ccaaf99c9ae3cd975470e1a30caa80a6aaee9a2f19c05701d"}, + {file = "pillow-10.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:298478fe4f77a4408895605f3482b6cc6222c018b2ce565c2b6b9c354ac3229b"}, + {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:134ace6dc392116566980ee7436477d844520a26a4b1bd4053f6f47d096997fd"}, + {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:930044bb7679ab003b14023138b50181899da3f25de50e9dbee23b61b4de2126"}, + {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:c76e5786951e72ed3686e122d14c5d7012f16c8303a674d18cdcd6d89557fc5b"}, + {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b2724fdb354a868ddf9a880cb84d102da914e99119211ef7ecbdc613b8c96b3c"}, + {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dbc6ae66518ab3c5847659e9988c3b60dc94ffb48ef9168656e0019a93dbf8a1"}, + {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:06b2f7898047ae93fad74467ec3d28fe84f7831370e3c258afa533f81ef7f3df"}, + {file = "pillow-10.4.0-cp39-cp39-win32.whl", hash = "sha256:7970285ab628a3779aecc35823296a7869f889b8329c16ad5a71e4901a3dc4ef"}, + {file = "pillow-10.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:961a7293b2457b405967af9c77dcaa43cc1a8cd50d23c532e62d48ab6cdd56f5"}, + {file = "pillow-10.4.0-cp39-cp39-win_arm64.whl", hash = "sha256:32cda9e3d601a52baccb2856b8ea1fc213c90b340c542dcef77140dfa3278a9e"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a02364621fe369e06200d4a16558e056fe2805d3468350df3aef21e00d26214b"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1b5dea9831a90e9d0721ec417a80d4cbd7022093ac38a568db2dd78363b00908"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b885f89040bb8c4a1573566bbb2f44f5c505ef6e74cec7ab9068c900047f04b"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87dd88ded2e6d74d31e1e0a99a726a6765cda32d00ba72dc37f0651f306daaa8"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:2db98790afc70118bd0255c2eeb465e9767ecf1f3c25f9a1abb8ffc8cfd1fe0a"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f7baece4ce06bade126fb84b8af1c33439a76d8a6fd818970215e0560ca28c27"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cfdd747216947628af7b259d274771d84db2268ca062dd5faf373639d00113a3"}, + {file = "pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=7.3)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] +typing = ["typing-extensions ; python_version < \"3.10\""] +xmp = ["defusedxml"] + +[[package]] +name = "platformdirs" +version = "4.4.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"}, + {file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.14.1)"] + +[[package]] +name = "playwright" +version = "1.56.0" +description = "A high-level API to automate web browsers" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "playwright-1.56.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:b33eb89c516cbc6723f2e3523bada4a4eb0984a9c411325c02d7016a5d625e9c"}, + {file = "playwright-1.56.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b228b3395212b9472a4ee5f1afe40d376eef9568eb039fcb3e563de8f4f4657b"}, + {file = "playwright-1.56.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:0ef7e6fd653267798a8a968ff7aa2dcac14398b7dd7440ef57524e01e0fbbd65"}, + {file = "playwright-1.56.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:404be089b49d94bc4c1fe0dfb07664bda5ffe87789034a03bffb884489bdfb5c"}, + {file = "playwright-1.56.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64cda7cf4e51c0d35dab55190841bfcdfb5871685ec22cb722cd0ad2df183e34"}, + {file = "playwright-1.56.0-py3-none-win32.whl", hash = "sha256:d87b79bcb082092d916a332c27ec9732e0418c319755d235d93cc6be13bdd721"}, + {file = "playwright-1.56.0-py3-none-win_amd64.whl", hash = "sha256:3c7fc49bb9e673489bf2622855f9486d41c5101bbed964638552b864c4591f94"}, + {file = "playwright-1.56.0-py3-none-win_arm64.whl", hash = "sha256:2745490ae8dd58d27e5ea4d9aa28402e8e2991eb84fb4b2fd5fbde2106716f6f"}, +] + +[package.dependencies] +greenlet = ">=3.1.1,<4.0.0" +pyee = ">=13,<14" + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "propcache" +version = "0.4.1" +description = "Accelerated property cache" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db"}, + {file = "propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8"}, + {file = "propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c"}, + {file = "propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb"}, + {file = "propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37"}, + {file = "propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581"}, + {file = "propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf"}, + {file = "propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5"}, + {file = "propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f"}, + {file = "propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1"}, + {file = "propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6"}, + {file = "propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239"}, + {file = "propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2"}, + {file = "propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403"}, + {file = "propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75"}, + {file = "propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8"}, + {file = "propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db"}, + {file = "propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1"}, + {file = "propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf"}, + {file = "propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311"}, + {file = "propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66"}, + {file = "propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81"}, + {file = "propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e"}, + {file = "propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1"}, + {file = "propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b"}, + {file = "propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566"}, + {file = "propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1"}, + {file = "propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717"}, + {file = "propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37"}, + {file = "propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a"}, + {file = "propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12"}, + {file = "propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c"}, + {file = "propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144"}, + {file = "propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f"}, + {file = "propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153"}, + {file = "propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992"}, + {file = "propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f"}, + {file = "propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393"}, + {file = "propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455"}, + {file = "propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85"}, + {file = "propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1"}, + {file = "propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9"}, + {file = "propcache-0.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3d233076ccf9e450c8b3bc6720af226b898ef5d051a2d145f7d765e6e9f9bcff"}, + {file = "propcache-0.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:357f5bb5c377a82e105e44bd3d52ba22b616f7b9773714bff93573988ef0a5fb"}, + {file = "propcache-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cbc3b6dfc728105b2a57c06791eb07a94229202ea75c59db644d7d496b698cac"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:182b51b421f0501952d938dc0b0eb45246a5b5153c50d42b495ad5fb7517c888"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b536b39c5199b96fc6245eb5fb796c497381d3942f169e44e8e392b29c9ebcc"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db65d2af507bbfbdcedb254a11149f894169d90488dd3e7190f7cdcb2d6cd57a"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd2dbc472da1f772a4dae4fa24be938a6c544671a912e30529984dd80400cd88"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:daede9cd44e0f8bdd9e6cc9a607fc81feb80fae7a5fc6cecaff0e0bb32e42d00"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:71b749281b816793678ae7f3d0d84bd36e694953822eaad408d682efc5ca18e0"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:0002004213ee1f36cfb3f9a42b5066100c44276b9b72b4e1504cddd3d692e86e"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fe49d0a85038f36ba9e3ffafa1103e61170b28e95b16622e11be0a0ea07c6781"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:99d43339c83aaf4d32bda60928231848eee470c6bda8d02599cc4cebe872d183"}, + {file = "propcache-0.4.1-cp39-cp39-win32.whl", hash = "sha256:a129e76735bc792794d5177069691c3217898b9f5cee2b2661471e52ffe13f19"}, + {file = "propcache-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:948dab269721ae9a87fd16c514a0a2c2a1bdb23a9a61b969b0f9d9ee2968546f"}, + {file = "propcache-0.4.1-cp39-cp39-win_arm64.whl", hash = "sha256:5fd37c406dd6dc85aa743e214cef35dc54bbdd1419baac4f6ae5e5b1a2976938"}, + {file = "propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237"}, + {file = "propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d"}, +] + +[[package]] +name = "pycparser" +version = "2.23" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" +files = [ + {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, + {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, +] + +[[package]] +name = "pydantic" +version = "2.12.4" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e"}, + {file = "pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.41.5" +typing-extensions = ">=4.14.1" +typing-inspection = ">=0.4.2" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"}, + {file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"}, + {file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"}, + {file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"}, + {file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"}, + {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"}, +] + +[package.dependencies] +typing-extensions = ">=4.14.1" + +[[package]] +name = "pyee" +version = "13.0.0" +description = "A rough port of Node.js's EventEmitter to Python with a few tricks of its own" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498"}, + {file = "pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37"}, +] + +[package.dependencies] +typing-extensions = "*" + +[package.extras] +dev = ["black", "build", "flake8", "flake8-black", "isort", "jupyter-console", "mkdocs", "mkdocs-include-markdown-plugin", "mkdocstrings[python]", "mypy", "pytest", "pytest-asyncio ; python_version >= \"3.4\"", "pytest-trio ; python_version >= \"3.7\"", "sphinx", "toml", "tox", "trio", "trio ; python_version > \"3.6\"", "trio-typing ; python_version > \"3.6\"", "twine", "twisted", "validate-pyproject[all]"] + +[[package]] +name = "pyjwt" +version = "2.8.0" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "PyJWT-2.8.0-py3-none-any.whl", hash = "sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320"}, + {file = "PyJWT-2.8.0.tar.gz", hash = "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de"}, +] + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + +[[package]] +name = "pyobjc-core" +version = "11.1" +description = "Python<->ObjC Interoperability Module" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "sys_platform == \"darwin\"" +files = [ + {file = "pyobjc_core-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4c7536f3e94de0a3eae6bb382d75f1219280aa867cdf37beef39d9e7d580173c"}, + {file = "pyobjc_core-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ec36680b5c14e2f73d432b03ba7c1457dc6ca70fa59fd7daea1073f2b4157d33"}, + {file = "pyobjc_core-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:765b97dea6b87ec4612b3212258024d8496ea23517c95a1c5f0735f96b7fd529"}, + {file = "pyobjc_core-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:18986f83998fbd5d3f56d8a8428b2f3e0754fd15cef3ef786ca0d29619024f2c"}, + {file = "pyobjc_core-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8849e78cfe6595c4911fbba29683decfb0bf57a350aed8a43316976ba6f659d2"}, + {file = "pyobjc_core-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cb9ed17a8d84a312a6e8b665dd22393d48336ea1d8277e7ad20c19a38edf731"}, + {file = "pyobjc_core-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:f2455683e807f8541f0d83fbba0f5d9a46128ab0d5cc83ea208f0bec759b7f96"}, + {file = "pyobjc_core-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4a99e6558b48b8e47c092051e7b3be05df1c8d0617b62f6fa6a316c01902d157"}, + {file = "pyobjc_core-11.1.tar.gz", hash = "sha256:b63d4d90c5df7e762f34739b39cc55bc63dbcf9fb2fb3f2671e528488c7a87fe"}, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "11.1" +description = "Wrappers for the Cocoa frameworks on macOS" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "sys_platform == \"darwin\"" +files = [ + {file = "pyobjc_framework_cocoa-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b27a5bdb3ab6cdeb998443ff3fce194ffae5f518c6a079b832dbafc4426937f9"}, + {file = "pyobjc_framework_cocoa-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7b9a9b8ba07f5bf84866399e3de2aa311ed1c34d5d2788a995bdbe82cc36cfa0"}, + {file = "pyobjc_framework_cocoa-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806de56f06dfba8f301a244cce289d54877c36b4b19818e3b53150eb7c2424d0"}, + {file = "pyobjc_framework_cocoa-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:54e93e1d9b0fc41c032582a6f0834befe1d418d73893968f3f450281b11603da"}, + {file = "pyobjc_framework_cocoa-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:fd5245ee1997d93e78b72703be1289d75d88ff6490af94462b564892e9266350"}, + {file = "pyobjc_framework_cocoa-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:aede53a1afc5433e1e7d66568cc52acceeb171b0a6005407a42e8e82580b4fc0"}, + {file = "pyobjc_framework_cocoa-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:1b5de4e1757bb65689d6dc1f8d8717de9ec8587eb0c4831c134f13aba29f9b71"}, + {file = "pyobjc_framework_cocoa-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bbee71eeb93b1b31ffbac8560b59a0524a8a4b90846a260d2c4f2188f3d4c721"}, + {file = "pyobjc_framework_cocoa-11.1.tar.gz", hash = "sha256:87df76b9b73e7ca699a828ff112564b59251bb9bbe72e610e670a4dc9940d038"}, +] + +[package.dependencies] +pyobjc-core = ">=11.1" + +[[package]] +name = "pyright" +version = "1.1.407" +description = "Command line wrapper for pyright" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pyright-1.1.407-py3-none-any.whl", hash = "sha256:6dd419f54fcc13f03b52285796d65e639786373f433e243f8b94cf93a7444d21"}, + {file = "pyright-1.1.407.tar.gz", hash = "sha256:099674dba5c10489832d4a4b2d302636152a9a42d317986c38474c76fe562262"}, +] + +[package.dependencies] +nodeenv = ">=1.6.0" +typing-extensions = ">=4.1" + +[package.extras] +all = ["nodejs-wheel-binaries", "twine (>=3.4.1)"] +dev = ["twine (>=3.4.1)"] +nodejs = ["nodejs-wheel-binaries"] + +[[package]] +name = "pysocks" +version = "1.7.1" +description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["main"] +files = [ + {file = "PySocks-1.7.1-py27-none-any.whl", hash = "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299"}, + {file = "PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5"}, + {file = "PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"}, +] + +[[package]] +name = "pytest" +version = "7.4.4" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, + {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "0.23.7" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest_asyncio-0.23.7-py3-none-any.whl", hash = "sha256:009b48127fbe44518a547bddd25611551b0e43ccdbf1e67d12479f569832c20b"}, + {file = "pytest_asyncio-0.23.7.tar.gz", hash = "sha256:5f5c72948f4c49e7db4f29f2521d4031f1c27f86e57b046126654083d4770268"}, +] + +[package.dependencies] +pytest = ">=7.0.0,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"}, + {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"}, +] + +[package.dependencies] +coverage = {version = ">=7.10.6", extras = ["toml"]} +pluggy = ">=1.2" +pytest = ">=7" + +[package.extras] +testing = ["process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "pytest-env" +version = "0.8.2" +description = "py.test plugin that allows you to add environment variables." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pytest_env-0.8.2-py3-none-any.whl", hash = "sha256:5e533273f4d9e6a41c3a3120e0c7944aae5674fa773b329f00a5eb1f23c53a38"}, + {file = "pytest_env-0.8.2.tar.gz", hash = "sha256:baed9b3b6bae77bd75b9238e0ed1ee6903a42806ae9d6aeffb8754cd5584d4ff"}, +] + +[package.dependencies] +pytest = ">=7.3.1" + +[package.extras] +test = ["coverage (>=7.2.7)", "pytest-mock (>=3.10)"] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d"}, + {file = "pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + +[[package]] +name = "pytest-testmon" +version = "2.1.4" +description = "selects tests affected by changed files and methods" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest_testmon-2.1.4-py3-none-any.whl", hash = "sha256:3d1178b455a727c94dde85228a34b7ef857751ba142c15874df82d8876e31194"}, + {file = "pytest_testmon-2.1.4.tar.gz", hash = "sha256:cc3dd31f9bf30f6ec11c5153da3c606df7545f3cd90bfb90ce6bd4c48e717aaf"}, +] + +[package.dependencies] +coverage = ">=6,<8" +pytest = ">=5,<9" + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +description = "pytest plugin to abort hanging tests" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2"}, + {file = "pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a"}, +] + +[package.dependencies] +pytest = ">=7.0.0" + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88"}, + {file = "pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1"}, +] + +[package.dependencies] +execnet = ">=2.1" +pytest = ">=7.0.0" + +[package.extras] +psutil = ["psutil (>=3.0)"] +setproctitle = ["setproctitle"] +testing = ["filelock"] + +[[package]] +name = "python-dotenv" +version = "1.0.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, + {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "python-socks" +version = "2.7.3" +description = "Proxy (SOCKS4, SOCKS5, HTTP CONNECT) client for Python" +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +files = [ + {file = "python_socks-2.7.3-py3-none-any.whl", hash = "sha256:e0fda261fa8d08bbcbb7852cdf5e7f576f15d5e55559f6bc16db1bad8bad399b"}, + {file = "python_socks-2.7.3.tar.gz", hash = "sha256:06f4ae34c5828c96f631872e102425bbf44ad841d65ce68329e8dc1af428c1f1"}, +] + +[package.extras] +anyio = ["anyio (>=3.3.4,<5.0.0)"] +asyncio = ["async-timeout (>=4.0) ; python_version < \"3.11\""] +curio = ["curio (>=1.4)"] +trio = ["trio (>=0.24)"] + +[[package]] +name = "pyyaml" +version = "6.0.3" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, + {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, + {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, + {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, +] +markers = {dev = "python_version < \"3.13\" or python_version >= \"3.14\""} + +[[package]] +name = "pyyaml-ft" +version = "8.0.0" +description = "YAML parser and emitter for Python with support for free-threading" +optional = false +python-versions = ">=3.13" +groups = ["dev"] +markers = "python_version == \"3.13\"" +files = [ + {file = "pyyaml_ft-8.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8c1306282bc958bfda31237f900eb52c9bedf9b93a11f82e1aab004c9a5657a6"}, + {file = "pyyaml_ft-8.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30c5f1751625786c19de751e3130fc345ebcba6a86f6bddd6e1285342f4bbb69"}, + {file = "pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fa992481155ddda2e303fcc74c79c05eddcdbc907b888d3d9ce3ff3e2adcfb0"}, + {file = "pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cec6c92b4207004b62dfad1f0be321c9f04725e0f271c16247d8b39c3bf3ea42"}, + {file = "pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06237267dbcab70d4c0e9436d8f719f04a51123f0ca2694c00dd4b68c338e40b"}, + {file = "pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a7f332bc565817644cdb38ffe4739e44c3e18c55793f75dddb87630f03fc254"}, + {file = "pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7d10175a746be65f6feb86224df5d6bc5c049ebf52b89a88cf1cd78af5a367a8"}, + {file = "pyyaml_ft-8.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:58e1015098cf8d8aec82f360789c16283b88ca670fe4275ef6c48c5e30b22a96"}, + {file = "pyyaml_ft-8.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5f3e2ceb790d50602b2fd4ec37abbd760a8c778e46354df647e7c5a4ebb"}, + {file = "pyyaml_ft-8.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d445bf6ea16bb93c37b42fdacfb2f94c8e92a79ba9e12768c96ecde867046d1"}, + {file = "pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c56bb46b4fda34cbb92a9446a841da3982cdde6ea13de3fbd80db7eeeab8b49"}, + {file = "pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dab0abb46eb1780da486f022dce034b952c8ae40753627b27a626d803926483b"}, + {file = "pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd48d639cab5ca50ad957b6dd632c7dd3ac02a1abe0e8196a3c24a52f5db3f7a"}, + {file = "pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:052561b89d5b2a8e1289f326d060e794c21fa068aa11255fe71d65baf18a632e"}, + {file = "pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3bb4b927929b0cb162fb1605392a321e3333e48ce616cdcfa04a839271373255"}, + {file = "pyyaml_ft-8.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:de04cfe9439565e32f178106c51dd6ca61afaa2907d143835d501d84703d3793"}, + {file = "pyyaml_ft-8.0.0.tar.gz", hash = "sha256:0c947dce03954c7b5d38869ed4878b2e6ff1d44b08a0d84dc83fdad205ae39ab"}, +] + +[[package]] +name = "requests" +version = "2.31.0" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, + {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "ruff" +version = "0.14.6" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.14.6-py3-none-linux_armv6l.whl", hash = "sha256:d724ac2f1c240dbd01a2ae98db5d1d9a5e1d9e96eba999d1c48e30062df578a3"}, + {file = "ruff-0.14.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9f7539ea257aa4d07b7ce87aed580e485c40143f2473ff2f2b75aee003186004"}, + {file = "ruff-0.14.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7f6007e55b90a2a7e93083ba48a9f23c3158c433591c33ee2e99a49b889c6332"}, + {file = "ruff-0.14.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a8e7b9d73d8728b68f632aa8e824ef041d068d231d8dbc7808532d3629a6bef"}, + {file = "ruff-0.14.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d50d45d4553a3ebcbd33e7c5e0fe6ca4aafd9a9122492de357205c2c48f00775"}, + {file = "ruff-0.14.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:118548dd121f8a21bfa8ab2c5b80e5b4aed67ead4b7567790962554f38e598ce"}, + {file = "ruff-0.14.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:57256efafbfefcb8748df9d1d766062f62b20150691021f8ab79e2d919f7c11f"}, + {file = "ruff-0.14.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff18134841e5c68f8e5df1999a64429a02d5549036b394fafbe410f886e1989d"}, + {file = "ruff-0.14.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c4b7ec1e66a105d5c27bd57fa93203637d66a26d10ca9809dc7fc18ec58440"}, + {file = "ruff-0.14.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:167843a6f78680746d7e226f255d920aeed5e4ad9c03258094a2d49d3028b105"}, + {file = "ruff-0.14.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:16a33af621c9c523b1ae006b1b99b159bf5ac7e4b1f20b85b2572455018e0821"}, + {file = "ruff-0.14.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1432ab6e1ae2dc565a7eea707d3b03a0c234ef401482a6f1621bc1f427c2ff55"}, + {file = "ruff-0.14.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4c55cfbbe7abb61eb914bfd20683d14cdfb38a6d56c6c66efa55ec6570ee4e71"}, + {file = "ruff-0.14.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:efea3c0f21901a685fff4befda6d61a1bf4cb43de16da87e8226a281d614350b"}, + {file = "ruff-0.14.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:344d97172576d75dc6afc0e9243376dbe1668559c72de1864439c4fc95f78185"}, + {file = "ruff-0.14.6-py3-none-win32.whl", hash = "sha256:00169c0c8b85396516fdd9ce3446c7ca20c2a8f90a77aa945ba6b8f2bfe99e85"}, + {file = "ruff-0.14.6-py3-none-win_amd64.whl", hash = "sha256:390e6480c5e3659f8a4c8d6a0373027820419ac14fa0d2713bd8e6c3e125b8b9"}, + {file = "ruff-0.14.6-py3-none-win_arm64.whl", hash = "sha256:d43c81fbeae52cfa8728d8766bbf46ee4298c888072105815b392da70ca836b2"}, + {file = "ruff-0.14.6.tar.gz", hash = "sha256:6f0c742ca6a7783a736b867a263b9a7a80a45ce9bee391eeda296895f1b4e1cc"}, +] + +[[package]] +name = "screeninfo" +version = "0.8.1" +description = "Fetch location and size of physical screens." +optional = false +python-versions = ">=3.6.2,<4.0.0" +groups = ["main"] +files = [ + {file = "screeninfo-0.8.1-py3-none-any.whl", hash = "sha256:e97d6b173856edcfa3bd282f81deb528188aff14b11ec3e195584e7641be733c"}, + {file = "screeninfo-0.8.1.tar.gz", hash = "sha256:9983076bcc7e34402a1a9e4d7dabf3729411fd2abb3f3b4be7eba73519cd2ed1"}, +] + +[package.dependencies] +Cython = {version = "*", markers = "sys_platform == \"darwin\""} +pyobjc-framework-Cocoa = {version = "*", markers = "sys_platform == \"darwin\""} + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "starlette" +version = "0.46.2" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35"}, + {file = "starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5"}, +] + +[package.dependencies] +anyio = ">=3.6.2,<5" +typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} + +[package.extras] +full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] + +[[package]] +name = "tomli" +version = "2.3.0" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_full_version <= \"3.11.0a6\"" +files = [ + {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, + {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, + {file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, + {file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"}, + {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, + {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, + {file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, + {file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, + {file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, + {file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, + {file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, + {file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"}, + {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, + {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, + {file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, + {file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, + {file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, + {file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, + {file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, + {file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"}, + {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, + {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, + {file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, + {file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, + {file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, + {file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, + {file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, + {file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"}, + {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, + {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, + {file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, + {file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, + {file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, + {file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, + {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, + {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"}, + {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, + {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, + {file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, + {file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, + {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, + {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +description = "Fast, Extensible Progress Meter" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2"}, + {file = "tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +dev = ["nbval", "pytest (>=6)", "pytest-asyncio (>=0.24)", "pytest-cov", "pytest-timeout"] +discord = ["requests"] +notebook = ["ipywidgets (>=6)"] +slack = ["slack-sdk"] +telegram = ["requests"] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + +[[package]] +name = "tzdata" +version = "2025.2" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +groups = ["main"] +files = [ + {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, + {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, +] + +[[package]] +name = "ua-parser" +version = "1.0.1" +description = "Python port of Browserscope's user agent parser" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "ua_parser-1.0.1-py3-none-any.whl", hash = "sha256:b059f2cb0935addea7e551251cbbf42e9a8872f86134163bc1a4f79e0945ffea"}, + {file = "ua_parser-1.0.1.tar.gz", hash = "sha256:f9d92bf19d4329019cef91707aecc23c6d65143ad7e29a233f0580fb0d15547d"}, +] + +[package.dependencies] +ua-parser-builtins = "*" + +[package.extras] +re2 = ["google-re2"] +regex = ["ua-parser-rs"] +yaml = ["PyYaml"] + +[[package]] +name = "ua-parser-builtins" +version = "0.18.0.post1" +description = "Precompiled rules for User Agent Parser" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "ua_parser_builtins-0.18.0.post1-py3-none-any.whl", hash = "sha256:eb4f93504040c3a990a6b0742a2afd540d87d7f9f05fd66e94c101db1564674d"}, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, + {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "uvicorn" +version = "0.29.0" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "uvicorn-0.29.0-py3-none-any.whl", hash = "sha256:2c2aac7ff4f4365c206fd773a39bf4ebd1047c238f8b8268ad996829323473de"}, + {file = "uvicorn-0.29.0.tar.gz", hash = "sha256:6a69214c0b6a087462412670b3ef21224fa48cae0e452b5883e8e8bdfdd11dd0"}, +] + +[package.dependencies] +click = ">=7.0" +h11 = ">=0.8" +typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} + +[package.extras] +standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[[package]] +name = "uvloop" +version = "0.22.1" +description = "Fast implementation of asyncio event loop on top of libuv" +optional = false +python-versions = ">=3.8.1" +groups = ["main"] +markers = "sys_platform != \"win32\"" +files = [ + {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c"}, + {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792"}, + {file = "uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86"}, + {file = "uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd"}, + {file = "uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2"}, + {file = "uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec"}, + {file = "uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9"}, + {file = "uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77"}, + {file = "uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21"}, + {file = "uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702"}, + {file = "uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733"}, + {file = "uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473"}, + {file = "uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42"}, + {file = "uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6"}, + {file = "uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370"}, + {file = "uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4"}, + {file = "uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2"}, + {file = "uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0"}, + {file = "uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705"}, + {file = "uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8"}, + {file = "uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d"}, + {file = "uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e"}, + {file = "uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e"}, + {file = "uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad"}, + {file = "uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142"}, + {file = "uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74"}, + {file = "uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35"}, + {file = "uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25"}, + {file = "uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6"}, + {file = "uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079"}, + {file = "uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289"}, + {file = "uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3"}, + {file = "uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c"}, + {file = "uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21"}, + {file = "uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88"}, + {file = "uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e"}, + {file = "uvloop-0.22.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:80eee091fe128e425177fbd82f8635769e2f32ec9daf6468286ec57ec0313efa"}, + {file = "uvloop-0.22.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:017bd46f9e7b78e81606329d07141d3da446f8798c6baeec124260e22c262772"}, + {file = "uvloop-0.22.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3e5c6727a57cb6558592a95019e504f605d1c54eb86463ee9f7a2dbd411c820"}, + {file = "uvloop-0.22.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:57df59d8b48feb0e613d9b1f5e57b7532e97cbaf0d61f7aa9aa32221e84bc4b6"}, + {file = "uvloop-0.22.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:55502bc2c653ed2e9692e8c55cb95b397d33f9f2911e929dc97c4d6b26d04242"}, + {file = "uvloop-0.22.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4a968a72422a097b09042d5fa2c5c590251ad484acf910a651b4b620acd7f193"}, + {file = "uvloop-0.22.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b45649628d816c030dba3c80f8e2689bab1c89518ed10d426036cdc47874dfc4"}, + {file = "uvloop-0.22.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ea721dd3203b809039fcc2983f14608dae82b212288b346e0bfe46ec2fab0b7c"}, + {file = "uvloop-0.22.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ae676de143db2b2f60a9696d7eca5bb9d0dd6cc3ac3dad59a8ae7e95f9e1b54"}, + {file = "uvloop-0.22.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17d4e97258b0172dfa107b89aa1eeba3016f4b1974ce85ca3ef6a66b35cbf659"}, + {file = "uvloop-0.22.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:05e4b5f86e621cf3927631789999e697e58f0d2d32675b67d9ca9eb0bca55743"}, + {file = "uvloop-0.22.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:286322a90bea1f9422a470d5d2ad82d38080be0a29c4dd9b3e6384320a4d11e7"}, + {file = "uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f"}, +] + +[package.extras] +dev = ["Cython (>=3.0,<4.0)", "setuptools (>=60)"] +docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx_rtd_theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +test = ["aiohttp (>=3.10.5)", "flake8 (>=6.1,<7.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=25.3.0,<25.4.0)", "pycodestyle (>=2.11.0,<2.12.0)"] + +[[package]] +name = "websockets" +version = "12.0" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "websockets-12.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d554236b2a2006e0ce16315c16eaa0d628dab009c33b63ea03f41c6107958374"}, + {file = "websockets-12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2d225bb6886591b1746b17c0573e29804619c8f755b5598d875bb4235ea639be"}, + {file = "websockets-12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eb809e816916a3b210bed3c82fb88eaf16e8afcf9c115ebb2bacede1797d2547"}, + {file = "websockets-12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c588f6abc13f78a67044c6b1273a99e1cf31038ad51815b3b016ce699f0d75c2"}, + {file = "websockets-12.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5aa9348186d79a5f232115ed3fa9020eab66d6c3437d72f9d2c8ac0c6858c558"}, + {file = "websockets-12.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6350b14a40c95ddd53e775dbdbbbc59b124a5c8ecd6fbb09c2e52029f7a9f480"}, + {file = "websockets-12.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:70ec754cc2a769bcd218ed8d7209055667b30860ffecb8633a834dde27d6307c"}, + {file = "websockets-12.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6e96f5ed1b83a8ddb07909b45bd94833b0710f738115751cdaa9da1fb0cb66e8"}, + {file = "websockets-12.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4d87be612cbef86f994178d5186add3d94e9f31cc3cb499a0482b866ec477603"}, + {file = "websockets-12.0-cp310-cp310-win32.whl", hash = "sha256:befe90632d66caaf72e8b2ed4d7f02b348913813c8b0a32fae1cc5fe3730902f"}, + {file = "websockets-12.0-cp310-cp310-win_amd64.whl", hash = "sha256:363f57ca8bc8576195d0540c648aa58ac18cf85b76ad5202b9f976918f4219cf"}, + {file = "websockets-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5d873c7de42dea355d73f170be0f23788cf3fa9f7bed718fd2830eefedce01b4"}, + {file = "websockets-12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3f61726cae9f65b872502ff3c1496abc93ffbe31b278455c418492016e2afc8f"}, + {file = "websockets-12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed2fcf7a07334c77fc8a230755c2209223a7cc44fc27597729b8ef5425aa61a3"}, + {file = "websockets-12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e332c210b14b57904869ca9f9bf4ca32f5427a03eeb625da9b616c85a3a506c"}, + {file = "websockets-12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5693ef74233122f8ebab026817b1b37fe25c411ecfca084b29bc7d6efc548f45"}, + {file = "websockets-12.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e9e7db18b4539a29cc5ad8c8b252738a30e2b13f033c2d6e9d0549b45841c04"}, + {file = "websockets-12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6e2df67b8014767d0f785baa98393725739287684b9f8d8a1001eb2839031447"}, + {file = "websockets-12.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bea88d71630c5900690fcb03161ab18f8f244805c59e2e0dc4ffadae0a7ee0ca"}, + {file = "websockets-12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dff6cdf35e31d1315790149fee351f9e52978130cef6c87c4b6c9b3baf78bc53"}, + {file = "websockets-12.0-cp311-cp311-win32.whl", hash = "sha256:3e3aa8c468af01d70332a382350ee95f6986db479ce7af14d5e81ec52aa2b402"}, + {file = "websockets-12.0-cp311-cp311-win_amd64.whl", hash = "sha256:25eb766c8ad27da0f79420b2af4b85d29914ba0edf69f547cc4f06ca6f1d403b"}, + {file = "websockets-12.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0e6e2711d5a8e6e482cacb927a49a3d432345dfe7dea8ace7b5790df5932e4df"}, + {file = "websockets-12.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:dbcf72a37f0b3316e993e13ecf32f10c0e1259c28ffd0a85cee26e8549595fbc"}, + {file = "websockets-12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12743ab88ab2af1d17dd4acb4645677cb7063ef4db93abffbf164218a5d54c6b"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b645f491f3c48d3f8a00d1fce07445fab7347fec54a3e65f0725d730d5b99cb"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9893d1aa45a7f8b3bc4510f6ccf8db8c3b62120917af15e3de247f0780294b92"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f38a7b376117ef7aff996e737583172bdf535932c9ca021746573bce40165ed"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f764ba54e33daf20e167915edc443b6f88956f37fb606449b4a5b10ba42235a5"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:1e4b3f8ea6a9cfa8be8484c9221ec0257508e3a1ec43c36acdefb2a9c3b00aa2"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9fdf06fd06c32205a07e47328ab49c40fc1407cdec801d698a7c41167ea45113"}, + {file = "websockets-12.0-cp312-cp312-win32.whl", hash = "sha256:baa386875b70cbd81798fa9f71be689c1bf484f65fd6fb08d051a0ee4e79924d"}, + {file = "websockets-12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae0a5da8f35a5be197f328d4727dbcfafa53d1824fac3d96cdd3a642fe09394f"}, + {file = "websockets-12.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5f6ffe2c6598f7f7207eef9a1228b6f5c818f9f4d53ee920aacd35cec8110438"}, + {file = "websockets-12.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9edf3fc590cc2ec20dc9d7a45108b5bbaf21c0d89f9fd3fd1685e223771dc0b2"}, + {file = "websockets-12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8572132c7be52632201a35f5e08348137f658e5ffd21f51f94572ca6c05ea81d"}, + {file = "websockets-12.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:604428d1b87edbf02b233e2c207d7d528460fa978f9e391bd8aaf9c8311de137"}, + {file = "websockets-12.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a9d160fd080c6285e202327aba140fc9a0d910b09e423afff4ae5cbbf1c7205"}, + {file = "websockets-12.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87b4aafed34653e465eb77b7c93ef058516cb5acf3eb21e42f33928616172def"}, + {file = "websockets-12.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b2ee7288b85959797970114deae81ab41b731f19ebcd3bd499ae9ca0e3f1d2c8"}, + {file = "websockets-12.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7fa3d25e81bfe6a89718e9791128398a50dec6d57faf23770787ff441d851967"}, + {file = "websockets-12.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a571f035a47212288e3b3519944f6bf4ac7bc7553243e41eac50dd48552b6df7"}, + {file = "websockets-12.0-cp38-cp38-win32.whl", hash = "sha256:3c6cc1360c10c17463aadd29dd3af332d4a1adaa8796f6b0e9f9df1fdb0bad62"}, + {file = "websockets-12.0-cp38-cp38-win_amd64.whl", hash = "sha256:1bf386089178ea69d720f8db6199a0504a406209a0fc23e603b27b300fdd6892"}, + {file = "websockets-12.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ab3d732ad50a4fbd04a4490ef08acd0517b6ae6b77eb967251f4c263011a990d"}, + {file = "websockets-12.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1d9697f3337a89691e3bd8dc56dea45a6f6d975f92e7d5f773bc715c15dde28"}, + {file = "websockets-12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1df2fbd2c8a98d38a66f5238484405b8d1d16f929bb7a33ed73e4801222a6f53"}, + {file = "websockets-12.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23509452b3bc38e3a057382c2e941d5ac2e01e251acce7adc74011d7d8de434c"}, + {file = "websockets-12.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e5fc14ec6ea568200ea4ef46545073da81900a2b67b3e666f04adf53ad452ec"}, + {file = "websockets-12.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46e71dbbd12850224243f5d2aeec90f0aaa0f2dde5aeeb8fc8df21e04d99eff9"}, + {file = "websockets-12.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b81f90dcc6c85a9b7f29873beb56c94c85d6f0dac2ea8b60d995bd18bf3e2aae"}, + {file = "websockets-12.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a02413bc474feda2849c59ed2dfb2cddb4cd3d2f03a2fedec51d6e959d9b608b"}, + {file = "websockets-12.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bbe6013f9f791944ed31ca08b077e26249309639313fff132bfbf3ba105673b9"}, + {file = "websockets-12.0-cp39-cp39-win32.whl", hash = "sha256:cbe83a6bbdf207ff0541de01e11904827540aa069293696dd528a6640bd6a5f6"}, + {file = "websockets-12.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc4e7fa5414512b481a2483775a8e8be7803a35b30ca805afa4998a84f9fd9e8"}, + {file = "websockets-12.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:248d8e2446e13c1d4326e0a6a4e9629cb13a11195051a73acf414812700badbd"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44069528d45a933997a6fef143030d8ca8042f0dfaad753e2906398290e2870"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4e37d36f0d19f0a4413d3e18c0d03d0c268ada2061868c1e6f5ab1a6d575077"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d829f975fc2e527a3ef2f9c8f25e553eb7bc779c6665e8e1d52aa22800bb38b"}, + {file = "websockets-12.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2c71bd45a777433dd9113847af751aae36e448bc6b8c361a566cb043eda6ec30"}, + {file = "websockets-12.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0bee75f400895aef54157b36ed6d3b308fcab62e5260703add87f44cee9c82a6"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:423fc1ed29f7512fceb727e2d2aecb952c46aa34895e9ed96071821309951123"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27a5e9964ef509016759f2ef3f2c1e13f403725a5e6a1775555994966a66e931"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3181df4583c4d3994d31fb235dc681d2aaad744fbdbf94c4802485ececdecf2"}, + {file = "websockets-12.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:b067cb952ce8bf40115f6c19f478dc71c5e719b7fbaa511359795dfd9d1a6468"}, + {file = "websockets-12.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:00700340c6c7ab788f176d118775202aadea7602c5cc6be6ae127761c16d6b0b"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e469d01137942849cff40517c97a30a93ae79917752b34029f0ec72df6b46399"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffefa1374cd508d633646d51a8e9277763a9b78ae71324183693959cf94635a7"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba0cab91b3956dfa9f512147860783a1829a8d905ee218a9837c18f683239611"}, + {file = "websockets-12.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2cb388a5bfb56df4d9a406783b7f9dbefb888c09b71629351cc6b036e9259370"}, + {file = "websockets-12.0-py3-none-any.whl", hash = "sha256:dc284bbc8d7c78a6c69e0c7325ab46ee5e40bb4d50e494d8131a07ef47500e9e"}, + {file = "websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b"}, +] + +[[package]] +name = "werkzeug" +version = "3.1.3" +description = "The comprehensive WSGI web application library." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e"}, + {file = "werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746"}, +] + +[package.dependencies] +MarkupSafe = ">=2.1.1" + +[package.extras] +watchdog = ["watchdog (>=2.3)"] + +[[package]] +name = "yarl" +version = "1.22.0" +description = "Yet another URL library" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "yarl-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e"}, + {file = "yarl-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f"}, + {file = "yarl-1.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07a524d84df0c10f41e3ee918846e1974aba4ec017f990dc735aad487a0bdfdf"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b329cb8146d7b736677a2440e422eadd775d1806a81db2d4cded80a48efc1a"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75976c6945d85dbb9ee6308cd7ff7b1fb9409380c82d6119bd778d8fcfe2931c"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:80ddf7a5f8c86cb3eb4bc9028b07bbbf1f08a96c5c0bc1244be5e8fefcb94147"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d332fc2e3c94dad927f2112395772a4e4fedbcf8f80efc21ed7cdfae4d574fdb"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cf71bf877efeac18b38d3930594c0948c82b64547c1cf420ba48722fe5509f6"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:663e1cadaddae26be034a6ab6072449a8426ddb03d500f43daf952b74553bba0"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6dcbb0829c671f305be48a7227918cfcd11276c2d637a8033a99a02b67bf9eda"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f0d97c18dfd9a9af4490631905a3f131a8e4c9e80a39353919e2cfed8f00aedc"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:437840083abe022c978470b942ff832c3940b2ad3734d424b7eaffcd07f76737"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a899cbd98dce6f5d8de1aad31cb712ec0a530abc0a86bd6edaa47c1090138467"}, + {file = "yarl-1.22.0-cp310-cp310-win32.whl", hash = "sha256:595697f68bd1f0c1c159fcb97b661fc9c3f5db46498043555d04805430e79bea"}, + {file = "yarl-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb95a9b1adaa48e41815a55ae740cfda005758104049a640a398120bf02515ca"}, + {file = "yarl-1.22.0-cp310-cp310-win_arm64.whl", hash = "sha256:b85b982afde6df99ecc996990d4ad7ccbdbb70e2a4ba4de0aecde5922ba98a0b"}, + {file = "yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511"}, + {file = "yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6"}, + {file = "yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e"}, + {file = "yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca"}, + {file = "yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b"}, + {file = "yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376"}, + {file = "yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f"}, + {file = "yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2"}, + {file = "yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520"}, + {file = "yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8"}, + {file = "yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c"}, + {file = "yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74"}, + {file = "yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53"}, + {file = "yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a"}, + {file = "yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67"}, + {file = "yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95"}, + {file = "yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d"}, + {file = "yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b"}, + {file = "yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10"}, + {file = "yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3"}, + {file = "yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62"}, + {file = "yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03"}, + {file = "yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249"}, + {file = "yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b"}, + {file = "yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4"}, + {file = "yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683"}, + {file = "yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da"}, + {file = "yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2"}, + {file = "yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79"}, + {file = "yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33"}, + {file = "yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1"}, + {file = "yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca"}, + {file = "yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c"}, + {file = "yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e"}, + {file = "yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27"}, + {file = "yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1"}, + {file = "yarl-1.22.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3aa27acb6de7a23785d81557577491f6c38a5209a254d1191519d07d8fe51748"}, + {file = "yarl-1.22.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:af74f05666a5e531289cb1cc9c883d1de2088b8e5b4de48004e5ca8a830ac859"}, + {file = "yarl-1.22.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:62441e55958977b8167b2709c164c91a6363e25da322d87ae6dd9c6019ceecf9"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b580e71cac3f8113d3135888770903eaf2f507e9421e5697d6ee6d8cd1c7f054"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e81fda2fb4a07eda1a2252b216aa0df23ebcd4d584894e9612e80999a78fd95b"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99b6fc1d55782461b78221e95fc357b47ad98b041e8e20f47c1411d0aacddc60"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:088e4e08f033db4be2ccd1f34cf29fe994772fb54cfe004bbf54db320af56890"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e4e1f6f0b4da23e61188676e3ed027ef0baa833a2e633c29ff8530800edccba"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:84fc3ec96fce86ce5aa305eb4aa9358279d1aa644b71fab7b8ed33fe3ba1a7ca"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5dbeefd6ca588b33576a01b0ad58aa934bc1b41ef89dee505bf2932b22ddffba"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:14291620375b1060613f4aab9ebf21850058b6b1b438f386cc814813d901c60b"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:a4fcfc8eb2c34148c118dfa02e6427ca278bfd0f3df7c5f99e33d2c0e81eae3e"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:029866bde8d7b0878b9c160e72305bbf0a7342bcd20b9999381704ae03308dc8"}, + {file = "yarl-1.22.0-cp39-cp39-win32.whl", hash = "sha256:4dcc74149ccc8bba31ce1944acee24813e93cfdee2acda3c172df844948ddf7b"}, + {file = "yarl-1.22.0-cp39-cp39-win_amd64.whl", hash = "sha256:10619d9fdee46d20edc49d3479e2f8269d0779f1b031e6f7c2aa1c76be04b7ed"}, + {file = "yarl-1.22.0-cp39-cp39-win_arm64.whl", hash = "sha256:dd7afd3f8b0bfb4e0d9fc3c31bfe8a4ec7debe124cfd90619305def3c8ca8cd2"}, + {file = "yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff"}, + {file = "yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71"}, +] + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" +propcache = ">=0.2.1" + +[[package]] +name = "zipp" +version = "3.23.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version == \"3.9\"" +files = [ + {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, + {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.9,<4.0" +content-hash = "3e0eceaf1fc4442750f5ed2b33c2fa2671cacad3542c4e12508f723e0ce8c68d" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..675222d6972b5a1ba3889979e983057080c29a7c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,90 @@ +[tool.poetry] +name = "aistudioproxyapi" +version = "0.1.0" +description = "" +authors = ["Your Name "] +readme = "README.md" +package-mode = false + +[tool.poetry.dependencies] +python = ">=3.9,<4.0" +fastapi = "==0.115.12" +pydantic = ">=2.7.1,<3.0.0" +uvicorn = "==0.29.0" +python-dotenv = "==1.0.1" +websockets = "==12.0" +uvloop = {version = "*", markers = "sys_platform != 'win32'"} +playwright = "*" +camoufox = {version = "0.4.11", extras = ["geoip"]} +cryptography = "==42.0.5" +aiohttp = "~=3.9.5" +requests = "==2.31.0" +pyjwt = "==2.8.0" +Flask = "==3.0.3" +aiosocks = "~=0.2.6" +python-socks = "~=2.7.1" +tzdata = "^2025.2" +colorama = "^0.4.6" +customtkinter = "^5.2.0" +pillow = "^10.0.0" + +[tool.poetry.group.dev.dependencies] +pytest = "^7.0.0" +pytest-cov = "^7.0.0" +pytest-mock = "^3.15.1" +httpx = "^0.28.1" +pytest-asyncio = "0.23.7" +pytest-env = "0.8.2" +pytest-timeout = "^2.4.0" +pyright = "^1.1.407" +ruff = "^0.14.6" +pytest-testmon = "^2.1.4" +monkeytype = "^23.3.0" +pytest-xdist = "^3.8.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +python_files = "test_*.py" +addopts = "-v --cov=api_utils --cov=browser_utils --cov=stream --cov=config --cov=models --cov=launcher --cov=logging_utils --cov=server.py --cov-report=term-missing --ignore=tests/browser_utils/test_operations_old.py --tb=short" +timeout = 120 +timeout_method = "thread" +markers = [ + "integration: Integration tests that use real components (locks, queues, state) instead of mocks" +] +env = [ + "LAUNCH_MODE=test", + "STREAM_PORT=0", +] + +[tool.coverage.run] +omit = [ + "tests/*", + "**/__init__.py", + "launch_camoufox.py", + "monkeytype_config.py", + "deprecated/*", +] + +[tool.coverage.report] +exclude_lines = [ + "if __name__ == .__main__.:", + "pragma: no cover", +] + +[tool.ruff] +line-length = 88 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "W"] +ignore = [ + "E501", # line-too-long (686 instances, too risky to fix) + "E402", # module-import-not-at-top (intentional - load_dotenv first) + "F405", # undefined-local-with-import-star-usage (config imports) + "F403", # undefined-local-with-import-star (config imports) +] diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..5f43d082f307c807a3c379dae593b69f0dbd9831 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,76 @@ +{ + "include": ["."], + "exclude": [ + "**/__pycache__", + "**/*.pyc", + ".git", + ".venv", + "**/node_modules", + "deprecated_javascript_version", + "errors_py", + "logs", + "tests/**", + "gui/**", + "static/frontend/**", + "launch_camoufox.py", + "simple_launcher.py" + ], + "extraPaths": [ + ".", + "./api_utils", + "./browser_utils", + "./config", + "./models", + "./logging_utils", + "./stream" + ], + "pythonVersion": "3.10", + "pythonPlatform": "All", + "typeCheckingMode": "standard", + "useLibraryCodeForTypes": true, + "autoImportCompletions": true, + "stubPath": "", + + "reportMissingImports": "none", + "reportUndefinedVariable": "error", + "reportAssertAlwaysTrue": "error", + "reportInvalidStringEscapeSequence": "error", + "reportInvalidTypeVarUse": "error", + "reportConstantRedefinition": "none", + "reportGeneralTypeIssues": "none", + "reportPropertyTypeMismatch": "error", + "reportFunctionMemberAccess": "error", + "reportIncompatibleMethodOverride": "error", + "reportIncompatibleVariableOverride": "error", + "reportInconsistentConstructor": "error", + + "reportOptionalSubscript": "error", + "reportOptionalMemberAccess": "none", + "reportOptionalCall": "none", + "reportOptionalIterable": "error", + "reportOptionalContextManager": "error", + "reportOptionalOperand": "none", + "reportReturnType": "none", + "reportCallIssue": "none", + "reportPrivateImportUsage": "none", + "reportPossiblyUnboundVariable": "none", + + "reportUnknownParameterType": "none", + "reportUnknownArgumentType": "none", + "reportUnknownLambdaType": "none", + "reportUnknownVariableType": "none", + "reportUnknownMemberType": "none", + + "reportMissingParameterType": "none", + "reportArgumentType": "none", + "reportAttributeAccessIssue": "none", + "reportMissingTypeArgument": "none", + "reportMissingTypeStubs": "none", + + "reportPrivateUsage": "none", + "reportUnnecessaryIsInstance": "none", + "reportUnnecessaryCast": "warning", + "reportUnnecessaryComparison": "warning", + "reportDuplicateImport": "warning", + "reportTypedDictNotRequiredAccess": "warning" +} diff --git a/scripts/fetch_camoufox_data.py b/scripts/fetch_camoufox_data.py new file mode 100644 index 0000000000000000000000000000000000000000..2de749f62a45530f89909b7fbfb40385ae9300b9 --- /dev/null +++ b/scripts/fetch_camoufox_data.py @@ -0,0 +1,106 @@ +import ssl +import sys +import traceback + +# --- WARNING: THIS SCRIPT DISABLES SSL VERIFICATION --- # +# --- USE ONLY IF YOU TRUST YOUR NETWORK --- # +# --- AND `camoufox fetch` FAILS DUE TO SSL --- # + +print("=" * 60) +print("WARNING: This script will temporarily disable SSL certificate verification") +print(" globally for this Python process to attempt fetching Camoufox data.") +print(" This can expose you to security risks like man-in-the-middle attacks.") +print("=" * 60) + +confirm = ( + input("Do you understand the risks and want to proceed? (yes/NO): ").strip().lower() +) + +if confirm != "yes": + print("Operation cancelled by user.") + sys.exit(0) + +print("\nAttempting to disable SSL verification...") +original_ssl_context = None +try: + # Store the original context creation function + if hasattr(ssl, "_create_default_https_context"): + original_ssl_context = ssl._create_default_https_context + + # Get the unverified context creation function + _create_unverified_https_context = ssl._create_unverified_context + + # Monkey patch the default context creation + ssl._create_default_https_context = _create_unverified_https_context + print("SSL verification temporarily disabled for this process.") +except AttributeError: + print( + "ERROR: Cannot disable SSL verification on this Python version (missing necessary SSL functions)." + ) + sys.exit(1) +except Exception as e: + print( + f"ERROR: An unexpected error occurred while trying to disable SSL verification: {e}" + ) + traceback.print_exc() + sys.exit(1) + +# Now, try to import and run the fetch command logic from camoufox +print("\nAttempting to run Camoufox fetch logic...") +fetch_success = False +try: + # The exact way to trigger fetch programmatically might differ. + # This tries to import the CLI module and run the fetch command. + from camoufox import cli # type: ignore[attr-defined] + + # Simulate command line arguments: ['fetch'] + # Note: cli.cli() might exit the process directly on completion or error. + # We assume it might raise an exception or return normally. + cli.cli(["fetch"]) + print("Camoufox fetch process seems to have completed.") + # We assume success if no exception was raised and the process didn't exit. + # A more robust check would involve verifying the downloaded files, + # but that's beyond the scope of this simple script. + fetch_success = True +except ImportError: + print( + "\nERROR: Could not import camoufox.cli. Make sure camoufox package is installed." + ) + print(" Try running: pip show camoufox") +except FileNotFoundError as e: + print(f"\nERROR during fetch (FileNotFoundError): {e}") + print( + " This might indicate issues with file paths or permissions during download/extraction." + ) + print(" Please check network connectivity and directory write permissions.") +except SystemExit as e: + # The CLI might use sys.exit(). We interpret non-zero exit codes as failure. + if e.code == 0: + print("Camoufox fetch process exited successfully (code 0).") + fetch_success = True + else: + print(f"\nERROR: Camoufox fetch process exited with error code: {e.code}") +except Exception as e: + print(f"\nERROR: An unexpected error occurred while running camoufox fetch: {e}") + traceback.print_exc() +finally: + # Attempt to restore the original SSL context + if original_ssl_context: + try: + ssl._create_default_https_context = original_ssl_context + print("\nOriginal SSL context restored.") + except Exception as restore_e: + print(f"\nWarning: Failed to restore original SSL context: {restore_e}") + else: + # If we couldn't store the original, we can't restore it. + # The effect was process-local anyway. + pass + +if fetch_success: + print( + "\nFetch attempt finished. Please verify if Camoufox browser files were downloaded successfully." + ) +else: + print("\nFetch attempt failed or exited with an error.") + +print("Script finished.") diff --git a/scripts/huggingface/entrypoint.py b/scripts/huggingface/entrypoint.py new file mode 100644 index 0000000000000000000000000000000000000000..f9e69e0213ecb6ea03c56ecd93168da774a8347b --- /dev/null +++ b/scripts/huggingface/entrypoint.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Runtime bootstrap for Hugging Face Spaces.""" + +from __future__ import annotations + +import base64 +import json +import os +import re +import sys +from pathlib import Path + + +APP_DIR = Path(__file__).resolve().parents[2] +AUTH_DIR = APP_DIR / "auth_profiles" / "active" +KEY_FILE = APP_DIR / "auth_profiles" / "key.txt" +HF_AUTH_PATH = AUTH_DIR / "hf-auth.json" + + +def log(message: str) -> None: + print(f"[hf-entrypoint] {message}", flush=True) + + +def _decode_auth_secret() -> str | None: + raw_json = os.environ.get("AISTUDIO_AUTH_JSON", "").strip() + if raw_json: + return raw_json + + encoded_json = os.environ.get("AISTUDIO_AUTH_JSON_B64", "").strip() + if not encoded_json: + return None + + compact = "".join(encoded_json.split()) + try: + return base64.b64decode(compact, validate=True).decode("utf-8") + except Exception as exc: + raise RuntimeError("AISTUDIO_AUTH_JSON_B64 is not valid base64 JSON") from exc + + +def _write_auth_from_secret() -> str | None: + auth_secret = _decode_auth_secret() + if not auth_secret: + return None + + try: + auth_data = json.loads(auth_secret) + except json.JSONDecodeError as exc: + raise RuntimeError("AISTUDIO auth secret is not valid JSON") from exc + + if not isinstance(auth_data, dict) or not isinstance(auth_data.get("cookies"), list): + raise RuntimeError("AISTUDIO auth JSON must be a Playwright storage_state file") + + AUTH_DIR.mkdir(parents=True, exist_ok=True) + HF_AUTH_PATH.write_text( + json.dumps(auth_data, ensure_ascii=False), + encoding="utf-8", + ) + HF_AUTH_PATH.chmod(0o600) + log(f"Wrote auth profile to {HF_AUTH_PATH}") + return str(HF_AUTH_PATH) + + +def resolve_auth_path() -> str: + configured_path = os.environ.get("ACTIVE_AUTH_JSON_PATH", "").strip() + if configured_path: + path = Path(configured_path) + if path.exists(): + log(f"Using ACTIVE_AUTH_JSON_PATH={path}") + return str(path) + raise RuntimeError(f"ACTIVE_AUTH_JSON_PATH does not exist: {path}") + + generated_path = _write_auth_from_secret() + if generated_path: + return generated_path + + existing_profiles = sorted(AUTH_DIR.glob("*.json")) + if existing_profiles: + log(f"Using existing auth profile {existing_profiles[0]}") + return str(existing_profiles[0]) + + raise RuntimeError( + "No auth profile found. Add AISTUDIO_AUTH_JSON_B64 or AISTUDIO_AUTH_JSON " + "as a Hugging Face Space Secret." + ) + + +def write_api_keys() -> None: + raw_keys = ( + os.environ.get("AISTUDIO_PROXY_API_KEYS") + or os.environ.get("AISTUDIO_PROXY_API_KEY") + or os.environ.get("API_KEYS") + or os.environ.get("API_KEY") + or "" + ) + keys = [key.strip() for key in re.split(r"[\n,;]+", raw_keys) if key.strip()] + + KEY_FILE.parent.mkdir(parents=True, exist_ok=True) + if keys: + KEY_FILE.write_text("\n".join(keys) + "\n", encoding="utf-8") + KEY_FILE.chmod(0o600) + log(f"Configured {len(keys)} API key(s)") + else: + KEY_FILE.touch(exist_ok=True) + log("No API key secret configured; /v1 endpoints will be public") + + +def main() -> None: + os.chdir(APP_DIR) + active_auth_path = resolve_auth_path() + write_api_keys() + + server_port = os.environ.get("PORT") or os.environ.get("SERVER_PORT") or "7860" + stream_port = os.environ.get("STREAM_PORT", "3120") + + os.environ["PORT"] = server_port + os.environ["SERVER_PORT"] = server_port + os.environ.setdefault("DEFAULT_FASTAPI_PORT", server_port) + os.environ["ACTIVE_AUTH_JSON_PATH"] = active_auth_path + + command = [ + sys.executable, + "launch_camoufox.py", + "--headless", + "--server-port", + server_port, + "--stream-port", + stream_port, + "--active-auth-json", + active_auth_path, + "--helper", + "", + ] + + internal_proxy = os.environ.get("INTERNAL_CAMOUFOX_PROXY") + if internal_proxy is not None: + command.extend(["--internal-camoufox-proxy", internal_proxy]) + + log("Starting AI Studio Proxy API") + os.execvp(command[0], command) + + +if __name__ == "__main__": + try: + main() + except Exception as exc: + log(f"Startup failed: {exc}") + sys.exit(1) diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..504ce618f3dda1fb6bc25571f413c8b26c8c8276 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,252 @@ +# AI Studio Proxy API 一键安装脚本 (Windows PowerShell) +# 使用 Poetry 进行依赖管理 + +# 设置错误处理 +$ErrorActionPreference = "Stop" + +# 颜色函数 +function Write-ColorOutput { + param( + [string]$Message, + [string]$Color = "White" + ) + Write-Host $Message -ForegroundColor $Color +} + +function Log-Info { + param([string]$Message) + Write-ColorOutput "[INFO] $Message" "Blue" +} + +function Log-Success { + param([string]$Message) + Write-ColorOutput "[SUCCESS] $Message" "Green" +} + +function Log-Warning { + param([string]$Message) + Write-ColorOutput "[WARNING] $Message" "Yellow" +} + +function Log-Error { + param([string]$Message) + Write-ColorOutput "[ERROR] $Message" "Red" +} + +# 检查命令是否存在 +function Test-Command { + param([string]$Command) + try { + Get-Command $Command -ErrorAction Stop | Out-Null + return $true + } + catch { + return $false + } +} + +# 检查 Python 版本 +function Test-Python { + Log-Info "检查 Python 版本..." + + $pythonCmd = $null + if (Test-Command "python") { + $pythonCmd = "python" + } + elseif (Test-Command "py") { + $pythonCmd = "py" + } + else { + Log-Error "未找到 Python。请先安装 Python 3.9+" + exit 1 + } + + try { + $pythonVersion = & $pythonCmd --version 2>&1 + $versionMatch = $pythonVersion -match "Python (\d+)\.(\d+)" + + if ($versionMatch) { + $major = [int]$matches[1] + $minor = [int]$matches[2] + + if ($major -lt 3 -or ($major -eq 3 -and $minor -lt 9)) { + Log-Error "Python 版本过低: $pythonVersion。需要 Python 3.9+" + exit 1 + } + + Log-Success "Python 版本: $pythonVersion ✓" + return $pythonCmd + } + else { + Log-Error "无法解析 Python 版本" + exit 1 + } + } + catch { + Log-Error "Python 版本检查失败: $_" + exit 1 + } +} + +# 安装 Poetry +function Install-Poetry { + if (Test-Command "poetry") { + Log-Success "Poetry 已安装 ✓" + return + } + + Log-Info "安装 Poetry..." + try { + (Invoke-WebRequest -Uri https://install.python-poetry.org -UseBasicParsing).Content | py - + + # 刷新环境变量 + $env:PATH = [System.Environment]::GetEnvironmentVariable("PATH", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("PATH", "User") + + if (Test-Command "poetry") { + Log-Success "Poetry 安装成功 ✓" + } + else { + Log-Error "Poetry 安装失败。请手动安装 Poetry" + exit 1 + } + } + catch { + Log-Error "Poetry 安装失败: $_" + exit 1 + } +} + +# 克隆项目 +function Clone-Project { + Log-Info "克隆项目..." + + if (Test-Path "AIstudioProxyAPI") { + Log-Warning "项目目录已存在,跳过克隆" + Set-Location "AIstudioProxyAPI" + } + else { + try { + git clone https://github.com/CJackHwang/AIstudioProxyAPI.git + Set-Location "AIstudioProxyAPI" + Log-Success "项目克隆成功 ✓" + } + catch { + Log-Error "项目克隆失败: $_" + exit 1 + } + } +} + +# 安装依赖 +function Install-Dependencies { + Log-Info "安装项目依赖..." + try { + poetry install + Log-Success "依赖安装成功 ✓" + } + catch { + Log-Error "依赖安装失败: $_" + exit 1 + } +} + +# 下载 Camoufox +function Download-Camoufox { + Log-Info "下载 Camoufox 浏览器..." + try { + poetry run camoufox fetch + Log-Success "Camoufox 下载成功 ✓" + } + catch { + Log-Warning "Camoufox 下载失败,但不影响主要功能: $_" + } +} + +# 安装 Playwright 依赖 +function Install-PlaywrightDeps { + Log-Info "安装 Playwright 依赖..." + try { + poetry run playwright install-deps firefox + } + catch { + Log-Warning "Playwright 依赖安装失败,但不影响主要功能" + } +} + +# 创建配置文件 +function Create-Config { + Log-Info "创建配置文件..." + + if (!(Test-Path ".env") -and (Test-Path ".env.example")) { + Copy-Item ".env.example" ".env" + Log-Success "配置文件创建成功 ✓" + Log-Info "请编辑 .env 文件进行个性化配置" + } + else { + Log-Warning "配置文件已存在或模板不存在" + } +} + +# 验证安装 +function Test-Installation { + Log-Info "验证安装..." + + try { + # 检查 Poetry 环境 + poetry env info | Out-Null + + # 检查关键依赖 + poetry run python -c "import fastapi, playwright, camoufox" + + Log-Success "安装验证成功 ✓" + } + catch { + Log-Error "安装验证失败: $_" + exit 1 + } +} + +# 显示后续步骤 +function Show-NextSteps { + Write-Host "" + Log-Success "🎉 安装完成!" + Write-Host "" + Write-Host "后续步骤:" + Write-Host "1. 进入项目目录: cd AIstudioProxyAPI" + Write-Host "2. 激活虚拟环境: poetry env activate" + Write-Host "3. 配置环境变量: notepad .env" + Write-Host "4. 首次认证设置: poetry run python launch_camoufox.py --debug" + Write-Host "5. 日常运行: poetry run python launch_camoufox.py --headless" + Write-Host "" + Write-Host "详细文档:" + Write-Host "- 快速开始: docs/quick-start.md" + Write-Host "- 配置参考: docs/configuration-reference.md" + Write-Host "- 认证与轮转: docs/auth-rotation-cookie-refresh.md" + Write-Host "" +} + +# 主函数 +function Main { + Write-Host "🚀 AI Studio Proxy API 一键安装脚本" + Write-Host "使用 Poetry 进行现代化依赖管理" + Write-Host "" + + $pythonCmd = Test-Python + Install-Poetry + Clone-Project + Install-Dependencies + Download-Camoufox + Install-PlaywrightDeps + Create-Config + Test-Installation + Show-NextSteps +} + +# 运行主函数 +try { + Main +} +catch { + Log-Error "安装过程中发生错误: $_" + exit 1 +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000000000000000000000000000000000000..2af62c5d4adc3c5d32b8f5aae0ce39d93994da1f --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,188 @@ +#!/bin/bash + +# AI Studio Proxy API 一键安装脚本 (macOS/Linux) +# 使用 Poetry 进行依赖管理 + +set -e + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# 日志函数 +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# 检查命令是否存在 +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# 检查 Python 版本 +check_python() { + log_info "检查 Python 版本..." + + if command_exists python3; then + PYTHON_CMD="python3" + elif command_exists python; then + PYTHON_CMD="python" + else + log_error "未找到 Python。请先安装 Python 3.9+" + exit 1 + fi + + PYTHON_VERSION=$($PYTHON_CMD --version 2>&1 | cut -d' ' -f2) + PYTHON_MAJOR=$(echo $PYTHON_VERSION | cut -d'.' -f1) + PYTHON_MINOR=$(echo $PYTHON_VERSION | cut -d'.' -f2) + + if [ "$PYTHON_MAJOR" -lt 3 ] || ([ "$PYTHON_MAJOR" -eq 3 ] && [ "$PYTHON_MINOR" -lt 9 ]); then + log_error "Python 版本过低: $PYTHON_VERSION。需要 Python 3.9+" + exit 1 + fi + + log_success "Python 版本: $PYTHON_VERSION ✓" +} + +# 安装 Poetry +install_poetry() { + if command_exists poetry; then + log_success "Poetry 已安装 ✓" + return + fi + + log_info "安装 Poetry..." + curl -sSL https://install.python-poetry.org | $PYTHON_CMD - + + # 添加 Poetry 到 PATH + export PATH="$HOME/.local/bin:$PATH" + + if command_exists poetry; then + log_success "Poetry 安装成功 ✓" + else + log_error "Poetry 安装失败。请手动安装 Poetry" + exit 1 + fi +} + +# 克隆项目 +clone_project() { + log_info "克隆项目..." + + if [ -d "AIstudioProxyAPI" ]; then + log_warning "项目目录已存在,跳过克隆" + cd AIstudioProxyAPI + else + git clone https://github.com/CJackHwang/AIstudioProxyAPI.git + cd AIstudioProxyAPI + log_success "项目克隆成功 ✓" + fi +} + +# 安装依赖 +install_dependencies() { + log_info "安装项目依赖..." + poetry install + log_success "依赖安装成功 ✓" +} + +# 下载 Camoufox +download_camoufox() { + log_info "下载 Camoufox 浏览器..." + poetry run camoufox fetch + log_success "Camoufox 下载成功 ✓" +} + +# 安装 Playwright 依赖 +install_playwright_deps() { + log_info "安装 Playwright 依赖..." + poetry run playwright install-deps firefox || { + log_warning "Playwright 依赖安装失败,但不影响主要功能" + } +} + +# 创建配置文件 +create_config() { + log_info "创建配置文件..." + + if [ ! -f ".env" ] && [ -f ".env.example" ]; then + cp .env.example .env + log_success "配置文件创建成功 ✓" + log_info "请编辑 .env 文件进行个性化配置" + else + log_warning "配置文件已存在或模板不存在" + fi +} + +# 验证安装 +verify_installation() { + log_info "验证安装..." + + # 检查 Poetry 环境 + poetry env info >/dev/null 2>&1 || { + log_error "Poetry 环境验证失败" + exit 1 + } + + # 检查关键依赖 + poetry run python -c "import fastapi, playwright, camoufox" || { + log_error "关键依赖验证失败" + exit 1 + } + + log_success "安装验证成功 ✓" +} + +# 显示后续步骤 +show_next_steps() { + echo + log_success "🎉 安装完成!" + echo + echo "后续步骤:" + echo "1. 进入项目目录: cd AIstudioProxyAPI" + echo "2. 激活虚拟环境: poetry env activate" + echo "3. 配置环境变量: nano .env" + echo "4. 首次认证设置: python launch_camoufox.py --debug" + echo "5. 日常运行: python launch_camoufox.py --headless" + echo + echo "详细文档:" + echo "- 快速开始: docs/quick-start.md" + echo "- 配置参考: docs/configuration-reference.md" + echo "- 认证与轮转: docs/auth-rotation-cookie-refresh.md" + echo +} + +# 主函数 +main() { + echo "🚀 AI Studio Proxy API 一键安装脚本" + echo "使用 Poetry 进行现代化依赖管理" + echo + + check_python + install_poetry + clone_project + install_dependencies + download_camoufox + install_playwright_deps + create_config + verify_installation + show_next_steps +} + +# 运行主函数 +main "$@" diff --git a/scripts/llm_mock.py b/scripts/llm_mock.py new file mode 100644 index 0000000000000000000000000000000000000000..6d8edfeb425c96f2e5a0ef637b5d69964378d1e2 --- /dev/null +++ b/scripts/llm_mock.py @@ -0,0 +1,359 @@ +import argparse # New import +import json +import logging +import sys # New import +import uuid +from datetime import datetime, timezone +from typing import Any, Dict + +import requests +from flask import Flask, jsonify, request + + +# Custom Log Handler, ensuring flush +class FlushingStreamHandler(logging.StreamHandler): + def emit(self, record): + try: + super().emit(record) + self.flush() + except Exception: + self.handleError(record) + + +# Configure logging +log_format = "%(asctime)s [%(levelname)s] %(message)s" +formatter = logging.Formatter(log_format) + +# Create a handler explicitly pointing to sys.stderr and using custom FlushingStreamHandler +# sys.stderr should be captured by gui_launcher.py's PIPE in subprocess +stderr_handler = FlushingStreamHandler(sys.stderr) +stderr_handler.setFormatter(formatter) +stderr_handler.setLevel(logging.INFO) + +# Get root logger and add our handler +# This ensures all logs propagated to root logger (including Flask and Werkzeug, if they don't have specific handlers) +# will pass through this handler. +root_logger = logging.getLogger() +# Clear potential default handlers added by basicConfig or other libraries to avoid duplicate logs or unexpected output +if root_logger.hasHandlers(): + root_logger.handlers.clear() +root_logger.addHandler(stderr_handler) +root_logger.setLevel(logging.INFO) # Ensure root logger level is set + +logger = logging.getLogger( + __name__ +) # Get logger named 'llm', inheriting root logger config + +app = Flask(__name__) +# Flask's app.logger propagates to root logger by default. +# If needed, app.logger and werkzeug logger can be configured separately, but propagating to root is usually sufficient. +# Example: +# app.logger.handlers.clear() # Clear default handler possibly added by Flask +# app.logger.addHandler(stderr_handler) +# app.logger.setLevel(logging.INFO) +# +# werkzeug_logger = logging.getLogger('werkzeug') +# werkzeug_logger.handlers.clear() +# werkzeug_logger.addHandler(stderr_handler) +# werkzeug_logger.setLevel(logging.INFO) + +# Enable model configuration: directly define enabled model names +# Users can add/remove model names, metadata is generated dynamically +ENABLED_MODELS = { + "gemini-2.5-pro-preview-05-06", + "gemini-2.5-flash-preview-04-17", + "gemini-2.0-flash", + "gemini-2.0-flash-lite", + "gemini-1.5-pro", + "gemini-1.5-flash", + "gemini-1.5-flash-8b", +} + +# API Configuration +API_URL = "" # Will be set in main function based on arguments +DEFAULT_MAIN_SERVER_PORT = 2048 +# Please replace with your API Key (Do not share publicly) +API_KEY = "123456" + +# Mock Ollama chat response database +OLLAMA_MOCK_RESPONSES = { + "What is the capital of France?": "The capital of France is Paris.", + "Tell me about AI.": "AI is the simulation of human intelligence in machines, enabling tasks like reasoning and learning.", + "Hello": "Hi! How can I assist you today?", +} + + +@app.route("/", methods=["GET"]) +def root_endpoint(): + """Mock Ollama root path, returns 'Ollama is running'""" + logger.info("Received root path request") + return "Ollama is running", 200 + + +@app.route("/api/tags", methods=["GET"]) +def tags_endpoint(): + """Mock Ollama /api/tags endpoint, dynamically generating enabled model list""" + logger.info("Received /api/tags request") + models = [] + for model_name in ENABLED_MODELS: + # Derive family: extract prefix from model name (e.g. "gpt-4o" -> "gpt") + family = ( + model_name.split("-")[0].lower() + if "-" in model_name + else model_name.lower() + ) + # Special handling for known models + if "llama" in model_name: + family = "llama" + format = "gguf" + size = 1234567890 + parameter_size = "405B" if "405b" in model_name else "unknown" + quantization_level = "Q4_0" + elif "mistral" in model_name: + family = "mistral" + format = "gguf" + size = 1234567890 + parameter_size = "unknown" + quantization_level = "unknown" + else: + format = "unknown" + size = 9876543210 + parameter_size = "unknown" + quantization_level = "unknown" + + models.append( + { + "name": model_name, + "model": model_name, + "modified_at": datetime.now(timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%S.%fZ" + ), + "size": size, + "digest": str(uuid.uuid4()), + "details": { + "parent_model": "", + "format": format, + "family": family, + "families": [family], + "parameter_size": parameter_size, + "quantization_level": quantization_level, + }, + } + ) + logger.info(f"Returning {len(models)} models: {[m['name'] for m in models]}") + return jsonify({"models": models}), 200 + + +def generate_ollama_mock_response(prompt: str, model: str) -> Dict[str, Any]: + """Generate mock Ollama chat response, conforming to /api/chat format""" + response_content = OLLAMA_MOCK_RESPONSES.get( + prompt, f"Echo: {prompt} (This is a response from the mock Ollama server.)" + ) + + return { + "model": model, + "created_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "message": {"role": "assistant", "content": response_content}, + "done": True, + "total_duration": 123456789, + "load_duration": 1234567, + "prompt_eval_count": 10, + "prompt_eval_duration": 2345678, + "eval_count": 20, + "eval_duration": 3456789, + } + + +def convert_api_to_ollama_response( + api_response: Dict[str, Any], model: str +) -> Dict[str, Any]: + """Convert API's OpenAI format response to Ollama format""" + try: + content = api_response["choices"][0]["message"]["content"] + total_duration = api_response.get("usage", {}).get("total_tokens", 30) * 1000000 + prompt_tokens = api_response.get("usage", {}).get("prompt_tokens", 10) + completion_tokens = api_response.get("usage", {}).get("completion_tokens", 20) + + return { + "model": model, + "created_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "message": {"role": "assistant", "content": content}, + "done": True, + "total_duration": total_duration, + "load_duration": 1234567, + "prompt_eval_count": prompt_tokens, + "prompt_eval_duration": prompt_tokens * 100000, + "eval_count": completion_tokens, + "eval_duration": completion_tokens * 100000, + } + except KeyError as e: + logger.error(f"Failed to convert API response: Missing key {str(e)}") + return {"error": f"Invalid API response format: Missing key {str(e)}"} + + +def print_request_params(data: Dict[str, Any], endpoint: str) -> None: + """Print request parameters""" + model = data.get("model", "unspecified") + temperature = data.get("temperature", "unspecified") + stream = data.get("stream", False) + + messages_info = [] + for msg in data.get("messages", []): + role = msg.get("role", "unknown") + content = msg.get("content", "") + content_preview = content[:50] + "..." if len(content) > 50 else content + messages_info.append(f"[{role}] {content_preview}") + + params_str = { + "Endpoint": endpoint, + "Model": model, + "Temperature": temperature, + "Stream": stream, + "Message Count": len(data.get("messages", [])), + "Messages Preview": messages_info, + } + + logger.info( + f"Request Parameters: {json.dumps(params_str, ensure_ascii=False, indent=2)}" + ) + + +@app.route("/api/chat", methods=["POST"]) +def ollama_chat_endpoint(): + """Mock Ollama /api/chat endpoint, available for all models""" + try: + data = request.get_json() + if not data or "messages" not in data: + logger.error("Invalid request: Missing 'messages' field") + return jsonify({"error": "Invalid request: Missing 'messages' field"}), 400 + + messages = data.get("messages", []) + if not messages or not isinstance(messages, list): + logger.error("Invalid request: 'messages' must be a non-empty list") + return jsonify( + {"error": "Invalid request: 'messages' must be a non-empty list"} + ), 400 + + model = data.get("model", "llama3.2") + user_message = next( + (msg["content"] for msg in reversed(messages) if msg.get("role") == "user"), + "", + ) + if not user_message: + logger.error("User message not found") + return jsonify({"error": "User message not found"}), 400 + + # Print request parameters + print_request_params(data, "/api/chat") + + logger.info(f"Processing /api/chat request, model: {model}") + + # Remove model restriction, all models use API + api_request = { + "model": model, + "messages": messages, + "stream": False, + "temperature": data.get("temperature", 0.7), + } + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {API_KEY}", + } + + try: + logger.info(f"Forwarding request to API: {API_URL}") + response = requests.post( + API_URL, json=api_request, headers=headers, timeout=300000 + ) + response.raise_for_status() + api_response = response.json() + ollama_response = convert_api_to_ollama_response(api_response, model) + logger.info(f"Received response from API, model: {model}") + return jsonify(ollama_response), 200 + except requests.RequestException as e: + logger.error(f"API request failed: {str(e)}") + # If API request fails, use mock response as fallback + logger.info(f"Using mock response as fallback, model: {model}") + response = generate_ollama_mock_response(user_message, model) + return jsonify(response), 200 + + except Exception as e: + logger.error(f"/api/chat Server Error: {str(e)}") + return jsonify({"error": f"Server Error: {str(e)}"}), 500 + + +@app.route("/v1/chat/completions", methods=["POST"]) +def api_chat_endpoint(): + """Forward to API's /v1/chat/completions endpoint and convert to Ollama format""" + try: + data = request.get_json() + if not data or "messages" not in data: + logger.error("Invalid request: Missing 'messages' field") + return jsonify({"error": "Invalid request: Missing 'messages' field"}), 400 + + messages = data.get("messages", []) + if not messages or not isinstance(messages, list): + logger.error("Invalid request: 'messages' must be a non-empty list") + return jsonify( + {"error": "Invalid request: 'messages' must be a non-empty list"} + ), 400 + + model = data.get("model", "grok-3") + user_message = next( + (msg["content"] for msg in reversed(messages) if msg.get("role") == "user"), + "", + ) + if not user_message: + logger.error("User message not found") + return jsonify({"error": "User message not found"}), 400 + + # Print request parameters + print_request_params(data, "/v1/chat/completions") + + logger.info(f"Processing /v1/chat/completions request, model: {model}") + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {API_KEY}", + } + + try: + logger.info(f"Forwarding request to API: {API_URL}") + response = requests.post( + API_URL, json=data, headers=headers, timeout=300000 + ) + response.raise_for_status() + api_response = response.json() + ollama_response = convert_api_to_ollama_response(api_response, model) + logger.info(f"Received response from API, model: {model}") + return jsonify(ollama_response), 200 + except requests.RequestException as e: + logger.error(f"API request failed: {str(e)}") + return jsonify({"error": f"API request failed: {str(e)}"}), 500 + + except Exception as e: + logger.error(f"/v1/chat/completions Server Error: {str(e)}") + return jsonify({"error": f"Server Error: {str(e)}"}), 500 + + +def main(): + """Start mock server""" + global API_URL # Declare that we are modifying global variables + + parser = argparse.ArgumentParser(description="LLM Mock Service for AI Studio Proxy") + parser.add_argument( + "--main-server-port", + type=int, + default=DEFAULT_MAIN_SERVER_PORT, + help=f"Port of the main AI Studio Proxy server (default: {DEFAULT_MAIN_SERVER_PORT})", + ) + args = parser.parse_args() + + API_URL = f"http://localhost:{args.main_server_port}/v1/chat/completions" + + logger.info(f"Mock Ollama and API proxy server will forward requests to: {API_URL}") + logger.info("Starting Mock Ollama and API proxy server at: http://localhost:11434") + app.run(host="0.0.0.0", port=11434, debug=False) + + +if __name__ == "__main__": + main() diff --git a/scripts/multi-instance-manager/README.md b/scripts/multi-instance-manager/README.md new file mode 100644 index 0000000000000000000000000000000000000000..1baeb4118b5c2faa2fece904479a570090474dbe --- /dev/null +++ b/scripts/multi-instance-manager/README.md @@ -0,0 +1,246 @@ +# Multi-instance manager + +External helper for advanced users who want to run multiple isolated Docker containers from a pool of saved auth profiles. + +## What it does + +The manager launches one container per auth profile from [`auth_profiles/saved/`](../../auth_profiles/saved/). Each container gets: + +- its own host API port +- its own host stream port +- its own dedicated startup profile via [`ACTIVE_AUTH_JSON_PATH`](../../launcher/runner.py) +- the same Docker image and shared API key unless overridden + +By default, the manager enforces strict profile isolation at startup. A container starts with its matching file from [`auth_profiles/saved/`](../../auth_profiles/saved/) and does not auto-rotate into other profiles unless you explicitly enable that behavior. + +This component is intentionally isolated from the main application. It does not modify the core server code, the existing Docker setup, or the standard launch flow. + +## Resource warning + +> ⚠️ Each instance can consume around 1 GB RAM or more. +> Running many containers at once may cause memory pressure or OOM kills. +> Review available system memory before launching a large profile pool. + +The script shows a visible warning and asks for confirmation before startup unless auto-confirm is enabled. + +## Requirements + +- Docker installed and running +- Bash 3.2+ on macOS or a newer Bash on Linux +- built image available locally + - default expected name: `ai-studio-proxy:latest` + - if you built via `cd docker && docker compose build`, Docker Compose may create `docker-ai-studio-proxy:latest`; the launcher now detects this and falls back automatically when the default image is still configured + - if you use another tag, pass it explicitly with `--image ...` or set `IMAGE_NAME=...` +- prepared auth files in [`auth_profiles/saved/`](../../auth_profiles/saved/) +- optional [`docker/.env`](../../docker/.env) file if you want containers to inherit the usual Docker configuration + +## Files + +- [`run-multi-instance.sh`](run-multi-instance.sh) — launcher script +- [`README.md`](README.md) — usage guide + +## Startup contract + +The launcher now follows a strict startup contract: + +1. It mounts the shared [`auth_profiles/`](../../auth_profiles/) tree into the container. +2. It sets `ACTIVE_AUTH_JSON_PATH=/app/auth_profiles/saved/.json` for that specific container. +3. It copies the selected host profile into [`.multi-instance-runtime/active/`](../../.multi-instance-runtime/) and bind-mounts that copy as `/app/auth_profiles/active/.json` inside the container. +4. It disables `AUTO_AUTH_ROTATION_ON_STARTUP` and `AUTO_ROTATE_AUTH_PROFILE` by default. + +This dual-path startup is intentional. Although [`launcher/runner.py::_resolve_auth_file_path()`](../../launcher/runner.py#L270) and [`browser_utils/initialization/core.py::initialize_page_logic()`](../../browser_utils/initialization/core.py#L48) can consume [`ACTIVE_AUTH_JSON_PATH`](../../launcher/runner.py#L413), real Docker headless startup may still traverse logic that expects at least one file under [`auth_profiles/active/`](../../auth_profiles/active/). The runtime copy avoids polluting the shared host [`auth_profiles/active/`](../../auth_profiles/active/) pool while still satisfying that container-local requirement. + +If you really want cross-profile rotation inside each container, use [`--enable-auth-rotation`](run-multi-instance.sh). + +## Quick start + +Build the image first if needed: + +```bash +cd docker +docker compose build +``` + +After a regular Compose build, the local image is often named `docker-ai-studio-proxy:latest`. The manager still starts from its default `ai-studio-proxy:latest`, but now automatically falls back to `docker-ai-studio-proxy:latest` when that Compose image exists and no custom image override was requested. + +Then run the manager from the repository root: + +```bash +bash scripts/multi-instance-manager/run-multi-instance.sh +``` + +## Common examples + +Use defaults: + +```bash +bash scripts/multi-instance-manager/run-multi-instance.sh +``` + +Use another image and custom container prefix: + +```bash +bash scripts/multi-instance-manager/run-multi-instance.sh \ + --image docker-ai-studio-proxy:latest \ + --container-prefix team-a +``` + +Use an explicit image override via environment variable: + +```bash +IMAGE_NAME=docker-ai-studio-proxy:latest \ + bash scripts/multi-instance-manager/run-multi-instance.sh +``` + +Shift ports upward to avoid collisions: + +```bash +bash scripts/multi-instance-manager/run-multi-instance.sh \ + --base-api-port 3048 \ + --base-stream-port 4120 +``` + +Skip confirmation for automation: + +```bash +bash scripts/multi-instance-manager/run-multi-instance.sh --auto-confirm +``` + +Preview actions without launching containers: + +```bash +bash scripts/multi-instance-manager/run-multi-instance.sh --dry-run +``` + +Allow auth rotation inside each launched container: + +```bash +bash scripts/multi-instance-manager/run-multi-instance.sh --enable-auth-rotation +``` + +## Options + +| Option | Description | Default | +| --- | --- | --- | +| `--project-root PATH` | Repository root path | auto-detected | +| `--profile-dir PATH` | Directory with saved auth profiles | `auth_profiles/saved` | +| `--docker-env-file PATH` | Path to mounted Docker env file | `docker/.env` | +| `--image NAME` | Docker image name | `ai-studio-proxy:latest` with automatic fallback to `docker-ai-studio-proxy:latest` after a standard `docker compose build` | +| `--container-prefix PREFIX` | Prefix for launched containers | `ai-proxy` | +| `--api-key KEY` | Shared API key for all containers | `sk-dummy` | +| `--log-level LEVEL` | `SERVER_LOG_LEVEL` value | `INFO` | +| `--base-api-port PORT` | Starting host API port | `2048` | +| `--base-stream-port PORT` | Starting host stream port | `3120` | +| `--memory-per-instance-gb N` | Estimated RAM per container | `1` | +| `--auto-confirm` | Skip confirmation prompt | disabled | +| `--dry-run` | Show commands without running Docker | disabled | +| `--no-docker-env` | Do not mount [`docker/.env`](../../docker/.env) | disabled | +| `--enable-auth-rotation` | Re-enable auth rotation inside launched containers | disabled | +| `--help` | Show help | disabled | + +## Environment variables + +All major parameters can also be set with environment variables before launch: + +```bash +export IMAGE_NAME=docker-ai-studio-proxy:latest +export BASE_API_PORT=3048 +export BASE_STREAM_PORT=4120 +export AUTO_CONFIRM=true +bash scripts/multi-instance-manager/run-multi-instance.sh +``` + +When `IMAGE_NAME` or `--image` is set explicitly, the launcher treats that as an intentional override and does not silently replace it with the Compose image name. + +Supported variables: + +- `PROJECT_ROOT` +- `PROFILE_DIR` +- `DOCKER_ENV_FILE` +- `IMAGE_NAME` +- `CONTAINER_PREFIX` +- `API_KEY` +- `SERVER_LOG_LEVEL` +- `BASE_API_PORT` +- `BASE_STREAM_PORT` +- `MEMORY_PER_INSTANCE_GB` +- `AUTO_CONFIRM` +- `DRY_RUN` +- `MOUNT_DOCKER_ENV` +- `DISABLE_AUTH_ROTATION` +- `EXTRA_DOCKER_ARGS` + +## How the manager maps profiles + +If the saved pool contains: + +- `auth_profiles/saved/user-a.json` +- `auth_profiles/saved/user-b.json` + +The script will launch containers similar to: + +- `ai-proxy-user-a` on API `2048` and stream `3120` +- `ai-proxy-user-b` on API `2049` and stream `3121` + +Inside each container, startup resolves through two aligned paths: + +- `/app/auth_profiles/saved/user-a.json` and `/app/auth_profiles/active/user-a.json` +- `/app/auth_profiles/saved/user-b.json` and `/app/auth_profiles/active/user-b.json` + +The saved-path mapping is still passed through `ACTIVE_AUTH_JSON_PATH`, and the same host file is also mounted into the container-local [`auth_profiles/active/`](../../auth_profiles/active/) directory. This keeps one-container-one-profile isolation while avoiding the Docker headless startup failure that reports no active profile. + +## Port collision behavior + +The launcher checks host ports twice: + +1. before removing a same-named container +2. again after removal and before starting the replacement container + +This prevents a false success path where an old container was removed even though the target port actually belonged to another listener. + +If a host port is occupied by another process or container, the script stops before launching replacements. + +## Safety checks + +Before launch the script validates: + +- Docker CLI availability +- Docker daemon availability +- Docker image existence +- profile directory existence +- presence of `*.json` profiles +- host port collisions for all planned instances +- optional [`docker/.env`](../../docker/.env) existence when mounting is enabled + +## Stop and inspect containers + +List launched containers: + +```bash +docker ps --filter "name=^ai-proxy-" +``` + +Stop one container: + +```bash +docker rm -f ai-proxy-user-a +``` + +Stop all containers for the default prefix: + +```bash +containers=$(docker ps -aq --filter "name=^ai-proxy-") +[ -n "$containers" ] && docker rm -f $containers +``` + +Check logs for one instance: + +```bash +docker logs -f ai-proxy-user-a +``` + +## Notes + +- The manager is intended for advanced operators. +- It is an external wrapper, not a replacement for the standard single-instance flow. +- For the normal Docker path, continue using [`docker/README.md`](../../docker/README.md) and [`docs/deployment-and-operations.md`](../../docs/deployment-and-operations.md). diff --git a/scripts/multi-instance-manager/run-multi-instance.sh b/scripts/multi-instance-manager/run-multi-instance.sh new file mode 100644 index 0000000000000000000000000000000000000000..1d7ef4e7fb1f4c4982988ff7a173569e4b879c1f --- /dev/null +++ b/scripts/multi-instance-manager/run-multi-instance.sh @@ -0,0 +1,411 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEFAULT_PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +PROJECT_ROOT="${PROJECT_ROOT:-$DEFAULT_PROJECT_ROOT}" +PROFILE_DIR="${PROFILE_DIR:-$PROJECT_ROOT/auth_profiles/saved}" +DOCKER_ENV_FILE="${DOCKER_ENV_FILE:-$PROJECT_ROOT/docker/.env}" +DEFAULT_IMAGE_NAME="ai-studio-proxy:latest" +COMPOSE_DEFAULT_IMAGE_NAME="docker-ai-studio-proxy:latest" +IMAGE_NAME="${IMAGE_NAME:-$DEFAULT_IMAGE_NAME}" +IMAGE_NAME_SOURCE="default" +CONTAINER_PREFIX="${CONTAINER_PREFIX:-ai-proxy}" +API_KEY="${API_KEY:-sk-dummy}" +SERVER_LOG_LEVEL="${SERVER_LOG_LEVEL:-INFO}" +BASE_API_PORT="${BASE_API_PORT:-2048}" +BASE_STREAM_PORT="${BASE_STREAM_PORT:-3120}" +MEMORY_PER_INSTANCE_GB="${MEMORY_PER_INSTANCE_GB:-1}" +AUTO_CONFIRM="${AUTO_CONFIRM:-false}" +DRY_RUN="${DRY_RUN:-false}" +MOUNT_DOCKER_ENV="${MOUNT_DOCKER_ENV:-true}" +DISABLE_AUTH_ROTATION="${DISABLE_AUTH_ROTATION:-true}" +EXTRA_DOCKER_ARGS="${EXTRA_DOCKER_ARGS:-}" +RUNTIME_STATE_DIR="${RUNTIME_STATE_DIR:-$PROJECT_ROOT/.multi-instance-runtime}" + +usage() { + cat <<'EOF' +Usage: + bash scripts/multi-instance-manager/run-multi-instance.sh [options] + +Options: + --project-root PATH Path to repository root + --profile-dir PATH Directory with saved auth profiles + --docker-env-file PATH Path to docker/.env file to mount into containers + --image NAME Docker image name to run + --container-prefix PREFIX Prefix for container names + --api-key KEY Shared API key for launched containers + --log-level LEVEL SERVER_LOG_LEVEL value + --base-api-port PORT Starting host port for API service + --base-stream-port PORT Starting host port for stream service + --memory-per-instance-gb N Estimated RAM usage per instance + --auto-confirm Skip interactive confirmation + --dry-run Print actions without running docker + --no-docker-env Do not mount docker/.env into containers + --enable-auth-rotation Allow containers to rotate into other profiles + --help Show this help + +Environment variables can also be used: + PROJECT_ROOT PROFILE_DIR DOCKER_ENV_FILE IMAGE_NAME CONTAINER_PREFIX API_KEY + SERVER_LOG_LEVEL BASE_API_PORT BASE_STREAM_PORT MEMORY_PER_INSTANCE_GB + AUTO_CONFIRM DRY_RUN MOUNT_DOCKER_ENV DISABLE_AUTH_ROTATION EXTRA_DOCKER_ARGS + RUNTIME_STATE_DIR +EOF +} + +log() { + printf '%s\n' "$*" +} + +warn() { + printf '⚠️ %s\n' "$*" >&2 +} + +die() { + printf '❌ %s\n' "$*" >&2 + exit 1 +} + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +trim() { + local value="$1" + value="${value#${value%%[![:space:]]*}}" + value="${value%${value##*[![:space:]]}}" + printf '%s' "$value" +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --project-root) + PROJECT_ROOT="$2" + shift 2 + ;; + --profile-dir) + PROFILE_DIR="$2" + shift 2 + ;; + --docker-env-file) + DOCKER_ENV_FILE="$2" + shift 2 + ;; + --image) + IMAGE_NAME="$2" + IMAGE_NAME_SOURCE="cli" + shift 2 + ;; + --container-prefix) + CONTAINER_PREFIX="$2" + shift 2 + ;; + --api-key) + API_KEY="$2" + shift 2 + ;; + --log-level) + SERVER_LOG_LEVEL="$2" + shift 2 + ;; + --base-api-port) + BASE_API_PORT="$2" + shift 2 + ;; + --base-stream-port) + BASE_STREAM_PORT="$2" + shift 2 + ;; + --memory-per-instance-gb) + MEMORY_PER_INSTANCE_GB="$2" + shift 2 + ;; + --auto-confirm) + AUTO_CONFIRM="true" + shift + ;; + --dry-run) + DRY_RUN="true" + shift + ;; + --no-docker-env) + MOUNT_DOCKER_ENV="false" + shift + ;; + --enable-auth-rotation) + DISABLE_AUTH_ROTATION="false" + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + die "Unknown option: $1" + ;; + esac + done +} + +require_integer() { + local name="$1" + local value="$2" + [[ "$value" =~ ^[0-9]+$ ]] || die "$name must be a non-negative integer, got: $value" +} + +validate_paths() { + [[ -d "$PROJECT_ROOT" ]] || die "Project root not found: $PROJECT_ROOT" + [[ -d "$PROFILE_DIR" ]] || die "Profile directory not found: $PROFILE_DIR" + + if [[ "$MOUNT_DOCKER_ENV" == "true" && ! -f "$DOCKER_ENV_FILE" ]]; then + die "docker env file not found: $DOCKER_ENV_FILE" + fi + + mkdir -p "$RUNTIME_STATE_DIR/active" +} + +resolve_image_name() { + if docker image inspect "$IMAGE_NAME" >/dev/null 2>&1; then + return 0 + fi + + if [[ "$IMAGE_NAME_SOURCE" == "default" && "$IMAGE_NAME" == "$DEFAULT_IMAGE_NAME" ]] \ + && docker image inspect "$COMPOSE_DEFAULT_IMAGE_NAME" >/dev/null 2>&1; then + warn "Docker image $DEFAULT_IMAGE_NAME not found; falling back to Compose-built image $COMPOSE_DEFAULT_IMAGE_NAME" + IMAGE_NAME="$COMPOSE_DEFAULT_IMAGE_NAME" + return 0 + fi + + die "docker image not found: $IMAGE_NAME" +} + +check_docker() { + command_exists docker || die "docker command not found" + docker info >/dev/null 2>&1 || die "docker daemon is not available" + resolve_image_name +} + +discover_profiles() { + shopt -s nullglob + PROFILE_FILES=("$PROFILE_DIR"/*.json) + shopt -u nullglob + + [[ ${#PROFILE_FILES[@]} -gt 0 ]] || die "No .json auth profiles found in $PROFILE_DIR" +} + +print_memory_warning() { + local instance_count="${#PROFILE_FILES[@]}" + local estimated_total_gb=$((instance_count * MEMORY_PER_INSTANCE_GB)) + + log "============================================================" + warn "Multi-instance mode launches one isolated Docker container per auth profile." + warn "Each instance may consume about ${MEMORY_PER_INSTANCE_GB} GB RAM or more." + warn "Planned instances: ${instance_count}" + warn "Estimated RAM requirement: ~${estimated_total_gb} GB" + warn "Running too many instances can trigger memory pressure or OOM kills." + log "============================================================" +} + +confirm_launch() { + [[ "$AUTO_CONFIRM" == "true" ]] && return 0 + + local answer + printf 'Continue launching %s containers? [y/N]: ' "${#PROFILE_FILES[@]}" + read -r answer + answer="$(trim "$answer")" + [[ "$answer" =~ ^([yY][eE][sS]|[yY])$ ]] || die "Launch cancelled by user" +} + +port_in_use() { + local port="$1" + + if command_exists lsof; then + lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1 + return $? + fi + + docker ps --format '{{.Ports}}' | grep -E "(^|[ ,])0\.0\.0\.0:${port}->|(^|[ ,])127\.0\.0\.1:${port}->|(^|[ ,])\[::\]:${port}->" >/dev/null 2>&1 +} + +container_exists() { + local container_name="$1" + docker ps -a --format '{{.Names}}' | grep -Fx "$container_name" >/dev/null 2>&1 +} + +container_uses_port() { + local container_name="$1" + local port="$2" + + docker ps --filter "name=^${container_name}$" --format '{{.Ports}}' | grep -E "(^|[ ,])127\.0\.0\.1:${port}->|(^|[ ,])0\.0\.0\.0:${port}->|(^|[ ,])\[::\]:${port}->" >/dev/null 2>&1 +} + +check_port_available() { + local port="$1" + local port_role="$2" + local container_name="$3" + + if port_in_use "$port"; then + if [[ -n "$container_name" ]] && container_uses_port "$container_name" "$port"; then + return 0 + fi + + die "Host ${port_role} port already in use by another listener: $port" + fi +} + +sanitize_name() { + local value="$1" + value="$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]')" + value="${value//[^a-z0-9_.-]/-}" + value="${value#-}" + value="${value%-}" + printf '%s' "$value" +} + +stop_existing_container() { + local container_name="$1" + + if container_exists "$container_name"; then + log "♻️ Removing existing container: $container_name" + if [[ "$DRY_RUN" != "true" ]]; then + docker rm -f "$container_name" >/dev/null + fi + fi +} + +prepare_container_ports() { + local container_name="$1" + local api_port="$2" + local stream_port="$3" + + check_port_available "$api_port" "API" "$container_name" + check_port_available "$stream_port" "stream" "$container_name" + stop_existing_container "$container_name" + check_port_available "$api_port" "API" "" + check_port_available "$stream_port" "stream" "" +} + +launch_container() { + local profile_path="$1" + local profile_name="$2" + local api_port="$3" + local stream_port="$4" + local container_name="$5" + local container_profile_path="/app/auth_profiles/saved/${profile_name}.json" + local runtime_active_host_path="$RUNTIME_STATE_DIR/active/${container_name}.json" + local container_active_profile_path="/app/auth_profiles/active/${profile_name}.json" + + cp "$profile_path" "$runtime_active_host_path" + + local -a docker_args=( + run -d + --name "$container_name" + --restart unless-stopped + -p "127.0.0.1:${api_port}:2048" + -p "127.0.0.1:${stream_port}:3120" + -v "$PROJECT_ROOT/auth_profiles:/app/auth_profiles" + -v "$runtime_active_host_path:${container_active_profile_path}:ro" + -e "ACTIVE_AUTH_JSON_PATH=${container_profile_path}" + -e "API_KEY=${API_KEY}" + -e "SERVER_LOG_LEVEL=${SERVER_LOG_LEVEL}" + ) + + if [[ "$MOUNT_DOCKER_ENV" == "true" ]]; then + docker_args+=( -v "$DOCKER_ENV_FILE:/app/.env:ro" ) + fi + + if [[ "$DISABLE_AUTH_ROTATION" == "true" ]]; then + docker_args+=( + -e "AUTO_AUTH_ROTATION_ON_STARTUP=false" + -e "AUTO_ROTATE_AUTH_PROFILE=false" + ) + fi + + if [[ -n "$EXTRA_DOCKER_ARGS" ]]; then + # shellcheck disable=SC2206 + local -a extra_args=( $EXTRA_DOCKER_ARGS ) + docker_args+=( "${extra_args[@]}" ) + fi + + docker_args+=( "$IMAGE_NAME" ) + + log "------------------------------------------------------------" + log "🚀 Launching profile: $profile_name" + log " Container: $container_name" + log " API port: $api_port" + log " Stream: $stream_port" + log " Saved auth: $container_profile_path" + log " Active mount: $container_active_profile_path" + log " Startup mode: dual-path (ACTIVE_AUTH_JSON_PATH + active-file mount)" + if [[ "$DISABLE_AUTH_ROTATION" == "true" ]]; then + log " Rotation: disabled for strict profile isolation" + fi + + if [[ "$DRY_RUN" == "true" ]]; then + printf 'DRY RUN: docker' + printf ' %q' "${docker_args[@]}" + printf '\n' + else + docker "${docker_args[@]}" >/dev/null + fi +} + +print_summary() { + log "" + log "✅ Done. Processed profiles: ${#PROFILE_FILES[@]}" + log "🔑 Shared API key: ${API_KEY}" + log "🐳 Container prefix: ${CONTAINER_PREFIX}" + + if [[ "$DISABLE_AUTH_ROTATION" == "true" ]]; then + log "🔒 Auth rotation: disabled" + else + log "🔁 Auth rotation: enabled" + fi + + if [[ "$DRY_RUN" == "true" ]]; then + log "🧪 Dry run mode was enabled. No containers were started." + else + docker ps --filter "name=^${CONTAINER_PREFIX}-" --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' + fi +} + +main() { + parse_args "$@" + + if [[ -n "${IMAGE_NAME:-}" && "$IMAGE_NAME" != "$DEFAULT_IMAGE_NAME" && "$IMAGE_NAME_SOURCE" == "default" ]]; then + IMAGE_NAME_SOURCE="env" + fi + + require_integer "BASE_API_PORT" "$BASE_API_PORT" + require_integer "BASE_STREAM_PORT" "$BASE_STREAM_PORT" + require_integer "MEMORY_PER_INSTANCE_GB" "$MEMORY_PER_INSTANCE_GB" + + validate_paths + check_docker + discover_profiles + + log "🔍 Found profiles: ${#PROFILE_FILES[@]} in $PROFILE_DIR" + print_memory_warning + confirm_launch + + local idx profile_path profile_basename profile_name container_name api_port stream_port + + for idx in "${!PROFILE_FILES[@]}"; do + profile_path="${PROFILE_FILES[$idx]}" + profile_basename="$(basename "$profile_path")" + profile_name="${profile_basename%.json}" + container_name="$(sanitize_name "${CONTAINER_PREFIX}-${profile_name}")" + api_port=$((BASE_API_PORT + idx)) + stream_port=$((BASE_STREAM_PORT + idx)) + + prepare_container_ports "$container_name" "$api_port" "$stream_port" + launch_container "$profile_path" "$profile_name" "$api_port" "$stream_port" "$container_name" + done + + print_summary +} + +main "$@" diff --git a/scripts/update_browserforge_data.py b/scripts/update_browserforge_data.py new file mode 100644 index 0000000000000000000000000000000000000000..e80741b5389230553146777c354f10c1077abadb --- /dev/null +++ b/scripts/update_browserforge_data.py @@ -0,0 +1,15 @@ +from browserforge.download import REMOTE_PATHS, Download, Remove + +# Modify REMOTE_PATHS directly +REMOTE_PATHS["headers"] = ( + "https://raw.githubusercontent.com/apify/fingerprint-suite/667526247a519ec6fe7d99e640c45fbe403fb611/packages/header-generator/src/data_files" +) +REMOTE_PATHS["fingerprints"] = ( + "https://raw.githubusercontent.com/apify/fingerprint-suite/667526247a519ec6fe7d99e640c45fbe403fb611/packages/fingerprint-generator/src/data_files" +) + +# Removes previously downloaded browserforge files if they exist +Remove() + +# Downloads updated fingerprint + header definitions +Download(headers=True, fingerprints=True) diff --git a/server.py b/server.py new file mode 100644 index 0000000000000000000000000000000000000000..2a1c0c9c731974e857b55696e050e5b6e4d94edb --- /dev/null +++ b/server.py @@ -0,0 +1,85 @@ +import asyncio +import logging +import os + +from dotenv import load_dotenv + +load_dotenv() + +# --- Centralized state module --- +from api_utils.server_state import state + + +def clear_debug_logs() -> None: + state.clear_debug_logs() + + +# --- Imports --- + +from browser_utils.auth_rotation import perform_auth_rotation +from config import ( + GlobalState, +) + + +async def quota_watchdog(): + """Background watchdog to monitor quota exceeded events.""" + # Use state's logger if available + logger = getattr(state, "logger", logging.getLogger("AIStudioProxyServer")) + logger.info("👀 Quota Watchdog Started") + while True: + try: + await GlobalState.QUOTA_EXCEEDED_EVENT.wait() + logger.critical( + "🚨 Watchdog detected Quota Exceeded! Initiating Rotation..." + ) + + if not GlobalState.AUTH_ROTATION_LOCK.is_set(): + logger.info("Watchdog: Rotation already in progress. Waiting...") + await asyncio.sleep(1) + continue + + GlobalState.start_recovery() + try: + current_model_id = state.current_ai_studio_model_id + success = await perform_auth_rotation( + target_model_id=current_model_id or "" + ) + if success: + logger.info("Watchdog: Rotation successful.") + else: + logger.error("Watchdog: Rotation failed.") + finally: + GlobalState.finish_recovery() + + if GlobalState.IS_QUOTA_EXCEEDED: + logger.warning("Watchdog: Quota flag still set. Forcing reset.") + GlobalState.reset_quota_status() + + except asyncio.CancelledError: + logger.info("Watchdog: Task cancelled.") + break + except Exception as e: + logger.error(f"Watchdog Error: {e}", exc_info=True) + await asyncio.sleep(5) + + +# Register quota_watchdog in state for easier access and to avoid circular import issues +state.quota_watchdog = quota_watchdog + + +from api_utils import ( + create_app, +) + +# --- FastAPI App --- +app = create_app() + + +if __name__ == "__main__": + import uvicorn + + port = int(os.environ.get("PORT", 2048)) + uvicorn.run( + "server:app", host="0.0.0.0", port=port, log_level="info", access_log=False + ) diff --git a/simple_launcher.py b/simple_launcher.py new file mode 100644 index 0000000000000000000000000000000000000000..5d84b50cbde160a41f2eacd4c395dee1600bb15c --- /dev/null +++ b/simple_launcher.py @@ -0,0 +1,1251 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +AI Studio Proxy API - Simple GUI Launcher +Easy to use, modern interface launcher. + +Features: +- Modern dark theme +- GNOME system tray support +- Account management (add, delete) +- Proxy settings +- Port configuration +- API test button +- Log saving + +Usage: + poetry run python simple_launcher.py +""" + +import json +import os +import platform +import signal +import socket +import subprocess +import sys +import threading +import time +import tkinter as tk +import webbrowser +from datetime import datetime +from pathlib import Path +from tkinter import messagebox, scrolledtext, simpledialog, ttk +from typing import Any, Dict, List, Optional + +# Project directories +SCRIPT_DIR = Path(__file__).parent.absolute() +AUTH_PROFILES_DIR = SCRIPT_DIR / "auth_profiles" +SAVED_AUTH_DIR = AUTH_PROFILES_DIR / "saved" +ACTIVE_AUTH_DIR = AUTH_PROFILES_DIR / "active" +LAUNCH_SCRIPT = SCRIPT_DIR / "launch_camoufox.py" +CONFIG_FILE = SCRIPT_DIR / "simple_launcher_config.json" +LOG_FILE = SCRIPT_DIR / "logs" / "simple_launcher.log" + +# Default settings +DEFAULT_CONFIG = { + "fastapi_port": 2048, + "camoufox_port": 9222, + "stream_port": 3120, + "proxy_address": "", + "proxy_enabled": False, + "last_account": "", + "dark_mode": True, + "minimize_to_tray": True, +} + +# Modern Color Palette (Dark Theme) +COLORS = { + "bg_dark": "#1a1a2e", + "bg_medium": "#16213e", + "bg_light": "#0f3460", + "accent": "#e94560", + "accent_hover": "#ff6b6b", + "success": "#00d26a", + "warning": "#ffc107", + "error": "#dc3545", + "text_primary": "#ffffff", + "text_secondary": "#a0a0a0", + "border": "#2d2d44", +} + + +class ModernStyle: + """Modern style manager""" + + @staticmethod + def apply(root): + """Apply dark theme styles""" + style = ttk.Style() + + # Set theme + style.theme_use("clam") + + # Frame styles + style.configure("TFrame", background=COLORS["bg_dark"]) + style.configure("Card.TFrame", background=COLORS["bg_medium"]) + + # Label styles + style.configure( + "TLabel", + background=COLORS["bg_dark"], + foreground=COLORS["text_primary"], + font=("Segoe UI", 10), + ) + style.configure( + "Header.TLabel", + background=COLORS["bg_dark"], + foreground=COLORS["text_primary"], + font=("Segoe UI", 14, "bold"), + ) + style.configure( + "Status.TLabel", + background=COLORS["bg_dark"], + foreground=COLORS["success"], + font=("Segoe UI", 11, "bold"), + ) + + # LabelFrame styles + style.configure( + "TLabelframe", + background=COLORS["bg_medium"], + foreground=COLORS["text_primary"], + ) + style.configure( + "TLabelframe.Label", + background=COLORS["bg_medium"], + foreground=COLORS["accent"], + font=("Segoe UI", 11, "bold"), + ) + + # Button styles + style.configure( + "TButton", + background=COLORS["bg_light"], + foreground=COLORS["text_primary"], + font=("Segoe UI", 10), + padding=(10, 5), + ) + style.map( + "TButton", + background=[ + ("active", COLORS["accent"]), + ("pressed", COLORS["accent_hover"]), + ], + ) + + style.configure( + "Accent.TButton", + background=COLORS["accent"], + foreground=COLORS["text_primary"], + font=("Segoe UI", 10, "bold"), + ) + + # Entry styles + style.configure( + "TEntry", + fieldbackground=COLORS["bg_light"], + foreground=COLORS["text_primary"], + insertcolor=COLORS["text_primary"], + ) + + # Combobox styles + style.configure( + "TCombobox", + fieldbackground=COLORS["bg_light"], + background=COLORS["bg_light"], + foreground=COLORS["text_primary"], + arrowcolor=COLORS["text_primary"], + ) + style.map( + "TCombobox", + fieldbackground=[("readonly", COLORS["bg_light"])], + selectbackground=[("readonly", COLORS["accent"])], + ) + + # Radiobutton styles - larger and more visible + style.configure( + "TRadiobutton", + background=COLORS["bg_medium"], + foreground=COLORS["text_primary"], + font=("Segoe UI", 11), + indicatorsize=20, + ) + style.map( + "TRadiobutton", + indicatorcolor=[ + ("selected", COLORS["accent"]), + ("!selected", COLORS["bg_light"]), + ], + background=[("active", COLORS["bg_light"])], + ) + + # Checkbutton styles - larger and more visible + style.configure( + "TCheckbutton", + background=COLORS["bg_medium"], + foreground=COLORS["text_primary"], + font=("Segoe UI", 11), + indicatorsize=18, + ) + style.map( + "TCheckbutton", + indicatorcolor=[ + ("selected", COLORS["accent"]), + ("!selected", COLORS["bg_light"]), + ], + background=[("active", COLORS["bg_light"])], + ) + + # Notebook styles + style.configure("TNotebook", background=COLORS["bg_dark"], borderwidth=0) + style.configure( + "TNotebook.Tab", + background=COLORS["bg_medium"], + foreground=COLORS["text_primary"], + padding=(15, 8), + font=("Segoe UI", 10), + ) + style.map( + "TNotebook.Tab", + background=[("selected", COLORS["accent"])], + foreground=[("selected", COLORS["text_primary"])], + ) + + # Root widget background + root.configure(bg=COLORS["bg_dark"]) + + +class TrayIcon: + """GNOME system tray support - AppIndicator3 (Wayland) or pystray (X11)""" + + def __init__(self, app): + self.app = app + self.indicator = None + self.supported = False + self.backend = None # "appindicator" or "pystray" + + def create_icon(self): + """Create tray icon - try AppIndicator3 first, then pystray""" + + # 1. Try GNOME AppIndicator3 first (for Wayland) + if self._try_appindicator(): + return + + # 2. Then try pystray (for X11) + if self._try_pystray(): + return + + print("⚠️ System tray support not found. Tray disabled.") + + def _try_appindicator(self) -> bool: + """Try to create tray with AppIndicator3""" + try: + import gi + + gi.require_version("Gtk", "3.0") + gi.require_version("AppIndicator3", "0.1") + from gi.repository import AppIndicator3, GLib, Gtk + + # Create menu + menu = Gtk.Menu() + + # Menu items + item_show = Gtk.MenuItem(label="📂 Show Window") + item_show.connect( + "activate", lambda w: GLib.idle_add(self.app._show_window) + ) + menu.append(item_show) + + menu.append(Gtk.SeparatorMenuItem()) + + item_start = Gtk.MenuItem(label="▶️ Start") + item_start.connect("activate", lambda w: GLib.idle_add(self.app._start)) + menu.append(item_start) + + item_stop = Gtk.MenuItem(label="⏹️ Stop") + item_stop.connect("activate", lambda w: GLib.idle_add(self.app._stop)) + menu.append(item_stop) + + menu.append(Gtk.SeparatorMenuItem()) + + item_test = Gtk.MenuItem(label="🔍 API Test") + item_test.connect("activate", lambda w: GLib.idle_add(self.app._api_test)) + menu.append(item_test) + + menu.append(Gtk.SeparatorMenuItem()) + + item_quit = Gtk.MenuItem(label="❌ Exit") + item_quit.connect( + "activate", lambda w: GLib.idle_add(self.app._close_completely) + ) + menu.append(item_quit) + + menu.show_all() + + # Create AppIndicator + self.indicator = AppIndicator3.Indicator.new( + "aistudio-proxy", + "network-server", # Use system icon + AppIndicator3.IndicatorCategory.APPLICATION_STATUS, + ) + self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE) + self.indicator.set_menu(menu) + self.indicator.set_title("AI Studio Proxy") + + # Run GTK main loop in separate thread + def gtk_main(): + try: + Gtk.main() + except Exception: + pass + + threading.Thread(target=gtk_main, daemon=True).start() + + self.supported = True + self.backend = "appindicator" + print("✅ GNOME AppIndicator3 tray started (Wayland compatible)") + return True + + except Exception as e: + print(f"⚠️ AppIndicator3 could not be started: {e}") + return False + + def _try_pystray(self) -> bool: + """Try to create tray with pystray""" + try: + import pystray + from PIL import Image, ImageDraw + + # Create a simple icon + size = 64 + image = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + draw.ellipse([4, 4, size - 4, size - 4], fill=COLORS["accent"]) + draw.ellipse([16, 16, size - 16, size - 16], fill=COLORS["bg_dark"]) + + # Create menu + menu = pystray.Menu( + pystray.MenuItem("📂 Show Window", self._pystray_show), + pystray.Menu.SEPARATOR, + pystray.MenuItem("▶️ Start", self._pystray_start), + pystray.MenuItem("⏹️ Stop", self._pystray_stop), + pystray.Menu.SEPARATOR, + pystray.MenuItem("🔍 API Test", self._pystray_test), + pystray.Menu.SEPARATOR, + pystray.MenuItem("❌ Exit", self._pystray_quit), + ) + + self.indicator = pystray.Icon( + "AI Studio Proxy", image, "AI Studio Proxy", menu + ) + threading.Thread(target=self.indicator.run, daemon=True).start() + + self.supported = True + self.backend = "pystray" + print("✅ pystray tray started (X11)") + return True + + except Exception as e: + print(f"⚠️ pystray could not be started: {e}") + return False + + def _pystray_show(self, icon=None, item=None): + self.app.root.after(0, self.app._show_window) + + def _pystray_start(self, icon=None, item=None): + self.app.root.after(0, self.app._start) + + def _pystray_stop(self, icon=None, item=None): + self.app.root.after(0, self.app._stop) + + def _pystray_test(self, icon=None, item=None): + self.app.root.after(0, self.app._api_test) + + def _pystray_quit(self, icon=None, item=None): + self.app.root.after(0, self.app._close_completely) + + def update_status(self, running: bool): + """Update tray icon status""" + if not self.supported: + return + # Status update - can be extended later + + def stop(self): + """Stop tray icon""" + try: + if self.backend == "appindicator": + import gi + + gi.require_version("Gtk", "3.0") + from gi.repository import Gtk + + Gtk.main_quit() + elif self.backend == "pystray" and self.indicator: + self.indicator.stop() + except Exception: + pass + + +class SimpleGUILauncher: + """Simple GUI Launcher""" + + def __init__(self): + self.root = tk.Tk() + self.root.title("🚀 AI Studio Proxy API") + self.root.geometry("1050x700") + self.root.minsize(800, 500) + + # Apply modern style + ModernStyle.apply(self.root) + + # Load configuration + self.config = self._load_config() + + # Variables + self.selected_account = tk.StringVar(value=self.config.get("last_account", "")) + self.run_mode = tk.StringVar(value="headless") + self.status = tk.StringVar(value="⚪ Ready") + self.fastapi_port = tk.StringVar( + value=str(self.config.get("fastapi_port", 2048)) + ) + self.stream_port = tk.StringVar(value=str(self.config.get("stream_port", 3120))) + self.proxy_address = tk.StringVar(value=self.config.get("proxy_address", "")) + self.proxy_enabled = tk.BooleanVar( + value=self.config.get("proxy_enabled", False) + ) + + self.process: Optional[subprocess.Popen] = None + self.log_thread: Optional[threading.Thread] = None + self.running = False + + # Tray icon + self.tray = TrayIcon(self) + + # Create log directory + LOG_FILE.parent.mkdir(parents=True, exist_ok=True) + + # Create interface + self._create_interface() + + # Load accounts + self._load_accounts() + + # Start tray icon + if self.config.get("minimize_to_tray", True): + self.tray.create_icon() + + # Close handler + self.root.protocol("WM_DELETE_WINDOW", self._minimize_to_tray) + + def _load_config(self) -> Dict[str, Any]: + """Load configuration""" + try: + if CONFIG_FILE.exists(): + with open(CONFIG_FILE, "r", encoding="utf-8") as f: + return {**DEFAULT_CONFIG, **json.load(f)} + except Exception as e: + print(f"⚠️ Configuration could not be loaded: {e}") + return DEFAULT_CONFIG.copy() + + def _save_config(self): + """Save configuration""" + try: + config = { + "fastapi_port": int(self.fastapi_port.get() or 2048), + "stream_port": int(self.stream_port.get() or 3120), + "proxy_address": self.proxy_address.get(), + "proxy_enabled": self.proxy_enabled.get(), + "last_account": self.selected_account.get(), + "dark_mode": True, + "minimize_to_tray": True, + } + with open(CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2, ensure_ascii=False) + except Exception as e: + self._log(f"⚠️ Configuration could not be saved: {e}") + + def _create_interface(self): + """Create modern interface""" + + # Main container + main_container = ttk.Frame(self.root, padding="15") + main_container.pack(fill=tk.BOTH, expand=True) + + # === HEADER === + header_frame = ttk.Frame(main_container) + header_frame.pack(fill=tk.X, pady=(0, 15)) + + ttk.Label( + header_frame, text="🚀 AI Studio Proxy API", style="Header.TLabel" + ).pack(side=tk.LEFT) + + # Status indicator (top right) + self.status_label = ttk.Label( + header_frame, textvariable=self.status, style="Status.TLabel" + ) + self.status_label.pack(side=tk.RIGHT) + + # === NOTEBOOK (Tabs) === + notebook = ttk.Notebook(main_container) + notebook.pack(fill=tk.BOTH, expand=True) + + # Tab 1: Main Control + self.main_tab = ttk.Frame(notebook, padding="10") + notebook.add(self.main_tab, text="🎮 Control") + self._create_main_tab() + + # Tab 2: Account Management + self.account_tab = ttk.Frame(notebook, padding="10") + notebook.add(self.account_tab, text="👤 Accounts") + self._create_account_tab() + + # Tab 3: Settings + self.settings_tab = ttk.Frame(notebook, padding="10") + notebook.add(self.settings_tab, text="⚙️ Settings") + self._create_settings_tab() + + # Tab 4: Logs + self.log_tab = ttk.Frame(notebook, padding="10") + notebook.add(self.log_tab, text="📋 Logs") + self._create_log_tab() + + def _create_main_tab(self): + """Main control tab""" + + # Account selection (quick access) + quick_frame = ttk.LabelFrame(self.main_tab, text="⚡ Quick Start", padding="15") + quick_frame.pack(fill=tk.X, pady=(0, 15)) + + row1 = ttk.Frame(quick_frame) + row1.pack(fill=tk.X, pady=(0, 10)) + + ttk.Label(row1, text="Account:").pack(side=tk.LEFT) + self.account_combo = ttk.Combobox( + row1, textvariable=self.selected_account, state="readonly", width=30 + ) + self.account_combo.pack(side=tk.LEFT, padx=(10, 20)) + + ttk.Label(row1, text="Mode:").pack(side=tk.LEFT) + ttk.Radiobutton( + row1, text="Headless", variable=self.run_mode, value="headless" + ).pack(side=tk.LEFT, padx=(10, 5)) + ttk.Radiobutton( + row1, text="Visible", variable=self.run_mode, value="debug" + ).pack(side=tk.LEFT) + + # Large buttons + btn_frame = ttk.Frame(quick_frame) + btn_frame.pack(fill=tk.X, pady=(10, 0)) + + self.start_btn = tk.Button( + btn_frame, + text="▶️ START", + command=self._start, + bg=COLORS["success"], + fg="white", + font=("Segoe UI", 14, "bold"), + height=2, + width=15, + cursor="hand2", + activebackground=COLORS["accent"], + ) + self.start_btn.pack(side=tk.LEFT, padx=(0, 10), expand=True, fill=tk.X) + + self.stop_btn = tk.Button( + btn_frame, + text="⏹️ STOP", + command=self._stop, + bg=COLORS["error"], + fg="white", + font=("Segoe UI", 14, "bold"), + height=2, + width=15, + cursor="hand2", + state=tk.DISABLED, + activebackground=COLORS["accent_hover"], + ) + self.stop_btn.pack(side=tk.LEFT, expand=True, fill=tk.X) + + # Info cards + info_frame = ttk.Frame(self.main_tab) + info_frame.pack(fill=tk.X, pady=(15, 0)) + + # API Info + api_card = ttk.LabelFrame(info_frame, text="🌐 API Info", padding="10") + api_card.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 10)) + + self.api_url_label = ttk.Label( + api_card, text=f"http://127.0.0.1:{self.fastapi_port.get()}" + ) + self.api_url_label.pack(anchor=tk.W) + + api_btn_frame = ttk.Frame(api_card) + api_btn_frame.pack(fill=tk.X, pady=(10, 0)) + + ttk.Button(api_btn_frame, text="🔍 Test", command=self._api_test).pack( + side=tk.LEFT, padx=(0, 5) + ) + ttk.Button( + api_btn_frame, text="🌐 Open in Browser", command=self._open_in_browser + ).pack(side=tk.LEFT) + + # Status card + status_card = ttk.LabelFrame(info_frame, text="📊 Status", padding="10") + status_card.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + self.status_details = ttk.Label(status_card, text="Service is stopped") + self.status_details.pack(anchor=tk.W) + + self.pid_label = ttk.Label(status_card, text="PID: -") + self.pid_label.pack(anchor=tk.W) + + def _create_account_tab(self): + """Account management tab""" + + # Account list + list_frame = ttk.LabelFrame( + self.account_tab, text="📋 Saved Accounts", padding="10" + ) + list_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) + + # Listbox + self.account_listbox = tk.Listbox( + list_frame, + bg=COLORS["bg_light"], + fg=COLORS["text_primary"], + selectbackground=COLORS["accent"], + font=("Consolas", 11), + height=10, + ) + self.account_listbox.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) + + # Account buttons + btn_frame = ttk.Frame(list_frame) + btn_frame.pack(fill=tk.X) + + ttk.Button( + btn_frame, text="➕ Add New Account", command=self._add_new_account + ).pack(side=tk.LEFT, padx=(0, 5)) + ttk.Button( + btn_frame, text="🗑️ Delete Selected", command=self._delete_account + ).pack(side=tk.LEFT, padx=(0, 5)) + ttk.Button(btn_frame, text="🔄 Refresh", command=self._load_accounts).pack( + side=tk.LEFT + ) + + # Account details + detail_frame = ttk.LabelFrame( + self.account_tab, text="ℹ️ Account Details", padding="10" + ) + detail_frame.pack(fill=tk.X) + + self.account_detail = ttk.Label( + detail_frame, text="Select an account to see details" + ) + self.account_detail.pack(anchor=tk.W) + + # Listbox selection event + self.account_listbox.bind("<>", self._account_selected) + + def _create_settings_tab(self): + """Settings tab""" + + # Port settings + port_frame = ttk.LabelFrame( + self.settings_tab, text="🔌 Port Settings", padding="10" + ) + port_frame.pack(fill=tk.X, pady=(0, 10)) + + row1 = ttk.Frame(port_frame) + row1.pack(fill=tk.X, pady=(0, 5)) + + ttk.Label(row1, text="FastAPI Port:").pack(side=tk.LEFT) + ttk.Entry(row1, textvariable=self.fastapi_port, width=10).pack( + side=tk.LEFT, padx=(10, 20) + ) + + ttk.Label(row1, text="Stream Port:").pack(side=tk.LEFT) + ttk.Entry(row1, textvariable=self.stream_port, width=10).pack( + side=tk.LEFT, padx=(10, 0) + ) + + # Proxy settings + proxy_frame = ttk.LabelFrame( + self.settings_tab, text="🌍 Proxy Settings", padding="10" + ) + proxy_frame.pack(fill=tk.X, pady=(0, 10)) + + ttk.Checkbutton( + proxy_frame, text="Use Proxy", variable=self.proxy_enabled + ).pack(anchor=tk.W) + + proxy_row = ttk.Frame(proxy_frame) + proxy_row.pack(fill=tk.X, pady=(5, 0)) + + ttk.Label(proxy_row, text="Address:").pack(side=tk.LEFT) + ttk.Entry(proxy_row, textvariable=self.proxy_address, width=40).pack( + side=tk.LEFT, padx=(10, 0) + ) + + ttk.Label( + proxy_frame, + text="Example: http://127.0.0.1:7890", + foreground=COLORS["text_secondary"], + ).pack(anchor=tk.W, pady=(5, 0)) + + # Save button + save_frame = ttk.Frame(self.settings_tab) + save_frame.pack(fill=tk.X, pady=(20, 0)) + + ttk.Button( + save_frame, text="💾 Save Settings", command=self._save_and_notify + ).pack(side=tk.LEFT) + ttk.Button( + save_frame, text="🔄 Reset to Default", command=self._reset_config + ).pack(side=tk.LEFT, padx=(10, 0)) + + def _create_log_tab(self): + """Log tab""" + + # Log area + self.log_area = scrolledtext.ScrolledText( + self.log_tab, + height=20, + state=tk.DISABLED, + font=("Consolas", 9), + bg=COLORS["bg_light"], + fg=COLORS["text_primary"], + insertbackground=COLORS["text_primary"], + ) + self.log_area.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) + + # Log buttons + btn_frame = ttk.Frame(self.log_tab) + btn_frame.pack(fill=tk.X) + + ttk.Button(btn_frame, text="🗑️ Clear", command=self._clear_logs).pack( + side=tk.LEFT, padx=(0, 5) + ) + ttk.Button(btn_frame, text="💾 Save to File", command=self._save_logs).pack( + side=tk.LEFT, padx=(0, 5) + ) + ttk.Button( + btn_frame, text="📂 Open Log File", command=self._open_log_file + ).pack(side=tk.LEFT) + + def _log(self, message: str, save_to_file: bool = True): + """Add message to log area""" + timestamp = datetime.now().strftime("%H:%M:%S") + formatted = f"[{timestamp}] {message}" + + self.log_area.config(state=tk.NORMAL) + self.log_area.insert(tk.END, f"{formatted}\n") + self.log_area.see(tk.END) + self.log_area.config(state=tk.DISABLED) + + if save_to_file: + try: + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(f"{formatted}\n") + except Exception: + pass + + def _clear_logs(self): + """Clear log area""" + self.log_area.config(state=tk.NORMAL) + self.log_area.delete(1.0, tk.END) + self.log_area.config(state=tk.DISABLED) + + def _save_logs(self): + """Save logs to file""" + try: + log_content = self.log_area.get(1.0, tk.END) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + save_path = SCRIPT_DIR / "logs" / f"session_{timestamp}.log" + save_path.parent.mkdir(parents=True, exist_ok=True) + + with open(save_path, "w", encoding="utf-8") as f: + f.write(log_content) + + self._log(f"✅ Logs saved: {save_path.name}") + messagebox.showinfo("Success", f"Logs saved:\n{save_path}") + except Exception as e: + messagebox.showerror("Error", f"Logs could not be saved:\n{e}") + + def _open_log_file(self): + """Open log file""" + try: + if platform.system() == "Linux": + subprocess.Popen(["xdg-open", str(LOG_FILE.parent)]) + elif platform.system() == "Darwin": + subprocess.Popen(["open", str(LOG_FILE.parent)]) + else: + os.startfile(str(LOG_FILE.parent)) + except Exception as e: + messagebox.showerror("Error", f"Folder could not be opened:\n{e}") + + def _load_accounts(self): + """Load saved accounts""" + accounts = [] + + if SAVED_AUTH_DIR.exists(): + for file in sorted(SAVED_AUTH_DIR.glob("*.json")): + if file.name != ".gitkeep": + accounts.append(file.stem) + + # Update combobox + self.account_combo["values"] = accounts + + # Update listbox + self.account_listbox.delete(0, tk.END) + for acc in accounts: + self.account_listbox.insert(tk.END, f" 📧 {acc}") + + # Select last account + last = self.config.get("last_account", "") + if last and last in accounts: + self.selected_account.set(last) + try: + idx = accounts.index(last) + self.account_listbox.selection_set(idx) + except Exception: + pass + elif accounts: + self.selected_account.set(accounts[0]) + + if accounts: + self._log(f"✅ {len(accounts)} account(s) loaded") + else: + self._log("⚠️ No saved accounts found") + + def _account_selected(self, event): + """Show details when account is selected""" + selection = self.account_listbox.curselection() + if not selection: + return + + idx = selection[0] + account_name = self.account_listbox.get(idx).replace(" 📧 ", "") + + # Read file info + auth_file = SAVED_AUTH_DIR / f"{account_name}.json" + if auth_file.exists(): + stat = auth_file.stat() + mod_time = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M") + size_kb = stat.st_size / 1024 + + self.account_detail.config( + text=f"📁 File: {account_name}.json\n" + f"📅 Last modified: {mod_time}\n" + f"📊 Size: {size_kb:.1f} KB" + ) + + # Update combobox as well + self.selected_account.set(account_name) + + def _add_new_account(self): + """Add new account""" + file_name = simpledialog.askstring( + "New Account", + "Enter a name for the account\n(e.g.: my_gmail_account):", + parent=self.root, + ) + + if not file_name: + return + + if not file_name.replace("_", "").replace("-", "").isalnum(): + messagebox.showerror("Error", "Only letters, numbers, - and _ are allowed!") + return + + self._log(f"🔐 Adding new account: {file_name}") + self._log("📌 Browser will open, log in to your Google account") + self._log("📌 After logging in, account will be saved automatically") + + self._start_internal(mode="debug", save_auth_as=file_name, exit_on_save=True) + + def _delete_account(self): + """Delete selected account""" + selection = self.account_listbox.curselection() + if not selection: + messagebox.showwarning("Warning", "Please select an account to delete") + return + + idx = selection[0] + account_name = self.account_listbox.get(idx).replace(" 📧 ", "") + + if not messagebox.askyesno( + "Confirm", f"Are you sure you want to delete '{account_name}'?" + ): + return + + try: + auth_file = SAVED_AUTH_DIR / f"{account_name}.json" + if auth_file.exists(): + auth_file.unlink() + + # Also delete from active + active_file = ACTIVE_AUTH_DIR / f"{account_name}.json" + if active_file.exists(): + active_file.unlink() + + self._log(f"🗑️ Account deleted: {account_name}") + self._load_accounts() + + except Exception as e: + messagebox.showerror("Error", f"Account could not be deleted:\n{e}") + + def _api_test(self): + """Test the API""" + port = self.fastapi_port.get() + url = f"http://127.0.0.1:{port}/health" + + self._log(f"🔍 Testing API: {url}") + + try: + import urllib.request + + with urllib.request.urlopen(url, timeout=5) as response: + if response.status == 200: + self._log("✅ API is running!") + self.status_details.config(text="✅ API is active and responding") + messagebox.showinfo("Success", "API is running! ✅") + else: + self._log(f"⚠️ API responded but status code: {response.status}") + except urllib.error.URLError: + self._log("❌ Could not connect to API. Service may not be running.") + self.status_details.config(text="❌ API is not responding") + messagebox.showwarning( + "Warning", "Could not connect to API.\nIs the service running?" + ) + except Exception as e: + self._log(f"❌ API test error: {e}") + + def _open_in_browser(self): + """Open API in browser""" + port = self.fastapi_port.get() + webbrowser.open(f"http://127.0.0.1:{port}") + + def _save_and_notify(self): + """Save settings and notify""" + self._save_config() + self._log("💾 Settings saved") + messagebox.showinfo("Success", "Settings saved!") + + # Update API URL + self.api_url_label.config(text=f"http://127.0.0.1:{self.fastapi_port.get()}") + + def _reset_config(self): + """Reset to default settings""" + if messagebox.askyesno( + "Confirm", "All settings will be reset to default. Continue?" + ): + self.fastapi_port.set(str(DEFAULT_CONFIG["fastapi_port"])) + self.stream_port.set(str(DEFAULT_CONFIG["stream_port"])) + self.proxy_address.set("") + self.proxy_enabled.set(False) + self._save_config() + self._log("🔄 Settings reset to default") + + def _is_port_in_use(self, port: int) -> bool: + """Check if port is in use""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind(("127.0.0.1", port)) + return False + except OSError: + return True + + def _find_port_pids(self, port: int) -> List[int]: + """Find PIDs using the port""" + pids = [] + try: + if platform.system() == "Linux": + result = subprocess.run( + ["lsof", "-t", "-i", f":{port}"], + capture_output=True, + text=True, + timeout=5, + ) + if result.stdout.strip(): + pids = [int(p) for p in result.stdout.strip().split("\n") if p] + elif platform.system() == "Windows": + result = subprocess.run( + ["netstat", "-ano", "-p", "TCP"], + capture_output=True, + text=True, + timeout=5, + ) + for line in result.stdout.split("\n"): + if f":{port}" in line and "LISTENING" in line: + parts = line.split() + if parts: + try: + pids.append(int(parts[-1])) + except ValueError: + pass + except Exception as e: + self._log(f"⚠️ Could not find port PID: {e}") + return list(set(pids)) + + def _clean_ports(self) -> bool: + """Clean ports""" + ports = [ + int(self.fastapi_port.get() or 2048), + 9222, # Camoufox + int(self.stream_port.get() or 3120), + ] + cleaned = True + + for port in ports: + if self._is_port_in_use(port): + self._log(f"🔍 Port {port} is in use, cleaning...") + pids = self._find_port_pids(port) + + for pid in pids: + try: + if platform.system() == "Windows": + subprocess.run( + ["taskkill", "/F", "/PID", str(pid)], + capture_output=True, + timeout=5, + ) + else: + os.kill(pid, signal.SIGTERM) + time.sleep(0.5) + try: + os.kill(pid, 0) + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + self._log(f" ✅ PID {pid} terminated") + except Exception as e: + self._log(f" ❌ PID {pid}: {e}") + cleaned = False + + time.sleep(1) + if self._is_port_in_use(port): + self._log(f" ❌ Port {port} is still in use!") + cleaned = False + + return cleaned + + def _start(self): + """Start the service""" + if self.running: + messagebox.showwarning("Warning", "Service is already running!") + return + + account = self.selected_account.get() + if not account: + messagebox.showerror("Error", "Please select an account!") + return + + # Save last account + self.config["last_account"] = account + self._save_config() + + mode = self.run_mode.get() + self._start_internal(mode=mode, account=account) + + def _start_internal( + self, + mode: str, + account: str = None, + save_auth_as: str = None, + exit_on_save: bool = False, + ): + """Internal start function""" + + self._log("🔍 Checking ports...") + if not self._clean_ports(): + if not messagebox.askyesno( + "Warning", "Some ports could not be cleaned. Continue?" + ): + self._log("❌ User cancelled") + return + + # Build command + cmd = [sys.executable, str(LAUNCH_SCRIPT)] + + if mode == "headless": + cmd.append("--headless") + elif mode == "debug": + cmd.append("--debug") + + # Port settings + cmd.extend(["--server-port", self.fastapi_port.get()]) + cmd.extend(["--stream-port", self.stream_port.get()]) + + # Account + if account: + auth_file = SAVED_AUTH_DIR / f"{account}.json" + if auth_file.exists(): + cmd.extend(["--active-auth-json", str(auth_file)]) + + # Save + if save_auth_as: + cmd.extend(["--save-auth-as", save_auth_as]) + cmd.append("--auto-save-auth") + + if exit_on_save: + cmd.append("--exit-on-auth-save") + + # Proxy + if self.proxy_enabled.get() and self.proxy_address.get(): + cmd.extend(["--internal-camoufox-proxy", self.proxy_address.get()]) + + self._log(f"🚀 Starting: {mode} mode") + + # Environment variables + env = os.environ.copy() + env["DIRECT_LAUNCH"] = "true" + + # When saving auth (new account), skip manual Enter prompt after login + # Login completion will be detected automatically by URL change + if save_auth_as: + env["SUPPRESS_LOGIN_WAIT"] = "true" + + try: + self.process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + stdin=subprocess.PIPE if save_auth_as else None, + text=True, + bufsize=1, + cwd=str(SCRIPT_DIR), + env=env, + ) + + self.running = True + self.status.set("🟢 Running") + self.start_btn.config(state=tk.DISABLED) + self.stop_btn.config(state=tk.NORMAL) + self.status_details.config(text="Service started") + self.pid_label.config(text=f"PID: {self.process.pid}") + + self.tray.update_status(True) + + self.log_thread = threading.Thread(target=self._read_logs, daemon=True) + self.log_thread.start() + + self._log(f"✅ Service started (PID: {self.process.pid})") + + except Exception as e: + self._log(f"❌ Start error: {e}") + messagebox.showerror("Error", f"Could not start:\n{e}") + self.running = False + + def _read_logs(self): + """Log reading thread""" + try: + while self.process and self.process.poll() is None: + line = self.process.stdout.readline() + if line: + self.root.after( + 0, + lambda log_line=line.strip(): self._log( + log_line, save_to_file=False + ), + ) + + exit_code = self.process.returncode if self.process else -1 + self.root.after(0, lambda: self._service_ended(exit_code)) + except Exception as e: + self.root.after(0, lambda err=e: self._log(f"❌ Log error: {err}")) + + def _service_ended(self, exit_code: int): + """When service ends""" + self.running = False + self.process = None + + if exit_code == 0: + self.status.set("⚪ Stopped") + self._log("✅ Service ended normally") + else: + self.status.set(f"🔴 Error ({exit_code})") + self._log(f"⚠️ Service stopped with error: {exit_code}") + + self.start_btn.config(state=tk.NORMAL) + self.stop_btn.config(state=tk.DISABLED) + self.status_details.config(text="Service is stopped") + self.pid_label.config(text="PID: -") + + self.tray.update_status(False) + + # Refresh account list (important after adding new account) + self._load_accounts() + self._log("🔄 Account list refreshed") + + def _stop(self): + """Stop the service""" + if not self.running or not self.process: + return + + self._log("🛑 Stopping...") + self.status.set("🟡 Stopping...") + + try: + if platform.system() == "Windows": + self.process.terminate() + else: + self.process.send_signal(signal.SIGINT) + + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self._log("⚠️ Force closing...") + self.process.kill() + self.process.wait(timeout=3) + + self._log("✅ Stopped") + except Exception as e: + self._log(f"❌ Stop error: {e}") + + self._service_ended(0) + + def _show_window(self): + """Show window""" + self.root.deiconify() + self.root.lift() + self.root.focus_force() + + def _minimize_to_tray(self): + """Minimize to tray""" + if self.tray.supported and self.running: + self.root.withdraw() + self._log("📌 Minimized to system tray") + else: + self._close_completely() + + def _close_completely(self): + """Close completely""" + if self.running: + if messagebox.askyesno("Confirm", "Service is running. Stop and exit?"): + self._stop() + else: + return + + self._save_config() + self.tray.stop() + self.root.destroy() + + def run(self): + """Run the application""" + self._log("🚀 AI Studio Proxy Simple Launcher ready") + self.root.mainloop() + + +def main(): + """Main function""" + SAVED_AUTH_DIR.mkdir(parents=True, exist_ok=True) + ACTIVE_AUTH_DIR.mkdir(parents=True, exist_ok=True) + + app = SimpleGUILauncher() + app.run() + + +if __name__ == "__main__": + main() diff --git a/simple_start.sh b/simple_start.sh new file mode 100644 index 0000000000000000000000000000000000000000..561f2aba70caa460ea187ec9bd1d3866b540372b --- /dev/null +++ b/simple_start.sh @@ -0,0 +1 @@ +poetry run python simple_launcher.py diff --git a/static/frontend/.gitignore b/static/frontend/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..5af298d5eeb8863b509b74e9158cd3cc9200d1de --- /dev/null +++ b/static/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs (only root-level, not component folders) +/logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/static/frontend/README.md b/static/frontend/README.md new file mode 100644 index 0000000000000000000000000000000000000000..d2e77611fd3d959fee0487c41bd27b318be32b04 --- /dev/null +++ b/static/frontend/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/static/frontend/eslint.config.js b/static/frontend/eslint.config.js new file mode 100644 index 0000000000000000000000000000000000000000..d35720892cca9f1c1c73d9b5d6cc58f250e12775 --- /dev/null +++ b/static/frontend/eslint.config.js @@ -0,0 +1,35 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import unusedImports from 'eslint-plugin-unused-imports' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + plugins: { + 'unused-imports': unusedImports, + }, + languageOptions: { + ecmaVersion: 'latest', // ES2025+ features + sourceType: 'module', + globals: globals.browser, + }, + rules: { + 'unused-imports/no-unused-imports': 'error', + '@typescript-eslint/no-unused-vars': ['error', { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }], + }, + }, +]) diff --git a/static/frontend/index.html b/static/frontend/index.html new file mode 100644 index 0000000000000000000000000000000000000000..a27d846aab890c8935c59df3c27ef5860c0aba00 --- /dev/null +++ b/static/frontend/index.html @@ -0,0 +1,19 @@ + + + + + + + + + AI Studio Proxy + + +
+ + + diff --git a/static/frontend/knip.config.ts b/static/frontend/knip.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..15eba521208a84b0fd593c60851d0815dc61ff64 --- /dev/null +++ b/static/frontend/knip.config.ts @@ -0,0 +1,14 @@ +import type { KnipConfig } from "knip"; + +const config: KnipConfig = { + entry: ["src/main.tsx"], + project: ["src/**/*.{ts,tsx}"], + ignore: ["src/**/*.test.{ts,tsx}"], + // Intentional public API exports from useModelCapabilities hook + ignoreExportsUsedInFile: { + interface: true, + type: true, + }, +}; + +export default config; diff --git a/static/frontend/package-lock.json b/static/frontend/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..4657c8b648d1ab6188a830e7adaee8a27c0125f2 --- /dev/null +++ b/static/frontend/package-lock.json @@ -0,0 +1,6798 @@ +{ + "name": "frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.1.0", + "dependencies": { + "@tanstack/react-query": "^5.90.12", + "lucide-react": "^0.561.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-markdown": "^10.1.0", + "rehype-katex": "^7.0.1", + "remark-math": "^6.0.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@typescript-eslint/eslint-plugin": "^8.50.0", + "@vitejs/plugin-react": "^5.1.1", + "@vitest/coverage-v8": "^4.0.16", + "eslint": "^9.39.2", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.26", + "eslint-plugin-unused-imports": "^4.3.0", + "globals": "^16.5.0", + "jsdom": "^27.3.0", + "knip": "^5.75.2", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4", + "vitest": "^4.0.16" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.29", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.29.tgz", + "integrity": "sha512-G90x0VW+9nW4dFajtjCoT+NM0scAfH9Mb08IcjgFHYbfiL/lU04dTF9JuVOi3/OH+DJCQdcIseSXkdCB9Ky6JA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", + "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", + "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.21", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.21.tgz", + "integrity": "sha512-plP8N8zKfEZ26figX4Nvajx8DuzfuRpLTqglQ5d0chfnt35Qt3X+m6ASZ+rG0D0kxe/upDVNwSIVJP5n4FuNfw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", + "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", + "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.0.tgz", + "integrity": "sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-resolver/binding-android-arm-eabi": { + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.16.0.tgz", + "integrity": "sha512-/kFX4o8KISHCZzHRs8fBp/wZOPdkhYGquhMP2PQjc8ePAVbtaXXDPAFkjUKhz2jXNPS4jGA1wNW+8grhnJgstw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-android-arm64": { + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.16.0.tgz", + "integrity": "sha512-kPySx7j7mPxW4mRDrdbADyzJV2XrxVeMPDmNnFvTt0/LT1IA26Uk9hzWKQb4k4aeJY58bnRY1soYSawW5wAlKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-arm64": { + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.16.0.tgz", + "integrity": "sha512-eB00fkys5TX6oI3lY+1hgHl6dwfmrbhHTmInmJmfD6BysHpE+DUqSdQIRS2v5NI6+j+J9EWBmbW3hRtolr+MSg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-x64": { + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.16.0.tgz", + "integrity": "sha512-B/yMSxqe4MZfh/VoMax0qixl4XxG/sAQVlYtdVGNteBAYKfX/uw2mglkYsApk6D4qD6fVgJ21RwI50lV7oD0Qg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-freebsd-x64": { + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.16.0.tgz", + "integrity": "sha512-aKj+PNsSdn0owueMt/6TtR8QuLBNL/q2HgMdN8nRCDmoCBPvQlwB2s+AcW+UW1vyiok+9qiI5tVjihbKwQ+Khg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.16.0.tgz", + "integrity": "sha512-fxod0D0eMsIlGF98KRAwR3zjLCbpRoknDHjCHx22A9TmyQthGo7t66gwkRCj5g2LBbpaPZ+i6cYd2l9bRrx8+Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.16.0.tgz", + "integrity": "sha512-5BoVnD0hpEID/13hnj0fCIojE26wfa9p4puCnm12/D5BhGlXA103n8iRaPZPLHS/prQGtrwMiFONiysD6vmIBA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.16.0.tgz", + "integrity": "sha512-dMoKX6A8iuIdShbc4PB/+q6Tx8grgQxNAJQfIAmpaDTZp5NxfgzKrssPL0TCdu3RQMblF8yfXLYUFnOdPYZeRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-musl": { + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.16.0.tgz", + "integrity": "sha512-oLJsyqVHw53ZZPl3+wPiRNXTvavBFSInRYBB5MaNf+y42+b4XJfH7hVYyc67er0c26cQUCfx2KzqltSx7Jg9jg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.16.0.tgz", + "integrity": "sha512-qL7GsXwyytVTIh/o8cLftRYvzrpniD8pFf0jDW3VXlVsl1joCrb4GM26udGls7Zxe76nsZpPvQVB5eZ9xmHxIA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.16.0.tgz", + "integrity": "sha512-CFJEvagoakxPtIoKtRgPoGUqeXSgd63c3/T9hOXrgelOaMv6aEWFfjvc/4Lk5ppk2wv4KeK4IqOKBe8Faqv1Mw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.16.0.tgz", + "integrity": "sha512-LVuE2tbZ7gjEjY1G8mjf7+pacj0/Rge9EoHxr8DY2gAxxy0qXe5Yh2Qxe3dwwFGObVNioqRH0IPkePmQ/KJK6w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.16.0.tgz", + "integrity": "sha512-D4Zk48WN7sKsbyq4xD2F09U4S0sIkHXTW9A33BaqjfNXOD/jFXM5nTPahHx2RxBLo5ZEgS3kUW1U8V0oCBcPcg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-gnu": { + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.16.0.tgz", + "integrity": "sha512-WyqsQwz+x1lDe/rwf5pl/FiTiS4eEM7hEHn1OwjP+EThzXXBup9BeZE5QVB421QGm9n4SyJT1gJgI1LCRvqbaA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-musl": { + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.16.0.tgz", + "integrity": "sha512-5XCuIoviaMsiAAuaQL4HqnYj1BkADcbtdf2s6Ru4YHF3P/bt2p05hd4xVo85cFT1VXlGYL66XVfepsAGymJs0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-openharmony-arm64": { + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.16.0.tgz", + "integrity": "sha512-gn54HKxOhWTxZG8pNeBMmbRwHT4k/eIf0KxBII2oHUrSTinNTcqu6xn1etqt1Yezi9KzJzkTMS0cl5kTFmCHUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi": { + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.16.0.tgz", + "integrity": "sha512-dUsUjffSI7nlt+TH9C4gGqmD/kNyx3Kghh8u+i8eZZAEFWDO+s51Yw3UADDa0BYrZDeaLjz8rgHWCE8lxpL2XQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.16.0.tgz", + "integrity": "sha512-6EhsnwzA6iT752sU5tv/r+XI5cz6sWUPHJZu3brTW3m96j6yCZ8vnfeKAkFCzuDwZAXOkRLPW8WKrL0GXWfCUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-ia32-msvc": { + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.16.0.tgz", + "integrity": "sha512-YpUXuKrslGs4+In1gZhY25menhzyBbMct4RvWT9je6mYA5VCQ6aGAZf/ky5b+5sNPpR2UBNbCcYk5pP/6MowMw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-x64-msvc": { + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.16.0.tgz", + "integrity": "sha512-x3hU0m0c/+frUSFaw3r5Xmde5q/PdsAfznh+8lZloGK2/qfIze0jyQG0H5M6AgrUIQE1oNn8vdGXanza5+naMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.5.tgz", + "integrity": "sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.5.tgz", + "integrity": "sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.5.tgz", + "integrity": "sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.5.tgz", + "integrity": "sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.5.tgz", + "integrity": "sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.5.tgz", + "integrity": "sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.5.tgz", + "integrity": "sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.5.tgz", + "integrity": "sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.5.tgz", + "integrity": "sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.5.tgz", + "integrity": "sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.5.tgz", + "integrity": "sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.5.tgz", + "integrity": "sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.5.tgz", + "integrity": "sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.5.tgz", + "integrity": "sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.5.tgz", + "integrity": "sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.5.tgz", + "integrity": "sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.5.tgz", + "integrity": "sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.5.tgz", + "integrity": "sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.5.tgz", + "integrity": "sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.5.tgz", + "integrity": "sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.5.tgz", + "integrity": "sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.5.tgz", + "integrity": "sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.12", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz", + "integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz", + "integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz", + "integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/katex": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz", + "integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==", + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz", + "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.0.tgz", + "integrity": "sha512-O7QnmOXYKVtPrfYzMolrCTfkezCJS9+ljLdKW/+DCvRsc3UAz+sbH6Xcsv7p30+0OwUbeWfUDAQE0vpabZ3QLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.50.0", + "@typescript-eslint/type-utils": "8.50.0", + "@typescript-eslint/utils": "8.50.0", + "@typescript-eslint/visitor-keys": "8.50.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.50.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz", + "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.50.0", + "@typescript-eslint/types": "8.50.0", + "@typescript-eslint/typescript-estree": "8.50.0", + "@typescript-eslint/visitor-keys": "8.50.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.50.0.tgz", + "integrity": "sha512-Cg/nQcL1BcoTijEWyx4mkVC56r8dj44bFDvBdygifuS20f3OZCHmFbjF34DPSi07kwlFvqfv/xOLnJ5DquxSGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.50.0", + "@typescript-eslint/types": "^8.50.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.50.0.tgz", + "integrity": "sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.50.0", + "@typescript-eslint/visitor-keys": "8.50.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.50.0.tgz", + "integrity": "sha512-vxd3G/ybKTSlm31MOA96gqvrRGv9RJ7LGtZCn2Vrc5htA0zCDvcMqUkifcjrWNNKXHUU3WCkYOzzVSFBd0wa2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.50.0.tgz", + "integrity": "sha512-7OciHT2lKCewR0mFoBrvZJ4AXTMe/sYOe87289WAViOocEmDjjv8MvIOT2XESuKj9jp8u3SZYUSh89QA4S1kQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.50.0", + "@typescript-eslint/typescript-estree": "8.50.0", + "@typescript-eslint/utils": "8.50.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.50.0.tgz", + "integrity": "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.50.0.tgz", + "integrity": "sha512-W7SVAGBR/IX7zm1t70Yujpbk+zdPq/u4soeFSknWFdXIFuWsBGBOUu/Tn/I6KHSKvSh91OiMuaSnYp3mtPt5IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.50.0", + "@typescript-eslint/tsconfig-utils": "8.50.0", + "@typescript-eslint/types": "8.50.0", + "@typescript-eslint/visitor-keys": "8.50.0", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.50.0.tgz", + "integrity": "sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.50.0", + "@typescript-eslint/types": "8.50.0", + "@typescript-eslint/typescript-estree": "8.50.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.50.0.tgz", + "integrity": "sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.50.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", + "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.53", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.16.tgz", + "integrity": "sha512-2rNdjEIsPRzsdu6/9Eq0AYAzYdpP6Bx9cje9tL3FE5XzXRQF1fNU9pe/1yE8fCrS0HD+fBtt6gLPh6LI57tX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.16", + "ast-v8-to-istanbul": "^0.3.8", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.16", + "vitest": "4.0.16" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", + "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", + "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.16", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", + "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz", + "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.16", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz", + "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz", + "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", + "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.9.tgz", + "integrity": "sha512-dSC6tJeOJxbZrPzPbv5mMd6CMiQ1ugaVXXPRad2fXUSsy1kstFn9XQWemV9VW7Y7kpxgQ/4WMoZfwdH8XSU48w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.9", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.9.tgz", + "integrity": "sha512-V8fbOCSeOFvlDj7LLChUcqbZrdKD9RU/VR260piF1790vT0mfLSwGc/Qzxv3IqiTukOpNtItePa0HBpMAj7MDg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001760", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", + "integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chai": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", + "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.5.tgz", + "integrity": "sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decode-named-character-reference": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", + "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-plugin-unused-imports": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.3.0.tgz", + "integrity": "sha512-ZFBmXMGBYfHttdRtOG9nFFpmUvMtbHSjsKrS20vdWdbfiVYsO3yA2SGYy9i9XmZJDfMGBflZGBCm70SEnFQtOA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", + "eslint": "^9.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fd-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", + "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "walk-up-path": "^4.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/formatly": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz", + "integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fd-package-json": "^2.0.0" + }, + "bin": { + "formatly": "bin/index.mjs" + }, + "engines": { + "node": ">=18.3.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "27.3.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.3.0.tgz", + "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/katex": { + "version": "0.16.27", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.27.tgz", + "integrity": "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/knip": { + "version": "5.75.2", + "resolved": "https://registry.npmjs.org/knip/-/knip-5.75.2.tgz", + "integrity": "sha512-ZSO9gGKG/RztUYawrMbPTqO9VG3qvTW/ddINJqYM+N+9F/0bX6HEBolo8+HKaBuTIgyM4dn2rZ8M+JLObRdzog==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/webpro" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/knip" + } + ], + "license": "ISC", + "dependencies": { + "@nodelib/fs.walk": "^1.2.3", + "fast-glob": "^3.3.3", + "formatly": "^0.3.0", + "jiti": "^2.6.0", + "js-yaml": "^4.1.1", + "minimist": "^1.2.8", + "oxc-resolver": "^11.15.0", + "picocolors": "^1.1.1", + "picomatch": "^4.0.1", + "smol-toml": "^1.5.2", + "strip-json-comments": "5.0.3", + "zod": "^4.1.11" + }, + "bin": { + "knip": "bin/knip.js", + "knip-bun": "bin/knip-bun.js" + }, + "engines": { + "node": ">=18.18.0" + }, + "peerDependencies": { + "@types/node": ">=18", + "typescript": ">=5.0.4 <7" + } + }, + "node_modules/knip/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.561.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.561.0.tgz", + "integrity": "sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", + "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/oxc-resolver": { + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.16.0.tgz", + "integrity": "sha512-I4sHGa1fZUpTQ9ftS0E0cBYbBjNnIKXRSX/trFMIJDIJ4n21dCrLAZhnJS0TSfRIRqZNFyceNZr2kablfgNyTA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-resolver/binding-android-arm-eabi": "11.16.0", + "@oxc-resolver/binding-android-arm64": "11.16.0", + "@oxc-resolver/binding-darwin-arm64": "11.16.0", + "@oxc-resolver/binding-darwin-x64": "11.16.0", + "@oxc-resolver/binding-freebsd-x64": "11.16.0", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.16.0", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.16.0", + "@oxc-resolver/binding-linux-arm64-gnu": "11.16.0", + "@oxc-resolver/binding-linux-arm64-musl": "11.16.0", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.16.0", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.16.0", + "@oxc-resolver/binding-linux-riscv64-musl": "11.16.0", + "@oxc-resolver/binding-linux-s390x-gnu": "11.16.0", + "@oxc-resolver/binding-linux-x64-gnu": "11.16.0", + "@oxc-resolver/binding-linux-x64-musl": "11.16.0", + "@oxc-resolver/binding-openharmony-arm64": "11.16.0", + "@oxc-resolver/binding-wasm32-wasi": "11.16.0", + "@oxc-resolver/binding-win32-arm64-msvc": "11.16.0", + "@oxc-resolver/binding-win32-ia32-msvc": "11.16.0", + "@oxc-resolver/binding-win32-x64-msvc": "11.16.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.5.tgz", + "integrity": "sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.5", + "@rollup/rollup-android-arm64": "4.53.5", + "@rollup/rollup-darwin-arm64": "4.53.5", + "@rollup/rollup-darwin-x64": "4.53.5", + "@rollup/rollup-freebsd-arm64": "4.53.5", + "@rollup/rollup-freebsd-x64": "4.53.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.5", + "@rollup/rollup-linux-arm-musleabihf": "4.53.5", + "@rollup/rollup-linux-arm64-gnu": "4.53.5", + "@rollup/rollup-linux-arm64-musl": "4.53.5", + "@rollup/rollup-linux-loong64-gnu": "4.53.5", + "@rollup/rollup-linux-ppc64-gnu": "4.53.5", + "@rollup/rollup-linux-riscv64-gnu": "4.53.5", + "@rollup/rollup-linux-riscv64-musl": "4.53.5", + "@rollup/rollup-linux-s390x-gnu": "4.53.5", + "@rollup/rollup-linux-x64-gnu": "4.53.5", + "@rollup/rollup-linux-x64-musl": "4.53.5", + "@rollup/rollup-openharmony-arm64": "4.53.5", + "@rollup/rollup-win32-arm64-msvc": "4.53.5", + "@rollup/rollup-win32-ia32-msvc": "4.53.5", + "@rollup/rollup-win32-x64-gnu": "4.53.5", + "@rollup/rollup-win32-x64-msvc": "4.53.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/smol-toml": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.5.2.tgz", + "integrity": "sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", + "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", + "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.50.0.tgz", + "integrity": "sha512-Q1/6yNUmCpH94fbgMUMg2/BSAr/6U7GBk61kZTv1/asghQOWOjTlp9K8mixS5NcJmm2creY+UFfGeW/+OcA64A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.50.0", + "@typescript-eslint/parser": "8.50.0", + "@typescript-eslint/typescript-estree": "8.50.0", + "@typescript-eslint/utils": "8.50.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", + "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", + "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.16", + "@vitest/mocker": "4.0.16", + "@vitest/pretty-format": "4.0.16", + "@vitest/runner": "4.0.16", + "@vitest/snapshot": "4.0.16", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.16", + "@vitest/browser-preview": "4.0.16", + "@vitest/browser-webdriverio": "4.0.16", + "@vitest/ui": "4.0.16", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/walk-up-path": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", + "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", + "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", + "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/static/frontend/package.json b/static/frontend/package.json new file mode 100644 index 0000000000000000000000000000000000000000..6d9db0550721780b3b7026e9fc6def29012871d8 --- /dev/null +++ b/static/frontend/package.json @@ -0,0 +1,47 @@ +{ + "name": "frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "quality": "npm run typecheck && npm run lint && npm run test" + }, + "dependencies": { + "@tanstack/react-query": "^5.90.12", + "lucide-react": "^0.561.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-markdown": "^10.1.0", + "rehype-katex": "^7.0.1", + "remark-math": "^6.0.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@typescript-eslint/eslint-plugin": "^8.50.0", + "@vitejs/plugin-react": "^5.1.1", + "@vitest/coverage-v8": "^4.0.16", + "eslint": "^9.39.2", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.26", + "eslint-plugin-unused-imports": "^4.3.0", + "globals": "^16.5.0", + "jsdom": "^27.3.0", + "knip": "^5.75.2", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4", + "vitest": "^4.0.16" + } +} diff --git a/static/frontend/src/App.css b/static/frontend/src/App.css new file mode 100644 index 0000000000000000000000000000000000000000..b9d355df2a5956b526c004531b7b0ffe412461e0 --- /dev/null +++ b/static/frontend/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/static/frontend/src/App.tsx b/static/frontend/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..301b9de598a928e23ecced2111b67a4aca98a4ab --- /dev/null +++ b/static/frontend/src/App.tsx @@ -0,0 +1,35 @@ +/** + * Main App Component + */ + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ThemeProvider, SettingsProvider, ChatProvider, I18nProvider } from '@/contexts'; +import { Layout } from '@/components/layout/Layout'; +import '@/styles/index.css'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30000, // 30 seconds + retry: 2, + }, + }, +}); + +function App() { + return ( + + + + + + + + + + + + ); +} + +export default App; diff --git a/static/frontend/src/api/client.test.ts b/static/frontend/src/api/client.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7d958fc5d705a71e9070ef375ebce76e8a953902 --- /dev/null +++ b/static/frontend/src/api/client.test.ts @@ -0,0 +1,619 @@ +/** + * API Client Tests + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { ApiError } from "./client"; + +// Mock fetch globally +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +describe("ApiError", () => { + it("creates error with status and message", () => { + const error = new ApiError(404, "Not Found"); + + expect(error.status).toBe(404); + expect(error.userMessage).toBe("Not Found"); + expect(error.message).toBe("Not Found"); + expect(error.name).toBe("ApiError"); + }); + + it("stores optional details", () => { + const details = { code: "ERR_001", context: "test" }; + const error = new ApiError(500, "Server Error", details); + + expect(error.details).toEqual(details); + }); + + it("is instanceof Error", () => { + const error = new ApiError(400, "Bad Request"); + + expect(error instanceof Error).toBe(true); + expect(error instanceof ApiError).toBe(true); + }); +}); + +describe("fetchApi (via exported functions)", () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("fetchModels", () => { + it("returns models on success", async () => { + const mockResponse = { + object: "list", + data: [ + { + id: "gemini-2.5-pro", + object: "model", + created: 1234567890, + owned_by: "google", + }, + ], + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }); + + // Dynamic import to get fresh module with mocked fetch + const { fetchModels } = await import("./client"); + const result = await fetchModels(); + + expect(result).toEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + "/v1/models", + expect.objectContaining({ + headers: expect.objectContaining({ + "Content-Type": "application/json", + }), + }) + ); + }); + + it("throws ApiError on non-ok response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: "Service Unavailable", + text: async () => "Server overloaded", + }); + + const { fetchModels } = await import("./client"); + + await expect(fetchModels()).rejects.toThrow(ApiError); + }); + }); + + describe("fetchHealth", () => { + it("returns health status on success", async () => { + const mockHealth = { + status: "healthy", + browser_connected: true, + page_ready: true, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockHealth, + }); + + const { fetchHealth } = await import("./client"); + const result = await fetchHealth(); + + expect(result).toEqual(mockHealth); + }); + }); + + describe("sendChatCompletion", () => { + it("sends POST request with stream: false", async () => { + const mockResponse = { + id: "chatcmpl-123", + object: "chat.completion", + created: Date.now(), + model: "gemini-2.5-pro", + choices: [ + { + index: 0, + message: { role: "assistant", content: "Hello!" }, + finish_reason: "stop", + }, + ], + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }); + + const { sendChatCompletion } = await import("./client"); + const result = await sendChatCompletion({ + model: "gemini-2.5-pro", + messages: [{ role: "user", content: "Hi" }], + }); + + expect(result).toEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + "/v1/chat/completions", + expect.objectContaining({ + method: "POST", + body: expect.stringContaining('"stream":false'), + }) + ); + }); + }); + + describe("Proxy Config API", () => { + it("fetchProxyConfig returns config", async () => { + const mockConfig = { enabled: true, address: "http://proxy:8080" }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockConfig, + }); + + const { fetchProxyConfig } = await import("./client"); + const result = await fetchProxyConfig(); + + expect(result).toEqual(mockConfig); + }); + + it("updateProxyConfig sends POST", async () => { + const mockResponse = { + success: true, + config: { enabled: true, address: "http://new-proxy:8080" }, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }); + + const { updateProxyConfig } = await import("./client"); + const result = await updateProxyConfig({ + enabled: true, + address: "http://new-proxy:8080", + }); + + expect(result).toEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + "/api/proxy/config", + expect.objectContaining({ + method: "POST", + }) + ); + }); + + it("testProxyConnectivity sends test request", async () => { + const mockResult = { + success: true, + message: "Connected", + latency_ms: 42, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResult, + }); + + const { testProxyConnectivity } = await import("./client"); + const result = await testProxyConnectivity( + "http://proxy:8080", + "http://example.com" + ); + + expect(result).toEqual(mockResult); + }); + + it("testProxyConnectivity uses default test URL when not provided", async () => { + const mockResult = { + success: true, + message: "Connected", + latency_ms: 50, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResult, + }); + + const { testProxyConnectivity } = await import("./client"); + const result = await testProxyConnectivity("http://proxy:8080"); + + expect(result).toEqual(mockResult); + expect(mockFetch).toHaveBeenCalledWith( + "/api/proxy/test", + expect.objectContaining({ + method: "POST", + body: expect.stringContaining("httpbin.org"), + }) + ); + }); + }); + + describe("Auth Files API", () => { + it("fetchAuthFiles returns file list", async () => { + const mockResponse = { + saved_files: [ + { + name: "auth1.json", + path: "/data/auth1.json", + size_bytes: 1234, + is_active: true, + }, + ], + active_file: "auth1.json", + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }); + + const { fetchAuthFiles } = await import("./client"); + const result = await fetchAuthFiles(); + + expect(result).toEqual(mockResponse); + }); + + it("activateAuthFile sends activation request", async () => { + const mockResponse = { + success: true, + message: "Activated", + active_file: "auth2.json", + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }); + + const { activateAuthFile } = await import("./client"); + const result = await activateAuthFile("auth2.json"); + + expect(result).toEqual(mockResponse); + }); + + it("deactivateAuth sends DELETE request", async () => { + const mockResponse = { success: true, message: "Deactivated" }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }); + + const { deactivateAuth } = await import("./client"); + const result = await deactivateAuth(); + + expect(result).toEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + "/api/auth/deactivate", + expect.objectContaining({ + method: "DELETE", + }) + ); + }); + }); + + describe("Ports API", () => { + it("fetchPortConfig returns port configuration", async () => { + const mockConfig = { + fastapi_port: 2048, + camoufox_debug_port: 9222, + stream_proxy_port: 8888, + stream_proxy_enabled: true, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockConfig, + }); + + const { fetchPortConfig } = await import("./client"); + const result = await fetchPortConfig(); + + expect(result).toEqual(mockConfig); + }); + + it("fetchPortStatus returns status info", async () => { + const mockStatus = { + ports: [ + { + port: 2048, + port_type: "fastapi", + in_use: true, + processes: [{ pid: 1234, name: "python" }], + }, + ], + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockStatus, + }); + + const { fetchPortStatus } = await import("./client"); + const result = await fetchPortStatus(); + + expect(result).toEqual(mockStatus); + }); + + it("killProcess sends kill request with confirmation", async () => { + const mockResponse = { + success: true, + message: "Process killed", + pid: 1234, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }); + + const { killProcess } = await import("./client"); + const result = await killProcess(1234); + + expect(result).toEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + "/api/ports/kill", + expect.objectContaining({ + method: "POST", + body: expect.stringContaining('"confirm":true'), + }) + ); + }); + }); + + describe("Network Error Handling", () => { + it("throws ApiError with status 0 on network failure", async () => { + mockFetch.mockRejectedValueOnce(new TypeError("Failed to fetch")); + + const { fetchModels } = await import("./client"); + + await expect(fetchModels()).rejects.toMatchObject({ + status: 0, + userMessage: expect.stringContaining("网络错误"), + }); + }); + }); + + describe("streamChatCompletion", () => { + // Helper to create a mock ReadableStream + function createMockStream(chunks: string[]): ReadableStream { + const encoder = new TextEncoder(); + let index = 0; + + return new ReadableStream({ + pull(controller) { + if (index < chunks.length) { + controller.enqueue(encoder.encode(chunks[index])); + index++; + } else { + controller.close(); + } + }, + }); + } + + it("yields chunks from SSE stream", async () => { + const mockChunks = [ + 'data: {"id":"1","choices":[{"delta":{"content":"Hello"}}]}\n', + 'data: {"id":"2","choices":[{"delta":{"content":" World"}}]}\n', + "data: [DONE]\n", + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + body: createMockStream(mockChunks), + }); + + const { streamChatCompletion } = await import("./client"); + const results: unknown[] = []; + + for await (const chunk of streamChatCompletion({ + model: "gemini-2.5-pro", + messages: [{ role: "user", content: "Hi" }], + })) { + results.push(chunk); + } + + expect(results).toHaveLength(2); + expect(results[0]).toEqual({ + id: "1", + choices: [{ delta: { content: "Hello" } }], + }); + expect(results[1]).toEqual({ + id: "2", + choices: [{ delta: { content: " World" } }], + }); + }); + + it("throws ApiError on non-ok response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }); + + const { streamChatCompletion } = await import("./client"); + const generator = streamChatCompletion({ + model: "gemini-2.5-pro", + messages: [{ role: "user", content: "Hi" }], + }); + + await expect(generator.next()).rejects.toThrow(); + }); + + it("throws ApiError when response body is null", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + body: null, + }); + + const { streamChatCompletion } = await import("./client"); + const generator = streamChatCompletion({ + model: "gemini-2.5-pro", + messages: [{ role: "user", content: "Hi" }], + }); + + await expect(generator.next()).rejects.toMatchObject({ + userMessage: expect.stringContaining("无法读取响应流"), + }); + }); + + it("skips malformed JSON without crashing", async () => { + const mockChunks = [ + 'data: {"id":"1","valid":true}\n', + "data: {invalid json\n", + 'data: {"id":"2","valid":true}\n', + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + body: createMockStream(mockChunks), + }); + + const { streamChatCompletion } = await import("./client"); + const results: unknown[] = []; + + for await (const chunk of streamChatCompletion({ + model: "test", + messages: [{ role: "user", content: "test" }], + })) { + results.push(chunk); + } + + // Should have 2 valid chunks, malformed one was skipped + expect(results).toHaveLength(2); + }); + + it("handles chunked data spanning multiple reads", async () => { + // Simulate data split across chunk boundaries + const mockChunks = [ + 'data: {"id":"1","partial":', + '"value"}\ndata: {"id":"2"}\n', + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + body: createMockStream(mockChunks), + }); + + const { streamChatCompletion } = await import("./client"); + const results: unknown[] = []; + + for await (const chunk of streamChatCompletion({ + model: "test", + messages: [{ role: "user", content: "test" }], + })) { + results.push(chunk); + } + + expect(results).toHaveLength(2); + expect(results[0]).toEqual({ id: "1", partial: "value" }); + expect(results[1]).toEqual({ id: "2" }); + }); + + it("ignores empty lines and non-data lines", async () => { + const mockChunks = [ + "\n", + ":comment line\n", + "event: message\n", + 'data: {"id":"1"}\n', + "\n", + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + body: createMockStream(mockChunks), + }); + + const { streamChatCompletion } = await import("./client"); + const results: unknown[] = []; + + for await (const chunk of streamChatCompletion({ + model: "test", + messages: [{ role: "user", content: "test" }], + })) { + results.push(chunk); + } + + expect(results).toHaveLength(1); + expect(results[0]).toEqual({ id: "1" }); + }); + + it("passes abort signal to fetch", async () => { + const controller = new AbortController(); + + mockFetch.mockResolvedValueOnce({ + ok: true, + body: createMockStream(['data: {"id":"1"}\n']), + }); + + const { streamChatCompletion } = await import("./client"); + + // Just verify it doesn't throw when signal is passed + for await (const _ of streamChatCompletion( + { model: "test", messages: [{ role: "user", content: "test" }] }, + controller.signal + )) { + // consume + } + + expect(mockFetch).toHaveBeenCalledWith( + "/v1/chat/completions", + expect.objectContaining({ signal: controller.signal }) + ); + }); + }); + + describe("fetchActiveAuth", () => { + it("returns active auth file", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ active_file: "auth.json" }), + }); + + const { fetchActiveAuth } = await import("./client"); + const result = await fetchActiveAuth(); + + expect(result).toEqual({ active_file: "auth.json" }); + }); + }); + + describe("updatePortConfig", () => { + it("sends port config update", async () => { + const mockResponse = { + success: true, + message: "Updated", + config: { + fastapi_port: 3000, + camoufox_debug_port: 9222, + stream_proxy_port: 3120, + stream_proxy_enabled: true, + }, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }); + + const { updatePortConfig } = await import("./client"); + const result = await updatePortConfig({ + fastapi_port: 3000, + camoufox_debug_port: 9222, + stream_proxy_port: 3120, + stream_proxy_enabled: true, + }); + + expect(result).toEqual(mockResponse); + }); + }); +}); diff --git a/static/frontend/src/api/client.ts b/static/frontend/src/api/client.ts new file mode 100644 index 0000000000000000000000000000000000000000..bf8d82badec276243dbdc4b249ed8a627331227b --- /dev/null +++ b/static/frontend/src/api/client.ts @@ -0,0 +1,256 @@ +/** + * API Client - Centralized HTTP client with error handling + */ + +import type { + ChatCompletionRequest, + ChatCompletionResponse, + ChatCompletionChunk, + ModelsResponse, + HealthStatus, +} from "@/types"; + +// Custom API Error +export class ApiError extends Error { + status: number; + userMessage: string; + details?: unknown; + + constructor(status: number, userMessage: string, details?: unknown) { + super(userMessage); + this.name = "ApiError"; + this.status = status; + this.userMessage = userMessage; + this.details = details; + } +} + +// Base fetch wrapper with error handling +async function fetchApi(url: string, options?: RequestInit): Promise { + try { + const response = await fetch(url, { + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + ...options, + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new ApiError( + response.status, + `Request failed: ${response.statusText}`, + errorBody + ); + } + + return response.json(); + } catch (error) { + if (error instanceof ApiError) throw error; + throw new ApiError(0, "Network error", error); + } +} + +// Models API +export async function fetchModels(): Promise { + return fetchApi("/v1/models"); +} + +// Health API +export async function fetchHealth(): Promise { + return fetchApi("/health"); +} + +// Chat API (non-streaming) +export async function sendChatCompletion( + request: ChatCompletionRequest +): Promise { + return fetchApi("/v1/chat/completions", { + method: "POST", + body: JSON.stringify({ ...request, stream: false }), + }); +} + +// Chat API (streaming) - Returns async generator +export async function* streamChatCompletion( + request: ChatCompletionRequest, + signal?: AbortSignal +): AsyncGenerator { + const response = await fetch("/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...request, stream: true }), + signal, + }); + + if (!response.ok) { + throw new ApiError(response.status, `Request failed: ${response.statusText}`); + } + + const reader = response.body?.getReader(); + if (!reader) throw new ApiError(0, "Cannot read response stream"); + + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed === "data: [DONE]") continue; + if (!trimmed.startsWith("data: ")) continue; + + try { + const json = JSON.parse(trimmed.slice(6)); + yield json as ChatCompletionChunk; + } catch { + // Skip malformed JSON + } + } + } + } finally { + reader.releaseLock(); + } +} + +// ============================================ +// Proxy Configuration API +// ============================================ + +export interface ProxyConfig { + enabled: boolean; + address: string; +} + +export interface ProxyTestResult { + success: boolean; + message: string; + latency_ms?: number; +} + +export async function fetchProxyConfig(): Promise { + return fetchApi("/api/proxy/config"); +} + +export async function updateProxyConfig( + config: ProxyConfig +): Promise<{ success: boolean; config: ProxyConfig }> { + return fetchApi("/api/proxy/config", { + method: "POST", + body: JSON.stringify(config), + }); +} + +export async function testProxyConnectivity( + address: string, + testUrl?: string +): Promise { + return fetchApi("/api/proxy/test", { + method: "POST", + body: JSON.stringify({ + address, + test_url: testUrl || "http://httpbin.org/get", + }), + }); +} + +// ============================================ +// Auth Files API +// ============================================ + +interface AuthFileInfo { + name: string; + path: string; + size_bytes: number; + is_active: boolean; +} + +interface AuthFilesResponse { + saved_files: AuthFileInfo[]; + active_file: string | null; +} + +export async function fetchAuthFiles(): Promise { + return fetchApi("/api/auth/files"); +} + +export async function fetchActiveAuth(): Promise<{ + active_file: string | null; +}> { + return fetchApi("/api/auth/active"); +} + +export async function activateAuthFile( + filename: string +): Promise<{ success: boolean; message: string; active_file: string }> { + return fetchApi("/api/auth/activate", { + method: "POST", + body: JSON.stringify({ filename }), + }); +} + +export async function deactivateAuth(): Promise<{ + success: boolean; + message: string; +}> { + return fetchApi("/api/auth/deactivate", { + method: "DELETE", + }); +} + +// ============================================ +// Ports Configuration API +// ============================================ + +export interface PortConfig { + fastapi_port: number; + camoufox_debug_port: number; + stream_proxy_port: number; + stream_proxy_enabled: boolean; +} + +interface ProcessInfo { + pid: number; + name: string; +} + +interface PortStatusInfo { + port: number; + port_type: string; + in_use: boolean; + processes: ProcessInfo[]; +} + +export async function fetchPortConfig(): Promise { + return fetchApi("/api/ports/config"); +} + +export async function updatePortConfig( + config: PortConfig +): Promise<{ success: boolean; message: string; config: PortConfig }> { + return fetchApi("/api/ports/config", { + method: "POST", + body: JSON.stringify(config), + }); +} + +export async function fetchPortStatus(): Promise<{ ports: PortStatusInfo[] }> { + return fetchApi("/api/ports/status"); +} + +export async function killProcess( + pid: number +): Promise<{ success: boolean; message: string; pid: number }> { + return fetchApi("/api/ports/kill", { + method: "POST", + body: JSON.stringify({ pid, confirm: true }), + }); +} diff --git a/static/frontend/src/api/index.ts b/static/frontend/src/api/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4f1cce44fa366e3eab210ac464b8af6481e32ea1 --- /dev/null +++ b/static/frontend/src/api/index.ts @@ -0,0 +1 @@ +export * from './client'; diff --git a/static/frontend/src/assets/react.svg b/static/frontend/src/assets/react.svg new file mode 100644 index 0000000000000000000000000000000000000000..6c87de9bb3358469122cc991d5cf578927246184 --- /dev/null +++ b/static/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/frontend/src/components/chat/ChatPanel.module.css b/static/frontend/src/components/chat/ChatPanel.module.css new file mode 100644 index 0000000000000000000000000000000000000000..266604e4abbbb1c02777f2888c08118367814297 --- /dev/null +++ b/static/frontend/src/components/chat/ChatPanel.module.css @@ -0,0 +1,535 @@ +.chatPanel { + display: flex; + flex-direction: column; + height: 100%; + background-color: var(--bg-primary); +} + +.messageList { + flex: 1; + overflow-y: auto; + padding: var(--space-6); + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.emptyState { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: var(--text-muted); + text-align: center; + padding: var(--space-8); +} + +.emptyIcon { + width: 64px; + height: 64px; + margin-bottom: var(--space-4); + opacity: 0.5; +} + +.emptyTitle { + font-size: var(--text-xl); + font-weight: 600; + margin-bottom: var(--space-2); +} + +.emptyDescription { + font-size: var(--text-sm); + max-width: 320px; +} + +.message { + display: flex; + gap: var(--space-3); + max-width: 800px; + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.message.user { + align-self: flex-end; + flex-direction: row-reverse; +} + +.message.assistant { + align-self: flex-start; +} + +.avatar { + width: 36px; + height: 36px; + border-radius: var(--radius-full); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: transform var(--transition-fast); +} + +.avatar:hover { + transform: scale(1.05); +} + +.avatar.user { + background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); + color: white; +} + +.avatar.assistant { + background-color: var(--bg-tertiary); + color: var(--accent-primary); + border: 1px solid var(--border-default); +} + +.messageWrapper { + display: flex; + flex-direction: column; + gap: var(--space-2); + max-width: 100%; +} + +.messageContent { + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-lg); + max-width: 100%; + word-break: break-word; + transition: all var(--transition-fast); +} + +.message.user .messageContent { + background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); + color: white; + border-bottom-right-radius: var(--radius-sm); +} + +.message.assistant .messageContent { + background-color: var(--bg-tertiary); + color: var(--text-primary); + border-bottom-left-radius: var(--radius-sm); + border: 1px solid var(--border-subtle); +} + +.messageText { + line-height: var(--leading-relaxed); + white-space: pre-wrap; +} + +.streamingCursor { + display: inline-block; + width: 8px; + height: 18px; + background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); + margin-left: 2px; + border-radius: 2px; + animation: blink 1s infinite; +} + +@keyframes blink { + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } +} + +.spinningIcon { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.messageError { + color: var(--accent-error); + font-size: var(--text-sm); + margin-top: var(--space-2); +} + +.statusBar { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-xs); + color: var(--text-muted); + padding: var(--space-1) var(--space-2); + background-color: var(--bg-secondary); + border-radius: var(--radius-sm); +} + +.messageActions { + display: flex; + gap: var(--space-1); + opacity: 0; + transition: opacity var(--transition-fast); +} + +.message:hover .messageActions { + opacity: 1; +} + +.actionButton { + padding: var(--space-1); + border-radius: var(--radius-sm); + color: var(--text-muted); + transition: all var(--transition-fast); +} + +.actionButton:hover:not(:disabled) { + background-color: var(--bg-tertiary); + color: var(--text-primary); +} + +.actionButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.editArea { + display: flex; + flex-direction: column; + gap: var(--space-2); + box-sizing: border-box; +} + +.editTextarea { + width: 100%; + min-height: 40px; + padding: var(--space-3); + background-color: var(--bg-secondary); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + color: var(--text-primary); + font-size: var(--text-base); + line-height: var(--leading-normal); + resize: none; + overflow: hidden; +} + +.editTextarea:focus { + border-color: var(--accent-primary); + outline: none; +} + +.editActions { + display: flex; + gap: var(--space-2); + justify-content: flex-end; +} + +.editActionButton { + display: flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-sm); + background-color: var(--accent-success); + color: white; + font-size: var(--text-xs); + transition: all var(--transition-fast); +} + +.editActionButton:hover { + opacity: 0.9; +} + +.editActionButton.cancel { + background-color: var(--bg-tertiary); + color: var(--text-primary); +} + +.editActionButton.cancel:hover { + background-color: var(--bg-elevated); +} + +/* Markdown Styling - Compact (matching AI Studio) */ +.messageText h1, .messageText h2, .messageText h3 { + margin-top: 0.5em; + margin-bottom: 0.15em; + font-weight: 600; +} + +.messageText h1:first-child, .messageText h2:first-child, .messageText h3:first-child { + margin-top: 0; +} + +.messageText h1 { font-size: 1.25em; } +.messageText h2 { font-size: 1.1em; } +.messageText h3 { font-size: 1em; } + +.messageText p { + margin-bottom: 0.3em; +} + +.messageText p + p { + margin-top: 0.2em; +} + +.messageText p:last-child { + margin-bottom: 0; +} + +.messageText ul, .messageText ol { + margin-left: 1.25em; + margin-bottom: 0.35em; + padding-left: 0; +} + +.messageText li { + margin-bottom: 0.15em; +} + +.messageText li:last-child { + margin-bottom: 0; +} + +.codeBlock { + background-color: var(--bg-secondary); + border-radius: var(--radius-md); + padding: var(--space-2) var(--space-3); + overflow-x: auto; + margin: 0.5em 0; +} + +.codeBlock code { + font-family: var(--font-mono); + font-size: var(--text-sm); + color: var(--text-primary); +} + +.inlineCode { + background-color: var(--bg-tertiary); + padding: 1px 4px; + border-radius: var(--radius-sm); + font-family: var(--font-mono); + font-size: 0.875em; + color: var(--accent-primary); +} + +.inputArea { + padding: var(--space-4); + background-color: var(--bg-secondary); + border-top: 1px solid var(--border-subtle); +} + +.inputWrapper { + display: flex; + gap: var(--space-3); + align-items: flex-end; + max-width: 800px; + margin: 0 auto; +} + +.textarea { + flex: 1; + resize: none; + padding: var(--space-3) var(--space-4); + background-color: var(--bg-tertiary); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + color: var(--text-primary); + font-size: var(--text-base); + line-height: var(--leading-normal); + min-height: 48px; + max-height: 200px; + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} + +.textarea::placeholder { + color: var(--text-muted); +} + +.textarea:focus { + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +.sendButton { + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); + color: white; + border-radius: var(--radius-lg); + transition: all var(--transition-fast); + flex-shrink: 0; +} + +.sendButton:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4); +} + +.sendButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.stopButton { + background: var(--accent-error); +} + +.stopButton:hover:not(:disabled) { + box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4); +} + +.actionButtons { + display: flex; + gap: var(--space-2); +} + +.clearButton { + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--bg-tertiary); + color: var(--text-secondary); + border-radius: var(--radius-lg); + transition: all var(--transition-fast); +} + +.clearButton:hover:not(:disabled) { + background-color: var(--bg-elevated); + color: var(--text-primary); +} + +.clearButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Thinking Section Styles */ +.thinkingSection { + margin-top: var(--space-2); + border-radius: var(--radius-md); + background-color: var(--bg-secondary); + border: 1px solid var(--border-subtle); + overflow: hidden; + transition: all var(--transition-fast); +} + +.thinkingSection.expanded { + border-color: var(--accent-primary); +} + +.thinkingHeader { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: var(--space-2) var(--space-3); + background: transparent; + border: none; + cursor: pointer; + color: var(--text-secondary); + font-size: var(--text-sm); + transition: all var(--transition-fast); +} + +.thinkingHeader:hover { + background-color: var(--bg-tertiary); + color: var(--text-primary); +} + +.thinkingHeaderLeft { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.thinkingHeaderRight { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.thinkingTimer { + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--accent-primary); + background-color: var(--bg-tertiary); + padding: 2px 6px; + border-radius: var(--radius-sm); +} + +.thinkingChevron { + transition: transform var(--transition-fast); +} + +.thinkingChevron.rotated { + transform: rotate(180deg); +} + +.thinkingContent { + padding: var(--space-3); + border-top: 1px solid var(--border-subtle); + font-size: var(--text-sm); + color: var(--text-secondary); + line-height: var(--leading-relaxed); + max-height: 300px; + overflow-y: auto; +} + +.thinkingContent p { + margin-bottom: 0.5em; +} + +.thinkingContent p:last-child { + margin-bottom: 0; +} + +/* Thinking Error State */ +.thinkingSection.thinkingError { + border-color: var(--accent-error); +} + +.thinkingSection.thinkingError .thinkingHeader { + color: var(--accent-error); +} + +.errorIcon { + color: var(--accent-error); +} + +.responseTimer { + margin-left: auto; + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--accent-success); + background-color: var(--bg-tertiary); + padding: 2px 6px; + border-radius: var(--radius-sm); +} + +.totalTimeBadge { + display: flex; + align-items: center; + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--text-muted); + padding: var(--space-1) 0; +} + +.totalTimeBadge span { + color: var(--accent-success); + background-color: var(--bg-tertiary); + padding: 2px 8px; + border-radius: var(--radius-sm); +} diff --git a/static/frontend/src/components/chat/ChatPanel.test.ts b/static/frontend/src/components/chat/ChatPanel.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..71125d311dd4edbc02e5d0d243fd0e02ef255937 --- /dev/null +++ b/static/frontend/src/components/chat/ChatPanel.test.ts @@ -0,0 +1,325 @@ +/** + * ChatPanel Component Tests + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +describe("ChatPanel useElapsedTimer Logic", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("Timer Initialization", () => { + it("initializes with 0ms values", () => { + const timer = { liveMs: 0, finalMs: 0, displayMs: 0 }; + expect(timer.liveMs).toBe(0); + expect(timer.finalMs).toBe(0); + expect(timer.displayMs).toBe(0); + }); + }); + + describe("Timer Behavior", () => { + it("calculates elapsed time correctly", () => { + const startTime = Date.now(); + vi.advanceTimersByTime(1500); + const elapsed = Date.now() - startTime; + expect(elapsed).toBe(1500); + }); + + it("updates every 100ms", () => { + let updateCount = 0; + const interval = setInterval(() => { + updateCount++; + }, 100); + + vi.advanceTimersByTime(500); + clearInterval(interval); + + expect(updateCount).toBe(5); + }); + }); + + describe("Active State Transitions", () => { + it("starts counting when isActive becomes true", () => { + let isActive = false; + let startTime: number | null = null; + + // Transition to active + isActive = true; + startTime = Date.now(); + + expect(isActive).toBe(true); + expect(startTime).not.toBeNull(); + }); + + it("captures final value when isActive becomes false", () => { + let isActive = true; + let finalMs = 0; + const startTime = Date.now(); + + vi.advanceTimersByTime(2000); + + // Transition to inactive + isActive = false; + finalMs = Date.now() - startTime; + + expect(isActive).toBe(false); + expect(finalMs).toBe(2000); + }); + }); + + describe("Display Logic", () => { + it("shows liveMs during active state", () => { + const isActive = true; + const liveMs = 1500; + const finalMs = 0; + const displayMs = isActive ? liveMs : finalMs; + expect(displayMs).toBe(1500); + }); + + it("shows finalMs after deactivation", () => { + const isActive = false; + const liveMs = 0; + const finalMs = 2000; + const displayMs = isActive ? liveMs : finalMs; + expect(displayMs).toBe(2000); + }); + }); +}); + +describe("ChatPanel formatTime Logic", () => { + const formatTime = (seconds: number): string => { + if (seconds < 60) { + return `${seconds}s`; + } + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, "0")}`; + }; + + it("formats seconds under 60", () => { + expect(formatTime(0)).toBe("0s"); + expect(formatTime(30)).toBe("30s"); + expect(formatTime(59)).toBe("59s"); + }); + + it("formats exactly 60 seconds as 1:00", () => { + expect(formatTime(60)).toBe("1:00"); + }); + + it("formats minutes and seconds", () => { + expect(formatTime(90)).toBe("1:30"); + expect(formatTime(125)).toBe("2:05"); + expect(formatTime(601)).toBe("10:01"); + }); + + it("pads single digit seconds with zero", () => { + expect(formatTime(65)).toBe("1:05"); + expect(formatTime(301)).toBe("5:01"); + }); +}); + +describe("ChatPanel Message Component Logic", () => { + describe("Edit Mode State", () => { + it("initializes with editing false", () => { + const isEditing = false; + expect(isEditing).toBe(false); + }); + + it("enters edit mode", () => { + let isEditing = false; + isEditing = true; + expect(isEditing).toBe(true); + }); + + it("exits edit mode on save", () => { + let isEditing = true; + isEditing = false; + expect(isEditing).toBe(false); + }); + + it("exits edit mode on cancel", () => { + let isEditing = true; + isEditing = false; + expect(isEditing).toBe(false); + }); + }); + + describe("Edit Content Management", () => { + it("syncs editContent with message content on edit start", () => { + const messageContent = "Original message"; + let editContent = ""; + + // Start editing + editContent = messageContent; + + expect(editContent).toBe("Original message"); + }); + + it("allows editContent modification", () => { + let editContent = "Original"; + editContent = "Modified content"; + expect(editContent).toBe("Modified content"); + }); + + it("restores original on cancel", () => { + const originalContent = "Original"; + let editContent = "Modified"; + + // Cancel + editContent = originalContent; + + expect(editContent).toBe("Original"); + }); + }); + + describe("Thinking Section Toggle", () => { + it("initializes with thinking collapsed", () => { + const showThinking = false; + expect(showThinking).toBe(false); + }); + + it("toggles thinking section open", () => { + let showThinking = false; + showThinking = !showThinking; + expect(showThinking).toBe(true); + }); + + it("toggles thinking section closed", () => { + let showThinking = true; + showThinking = !showThinking; + expect(showThinking).toBe(false); + }); + }); + + describe("Message Role Detection", () => { + it("identifies user message", () => { + const message = { role: "user", content: "Hello" }; + const isUser = message.role === "user"; + expect(isUser).toBe(true); + }); + + it("identifies assistant message", () => { + const message = { role: "assistant", content: "Hello" }; + const isUser = message.role === "user"; + expect(isUser).toBe(false); + }); + }); + + describe("Status Display Logic", () => { + it("shows status when showStatus is true and streaming", () => { + const showStatus = true; + const isStreaming = true; + const shouldShow = showStatus && isStreaming; + expect(shouldShow).toBe(true); + }); + + it("hides status when showStatus is false", () => { + const showStatus = false; + const isStreaming = true; + const shouldShow = showStatus && isStreaming; + expect(shouldShow).toBe(false); + }); + }); + + describe("Action Button Visibility", () => { + it("shows action buttons when not editing and not disabled", () => { + const isEditing = false; + const disabled = false; + const showActions = !isEditing && !disabled; + expect(showActions).toBe(true); + }); + + it("hides action buttons when editing", () => { + const isEditing = true; + const disabled = false; + const showActions = !isEditing && !disabled; + expect(showActions).toBe(false); + }); + + it("hides action buttons when disabled", () => { + const isEditing = false; + const disabled = true; + const showActions = !isEditing && !disabled; + expect(showActions).toBe(false); + }); + }); + + describe("Error Display", () => { + it("shows error when message has error", () => { + const message = { error: "Network error" }; + const hasError = !!message.error; + expect(hasError).toBe(true); + }); + + it("hides error when message has no error", () => { + const message = { error: undefined }; + const hasError = !!message.error; + expect(hasError).toBe(false); + }); + }); +}); + +describe("ChatPanel Input Logic", () => { + describe("Input Value Management", () => { + it("initializes with empty input", () => { + const input = ""; + expect(input).toBe(""); + }); + + it("updates input value", () => { + let input = ""; + input = "Hello, world!"; + expect(input).toBe("Hello, world!"); + }); + + it("clears input after send", () => { + let input = "Message to send"; + input = ""; + expect(input).toBe(""); + }); + }); + + describe("Send Validation", () => { + it("allows send when input has content", () => { + const input = "Hello"; + const isStreaming = false; + const canSend = input.trim().length > 0 && !isStreaming; + expect(canSend).toBe(true); + }); + + it("prevents send when input is empty", () => { + const input = " "; + const isStreaming = false; + const canSend = input.trim().length > 0 && !isStreaming; + expect(canSend).toBe(false); + }); + + it("prevents send when streaming", () => { + const input = "Hello"; + const isStreaming = true; + const canSend = input.trim().length > 0 && !isStreaming; + expect(canSend).toBe(false); + }); + }); + + describe("Keyboard Shortcuts", () => { + it("detects Enter key for send", () => { + const key = "Enter"; + const shiftKey = false; + const shouldSend = key === "Enter" && !shiftKey; + expect(shouldSend).toBe(true); + }); + + it("detects Shift+Enter for newline", () => { + const key = "Enter"; + const shiftKey = true; + const shouldSend = key === "Enter" && !shiftKey; + expect(shouldSend).toBe(false); + }); + }); +}); diff --git a/static/frontend/src/components/chat/ChatPanel.tsx b/static/frontend/src/components/chat/ChatPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f50a368cb3bae115c5be8536299c3de7afefa932 --- /dev/null +++ b/static/frontend/src/components/chat/ChatPanel.tsx @@ -0,0 +1,449 @@ +/** + * Chat Panel Component + * Main chat interface with markdown rendering and message actions + */ + +import { useRef, useEffect, useState, type KeyboardEvent } from 'react'; +import Markdown from 'react-markdown'; +import remarkMath from 'remark-math'; +import rehypeKatex from 'rehype-katex'; +// KaTeX CSS is loaded from CDN in index.html to reduce bundle size +import { + Send, + Square, + Trash2, + MessageSquare, + User, + Bot, + RefreshCw, + Pencil, + Check, + X, + Loader2, + Brain, + ChevronDown, + AlertCircle +} from 'lucide-react'; +import { useChat, useI18n } from '@/contexts'; +import type { ChatMessage } from '@/types'; +import styles from './ChatPanel.module.css'; + +/** + * Custom hook for elapsed time tracking with capture. + * Starts counting when isActive becomes true, stops and captures final value when it becomes false. + * Returns: { liveMs, finalMs, displayMs } where displayMs shows live during active, final after. + */ +function useElapsedTimer(isActive: boolean): { liveMs: number; finalMs: number; displayMs: number } { + const startTimeRef = useRef(null); + const [liveMs, setLiveMs] = useState(0); + const [finalMs, setFinalMs] = useState(0); + const wasActiveRef = useRef(false); + + // Handle activation/deactivation + useEffect(() => { + if (isActive && !wasActiveRef.current) { + // Just became active - start timer + startTimeRef.current = Date.now(); + setLiveMs(0); + } else if (!isActive && wasActiveRef.current) { + // Just became inactive - capture final time + if (startTimeRef.current !== null) { + const elapsed = Date.now() - startTimeRef.current; + setFinalMs(elapsed); + setLiveMs(elapsed); + startTimeRef.current = null; + } + } + wasActiveRef.current = isActive; + }, [isActive]); + + // Tick interval while active + useEffect(() => { + if (!isActive || startTimeRef.current === null) return; + + const interval = setInterval(() => { + if (startTimeRef.current !== null) { + setLiveMs(Date.now() - startTimeRef.current); + } + }, 100); // Update every 100ms for smooth display + + return () => clearInterval(interval); + }, [isActive]); + + // displayMs: live while active, final after + const displayMs = isActive ? liveMs : finalMs; + + return { liveMs, finalMs, displayMs }; +} + +export function ChatPanel() { + const { t } = useI18n(); + const { + messages, + isStreaming, + currentStatus, + sendMessage, + clearMessages, + stopGeneration, + regenerateFrom, + editMessage + } = useChat(); + const [input, setInput] = useState(''); + const messagesEndRef = useRef(null); + const textareaRef = useRef(null); + + // Auto-scroll to bottom on new messages + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + // Auto-resize textarea + useEffect(() => { + const textarea = textareaRef.current; + if (textarea) { + textarea.style.height = 'auto'; + textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; + } + }, [input]); + + const handleSend = async () => { + if (!input.trim() || isStreaming) return; + const content = input; + setInput(''); + await sendMessage(content); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + return ( +
+ {/* Message List */} +
+ {messages.length === 0 ? ( +
+ +

{t.chat.startConversation}

+

+ {t.chat.startDescription} +

+
+ ) : ( + messages.map((message, index) => ( + editMessage(message.id, newContent)} + onRegenerate={() => regenerateFrom(message.id)} + disabled={isStreaming} + /> + )) + )} +
+
+ + {/* Input Area */} +
+
+