diff --git a/.coderabbit.yaml b/.coderabbit.yaml index aa9d5614..10095e5f 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -8,8 +8,15 @@ reviews: poem: false review_status: true collapse_walkthrough: false + sequence_diagrams: false + finishing_touches: + docstrings: + enabled: false auto_review: enabled: true drafts: false chat: auto_reply: true +issue_enrichment: + planning: + enabled: false diff --git a/.github/workflows/containers.yml b/.github/workflows/containers.yml index 77204ff9..5fe21eaf 100644 --- a/.github/workflows/containers.yml +++ b/.github/workflows/containers.yml @@ -10,17 +10,37 @@ on: # Allows manual triggering of the workflow workflow_dispatch: + # Run on workflow file changes (without pushing) + push: + paths: + - '.github/workflows/containers.yml' + - 'docker/build-container.sh' + - 'docker/*.Containerfile' + jobs: build-and-push: runs-on: ubuntu-latest strategy: matrix: - platform: [intel, cuda, vulkan, cpu, musa] + platform: [intel, cuda, vulkan, cpu, musa, rocm] fail-fast: false steps: - name: Checkout code uses: actions/checkout@v4 + - name: Free up disk space + if: matrix.platform == 'rocm' + run: | + echo "Before cleanup:" + df -h + sudo rm -rf /usr/share/dotnet + sudo rm -rf /usr/local/lib/android + sudo rm -rf /opt/ghc + sudo rm -rf /opt/hostedtoolcache/CodeQL + sudo docker system prune -af + echo "After cleanup:" + df -h + - name: Log in to GitHub Container Registry uses: docker/login-action@v2 with: @@ -31,7 +51,7 @@ jobs: - name: Run build-container env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: ./docker/build-container.sh ${{ matrix.platform }} true + run: ./docker/build-container.sh ${{ matrix.platform }} ${{ github.event_name != 'push' }} # note make sure napmany/llmsnap has admin rights to the llmsnap package # see: https://github.com/actions/delete-package-versions/issues/74 diff --git a/.github/workflows/go-ci-windows.yml b/.github/workflows/go-ci-windows.yml index ed831b11..9d7c557d 100644 --- a/.github/workflows/go-ci-windows.yml +++ b/.github/workflows/go-ci-windows.yml @@ -3,9 +3,25 @@ name: Windows CI on: push: branches: [ "main" ] + # only run when backend source changes + # cmd/ is excluded because it contains utilities without tests + paths: + - '**/*.go' + - '!cmd/**' + - 'go.mod' + - 'go.sum' + - 'Makefile' + - '.github/workflows/go-ci-windows.yml' pull_request: branches: [ "main" ] + paths: + - '**/*.go' + - '!cmd/**' + - 'go.mod' + - 'go.sum' + - 'Makefile' + - '.github/workflows/go-ci-windows.yml' # Allows manual triggering of the workflow workflow_dispatch: @@ -28,7 +44,7 @@ jobs: uses: actions/cache/restore@v4 with: path: ./build - key: ${{ runner.os }}-simple-responder-${{ hashFiles('misc/simple-responder/simple-responder.go') }} + key: ${{ runner.os }}-simple-responder-${{ hashFiles('cmd/simple-responder/simple-responder.go') }} # necessary for testing proxy/Process swapping - name: Create simple-responder @@ -42,7 +58,7 @@ jobs: uses: actions/cache/save@v4 with: path: ./build - key: ${{ runner.os }}-simple-responder-${{ hashFiles('misc/simple-responder/simple-responder.go') }} + key: ${{ runner.os }}-simple-responder-${{ hashFiles('cmd/simple-responder/simple-responder.go') }} - name: Test all shell: bash diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml index 4dc1b90c..aa51b1bf 100644 --- a/.github/workflows/go-ci.yml +++ b/.github/workflows/go-ci.yml @@ -3,9 +3,25 @@ name: Linux CI on: push: branches: [ "main" ] + # only run when backend source changes + # cmd/ is excluded because it contains utilities without tests + paths: + - '**/*.go' + - '!cmd/**' + - 'go.mod' + - 'go.sum' + - 'Makefile' + - '.github/workflows/go-ci.yml' pull_request: branches: [ "main" ] + paths: + - '**/*.go' + - '!cmd/**' + - 'go.mod' + - 'go.sum' + - 'Makefile' + - '.github/workflows/go-ci.yml' # Allows manual triggering of the workflow workflow_dispatch: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2df1bec7..d922ed5b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,13 +3,13 @@ name: goreleaser on: push: tags: - - '*' + - "*" # Allows manual triggering of the workflow workflow_dispatch: inputs: tag: - description: 'Tag version to release (e.g. v144)' + description: "Tag version to release (e.g. v144)" required: true permissions: @@ -19,35 +19,30 @@ jobs: goreleaser: runs-on: ubuntu-latest steps: - - - name: Checkout + - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 ref: ${{ github.event.inputs.tag || github.ref }} - - - name: Set up Go + - name: Set up Go uses: actions/setup-go@v5 - - - name: Set up Node.js + - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: '23' - - - name: Install dependencies and build UI + node-version: "24" + - name: Install dependencies and build UI run: | - cd ui + cd ui-svelte npm ci npm run build - - - name: Run GoReleaser + - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 with: # either 'goreleaser' (default) or 'goreleaser-pro' distribution: goreleaser # 'latest', 'nightly', or a semver - version: '~> v2' + version: "~> v2" args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -76,4 +71,4 @@ jobs: "release": { "tag_name": "${{ steps.tag.outputs.tag }}" } - } \ No newline at end of file + } diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml new file mode 100644 index 00000000..235f7b4a --- /dev/null +++ b/.github/workflows/ui-tests.yml @@ -0,0 +1,42 @@ +name: UI Tests + +on: + push: + branches: [ "main" ] + paths: + - 'ui-svelte/**' + - '.github/workflows/ui-tests.yml' + + pull_request: + branches: [ "main" ] + paths: + - 'ui-svelte/**' + - '.github/workflows/ui-tests.yml' + + workflow_dispatch: + +jobs: + + run-tests: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ui-svelte + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + cache-dependency-path: ui-svelte/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run check + + - name: Run tests + run: npm test diff --git a/CLAUDE.md b/CLAUDE.md index 64198840..a790853f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,37 +7,45 @@ llmsnap is a light weight, transparent proxy server that provides automatic mode ## Tech stack - golang -- typescript, vite and react for UI (ui/) - -## Testing - -- `make test-dev` - Use this when making iterative changes. Runs `go test` and `staticcheck`. Fix any static checking errors. Use this only when changes are made to any code under the `proxy/` directory -- `make test-all` - runs at the end before completing work. Includes long running concurrency tests. +- typescript, vite and react for UI (located in ui/) ## Workflow Tasks -### Plan Improvements +- when summarizing changes only include details that require further action +- just say "Done." when there is no further action +- use `gh` to create PRs and load issues +- do include Co-Authored-By or created by when committing changes or creating PRs +- keep PR descriptions short and focused on changes. + - never include a test plan + +## Testing -Work plans are located in ai-plans/. Plans written by the user may be incomplete, contain inconsistencies or errors. +- Follow test naming conventions like `TestProxyManager_`, `TestProcessGroup_`, etc. +- Use `go test -v -run ` to run any new tests you've written. +- Use `make test-dev` after running new tests for a quick over all test run. This runs `go test` and `staticcheck`. Fix any static checking errors. Use this only when changes are made to any code under the `proxy/` directory +- Use `make test-all` before completing work. This includes long running concurrency tests. -When the user asks to improve a plan follow these guidelines for expanding and improving it. +### Commit message example format: -- Identify any inconsistencies. -- Expand plans out to be detailed specification of requirements and changes to be made. -- Plans should have at least these sections: - - Title - very short, describes changes - - Overview: A more detailed summary of goal and outcomes desired - - Design Requirements: Detailed descriptions of what needs to be done - - Testing Plan: Tests to be implemented - - Checklist: A detailed list of changes to be made +``` +proxy: add new feature -Look for "plan expansion" as explicit instructions to improve a plan. +Add new feature that implements functionality X and Y. -### Implementation of plans +- key change 1 +- key change 2 +- key change 3 -When the user says "paint it", respond with "commencing automated assembly". Then implement the changes as described by the plan. Update the checklist as you complete items. +fixes #123 +``` -## General Rules +## Code Reviews -- when summarizing changes only include details that require further action (action items) -- when there are no action items, just say "Done." +- use three levels High, Medium, Low severity +- label each discovered issue with a label like H1, M2, L3 respectively +- High severity are must fix issues (security, race conditions, critical bugs) +- Medium severity are recommended improvements (coding style, missing functionality, inconsistencies) +- Low severity are nice to have changes and nits +- Include a suggestion with each discovered item +- Limit your code review to three items with the highest priority first +- Double check your discovered items and recommended remediations diff --git a/Makefile b/Makefile index ae31b111..b68bca1f 100644 --- a/Makefile +++ b/Makefile @@ -36,11 +36,11 @@ test-all: proxy/ui_dist/placeholder.txt go test -race -count=1 ./proxy/... ui/node_modules: - cd ui && npm install + cd ui-svelte && npm install # build react UI ui: ui/node_modules - cd ui && npm run build + cd ui-svelte && npm run build # Build OSX binary mac: ui diff --git a/README.md b/README.md index bcc3dae8..1490c4d0 100644 --- a/README.md +++ b/README.md @@ -13,16 +13,21 @@ Built in Go for performance and simplicity, llmsnap has zero dependencies and is - ✅ Easy to deploy and configure: one binary, one configuration file. no external dependencies - ✅ On-demand model switching -- ✅ Use any local OpenAI compatible server (llama.cpp, vllm, tabbyAPI, etc.) +- ✅ Use any local OpenAI compatible server (llama.cpp, vllm, tabbyAPI, stable-diffusion.cpp, etc.) - future proof, upgrade your inference servers at any time. - ✅ OpenAI API supported endpoints: - `v1/completions` - `v1/chat/completions` + - `v1/responses` - `v1/embeddings` - `v1/audio/speech` ([#36](https://github.com/mostlygeek/llama-swap/issues/36)) - `v1/audio/transcriptions` ([docs](https://github.com/mostlygeek/llama-swap/issues/41#issuecomment-2722637867)) + - `v1/audio/voices` + - `v1/images/generations` + - `v1/images/edits` - ✅ Anthropic API supported endpoints: - `v1/messages` + - `v1/messages/count_tokens` - ✅ llama-server (llama.cpp) supported endpoints - `v1/rerank`, `v1/reranking`, `/rerank` - `/infill` - for code infilling @@ -35,6 +40,7 @@ Built in Go for performance and simplicity, llmsnap has zero dependencies and is - `/running` - list currently running models ([#61](https://github.com/mostlygeek/llama-swap/issues/61)) - `/log` - remote log monitoring - `/health` - just returns "OK" +- ✅ API Key support - define keys to restrict access to API endpoints - ✅ Customizable - Run multiple models at once with `Groups` ([#107](https://github.com/mostlygeek/llama-swap/issues/107)) - Automatic unloading of models after timeout by setting a `ttl` @@ -65,6 +71,7 @@ llmsnap can be installed in multiple ways ### Docker Install ([download images](https://github.com/napmany/llmsnap/pkgs/container/llmsnap)) Nightly container images with llmsnap and llama-server are built for multiple platforms (cuda, vulkan, intel, etc.) including [non-root variants with improved security](docs/container-security.md). +The stable-diffusion.cpp server is also included for the musa and vulkan platforms. ```shell $ docker pull ghcr.io/napmany/llmsnap:cuda diff --git a/cmd/simple-responder/simple-responder.go b/cmd/simple-responder/simple-responder.go index e1920233..eb042f4a 100644 --- a/cmd/simple-responder/simple-responder.go +++ b/cmd/simple-responder/simple-responder.go @@ -211,6 +211,11 @@ func main() { }) }) + r.GET("/v1/audio/voices", func(c *gin.Context) { + model := c.Query("model") + c.JSON(http.StatusOK, gin.H{"voices": []string{"voice1"}, "model": model}) + }) + r.GET("/slow-respond", func(c *gin.Context) { echo := c.Query("echo") delay := c.Query("delay") diff --git a/config-schema.json b/config-schema.json index fbe8c242..d1c184a9 100644 --- a/config-schema.json +++ b/config-schema.json @@ -99,6 +99,12 @@ "default": 1000, "description": "Maximum number of metrics to keep in memory. Controls how many metrics are stored before older ones are discarded." }, + "captureBuffer": { + "type": "integer", + "minimum": 0, + "default": 5, + "description": "Size in megabytes of the buffer for storing request/response captures. Set to 0 to disable captures." + }, "startPort": { "type": "integer", "default": 5800, @@ -200,11 +206,17 @@ "default": "", "pattern": "^[a-zA-Z0-9_, ]*$", "description": "Comma separated list of parameters to remove from the request. Used for server-side enforcement of sampling parameters." + }, + "setParams": { + "type": "object", + "additionalProperties": true, + "default": {}, + "description": "Dictionary of parameters to set/override in requests. Useful for enforcing specific parameter values. Protected params like 'model' cannot be overridden. Values can be strings, numbers, booleans, arrays, or objects." } }, "additionalProperties": false, "default": {}, - "description": "Dictionary of filter settings. Only stripParams is supported." + "description": "Dictionary of filter settings. Supports stripParams and setParams." }, "metadata": { "type": "object", @@ -359,6 +371,78 @@ }, "additionalProperties": false, "description": "A dictionary of event triggers and actions. Only supported hook is on_startup." + }, + "logToStdout": { + "type": "string", + "enum": [ + "proxy", + "upstream", + "both", + "none" + ], + "default": "proxy", + "description": "Controls what is logged to stdout. 'proxy': logs generated by llmsnap, 'upstream': copy of upstream process stdout logs, 'both': both interleaved together, 'none': no logs written to stdout." + }, + "apiKeys": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "default": [], + "description": "Require an API key when making requests to inference endpoints. When empty, authorization will not be checked. Each key is a non-empty string." + }, + "peers": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": [ + "proxy", + "models" + ], + "properties": { + "proxy": { + "type": "string", + "format": "uri", + "description": "A valid base URL to proxy requests to. Requested path to llmsnap will be appended to the end of the proxy value." + }, + "apiKey": { + "type": "string", + "default": "", + "description": "A string key to be injected into the request. If blank, no key will be added. Key will be injected into headers: Authorization: Bearer and x-api-key: ." + }, + "models": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "A list of models served by the peer." + }, + "filters": { + "type": "object", + "properties": { + "stripParams": { + "type": "string", + "default": "", + "pattern": "^[a-zA-Z0-9_, ]*$", + "description": "Comma separated list of parameters to remove from the request. Useful for removing parameters that the peer doesn't support." + }, + "setParams": { + "type": "object", + "additionalProperties": true, + "default": {}, + "description": "Dictionary of parameters to set/override in requests to this peer. Useful for injecting provider-specific settings. Protected params like 'model' cannot be overridden. Values can be strings, numbers, booleans, arrays, or objects." + } + }, + "additionalProperties": false, + "default": {}, + "description": "Dictionary of filter settings for peer requests. Supports stripParams and setParams." + } + } + }, + "default": {}, + "description": "A dictionary of remote peers and models they provide. Peers can be another llmsnap or any server that provides the /v1/ generative API endpoints supported by llmsnap." } } } diff --git a/config.example.yaml b/config.example.yaml index 2d4871d5..d84c6ddc 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -62,6 +62,11 @@ logToStdout: "proxy" # - useful for limiting memory usage when processing large volumes of metrics metricsMaxInMemory: 1000 +# captureBuffer: how many MBs to allocate for storing request/response captures +# - optional, default: 10 +# - set to 0 to disable +captureBuffer: 15 + # startPort: sets the starting port number for the automatic ${PORT} macro. # - optional, default: 5800 # - the ${PORT} macro can be used in model.cmd and model.proxy settings @@ -92,6 +97,9 @@ includeAliasesInList: false # - macro names must not be a reserved name: PORT or MODEL_ID # - macro values can be numbers, bools, or strings # - macros can contain other macros, but they must be defined before they are used +# - environment variables can be referenced with ${env.VAR_NAME} syntax +# - env macros are substituted first, before regular macros +# - if the env var is not set, config loading will fail with an error macros: # Example of a multi-line macro "latest-llama": > @@ -104,6 +112,24 @@ macros: # but they must be previously declared. "default_args": "--ctx-size ${default_ctx}" + # Example of environment variable macros + # - ${env.VAR_NAME} pulls the value from the system environment + # - useful for paths, secrets, or machine-specific configuration + "models_dir": "${env.HOME}/models" + +# apiKeys: require an API key when making requests to inference endpoints +# - optional, default: [] +# - when empty (the default) authorization will not be checked as llmsnap is default-allow +# - each key is a non-empty string +apiKeys: + - "sk-hunter2" + # tip, one liner: printf "sk-%s\n" "$(head -c 48 /dev/urandom | base64 )" + - "sk-gyCPiKUcIfPlaM4OSMZekkprgijPx6+OsmQs8Rsg0xZ9qpy6gKWsIKqHOk+cgXVx" + + # use environment variable macros to keep secrets out of the config + - "${env.API_KEY_1}" + - "${env.API_KEY_2}" + # models: a dictionary of model configurations # - required # - each key is the model's ID, used in API requests @@ -187,7 +213,7 @@ models: # filters: a dictionary of filter settings # - optional, default: empty dictionary - # - only stripParams is currently supported + # - same capabilities as peer filters (stripParams, setParams) filters: # stripParams: a comma separated list of parameters to remove from the request # - optional, default: "" @@ -197,6 +223,16 @@ models: # - recommended to stick to sampling parameters stripParams: "temperature, top_p, top_k" + # setParams: a dictionary of parameters to set/override in requests + # - optional, default: empty dictionary + # - useful for enforcing specific parameter values + # - protected params like "model" cannot be overridden + # - values can be strings, numbers, booleans, arrays, or objects + setParams: + # Example: enforce specific sampling parameters + temperature: 0.7 + top_p: 0.9 + # metadata: a dictionary of arbitrary values that are included in /v1/models # - optional, default: empty dictionary # - while metadata can contains complex types it is recommended to keep it simple @@ -430,3 +466,56 @@ hooks: # otherwise models will be loaded and swapped out preload: - "llama" + +# peers: a dictionary of remote peers and models they provide +# - optional, default empty dictionary +# - peers can be another llmsnap +# - peers can be any server that provides the /v1/ generative api endpoints supported by llmsnap +peers: + # keys is the peer'd ID + llmsnap-peer: + # proxy: a valid base URL to proxy requests to + # - required + # - requested path to llmsnap will be appended to the end of the proxy value + proxy: http://192.168.1.23 + # models: a list of models served by the peer + # - required + models: + - model_a + - model_b + - embeddings/model_c + openrouter: + proxy: https://openrouter.ai/api + # apiKey: a string key to be injected into the request + # - optional, default: "" + # - if blank, no key will be added to the request + # - key will be injected into headers: Authorization: Bearer and x-api-key: + # - can be a string or a macro + apiKey: ${env.OPENROUTER_API_KEY} + models: + - meta-llama/llama-3.1-8b-instruct + - qwen/qwen3-235b-a22b-2507 + - deepseek/deepseek-v3.2 + - z-ai/glm-4.7 + - moonshotai/kimi-k2-0905 + - minimax/minimax-m2.1 + # filters: a dictionary of filter settings for peer requests + # - optional, default: empty dictionary + # - same capabilities as model filters (stripParams, setParams) + filters: + # stripParams: a comma separated list of parameters to remove from the request + # - optional, default: "" + # - useful for removing parameters that the peer doesn't support + # - the `model` parameter can never be removed + stripParams: "temperature, top_p" + + # setParams: a dictionary of parameters to set/override in requests to this peer + # - optional, default: empty dictionary + # - useful for injecting provider-specific settings like data retention policies + # - protected params like "model" cannot be overridden + # - values can be strings, numbers, booleans, arrays, or objects + setParams: + # Example: enforce zero-data-retention for OpenRouter + provider: + data_collection: "deny" + zdr: true diff --git a/docker/build-container.sh b/docker/build-container.sh index f79ea702..e514d78b 100755 --- a/docker/build-container.sh +++ b/docker/build-container.sh @@ -1,28 +1,50 @@ #!/bin/bash +set -euo pipefail + cd $(dirname "$0") +# use this to test locally, example: +# GITHUB_TOKEN=$(gh auth token) LOG_DEBUG=1 DEBUG_ABORT_BUILD=1 ./docker/build-container.sh rocm +# you need read:package scope on the token. Generate a personal access token with +# the scopes: gist, read:org, repo, write:packages +# then: gh auth login (and copy/paste the new token) + +LOG_DEBUG=${LOG_DEBUG:-0} +DEBUG_ABORT_BUILD=${DEBUG_ABORT_BUILD:-} + +log_debug() { + if [ "$LOG_DEBUG" = "1" ]; then + echo "[DEBUG] $*" + fi +} + +log_info() { + echo "[INFO] $*" +} + ARCH=$1 PUSH_IMAGES=${2:-false} # List of allowed architectures -ALLOWED_ARCHS=("intel" "vulkan" "musa" "cuda" "cpu") +ALLOWED_ARCHS=("intel" "vulkan" "musa" "cuda" "cpu" "rocm") # Check if ARCH is in the allowed list if [[ ! " ${ALLOWED_ARCHS[@]} " =~ " ${ARCH} " ]]; then - echo "Error: ARCH must be one of the following: ${ALLOWED_ARCHS[@]}" + log_info "Error: ARCH must be one of the following: ${ALLOWED_ARCHS[@]}" exit 1 fi # Check if GITHUB_TOKEN is set and not empty -if [[ -z "$GITHUB_TOKEN" ]]; then - echo "Error: GITHUB_TOKEN is not set or is empty." +if [[ -z "${GITHUB_TOKEN:-}" ]]; then + log_info "Error: GITHUB_TOKEN is not set or is empty." exit 1 fi # Set llama.cpp base image, customizable using the BASE_LLAMACPP_IMAGE environment # variable, this permits testing with forked llama.cpp repositories BASE_IMAGE=${BASE_LLAMACPP_IMAGE:-ghcr.io/ggml-org/llama.cpp} +SD_IMAGE=${BASE_SDCPP_IMAGE:-ghcr.io/leejet/stable-diffusion.cpp} # Set llmsnap repository, automatically uses GITHUB_REPOSITORY variable # to enable easy container builds on forked repos @@ -32,25 +54,76 @@ LS_REPO=${GITHUB_REPOSITORY:-napmany/llmsnap} # have to strip out the 'v' due to .tar.gz file naming LS_VER=$(curl -s https://api.github.com/repos/${LS_REPO}/releases/latest | jq -r .tag_name | sed 's/v//') +# Fetches the most recent llama.cpp tag matching the given prefix +# Handles pagination to search beyond the first 100 results +# $1 - tag_prefix (e.g., "server" or "server-vulkan") +# Returns: the version number extracted from the tag +fetch_llama_tag() { + local tag_prefix=$1 + local page=1 + local per_page=100 + + while true; do + log_debug "Fetching page $page for tag prefix: $tag_prefix" + + local response=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \ + "https://api.github.com/users/ggml-org/packages/container/llama.cpp/versions?per_page=${per_page}&page=${page}") + + # Check for API errors + if echo "$response" | jq -e '.message' > /dev/null 2>&1; then + local error_msg=$(echo "$response" | jq -r '.message') + log_info "GitHub API error: $error_msg" + return 1 + fi + + # Check if response is empty array (no more pages) + if [ "$(echo "$response" | jq 'length')" -eq 0 ]; then + log_debug "No more pages (empty response)" + return 1 + fi + + # Extract matching tag from this page + local found_tag=$(echo "$response" | jq -r \ + ".[] | select(.metadata.container.tags[]? | startswith(\"$tag_prefix\")) | .metadata.container.tags[] | select(startswith(\"$tag_prefix\"))" \ + | sort -r | head -n1) + + if [ -n "$found_tag" ]; then + log_debug "Found tag: $found_tag on page $page" + echo "$found_tag" | awk -F '-' '{print $NF}' + return 0 + fi + + page=$((page + 1)) + + # Safety limit to prevent infinite loops + if [ $page -gt 50 ]; then + log_info "Reached pagination safety limit (50 pages)" + return 1 + fi + done +} + if [ "$ARCH" == "cpu" ]; then - # cpu only containers just use the server tag - LCPP_TAG=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \ - "https://api.github.com/users/ggml-org/packages/container/llama.cpp/versions" \ - | jq -r '.[] | select(.metadata.container.tags[] | startswith("server")) | .metadata.container.tags[]' \ - | sort -r | head -n1 | awk -F '-' '{print $3}') + LCPP_TAG=$(fetch_llama_tag "server") BASE_TAG=server-${LCPP_TAG} else - LCPP_TAG=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \ - "https://api.github.com/users/ggml-org/packages/container/llama.cpp/versions" \ - | jq -r --arg arch "$ARCH" '.[] | select(.metadata.container.tags[] | startswith("server-\($arch)")) | .metadata.container.tags[]' \ - | sort -r | head -n1 | awk -F '-' '{print $3}') + LCPP_TAG=$(fetch_llama_tag "server-${ARCH}") BASE_TAG=server-${ARCH}-${LCPP_TAG} fi +SD_TAG=master-${ARCH} + # Abort if LCPP_TAG is empty. if [[ -z "$LCPP_TAG" ]]; then - echo "Abort: Could not find llama-server container for arch: $ARCH" + log_info "Abort: Could not find llama-server container for arch: $ARCH" exit 1 +else + log_info "LCPP_TAG: $LCPP_TAG" +fi + +if [[ ! -z "$DEBUG_ABORT_BUILD" ]]; then + log_info "Abort: DEBUG_ABORT_BUILD set" + exit 0 fi for CONTAINER_TYPE in non-root root; do @@ -68,10 +141,22 @@ for CONTAINER_TYPE in non-root root; do USER_HOME=/app fi - echo "Building $CONTAINER_TYPE $CONTAINER_TAG $LS_VER" + log_info "Building $CONTAINER_TYPE $CONTAINER_TAG $LS_VER" docker build -f llmsnap.Containerfile --build-arg BASE_TAG=${BASE_TAG} --build-arg LS_VER=${LS_VER} --build-arg UID=${USER_UID} \ --build-arg LS_REPO=${LS_REPO} --build-arg GID=${USER_GID} --build-arg USER_HOME=${USER_HOME} -t ${CONTAINER_TAG} -t ${CONTAINER_LATEST} \ --build-arg BASE_IMAGE=${BASE_IMAGE} . + + # For architectures with stable-diffusion.cpp support, layer sd-server on top + case "$ARCH" in + "musa" | "vulkan") + log_info "Adding sd-server to $CONTAINER_TAG" + docker build -f llmsnap-sd.Containerfile \ + --build-arg BASE=${CONTAINER_TAG} \ + --build-arg SD_IMAGE=${SD_IMAGE} --build-arg SD_TAG=${SD_TAG} \ + --build-arg UID=${USER_UID} --build-arg GID=${USER_GID} \ + -t ${CONTAINER_TAG} -t ${CONTAINER_LATEST} . ;; + esac + if [ "$PUSH_IMAGES" == "true" ]; then docker push ${CONTAINER_TAG} docker push ${CONTAINER_LATEST} diff --git a/docker/config.example.yaml b/docker/config.example.yaml index 9f5f23ba..ee8bce58 100644 --- a/docker/config.example.yaml +++ b/docker/config.example.yaml @@ -15,4 +15,19 @@ models: cmd: > /app/llama-server -hf bartowski/SmolLM2-135M-Instruct-GGUF:Q4_K_M - --port 9999 \ No newline at end of file + --port 9999 + + z-image: + checkEndpoint: / + cmd: | + /app/sd-server + --listen-port 9999 + --diffusion-fa + --diffusion-model /models/z_image_turbo-Q8_0.gguf + --vae /models/ae.safetensors + --llm /models/qwen3-4b-instruct-2507-q8_0.gguf + --offload-to-cpu + --cfg-scale 1.0 + --height 512 --width 512 + --steps 8 + aliases: [gpt-image-1,dall-e-2,dall-e-3,gpt-image-1-mini,gpt-image-1.5] \ No newline at end of file diff --git a/docker/llmsnap-sd.Containerfile b/docker/llmsnap-sd.Containerfile new file mode 100644 index 00000000..5cc1c25d --- /dev/null +++ b/docker/llmsnap-sd.Containerfile @@ -0,0 +1,11 @@ +ARG SD_IMAGE=ghcr.io/leejet/stable-diffusion.cpp +ARG SD_TAG=master-vulkan +ARG BASE=llmsnap:latest + +FROM ${SD_IMAGE}:${SD_TAG} AS sd-source +FROM ${BASE} + +ARG UID=10001 +ARG GID=10001 + +COPY --from=sd-source --chown=${UID}:${GID} /sd-server /app/sd-server diff --git a/docs/configuration.md b/docs/configuration.md index faa6eeb5..3c4925f8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -87,7 +87,7 @@ llmsnap supports many more features to customize how you want to manage your env ## Full Configuration Example > [!NOTE] -> This is a copy of `config.example.yaml`. Always check that for the most up to date examples. +> Always check [config.example.yaml](https://github.com/napmany/llmsnap/blob/main/config.example.yaml) for the most up to date reference for all example configurations. ```yaml # add this modeline for validation in vscode @@ -174,6 +174,16 @@ sendLoadingState: true # all fields except for Id so chat UIs can use the alias equivalent to the original. includeAliasesInList: false +# apiKeys: require an API key when making requests to inference endpoints +# - optional, default: [] +# - when empty (the default) authorization will not be checked as llmsnap is default-allow +# - each key is a non-empty string +apiKeys: + - "sk-hunter2" + # hint, one liner: printf "sk-%s\n" "$(head -c 48 /dev/urandom | base64 )" + - "sk-gyCPiKUcIfPlaM4OSMZekkprgijPx6+OsmQs8Rsg0xZ9qpy6gKWsIKqHOk+cgXVx" + - "sk-+QtIn0Zjj4UHjiaZYiZEnru4mrwKM9RzhmJeK5SobNXLl8QMFXxGz1/2lEuvQpkb" + # macros: a dictionary of string substitutions # - optional, default: empty dictionary # - macros are reusable snippets @@ -522,4 +532,36 @@ hooks: # otherwise models will be loaded and swapped out preload: - "llama" + +# peers: a dictionary of remote peers and models they provide +# - optional, default empty dictionary +# - peers can be another llmsnap +# - peers can be any server that provides the /v1/ generative api endpoints supported by llmsnap +peers: + # keys is the peer'd ID + llmsnap-peer: + # proxy: a valid base URL to proxy requests to + # - required + # - requested path to llmsnap will be appended to the end of the proxy value + proxy: http://192.168.1.23 + # models: a list of models served by the peer + # - required + models: + - model_a + - model_b + - embeddings/model_c + openrouter: + proxy: https://openrouter.ai/api + # apiKey: a string key to be injected into the request + # - optional, default: "" + # - if blank, no key will be added to the request + # - key will be injected into headers: Authorization: Bearer and x-api-key: + apiKey: sk-your-openrouter-key + models: + - meta-llama/llama-3.1-8b-instruct + - qwen/qwen3-235b-a22b-2507 + - deepseek/deepseek-v3.2 + - z-ai/glm-4.7 + - moonshotai/kimi-k2-0905 + - minimax/minimax-m2.1 ``` diff --git a/proxy/config/config.go b/proxy/config/config.go index 181b5aa2..4596f3a1 100644 --- a/proxy/config/config.go +++ b/proxy/config/config.go @@ -87,6 +87,7 @@ type GroupConfig struct { var ( macroNameRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) macroPatternRegex = regexp.MustCompile(`\$\{([a-zA-Z0-9_-]+)\}`) + envMacroRegex = regexp.MustCompile(`\$\{env\.([a-zA-Z_][a-zA-Z0-9_]*)\}`) ) // set default values for GroupConfig @@ -124,6 +125,7 @@ type Config struct { LogTimeFormat string `yaml:"logTimeFormat"` LogToStdout string `yaml:"logToStdout"` MetricsMaxInMemory int `yaml:"metricsMaxInMemory"` + CaptureBuffer int `yaml:"captureBuffer"` Models map[string]ModelConfig `yaml:"models"` /* key is model ID */ Profiles map[string][]string `yaml:"profiles"` Groups map[string]GroupConfig `yaml:"groups"` /* key is group ID */ @@ -145,6 +147,12 @@ type Config struct { // present aliases to /v1/models OpenAI API listing IncludeAliasesInList bool `yaml:"includeAliasesInList"` + + // support API keys, see issue #433, #50, #251 + RequiredAPIKeys []string `yaml:"apiKeys"` + + // support remote peers, see issue #433, #296 + Peers PeerDictionaryConfig `yaml:"peers"` } func (c *Config) RealModelName(search string) (string, bool) { @@ -179,8 +187,16 @@ func LoadConfigFromReader(r io.Reader) (Config, error) { if err != nil { return Config{}, err } + yamlStr := string(data) - // default configuration values + // Phase 1: Substitute all ${env.VAR} macros at string level + // This is safe because env values are simple strings without YAML formatting + yamlStr, err = substituteEnvMacros(yamlStr) + if err != nil { + return Config{}, err + } + + // Unmarshal into full Config with defaults config := Config{ HealthCheckTimeout: 120, SleepRequestTimeout: 10, @@ -190,14 +206,13 @@ func LoadConfigFromReader(r io.Reader) (Config, error) { LogTimeFormat: "", LogToStdout: LogToStdoutProxy, MetricsMaxInMemory: 1000, + CaptureBuffer: 5, } - err = yaml.Unmarshal(data, &config) - if err != nil { + if err = yaml.Unmarshal([]byte(yamlStr), &config); err != nil { return Config{}, err } if config.HealthCheckTimeout < 15 { - // set a minimum of 15 seconds config.HealthCheckTimeout = 15 } @@ -232,55 +247,46 @@ func LoadConfigFromReader(r io.Reader) (Config, error) { } } - /* check macro constraint rules: - - - name must fit the regex ^[a-zA-Z0-9_-]+$ - - names must be less than 64 characters (no reason, just cause) - - name can not be any reserved macros: PORT, MODEL_ID - - macro values must be less than 1024 characters - */ + // Validate global macros for _, macro := range config.Macros { if err = validateMacro(macro.Name, macro.Value); err != nil { return Config{}, err } } - // Get and sort all model IDs first, makes testing more consistent + // Get and sort all model IDs for consistent port assignment modelIds := make([]string, 0, len(config.Models)) for modelId := range config.Models { modelIds = append(modelIds, modelId) } - sort.Strings(modelIds) // This guarantees stable iteration order + sort.Strings(modelIds) nextPort := config.StartPort for _, modelId := range modelIds { modelConfig := config.Models[modelId] - // Strip comments from command fields before macro expansion + // Strip comments from command fields modelConfig.Cmd = StripComments(modelConfig.Cmd) modelConfig.CmdStop = StripComments(modelConfig.CmdStop) - // validate model macros + // Validate model macros for _, macro := range modelConfig.Macros { if err = validateMacro(macro.Name, macro.Value); err != nil { return Config{}, fmt.Errorf("model %s: %s", modelId, err.Error()) } } - // Merge global config and model macros. Model macros take precedence - mergedMacros := make(MacroList, 0, len(config.Macros)+len(modelConfig.Macros)) + // Build merged macro list: MODEL_ID + global macros + model macros (model overrides global) + mergedMacros := make(MacroList, 0, len(config.Macros)+len(modelConfig.Macros)+1) mergedMacros = append(mergedMacros, MacroEntry{Name: "MODEL_ID", Value: modelId}) - - // Add global macros first mergedMacros = append(mergedMacros, config.Macros...) - // Add model macros (can override global) + // Add model macros (override globals with same name) for _, entry := range modelConfig.Macros { - // Remove any existing global macro with same name found := false for i, existing := range mergedMacros { if existing.Name == entry.Name { - mergedMacros[i] = entry // Override + mergedMacros[i] = entry found = true break } @@ -290,14 +296,12 @@ func LoadConfigFromReader(r io.Reader) (Config, error) { } } - // First pass: Substitute user-defined macros in reverse order (LIFO - last defined first) - // This allows later macros to reference earlier ones + // Substitute remaining macros in model fields (LIFO order) for i := len(mergedMacros) - 1; i >= 0; i-- { entry := mergedMacros[i] macroSlug := fmt.Sprintf("${%s}", entry.Name) macroStr := fmt.Sprintf("%v", entry.Value) - // Substitute in command fields modelConfig.Cmd = strings.ReplaceAll(modelConfig.Cmd, macroSlug, macroStr) modelConfig.CmdStop = strings.ReplaceAll(modelConfig.CmdStop, macroSlug, macroStr) modelConfig.Proxy = strings.ReplaceAll(modelConfig.Proxy, macroSlug, macroStr) @@ -314,9 +318,8 @@ func LoadConfigFromReader(r io.Reader) (Config, error) { modelConfig.WakeEndpoints[j].Body = strings.ReplaceAll(modelConfig.WakeEndpoints[j].Body, macroSlug, macroStr) } - // Substitute in metadata (recursive) + // Substitute in metadata (type-preserving) if len(modelConfig.Metadata) > 0 { - var err error result, err := substituteMacroInValue(modelConfig.Metadata, entry.Name, entry.Value) if err != nil { return Config{}, fmt.Errorf("model %s metadata: %s", modelId, err.Error()) @@ -325,18 +328,14 @@ func LoadConfigFromReader(r io.Reader) (Config, error) { } } - // Final pass: check if PORT macro is needed after macro expansion - // ${PORT} is a resource on the local machine so a new port is only allocated - // if it is required in either cmd or proxy keys + // Handle PORT macro - only allocate if cmd uses it cmdHasPort := strings.Contains(modelConfig.Cmd, "${PORT}") proxyHasPort := strings.Contains(modelConfig.Proxy, "${PORT}") - if cmdHasPort || proxyHasPort { // either has it - if !cmdHasPort && proxyHasPort { // but both don't have it + if cmdHasPort || proxyHasPort { + if !cmdHasPort && proxyHasPort { return Config{}, fmt.Errorf("model %s: proxy uses ${PORT} but cmd does not - ${PORT} is only available when used in cmd", modelId) } - // Add PORT macro and substitute it - portEntry := MacroEntry{Name: "PORT", Value: nextPort} macroSlug := "${PORT}" macroStr := fmt.Sprintf("%v", nextPort) @@ -354,10 +353,8 @@ func LoadConfigFromReader(r io.Reader) (Config, error) { modelConfig.WakeEndpoints[j].Body = strings.ReplaceAll(modelConfig.WakeEndpoints[j].Body, macroSlug, macroStr) } - // Substitute PORT in metadata if len(modelConfig.Metadata) > 0 { - var err error - result, err := substituteMacroInValue(modelConfig.Metadata, portEntry.Name, portEntry.Value) + result, err := substituteMacroInValue(modelConfig.Metadata, "PORT", nextPort) if err != nil { return Config{}, fmt.Errorf("model %s metadata: %s", modelId, err.Error()) } @@ -367,7 +364,7 @@ func LoadConfigFromReader(r io.Reader) (Config, error) { nextPort++ } - // make sure there are no unknown macros that have not been replaced + // Validate no unknown macros remain fieldMap := map[string]string{ "cmd": modelConfig.Cmd, "cmdStop": modelConfig.CmdStop, @@ -381,13 +378,11 @@ func LoadConfigFromReader(r io.Reader) (Config, error) { for _, match := range matches { macroName := match[1] if macroName == "PID" && fieldName == "cmdStop" { - continue // this is ok, has to be replaced by process later + continue // replaced at runtime } - // Reserved macros are always valid (they should have been substituted already) if macroName == "PORT" || macroName == "MODEL_ID" { return Config{}, fmt.Errorf("macro '${%s}' should have been substituted in %s.%s", macroName, modelId, fieldName) } - // Any other macro is unknown return Config{}, fmt.Errorf("unknown macro '${%s}' found in %s.%s", macroName, modelId, fieldName) } } @@ -400,24 +395,18 @@ func LoadConfigFromReader(r io.Reader) (Config, error) { return Config{}, err } - // Check for unknown macros in metadata if len(modelConfig.Metadata) > 0 { - if err := validateMetadataForUnknownMacros(modelConfig.Metadata, modelId); err != nil { + if err := validateNestedForUnknownMacros(modelConfig.Metadata, fmt.Sprintf("model %s metadata", modelId)); err != nil { return Config{}, err } } - // Validate the proxy URL. if _, err := url.Parse(modelConfig.Proxy); err != nil { - return Config{}, fmt.Errorf( - "model %s: invalid proxy URL: %w", modelId, err, - ) + return Config{}, fmt.Errorf("model %s: invalid proxy URL: %w", modelId, err) } - // if sendLoadingState is nil, set it to the global config value - // see #366 if modelConfig.SendLoadingState == nil { - v := config.SendLoadingState // copy it + v := config.SendLoadingState modelConfig.SendLoadingState = &v } @@ -438,18 +427,17 @@ func LoadConfigFromReader(r io.Reader) (Config, error) { } config = AddDefaultGroupToConfig(config) - // check that members are all unique in the groups - memberUsage := make(map[string]string) // maps member to group it appears in + + // Validate group members + memberUsage := make(map[string]string) for groupID, groupConfig := range config.Groups { prevSet := make(map[string]bool) for _, member := range groupConfig.Members { - // Check for duplicates within this group if _, found := prevSet[member]; found { return Config{}, fmt.Errorf("duplicate model member %s found in group: %s", member, groupID) } prevSet[member] = true - // Check if member is used in another group if existingGroup, exists := memberUsage[member]; exists { return Config{}, fmt.Errorf("model member %s is used in multiple groups: %s and %s", member, existingGroup, groupID) } @@ -457,7 +445,7 @@ func LoadConfigFromReader(r io.Reader) (Config, error) { } } - // clean up hooks preload + // Clean up hooks preload if len(config.Hooks.OnStartup.Preload) > 0 { var toPreload []string for _, modelID := range config.Hooks.OnStartup.Preload { @@ -469,10 +457,56 @@ func LoadConfigFromReader(r io.Reader) (Config, error) { toPreload = append(toPreload, real) } } - config.Hooks.OnStartup.Preload = toPreload } + // Validate API keys (env macros already substituted at string level) + for i, apikey := range config.RequiredAPIKeys { + if apikey == "" { + return Config{}, fmt.Errorf("empty api key found in apiKeys") + } + if strings.Contains(apikey, " ") { + return Config{}, fmt.Errorf("api key cannot contain spaces: `%s`", apikey) + } + config.RequiredAPIKeys[i] = apikey + } + + // Process peers with global macro substitution + for peerName, peerConfig := range config.Peers { + // Substitute global macros (LIFO order) + for i := len(config.Macros) - 1; i >= 0; i-- { + entry := config.Macros[i] + macroSlug := fmt.Sprintf("${%s}", entry.Name) + macroStr := fmt.Sprintf("%v", entry.Value) + + peerConfig.ApiKey = strings.ReplaceAll(peerConfig.ApiKey, macroSlug, macroStr) + peerConfig.Filters.StripParams = strings.ReplaceAll(peerConfig.Filters.StripParams, macroSlug, macroStr) + + // Substitute in setParams (type-preserving) + if len(peerConfig.Filters.SetParams) > 0 { + result, err := substituteMacroInValue(peerConfig.Filters.SetParams, entry.Name, entry.Value) + if err != nil { + return Config{}, fmt.Errorf("peers.%s.filters.setParams: %w", peerName, err) + } + peerConfig.Filters.SetParams = result.(map[string]any) + } + } + + // Validate no unknown macros remain + if matches := macroPatternRegex.FindAllStringSubmatch(peerConfig.ApiKey, -1); len(matches) > 0 { + return Config{}, fmt.Errorf("peers.%s.apiKey: unknown macro '${%s}'", peerName, matches[0][1]) + } + if matches := macroPatternRegex.FindAllStringSubmatch(peerConfig.Filters.StripParams, -1); len(matches) > 0 { + return Config{}, fmt.Errorf("peers.%s.filters.stripParams: unknown macro '${%s}'", peerName, matches[0][1]) + } + if len(peerConfig.Filters.SetParams) > 0 { + if err := validateNestedForUnknownMacros(peerConfig.Filters.SetParams, fmt.Sprintf("peers.%s.filters.setParams", peerName)); err != nil { + return Config{}, err + } + } + config.Peers[peerName] = peerConfig + } + return config, nil } @@ -603,20 +637,26 @@ func validateMacro(name string, value any) error { return nil } -// validateMetadataForUnknownMacros recursively checks for any remaining macro references in metadata -func validateMetadataForUnknownMacros(value any, modelId string) error { +// validateNestedForUnknownMacros recursively checks for any remaining macro references in nested structures +func validateNestedForUnknownMacros(value any, context string) error { switch v := value.(type) { case string: matches := macroPatternRegex.FindAllStringSubmatch(v, -1) for _, match := range matches { macroName := match[1] - return fmt.Errorf("model %s metadata: unknown macro '${%s}'", modelId, macroName) + return fmt.Errorf("%s: unknown macro '${%s}'", context, macroName) + } + // Check for unsubstituted env macros + envMatches := envMacroRegex.FindAllStringSubmatch(v, -1) + for _, match := range envMatches { + varName := match[1] + return fmt.Errorf("%s: environment variable '%s' not set", context, varName) } return nil case map[string]any: for _, val := range v { - if err := validateMetadataForUnknownMacros(val, modelId); err != nil { + if err := validateNestedForUnknownMacros(val, context); err != nil { return err } } @@ -624,7 +664,7 @@ func validateMetadataForUnknownMacros(value any, modelId string) error { case []any: for _, val := range v { - if err := validateMetadataForUnknownMacros(val, modelId); err != nil { + if err := validateNestedForUnknownMacros(val, context); err != nil { return err } } @@ -700,3 +740,67 @@ func substituteMacroInValue(value any, macroName string, macroValue any) (any, e return value, nil } } + +// substituteEnvMacros replaces ${env.VAR_NAME} with environment variable values. +// Returns error if any referenced env var is not set or contains invalid characters. +// Env macros inside YAML comments are ignored by unmarshalling the YAML first +// (which strips comments) and only checking the comment-free version for macros. +func substituteEnvMacros(s string) (string, error) { + // Unmarshal and remarshal to strip YAML comments + var raw any + if err := yaml.Unmarshal([]byte(s), &raw); err != nil { + // If YAML is invalid, fall back to scanning the original string + // so the user gets the env var error rather than a confusing YAML parse error + return substituteEnvMacrosInString(s, s) + } + clean, err := yaml.Marshal(raw) + if err != nil { + return substituteEnvMacrosInString(s, s) + } + + return substituteEnvMacrosInString(s, string(clean)) +} + +// substituteEnvMacrosInString finds ${env.VAR} macros in scanStr and substitutes +// them in target. This separation allows scanning comment-free YAML while +// substituting in the original string. +func substituteEnvMacrosInString(target, scanStr string) (string, error) { + result := target + matches := envMacroRegex.FindAllStringSubmatch(scanStr, -1) + for _, match := range matches { + fullMatch := match[0] // ${env.VAR_NAME} + varName := match[1] // VAR_NAME + + value, exists := os.LookupEnv(varName) + if !exists { + return "", fmt.Errorf("environment variable '%s' is not set", varName) + } + + // Sanitize the value for safe YAML substitution + value, err := sanitizeEnvValueForYAML(value, varName) + if err != nil { + return "", err + } + + result = strings.ReplaceAll(result, fullMatch, value) + } + return result, nil +} + +// sanitizeEnvValueForYAML ensures an environment variable value is safe for YAML substitution. +// It rejects values with characters that break YAML structure and escapes quotes/backslashes +// for compatibility with double-quoted YAML strings. +func sanitizeEnvValueForYAML(value, varName string) (string, error) { + // Reject values that would break YAML structure regardless of quoting context + if strings.ContainsAny(value, "\n\r\x00") { + return "", fmt.Errorf("environment variable '%s' contains newlines or null bytes which are not allowed in YAML substitution", varName) + } + + // Escape backslashes and double quotes for safe use in double-quoted YAML strings. + // In unquoted contexts, these escapes appear literally (harmless for most use cases). + // In double-quoted contexts, they are interpreted correctly. + value = strings.ReplaceAll(value, `\`, `\\`) + value = strings.ReplaceAll(value, `"`, `\"`) + + return value, nil +} diff --git a/proxy/config/config_posix_test.go b/proxy/config/config_posix_test.go index 7127fc71..3000781d 100644 --- a/proxy/config/config_posix_test.go +++ b/proxy/config/config_posix_test.go @@ -221,6 +221,7 @@ groups: SleepRequestTimeout: 10, WakeRequestTimeout: 10, MetricsMaxInMemory: 1000, + CaptureBuffer: 5, Profiles: map[string][]string{ "test": {"model1", "model2"}, }, diff --git a/proxy/config/config_test.go b/proxy/config/config_test.go index 45679fce..997a7073 100644 --- a/proxy/config/config_test.go +++ b/proxy/config/config_test.go @@ -762,6 +762,618 @@ models: } } +func TestConfig_APIKeys_Invalid(t *testing.T) { + tests := []struct { + name string + content string + expectedErr string + }{ + { + name: "empty string", + content: `apiKeys: [""]`, + expectedErr: "empty api key found in apiKeys", + }, + { + name: "blank spaces only", + content: `apiKeys: [" "]`, + expectedErr: "api key cannot contain spaces: ` `", + }, + { + name: "contains leading space", + content: `apiKeys: [" key123"]`, + expectedErr: "api key cannot contain spaces: ` key123`", + }, + { + name: "contains trailing space", + content: `apiKeys: ["key123 "]`, + expectedErr: "api key cannot contain spaces: `key123 `", + }, + { + name: "contains middle space", + content: `apiKeys: ["key 123"]`, + expectedErr: "api key cannot contain spaces: `key 123`", + }, + { + name: "empty in list with valid keys", + content: `apiKeys: ["valid-key", "", "another-key"]`, + expectedErr: "empty api key found in apiKeys", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := LoadConfigFromReader(strings.NewReader(tt.content)) + if assert.Error(t, err) { + assert.Equal(t, tt.expectedErr, err.Error()) + } + }) + } +} + +func TestConfig_APIKeys_EnvMacros(t *testing.T) { + t.Run("env substitution in apiKeys", func(t *testing.T) { + t.Setenv("TEST_API_KEY", "secret-key-123") + + content := `apiKeys: ["${env.TEST_API_KEY}"]` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + assert.Equal(t, []string{"secret-key-123"}, config.RequiredAPIKeys) + }) + + t.Run("multiple env substitutions in apiKeys", func(t *testing.T) { + t.Setenv("TEST_API_KEY_1", "key-one") + t.Setenv("TEST_API_KEY_2", "key-two") + + content := `apiKeys: ["${env.TEST_API_KEY_1}", "${env.TEST_API_KEY_2}", "static-key"]` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + assert.Equal(t, []string{"key-one", "key-two", "static-key"}, config.RequiredAPIKeys) + }) + + t.Run("missing env var in apiKeys", func(t *testing.T) { + content := `apiKeys: ["${env.NONEXISTENT_API_KEY}"]` + _, err := LoadConfigFromReader(strings.NewReader(content)) + assert.Error(t, err) + // With string-level env substitution, error only includes var name + assert.Contains(t, err.Error(), "NONEXISTENT_API_KEY") + }) + + t.Run("env substitution results in empty key", func(t *testing.T) { + t.Setenv("TEST_EMPTY_KEY", "") + + content := `apiKeys: ["${env.TEST_EMPTY_KEY}"]` + _, err := LoadConfigFromReader(strings.NewReader(content)) + assert.Error(t, err) + assert.Equal(t, "empty api key found in apiKeys", err.Error()) + }) +} + +func TestConfig_EnvMacros(t *testing.T) { + t.Run("basic env substitution in cmd", func(t *testing.T) { + t.Setenv("TEST_MODEL_PATH", "/opt/models") + + content := ` +models: + test: + cmd: "${env.TEST_MODEL_PATH}/llama-server" + proxy: "http://localhost:8080" +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + assert.Equal(t, "/opt/models/llama-server", config.Models["test"].Cmd) + }) + + t.Run("env substitution in multiple fields", func(t *testing.T) { + t.Setenv("TEST_HOST", "myserver") + t.Setenv("TEST_PORT", "9999") + + content := ` +models: + test: + cmd: "server --host ${env.TEST_HOST}" + proxy: "http://${env.TEST_HOST}:${env.TEST_PORT}" + checkEndpoint: "http://${env.TEST_HOST}/health" +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + assert.Equal(t, "server --host myserver", config.Models["test"].Cmd) + assert.Equal(t, "http://myserver:9999", config.Models["test"].Proxy) + assert.Equal(t, "http://myserver/health", config.Models["test"].CheckEndpoint) + }) + + t.Run("env in global macro value", func(t *testing.T) { + t.Setenv("TEST_BASE_PATH", "/usr/local") + + content := ` +macros: + SERVER_PATH: "${env.TEST_BASE_PATH}/bin/server" +models: + test: + cmd: "${SERVER_PATH} --port 8080" + proxy: "http://localhost:8080" +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + assert.Equal(t, "/usr/local/bin/server --port 8080", config.Models["test"].Cmd) + }) + + t.Run("env in model-level macro value", func(t *testing.T) { + t.Setenv("TEST_MODEL_DIR", "/models/llama") + + content := ` +models: + test: + macros: + MODEL_FILE: "${env.TEST_MODEL_DIR}/model.gguf" + cmd: "server --model ${MODEL_FILE}" + proxy: "http://localhost:8080" +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + assert.Equal(t, "server --model /models/llama/model.gguf", config.Models["test"].Cmd) + }) + + t.Run("env in metadata", func(t *testing.T) { + t.Setenv("TEST_API_KEY", "secret123") + + content := ` +models: + test: + cmd: "server" + proxy: "http://localhost:8080" + metadata: + api_key: "${env.TEST_API_KEY}" + nested: + key: "${env.TEST_API_KEY}" +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + assert.Equal(t, "secret123", config.Models["test"].Metadata["api_key"]) + nested := config.Models["test"].Metadata["nested"].(map[string]any) + assert.Equal(t, "secret123", nested["key"]) + }) + + t.Run("env in filters.stripParams", func(t *testing.T) { + t.Setenv("TEST_STRIP_PARAMS", "temperature,top_p") + + content := ` +models: + test: + cmd: "server" + proxy: "http://localhost:8080" + filters: + stripParams: "${env.TEST_STRIP_PARAMS}" +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + assert.Equal(t, "temperature,top_p", config.Models["test"].Filters.StripParams) + }) + + t.Run("env in cmdStop", func(t *testing.T) { + t.Setenv("TEST_KILL_SIGNAL", "SIGTERM") + + content := ` +models: + test: + cmd: "server --port ${PORT}" + cmdStop: "kill -${env.TEST_KILL_SIGNAL} ${PID}" + proxy: "http://localhost:${PORT}" +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + assert.Contains(t, config.Models["test"].CmdStop, "-SIGTERM") + }) + + t.Run("missing env var returns error", func(t *testing.T) { + content := ` +models: + test: + cmd: "${env.UNDEFINED_VAR_12345}/server" + proxy: "http://localhost:8080" +` + _, err := LoadConfigFromReader(strings.NewReader(content)) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "UNDEFINED_VAR_12345") + assert.Contains(t, err.Error(), "not set") + } + }) + + t.Run("missing env var in global macro", func(t *testing.T) { + content := ` +macros: + PATH: "${env.UNDEFINED_GLOBAL_VAR}" +models: + test: + cmd: "server" + proxy: "http://localhost:8080" +` + _, err := LoadConfigFromReader(strings.NewReader(content)) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "UNDEFINED_GLOBAL_VAR") + assert.Contains(t, err.Error(), "not set") + } + }) + + t.Run("missing env var in model macro", func(t *testing.T) { + content := ` +models: + test: + macros: + MY_PATH: "${env.UNDEFINED_MODEL_VAR}" + cmd: "server" + proxy: "http://localhost:8080" +` + _, err := LoadConfigFromReader(strings.NewReader(content)) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "UNDEFINED_MODEL_VAR") + assert.Contains(t, err.Error(), "not set") + } + }) + + t.Run("missing env var in metadata", func(t *testing.T) { + content := ` +models: + test: + cmd: "server" + proxy: "http://localhost:8080" + metadata: + key: "${env.UNDEFINED_META_VAR}" +` + _, err := LoadConfigFromReader(strings.NewReader(content)) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "UNDEFINED_META_VAR") + assert.Contains(t, err.Error(), "not set") + } + }) + + t.Run("env combined with regular macros", func(t *testing.T) { + t.Setenv("TEST_ROOT", "/data") + + content := ` +macros: + MODEL_BASE: "${env.TEST_ROOT}/models" +models: + test: + cmd: "server --model ${MODEL_BASE}/${MODEL_ID}.gguf" + proxy: "http://localhost:8080" +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + assert.Equal(t, "server --model /data/models/test.gguf", config.Models["test"].Cmd) + }) + + t.Run("multiple env vars in same string", func(t *testing.T) { + t.Setenv("TEST_USER", "admin") + t.Setenv("TEST_PASS", "secret") + + content := ` +models: + test: + cmd: "server --auth ${env.TEST_USER}:${env.TEST_PASS}" + proxy: "http://localhost:8080" +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + assert.Equal(t, "server --auth admin:secret", config.Models["test"].Cmd) + }) + + t.Run("env value with newline is rejected", func(t *testing.T) { + t.Setenv("TEST_MULTILINE", "line1\nline2") + + content := ` +models: + test: + cmd: "server --config ${env.TEST_MULTILINE}" + proxy: "http://localhost:8080" +` + _, err := LoadConfigFromReader(strings.NewReader(content)) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "TEST_MULTILINE") + assert.Contains(t, err.Error(), "newlines") + } + }) + + t.Run("env value with carriage return is rejected", func(t *testing.T) { + t.Setenv("TEST_CR", "line1\rline2") + + content := ` +models: + test: + cmd: "server --config ${env.TEST_CR}" + proxy: "http://localhost:8080" +` + _, err := LoadConfigFromReader(strings.NewReader(content)) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "TEST_CR") + assert.Contains(t, err.Error(), "newlines") + } + }) + + t.Run("env value with quotes is escaped for YAML", func(t *testing.T) { + t.Setenv("TEST_QUOTED", `value with "quotes"`) + + content := ` +models: + test: + cmd: "server --arg \"${env.TEST_QUOTED}\"" + proxy: "http://localhost:8080" +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + // Quotes are escaped before YAML parsing, then YAML unescapes them + // Final result preserves the original value with quotes + assert.Contains(t, config.Models["test"].Cmd, `"quotes"`) + }) + + t.Run("env value with backslash is escaped for YAML", func(t *testing.T) { + t.Setenv("TEST_BACKSLASH", `path\to\file`) + + content := ` +models: + test: + cmd: "server --path \"${env.TEST_BACKSLASH}\"" + proxy: "http://localhost:8080" +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + // Backslashes are escaped before YAML parsing, then YAML unescapes them + // Final result preserves the original value with backslashes + assert.Contains(t, config.Models["test"].Cmd, `path\to\file`) + }) +} + +func TestConfig_PeerApiKey_EnvMacros(t *testing.T) { + t.Run("env substitution in peer apiKey", func(t *testing.T) { + t.Setenv("TEST_PEER_API_KEY", "sk-peer-secret-123") + + content := ` +peers: + openrouter: + proxy: https://openrouter.ai/api + apiKey: "${env.TEST_PEER_API_KEY}" + models: + - llama-3.1-8b +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + assert.Equal(t, "sk-peer-secret-123", config.Peers["openrouter"].ApiKey) + }) + + t.Run("missing env var in peer apiKey", func(t *testing.T) { + content := ` +peers: + openrouter: + proxy: https://openrouter.ai/api + apiKey: "${env.NONEXISTENT_PEER_KEY}" + models: + - llama-3.1-8b +` + _, err := LoadConfigFromReader(strings.NewReader(content)) + assert.Error(t, err) + // With string-level env substitution, error only includes var name + assert.Contains(t, err.Error(), "NONEXISTENT_PEER_KEY") + }) + + t.Run("static apiKey unchanged", func(t *testing.T) { + content := ` +peers: + openrouter: + proxy: https://openrouter.ai/api + apiKey: sk-static-key + models: + - llama-3.1-8b +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + assert.Equal(t, "sk-static-key", config.Peers["openrouter"].ApiKey) + }) + + t.Run("multiple peers with env apiKeys", func(t *testing.T) { + t.Setenv("TEST_PEER_KEY_1", "key-one") + t.Setenv("TEST_PEER_KEY_2", "key-two") + + content := ` +peers: + peer1: + proxy: https://peer1.example.com + apiKey: "${env.TEST_PEER_KEY_1}" + models: + - model-a + peer2: + proxy: https://peer2.example.com + apiKey: "${env.TEST_PEER_KEY_2}" + models: + - model-b +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + assert.Equal(t, "key-one", config.Peers["peer1"].ApiKey) + assert.Equal(t, "key-two", config.Peers["peer2"].ApiKey) + }) + + t.Run("global macro substitution in peer apiKey", func(t *testing.T) { + content := ` +macros: + API_KEY: sk-from-global-macro +peers: + openrouter: + proxy: https://openrouter.ai/api + apiKey: "${API_KEY}" + models: + - llama-3.1-8b +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + assert.Equal(t, "sk-from-global-macro", config.Peers["openrouter"].ApiKey) + }) + + t.Run("global macro in peer filters.stripParams", func(t *testing.T) { + content := ` +macros: + STRIP_LIST: "temperature, top_p" +peers: + openrouter: + proxy: https://openrouter.ai/api + models: + - llama-3.1-8b + filters: + stripParams: "${STRIP_LIST}" +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + assert.Equal(t, "temperature, top_p", config.Peers["openrouter"].Filters.StripParams) + }) + + t.Run("global macro in peer filters.setParams", func(t *testing.T) { + content := ` +macros: + MAX_TOKENS: 4096 +peers: + openrouter: + proxy: https://openrouter.ai/api + models: + - llama-3.1-8b + filters: + setParams: + max_tokens: "${MAX_TOKENS}" +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + assert.Equal(t, 4096, config.Peers["openrouter"].Filters.SetParams["max_tokens"]) + }) + + t.Run("env macro in peer filters.setParams", func(t *testing.T) { + t.Setenv("TEST_RETENTION_POLICY", "deny") + + content := ` +peers: + openrouter: + proxy: https://openrouter.ai/api + models: + - llama-3.1-8b + filters: + setParams: + data_collection: "${env.TEST_RETENTION_POLICY}" +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + assert.Equal(t, "deny", config.Peers["openrouter"].Filters.SetParams["data_collection"]) + }) + + t.Run("env macro in peer filters.stripParams", func(t *testing.T) { + t.Setenv("TEST_STRIP_PARAMS", "frequency_penalty, presence_penalty") + + content := ` +peers: + openrouter: + proxy: https://openrouter.ai/api + models: + - llama-3.1-8b + filters: + stripParams: "${env.TEST_STRIP_PARAMS}" +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + assert.Equal(t, "frequency_penalty, presence_penalty", config.Peers["openrouter"].Filters.StripParams) + }) + + t.Run("unknown macro in peer apiKey fails", func(t *testing.T) { + content := ` +peers: + openrouter: + proxy: https://openrouter.ai/api + apiKey: "${UNDEFINED_MACRO}" + models: + - llama-3.1-8b +` + _, err := LoadConfigFromReader(strings.NewReader(content)) + assert.Error(t, err) + assert.Contains(t, err.Error(), "peers.openrouter.apiKey") + assert.Contains(t, err.Error(), "unknown macro") + }) + + t.Run("unknown macro in peer filters.setParams fails", func(t *testing.T) { + content := ` +peers: + openrouter: + proxy: https://openrouter.ai/api + models: + - llama-3.1-8b + filters: + setParams: + value: "${UNDEFINED_MACRO}" +` + _, err := LoadConfigFromReader(strings.NewReader(content)) + assert.Error(t, err) + assert.Contains(t, err.Error(), "peers.openrouter.filters.setParams") + assert.Contains(t, err.Error(), "unknown macro") + }) + + t.Run("env macros in comments are ignored", func(t *testing.T) { + content := ` +# apiKeys: +# - "${env.COMMENTED_OUT_KEY_1}" +# - "${env.COMMENTED_OUT_KEY_2}" +models: + test: + cmd: "server" + proxy: "http://localhost:8080" +` + // These env vars are NOT set, but should not cause an error + // because they only appear in comment lines + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + assert.Empty(t, config.RequiredAPIKeys) + }) + + t.Run("env macros in comments ignored while active ones resolve", func(t *testing.T) { + t.Setenv("TEST_ACTIVE_KEY", "active-key-value") + + content := ` +# apiKeys: ["${env.COMMENTED_OUT_KEY}"] +apiKeys: ["${env.TEST_ACTIVE_KEY}"] +models: + test: + cmd: "server" + proxy: "http://localhost:8080" +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + assert.Equal(t, []string{"active-key-value"}, config.RequiredAPIKeys) + }) + + t.Run("env macros in indented comments are ignored", func(t *testing.T) { + content := ` +models: + test: + cmd: | + server + --port 8080 + proxy: "http://localhost:8080" + # metadata: + # api_key: "${env.SOME_UNSET_KEY}" +` + _, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + }) + + t.Run("env macros in inline comments are ignored", func(t *testing.T) { + t.Setenv("TEST_INLINE_KEY", "real-value") + + content := ` +apiKeys: ["${env.TEST_INLINE_KEY}"] # TODO: add ${env.FUTURE_KEY} later +models: + test: + cmd: "server" + proxy: "http://localhost:8080" +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + assert.Equal(t, []string{"real-value"}, config.RequiredAPIKeys) + }) + +} + func TestConfig_SleepWakeBasicConfiguration(t *testing.T) { content := ` startPort: 10000 diff --git a/proxy/config/config_windows_test.go b/proxy/config/config_windows_test.go index 30c043c4..9e3ba29b 100644 --- a/proxy/config/config_windows_test.go +++ b/proxy/config/config_windows_test.go @@ -210,6 +210,7 @@ groups: SleepRequestTimeout: 10, WakeRequestTimeout: 10, MetricsMaxInMemory: 1000, + CaptureBuffer: 5, Profiles: map[string][]string{ "test": {"model1", "model2"}, }, diff --git a/proxy/config/filters.go b/proxy/config/filters.go new file mode 100644 index 00000000..39900075 --- /dev/null +++ b/proxy/config/filters.go @@ -0,0 +1,81 @@ +package config + +import ( + "slices" + "sort" + "strings" +) + +// ProtectedParams is a list of parameters that cannot be set or stripped via filters +// These are protected to prevent breaking the proxy's ability to route requests correctly +var ProtectedParams = []string{"model"} + +// Filters contains filter settings for modifying request parameters +// Used by both models and peers +type Filters struct { + // StripParams is a comma-separated list of parameters to remove from requests + // The "model" parameter can never be removed + StripParams string `yaml:"stripParams"` + + // SetParams is a dictionary of parameters to set/override in requests + // Protected params (like "model") cannot be set + SetParams map[string]any `yaml:"setParams"` +} + +// SanitizedStripParams returns a sorted list of parameters to strip, +// with duplicates, empty strings, and protected params removed +func (f Filters) SanitizedStripParams() []string { + if f.StripParams == "" { + return nil + } + + params := strings.Split(f.StripParams, ",") + cleaned := make([]string, 0, len(params)) + seen := make(map[string]bool) + + for _, param := range params { + trimmed := strings.TrimSpace(param) + // Skip protected params, empty strings, and duplicates + if slices.Contains(ProtectedParams, trimmed) || trimmed == "" || seen[trimmed] { + continue + } + seen[trimmed] = true + cleaned = append(cleaned, trimmed) + } + + if len(cleaned) == 0 { + return nil + } + + slices.Sort(cleaned) + return cleaned +} + +// SanitizedSetParams returns a copy of SetParams with protected params removed +// and keys sorted for consistent iteration order +func (f Filters) SanitizedSetParams() (map[string]any, []string) { + if len(f.SetParams) == 0 { + return nil, nil + } + + result := make(map[string]any, len(f.SetParams)) + keys := make([]string, 0, len(f.SetParams)) + + for key, value := range f.SetParams { + // Skip protected params + if slices.Contains(ProtectedParams, key) { + continue + } + result[key] = value + keys = append(keys, key) + } + + // Sort keys for consistent ordering + sort.Strings(keys) + + if len(result) == 0 { + return nil, nil + } + + return result, keys +} diff --git a/proxy/config/filters_test.go b/proxy/config/filters_test.go new file mode 100644 index 00000000..d1f54dcd --- /dev/null +++ b/proxy/config/filters_test.go @@ -0,0 +1,168 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFilters_SanitizedStripParams(t *testing.T) { + tests := []struct { + name string + stripParams string + want []string + }{ + { + name: "empty string", + stripParams: "", + want: nil, + }, + { + name: "single param", + stripParams: "temperature", + want: []string{"temperature"}, + }, + { + name: "multiple params", + stripParams: "temperature, top_p, top_k", + want: []string{"temperature", "top_k", "top_p"}, // sorted + }, + { + name: "model param filtered", + stripParams: "model, temperature, top_p", + want: []string{"temperature", "top_p"}, + }, + { + name: "only model param", + stripParams: "model", + want: nil, + }, + { + name: "duplicates removed", + stripParams: "temperature, top_p, temperature", + want: []string{"temperature", "top_p"}, + }, + { + name: "extra whitespace", + stripParams: " temperature , top_p ", + want: []string{"temperature", "top_p"}, + }, + { + name: "empty values filtered", + stripParams: "temperature,,top_p,", + want: []string{"temperature", "top_p"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := Filters{StripParams: tt.stripParams} + got := f.SanitizedStripParams() + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFilters_SanitizedSetParams(t *testing.T) { + tests := []struct { + name string + setParams map[string]any + wantParams map[string]any + wantKeys []string + }{ + { + name: "empty setParams", + setParams: nil, + wantParams: nil, + wantKeys: nil, + }, + { + name: "empty map", + setParams: map[string]any{}, + wantParams: nil, + wantKeys: nil, + }, + { + name: "normal params", + setParams: map[string]any{ + "temperature": 0.7, + "top_p": 0.9, + }, + wantParams: map[string]any{ + "temperature": 0.7, + "top_p": 0.9, + }, + wantKeys: []string{"temperature", "top_p"}, + }, + { + name: "protected model param filtered", + setParams: map[string]any{ + "model": "should-be-filtered", + "temperature": 0.7, + }, + wantParams: map[string]any{ + "temperature": 0.7, + }, + wantKeys: []string{"temperature"}, + }, + { + name: "only protected param", + setParams: map[string]any{ + "model": "should-be-filtered", + }, + wantParams: nil, + wantKeys: nil, + }, + { + name: "complex nested values", + setParams: map[string]any{ + "provider": map[string]any{ + "data_collection": "deny", + "allow_fallbacks": false, + }, + "transforms": []string{"middle-out"}, + }, + wantParams: map[string]any{ + "provider": map[string]any{ + "data_collection": "deny", + "allow_fallbacks": false, + }, + "transforms": []string{"middle-out"}, + }, + wantKeys: []string{"provider", "transforms"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := Filters{SetParams: tt.setParams} + gotParams, gotKeys := f.SanitizedSetParams() + + assert.Equal(t, len(tt.wantKeys), len(gotKeys), "keys length mismatch") + for i, key := range gotKeys { + assert.Equal(t, tt.wantKeys[i], key, "key mismatch at %d", i) + } + + if tt.wantParams == nil { + assert.Nil(t, gotParams, "expected nil params") + return + } + + assert.Equal(t, len(tt.wantParams), len(gotParams), "params length mismatch") + for key, wantValue := range tt.wantParams { + gotValue, exists := gotParams[key] + assert.True(t, exists, "missing key: %s", key) + // Simple comparison for basic types + switch v := wantValue.(type) { + case string, int, float64, bool: + assert.Equal(t, v, gotValue, "value mismatch for key %s", key) + } + } + }) + } +} + +func TestProtectedParams(t *testing.T) { + // Verify that "model" is protected + assert.Contains(t, ProtectedParams, "model") +} diff --git a/proxy/config/model_config.go b/proxy/config/model_config.go index f8e0d783..0ff117db 100644 --- a/proxy/config/model_config.go +++ b/proxy/config/model_config.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "runtime" - "slices" "strings" ) @@ -160,16 +159,15 @@ func (m *ModelConfig) SanitizedCommand() ([]string, error) { return SanitizeCommand(m.Cmd) } -// ModelFilters see issue #174 +// ModelFilters embeds Filters and adds legacy support for strip_params field +// See issue #174 type ModelFilters struct { - StripParams string `yaml:"stripParams"` + Filters `yaml:",inline"` } func (m *ModelFilters) UnmarshalYAML(unmarshal func(interface{}) error) error { type rawModelFilters ModelFilters - defaults := rawModelFilters{ - StripParams: "", - } + defaults := rawModelFilters{} if err := unmarshal(&defaults); err != nil { return err @@ -190,25 +188,8 @@ func (m *ModelFilters) UnmarshalYAML(unmarshal func(interface{}) error) error { return nil } +// SanitizedStripParams wraps Filters.SanitizedStripParams for backwards compatibility +// Returns ([]string, error) to match existing API func (f ModelFilters) SanitizedStripParams() ([]string, error) { - if f.StripParams == "" { - return nil, nil - } - - params := strings.Split(f.StripParams, ",") - cleaned := make([]string, 0, len(params)) - seen := make(map[string]bool) - - for _, param := range params { - trimmed := strings.TrimSpace(param) - if trimmed == "model" || trimmed == "" || seen[trimmed] { - continue - } - seen[trimmed] = true - cleaned = append(cleaned, trimmed) - } - - // sort cleaned - slices.Sort(cleaned) - return cleaned, nil + return f.Filters.SanitizedStripParams(), nil } diff --git a/proxy/config/model_config_test.go b/proxy/config/model_config_test.go index 9f1e9b4f..32392952 100644 --- a/proxy/config/model_config_test.go +++ b/proxy/config/model_config_test.go @@ -72,3 +72,35 @@ models: assert.True(t, *config.Models["model2"].SendLoadingState) } } + +func TestConfig_ModelFiltersWithSetParams(t *testing.T) { + content := ` +models: + model1: + cmd: path/to/cmd --port ${PORT} + filters: + stripParams: "top_k" + setParams: + temperature: 0.7 + top_p: 0.9 + stop: + - "<|end|>" + - "<|stop|>" +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + + modelConfig := config.Models["model1"] + + // Check stripParams + stripParams, err := modelConfig.Filters.SanitizedStripParams() + assert.NoError(t, err) + assert.Equal(t, []string{"top_k"}, stripParams) + + // Check setParams + setParams, keys := modelConfig.Filters.SanitizedSetParams() + assert.NotNil(t, setParams) + assert.Equal(t, []string{"stop", "temperature", "top_p"}, keys) + assert.Equal(t, 0.7, setParams["temperature"]) + assert.Equal(t, 0.9, setParams["top_p"]) +} diff --git a/proxy/config/peer.go b/proxy/config/peer.go new file mode 100644 index 00000000..63b0aaf0 --- /dev/null +++ b/proxy/config/peer.go @@ -0,0 +1,49 @@ +package config + +import ( + "fmt" + "net/url" +) + +type PeerDictionaryConfig map[string]PeerConfig +type PeerConfig struct { + Proxy string `yaml:"proxy"` + ProxyURL *url.URL `yaml:"-"` + ApiKey string `yaml:"apiKey"` + Models []string `yaml:"models"` + Filters Filters `yaml:"filters"` +} + +func (c *PeerConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { + type rawPeerConfig PeerConfig + defaults := rawPeerConfig{ + Proxy: "", + ApiKey: "", + Models: []string{}, + Filters: Filters{}, + } + + if err := unmarshal(&defaults); err != nil { + return err + } + + // Validate proxy is not empty + if defaults.Proxy == "" { + return fmt.Errorf("proxy is required") + } + + // Validate proxy is a valid URL and store the parsed value + parsedURL, err := url.Parse(defaults.Proxy) + if err != nil { + return fmt.Errorf("invalid peer proxy URL (%s): %w", defaults.Proxy, err) + } + defaults.ProxyURL = parsedURL + + // Validate models is not empty + if len(defaults.Models) == 0 { + return fmt.Errorf("peer models can not be empty") + } + + *c = PeerConfig(defaults) + return nil +} diff --git a/proxy/config/peer_test.go b/proxy/config/peer_test.go new file mode 100644 index 00000000..c1c455b7 --- /dev/null +++ b/proxy/config/peer_test.go @@ -0,0 +1,209 @@ +package config + +import ( + "testing" + + "gopkg.in/yaml.v3" +) + +func TestPeerConfig_UnmarshalYAML(t *testing.T) { + tests := []struct { + name string + yaml string + wantErr string + }{ + { + name: "valid config", + yaml: ` +proxy: http://192.168.1.23 +models: + - model_a + - model_b +`, + wantErr: "", + }, + { + name: "valid config with apiKey", + yaml: ` +proxy: https://openrouter.ai/api +apiKey: sk-test-key +models: + - meta-llama/llama-3.1-8b-instruct +`, + wantErr: "", + }, + { + name: "missing proxy", + yaml: ` +models: + - model_a +`, + wantErr: "proxy is required", + }, + { + name: "empty proxy", + yaml: ` +proxy: "" +models: + - model_a +`, + wantErr: "proxy is required", + }, + { + name: "invalid proxy URL", + yaml: ` +proxy: "://invalid" +models: + - model_a +`, + wantErr: "invalid peer proxy URL", + }, + { + name: "missing models", + yaml: ` +proxy: http://localhost:8080 +`, + wantErr: "peer models can not be empty", + }, + { + name: "empty models", + yaml: ` +proxy: http://localhost:8080 +models: [] +`, + wantErr: "peer models can not be empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var config PeerConfig + err := yaml.Unmarshal([]byte(tt.yaml), &config) + + if tt.wantErr == "" { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + } else { + if err == nil { + t.Errorf("expected error containing %q, got nil", tt.wantErr) + } else if !contains(err.Error(), tt.wantErr) { + t.Errorf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + } + }) + } +} + +func TestPeerConfig_ProxyURL(t *testing.T) { + yamlData := ` +proxy: http://192.168.1.23:8080/api +apiKey: sk-test +models: + - model_a +` + var config PeerConfig + err := yaml.Unmarshal([]byte(yamlData), &config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if config.ProxyURL == nil { + t.Fatal("ProxyURL should not be nil") + } + + if config.ProxyURL.Host != "192.168.1.23:8080" { + t.Errorf("expected host %q, got %q", "192.168.1.23:8080", config.ProxyURL.Host) + } + + if config.ProxyURL.Scheme != "http" { + t.Errorf("expected scheme %q, got %q", "http", config.ProxyURL.Scheme) + } + + if config.ProxyURL.Path != "/api" { + t.Errorf("expected path %q, got %q", "/api", config.ProxyURL.Path) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchSubstring(s, substr) +} + +func searchSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +func TestPeerConfig_WithFilters(t *testing.T) { + yamlData := ` +proxy: https://openrouter.ai/api +apiKey: sk-test +models: + - model_a +filters: + setParams: + temperature: 0.7 + provider: + data_collection: deny +` + var config PeerConfig + err := yaml.Unmarshal([]byte(yamlData), &config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if config.Filters.SetParams == nil { + t.Fatal("Filters.SetParams should not be nil") + } + + if config.Filters.SetParams["temperature"] != 0.7 { + t.Errorf("expected temperature 0.7, got %v", config.Filters.SetParams["temperature"]) + } + + provider, ok := config.Filters.SetParams["provider"].(map[string]any) + if !ok { + t.Fatal("provider should be a map") + } + if provider["data_collection"] != "deny" { + t.Errorf("expected data_collection deny, got %v", provider["data_collection"]) + } +} + +func TestPeerConfig_WithBothFilters(t *testing.T) { + yamlData := ` +proxy: https://openrouter.ai/api +apiKey: sk-test +models: + - model_a +filters: + stripParams: "temperature, top_p" + setParams: + max_tokens: 1000 +` + var config PeerConfig + err := yaml.Unmarshal([]byte(yamlData), &config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Check stripParams + stripParams := config.Filters.SanitizedStripParams() + if len(stripParams) != 2 { + t.Errorf("expected 2 strip params, got %d", len(stripParams)) + } + if stripParams[0] != "temperature" || stripParams[1] != "top_p" { + t.Errorf("unexpected strip params: %v", stripParams) + } + + // Check setParams + if config.Filters.SetParams == nil { + t.Fatal("Filters.SetParams should not be nil") + } + if config.Filters.SetParams["max_tokens"] != 1000 { + t.Errorf("expected max_tokens 1000, got %v", config.Filters.SetParams["max_tokens"]) + } +} diff --git a/proxy/helpers_test.go b/proxy/helpers_test.go index 6b8a7997..3c4b6ea1 100644 --- a/proxy/helpers_test.go +++ b/proxy/helpers_test.go @@ -71,11 +71,15 @@ func getTestSimpleResponderConfig(expectedMessage string) config.ModelConfig { } func getTestSimpleResponderConfigPort(expectedMessage string, port int) config.ModelConfig { + // Convert path to forward slashes for cross-platform compatibility + // Windows handles forward slashes in paths correctly + cmdPath := filepath.ToSlash(simpleResponderPath) + // Create a YAML string with just the values we want to set yamlStr := fmt.Sprintf(` cmd: '%s --port %d --silent --respond %s' proxy: "http://127.0.0.1:%d" -`, simpleResponderPath, port, expectedMessage, port) +`, cmdPath, port, expectedMessage, port) var cfg config.ModelConfig if err := yaml.Unmarshal([]byte(yamlStr), &cfg); err != nil { diff --git a/proxy/metrics_monitor.go b/proxy/metrics_monitor.go index 739e1bfe..002e0e70 100644 --- a/proxy/metrics_monitor.go +++ b/proxy/metrics_monitor.go @@ -2,6 +2,8 @@ package proxy import ( "bytes" + "compress/flate" + "compress/gzip" "encoding/json" "fmt" "io" @@ -26,6 +28,28 @@ type TokenMetrics struct { PromptPerSecond float64 `json:"prompt_per_second"` TokensPerSecond float64 `json:"tokens_per_second"` DurationMs int `json:"duration_ms"` + HasCapture bool `json:"has_capture"` +} + +type ReqRespCapture struct { + ID int `json:"id"` + ReqPath string `json:"req_path"` + ReqHeaders map[string]string `json:"req_headers"` + ReqBody []byte `json:"req_body"` + RespHeaders map[string]string `json:"resp_headers"` + RespBody []byte `json:"resp_body"` +} + +// Size returns the approximate memory usage of this capture in bytes +func (c *ReqRespCapture) Size() int { + size := len(c.ReqPath) + len(c.ReqBody) + len(c.RespBody) + for k, v := range c.ReqHeaders { + size += len(k) + len(v) + } + for k, v := range c.RespHeaders { + size += len(k) + len(v) + } + return size } // TokenMetricsEvent represents a token metrics event @@ -44,19 +68,32 @@ type metricsMonitor struct { maxMetrics int nextID int logger *LogMonitor + + // capture fields + enableCaptures bool + captures map[int]ReqRespCapture // map for O(1) lookup by ID + captureOrder []int // track insertion order for FIFO eviction + captureSize int // current total size in bytes + maxCaptureSize int // max bytes for captures } -func newMetricsMonitor(logger *LogMonitor, maxMetrics int) *metricsMonitor { - mp := &metricsMonitor{ - logger: logger, - maxMetrics: maxMetrics, +// newMetricsMonitor creates a new metricsMonitor. captureBufferMB is the +// capture buffer size in megabytes; 0 disables captures. +func newMetricsMonitor(logger *LogMonitor, maxMetrics int, captureBufferMB int) *metricsMonitor { + return &metricsMonitor{ + logger: logger, + maxMetrics: maxMetrics, + enableCaptures: captureBufferMB > 0, + captures: make(map[int]ReqRespCapture), + captureOrder: make([]int, 0), + captureSize: 0, + maxCaptureSize: captureBufferMB * 1024 * 1024, } - - return mp } -// addMetrics adds a new metric to the collection and publishes an event -func (mp *metricsMonitor) addMetrics(metric TokenMetrics) { +// addMetrics adds a new metric to the collection and publishes an event. +// Returns the assigned metric ID. +func (mp *metricsMonitor) addMetrics(metric TokenMetrics) int { mp.mu.Lock() defer mp.mu.Unlock() @@ -67,6 +104,49 @@ func (mp *metricsMonitor) addMetrics(metric TokenMetrics) { mp.metrics = mp.metrics[len(mp.metrics)-mp.maxMetrics:] } event.Emit(TokenMetricsEvent{Metrics: metric}) + return metric.ID +} + +// addCapture adds a new capture to the buffer with size-based eviction. +// Captures are skipped if enableCaptures is false or if capture exceeds maxCaptureSize. +func (mp *metricsMonitor) addCapture(capture ReqRespCapture) { + if !mp.enableCaptures { + return + } + + mp.mu.Lock() + defer mp.mu.Unlock() + + captureSize := capture.Size() + if captureSize > mp.maxCaptureSize { + mp.logger.Warnf("capture size %d exceeds max %d, skipping", captureSize, mp.maxCaptureSize) + return + } + + // Evict oldest (FIFO) until room available + for mp.captureSize+captureSize > mp.maxCaptureSize && len(mp.captureOrder) > 0 { + oldestID := mp.captureOrder[0] + mp.captureOrder = mp.captureOrder[1:] + if evicted, exists := mp.captures[oldestID]; exists { + mp.captureSize -= evicted.Size() + delete(mp.captures, oldestID) + } + } + + mp.captures[capture.ID] = capture + mp.captureOrder = append(mp.captureOrder, capture.ID) + mp.captureSize += captureSize +} + +// getCaptureByID returns a capture by its ID, or nil if not found. +func (mp *metricsMonitor) getCaptureByID(id int) *ReqRespCapture { + mp.mu.RLock() + defer mp.mu.RUnlock() + + if capture, exists := mp.captures[id]; exists { + return &capture + } + return nil } // getMetrics returns a copy of the current metrics @@ -95,8 +175,36 @@ func (mp *metricsMonitor) wrapHandler( request *http.Request, next func(modelID string, w http.ResponseWriter, r *http.Request) error, ) error { + // Capture request body and headers if captures enabled + var reqBody []byte + var reqHeaders map[string]string + if mp.enableCaptures { + if request.Body != nil { + var err error + reqBody, err = io.ReadAll(request.Body) + if err != nil { + return fmt.Errorf("failed to read request body for capture: %w", err) + } + request.Body.Close() + request.Body = io.NopCloser(bytes.NewBuffer(reqBody)) + } + reqHeaders = make(map[string]string) + for key, values := range request.Header { + if len(values) > 0 { + reqHeaders[key] = values[0] + } + } + redactHeaders(reqHeaders) + } + requestStartTime := time.Now() recorder := newBodyCopier(writer, requestStartTime) + + // Filter Accept-Encoding to only include encodings we can decompress for metrics + if ae := request.Header.Get("Accept-Encoding"); ae != "" { + request.Header.Set("Accept-Encoding", filterAcceptEncoding(ae)) + } + if err := next(modelID, recorder, request); err != nil { return err } @@ -110,17 +218,35 @@ func (mp *metricsMonitor) wrapHandler( return nil } + // Initialize default metrics - these will always be recorded + tm := TokenMetrics{ + Timestamp: time.Now(), + Model: modelID, + DurationMs: int(time.Since(recorder.StartTime()).Milliseconds()), + } + body := recorder.body.Bytes() if len(body) == 0 { - mp.logger.Warn("metrics skipped, empty body") + mp.logger.Warn("metrics: empty body, recording minimal metrics") + mp.addMetrics(tm) return nil } + // Decompress if needed + if encoding := recorder.Header().Get("Content-Encoding"); encoding != "" { + var err error + body, err = decompressBody(body, encoding) + if err != nil { + mp.logger.Warnf("metrics: decompression failed: %v, path=%s, recording minimal metrics", err, request.URL.Path) + mp.addMetrics(tm) + return nil + } + } if strings.Contains(recorder.Header().Get("Content-Type"), "text/event-stream") { - if tm, err := processStreamingResponse(modelID, recorder.RequestTime(), body); err != nil { - mp.logger.Warnf("error processing streaming response: %v, path=%s", err, request.URL.Path) + if parsed, err := processStreamingResponse(modelID, recorder.RequestTime(), body); err != nil { + mp.logger.Warnf("error processing streaming response: %v, path=%s, recording minimal metrics", err, request.URL.Path) } else { - mp.addMetrics(tm) + tm = parsed } } else { if gjson.ValidBytes(body) { @@ -128,18 +254,57 @@ func (mp *metricsMonitor) wrapHandler( usage := parsed.Get("usage") timings := parsed.Get("timings") + // extract timings for infill - response is an array, timings are in the last element + // see #463 + if strings.HasPrefix(request.URL.Path, "/infill") { + if arr := parsed.Array(); len(arr) > 0 { + timings = arr[len(arr)-1].Get("timings") + } + } + // Track metrics even if usage/timings are missing (graceful degradation) - if tm, err := parseMetrics(modelID, recorder.RequestTime(), usage, timings); err != nil { - mp.logger.Warnf("error parsing metrics: %v, path=%s", err, request.URL.Path) + if parsedMetrics, err := parseMetrics(modelID, recorder.RequestTime(), usage, timings); err != nil { + mp.logger.Warnf("error parsing metrics: %v, path=%s, recording minimal metrics", err, request.URL.Path) } else { - mp.addMetrics(tm) + tm = parsedMetrics } - } else { - mp.logger.Warnf("metrics skipped, invalid JSON in response body path=%s", request.URL.Path) + mp.logger.Warnf("metrics: invalid JSON in response body path=%s, recording minimal metrics", request.URL.Path) } } + // Build capture if enabled and determine if it will be stored + var capture *ReqRespCapture + if mp.enableCaptures { + respHeaders := make(map[string]string) + for key, values := range recorder.Header() { + if len(values) > 0 { + respHeaders[key] = values[0] + } + } + redactHeaders(respHeaders) + delete(respHeaders, "Content-Encoding") + capture = &ReqRespCapture{ + ReqPath: request.URL.Path, + ReqHeaders: reqHeaders, + ReqBody: reqBody, + RespHeaders: respHeaders, + RespBody: body, + } + // Only set HasCapture if the capture will actually be stored (not too large) + if capture.Size() <= mp.maxCaptureSize { + tm.HasCapture = true + } + } + + metricID := mp.addMetrics(tm) + + // Store capture if enabled + if capture != nil { + capture.ID = metricID + mp.addCapture(*capture) + } + return nil } @@ -266,6 +431,25 @@ func parseMetrics(modelID string, start time.Time, usage, timings gjson.Result) }, nil } +// decompressBody decompresses the body based on Content-Encoding header +func decompressBody(body []byte, encoding string) ([]byte, error) { + switch strings.ToLower(strings.TrimSpace(encoding)) { + case "gzip": + reader, err := gzip.NewReader(bytes.NewReader(body)) + if err != nil { + return nil, err + } + defer reader.Close() + return io.ReadAll(reader) + case "deflate": + reader := flate.NewReader(bytes.NewReader(body)) + defer reader.Close() + return io.ReadAll(reader) + default: + return body, nil // Return as-is for unknown/no encoding + } +} + // responseBodyCopier records the response body and writes to the original response writer // while also capturing it in a buffer for later processing type responseBodyCopier struct { @@ -310,3 +494,43 @@ func (w *responseBodyCopier) StartTime() time.Time { func (w *responseBodyCopier) RequestTime() time.Time { return w.requestTime } + +// sensitiveHeaders lists headers that should be redacted in captures +var sensitiveHeaders = map[string]bool{ + "authorization": true, + "proxy-authorization": true, + "cookie": true, + "set-cookie": true, + "x-api-key": true, +} + +// redactHeaders replaces sensitive header values in-place with "[REDACTED]" +func redactHeaders(headers map[string]string) { + for key := range headers { + if sensitiveHeaders[strings.ToLower(key)] { + headers[key] = "[REDACTED]" + } + } +} + +// filterAcceptEncoding filters the Accept-Encoding header to only include +// encodings we can decompress (gzip, deflate). This respects the client's +// preferences while ensuring we can parse response bodies for metrics. +func filterAcceptEncoding(acceptEncoding string) string { + if acceptEncoding == "" { + return "" + } + + supported := map[string]bool{"gzip": true, "deflate": true} + var filtered []string + + for _, part := range strings.Split(acceptEncoding, ",") { + // Parse encoding and optional quality value (e.g., "gzip;q=1.0") + encoding := strings.TrimSpace(strings.Split(part, ";")[0]) + if supported[strings.ToLower(encoding)] { + filtered = append(filtered, strings.TrimSpace(part)) + } + } + + return strings.Join(filtered, ", ") +} diff --git a/proxy/metrics_monitor_test.go b/proxy/metrics_monitor_test.go index 2ffd4555..071b643d 100644 --- a/proxy/metrics_monitor_test.go +++ b/proxy/metrics_monitor_test.go @@ -1,6 +1,9 @@ package proxy import ( + "bytes" + "compress/flate" + "compress/gzip" "encoding/json" "net/http" "net/http/httptest" @@ -15,7 +18,7 @@ import ( func TestMetricsMonitor_AddMetrics(t *testing.T) { t.Run("adds metrics and assigns ID", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 10) + mm := newMetricsMonitor(testLogger, 10, 0) metric := TokenMetrics{ Model: "test-model", @@ -34,7 +37,7 @@ func TestMetricsMonitor_AddMetrics(t *testing.T) { }) t.Run("increments ID for each metric", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 10) + mm := newMetricsMonitor(testLogger, 10, 0) for i := 0; i < 5; i++ { mm.addMetrics(TokenMetrics{Model: "model"}) @@ -48,7 +51,7 @@ func TestMetricsMonitor_AddMetrics(t *testing.T) { }) t.Run("respects max metrics limit", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 3) + mm := newMetricsMonitor(testLogger, 3, 0) // Add 5 metrics for i := 0; i < 5; i++ { @@ -68,7 +71,7 @@ func TestMetricsMonitor_AddMetrics(t *testing.T) { }) t.Run("emits TokenMetricsEvent", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 10) + mm := newMetricsMonitor(testLogger, 10, 0) receivedEvent := make(chan TokenMetricsEvent, 1) cancel := event.On(func(e TokenMetricsEvent) { @@ -98,14 +101,14 @@ func TestMetricsMonitor_AddMetrics(t *testing.T) { func TestMetricsMonitor_GetMetrics(t *testing.T) { t.Run("returns empty slice when no metrics", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 10) + mm := newMetricsMonitor(testLogger, 10, 0) metrics := mm.getMetrics() assert.NotNil(t, metrics) assert.Equal(t, 0, len(metrics)) }) t.Run("returns copy of metrics", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 10) + mm := newMetricsMonitor(testLogger, 10, 0) mm.addMetrics(TokenMetrics{Model: "model1"}) mm.addMetrics(TokenMetrics{Model: "model2"}) @@ -125,7 +128,7 @@ func TestMetricsMonitor_GetMetrics(t *testing.T) { func TestMetricsMonitor_GetMetricsJSON(t *testing.T) { t.Run("returns valid JSON for empty metrics", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 10) + mm := newMetricsMonitor(testLogger, 10, 0) jsonData, err := mm.getMetricsJSON() assert.NoError(t, err) assert.NotNil(t, jsonData) @@ -137,7 +140,7 @@ func TestMetricsMonitor_GetMetricsJSON(t *testing.T) { }) t.Run("returns valid JSON with metrics", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 10) + mm := newMetricsMonitor(testLogger, 10, 0) mm.addMetrics(TokenMetrics{ Model: "model1", InputTokens: 100, @@ -165,7 +168,7 @@ func TestMetricsMonitor_GetMetricsJSON(t *testing.T) { func TestMetricsMonitor_WrapHandler(t *testing.T) { t.Run("successful non-streaming request with usage data", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 10) + mm := newMetricsMonitor(testLogger, 10, 0) responseBody := `{ "usage": { @@ -196,7 +199,7 @@ func TestMetricsMonitor_WrapHandler(t *testing.T) { }) t.Run("successful request with timings data", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 10) + mm := newMetricsMonitor(testLogger, 10, 0) responseBody := `{ "timings": { @@ -236,7 +239,7 @@ func TestMetricsMonitor_WrapHandler(t *testing.T) { }) t.Run("streaming request with SSE format", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 10) + mm := newMetricsMonitor(testLogger, 10, 0) // Note: SSE format requires proper line breaks - each data line followed by blank line responseBody := `data: {"choices":[{"text":"Hello"}]} @@ -272,7 +275,7 @@ data: [DONE] }) t.Run("non-OK status code does not record metrics", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 10) + mm := newMetricsMonitor(testLogger, 10, 0) nextHandler := func(modelID string, w http.ResponseWriter, r *http.Request) error { w.WriteHeader(http.StatusBadRequest) @@ -291,8 +294,8 @@ data: [DONE] assert.Equal(t, 0, len(metrics)) }) - t.Run("empty response body does not record metrics", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 10) + t.Run("empty response body records minimal metrics", func(t *testing.T) { + mm := newMetricsMonitor(testLogger, 10, 0) nextHandler := func(modelID string, w http.ResponseWriter, r *http.Request) error { w.WriteHeader(http.StatusOK) @@ -307,11 +310,14 @@ data: [DONE] assert.NoError(t, err) metrics := mm.getMetrics() - assert.Equal(t, 0, len(metrics)) + assert.Equal(t, 1, len(metrics)) + assert.Equal(t, "test-model", metrics[0].Model) + assert.Equal(t, 0, metrics[0].InputTokens) + assert.Equal(t, 0, metrics[0].OutputTokens) }) - t.Run("invalid JSON does not record metrics", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 10) + t.Run("invalid JSON records minimal metrics", func(t *testing.T) { + mm := newMetricsMonitor(testLogger, 10, 0) nextHandler := func(modelID string, w http.ResponseWriter, r *http.Request) error { w.Header().Set("Content-Type", "application/json") @@ -328,11 +334,14 @@ data: [DONE] assert.NoError(t, err) // Errors after response is sent are logged, not returned metrics := mm.getMetrics() - assert.Equal(t, 0, len(metrics)) + assert.Equal(t, 1, len(metrics)) + assert.Equal(t, "test-model", metrics[0].Model) + assert.Equal(t, 0, metrics[0].InputTokens) + assert.Equal(t, 0, metrics[0].OutputTokens) }) t.Run("next handler error is propagated", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 10) + mm := newMetricsMonitor(testLogger, 10, 0) expectedErr := assert.AnError nextHandler := func(modelID string, w http.ResponseWriter, r *http.Request) error { @@ -350,8 +359,8 @@ data: [DONE] assert.Equal(t, 0, len(metrics)) }) - t.Run("response without usage or timings records metrics with unknown values", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 10) + t.Run("response without usage or timings does not record metrics", func(t *testing.T) { + mm := newMetricsMonitor(testLogger, 10, 0) responseBody := `{"result": "ok"}` @@ -367,15 +376,11 @@ data: [DONE] ginCtx, _ := gin.CreateTestContext(rec) err := mm.wrapHandler("test-model", ginCtx.Writer, req, nextHandler) - assert.NoError(t, err) // Errors after response is sent are logged, not returned + assert.NoError(t, err) // With graceful degradation, should track the request even without usage data metrics := mm.getMetrics() assert.Equal(t, 1, len(metrics)) - assert.Equal(t, "test-model", metrics[0].Model) - assert.Equal(t, 0, metrics[0].InputTokens) - assert.Equal(t, 0, metrics[0].OutputTokens) - assert.Equal(t, -1, metrics[0].CachedTokens) }) } @@ -439,7 +444,7 @@ func TestMetricsMonitor_ResponseBodyCopier(t *testing.T) { func TestMetricsMonitor_Concurrent(t *testing.T) { t.Run("concurrent addMetrics is safe", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 1000) + mm := newMetricsMonitor(testLogger, 1000, 0) var wg sync.WaitGroup numGoroutines := 10 @@ -466,7 +471,7 @@ func TestMetricsMonitor_Concurrent(t *testing.T) { }) t.Run("concurrent reads and writes are safe", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 100) + mm := newMetricsMonitor(testLogger, 100, 0) done := make(chan bool) @@ -504,7 +509,7 @@ func TestMetricsMonitor_Concurrent(t *testing.T) { func TestMetricsMonitor_ParseMetrics(t *testing.T) { t.Run("prefers timings over usage data", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 10) + mm := newMetricsMonitor(testLogger, 10, 0) // Timings should take precedence over usage responseBody := `{ @@ -544,7 +549,7 @@ func TestMetricsMonitor_ParseMetrics(t *testing.T) { }) t.Run("handles missing cache_n in timings", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 10) + mm := newMetricsMonitor(testLogger, 10, 0) responseBody := `{ "timings": { @@ -577,7 +582,7 @@ func TestMetricsMonitor_ParseMetrics(t *testing.T) { }) t.Run("calculates TokensPerSecond when timings absent", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 10) + mm := newMetricsMonitor(testLogger, 10, 0) // vLLM-style response: only usage, no timings responseBody := `{ @@ -617,7 +622,7 @@ func TestMetricsMonitor_ParseMetrics(t *testing.T) { }) t.Run("prefers backend timings over calculation", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 10) + mm := newMetricsMonitor(testLogger, 10, 0) // Response with both usage and timings // Timings should be used even if they differ from calculated values @@ -661,7 +666,7 @@ func TestMetricsMonitor_ParseMetrics(t *testing.T) { }) t.Run("handles zero output tokens", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 10) + mm := newMetricsMonitor(testLogger, 10, 0) // Response with no completion tokens responseBody := `{ @@ -695,7 +700,7 @@ func TestMetricsMonitor_ParseMetrics(t *testing.T) { }) t.Run("handles very fast responses", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 10) + mm := newMetricsMonitor(testLogger, 10, 0) // Response that completes very quickly (< 1ms) responseBody := `{ @@ -737,7 +742,7 @@ func TestMetricsMonitor_ParseMetrics(t *testing.T) { func TestMetricsMonitor_StreamingResponse(t *testing.T) { t.Run("finds metrics in last valid SSE data", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 10) + mm := newMetricsMonitor(testLogger, 10, 0) // Metrics should be found in the last data line before [DONE] responseBody := `data: {"choices":[{"text":"First"}]} @@ -770,8 +775,8 @@ data: [DONE] assert.Equal(t, 50, metrics[0].OutputTokens) }) - t.Run("handles streaming with no valid JSON", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 10) + t.Run("handles streaming with no valid JSON records minimal metrics", func(t *testing.T) { + mm := newMetricsMonitor(testLogger, 10, 0) responseBody := `data: not json @@ -791,14 +796,17 @@ data: [DONE] ginCtx, _ := gin.CreateTestContext(rec) err := mm.wrapHandler("test-model", ginCtx.Writer, req, nextHandler) - assert.NoError(t, err) // Errors after response is sent are logged, not returned + assert.NoError(t, err) metrics := mm.getMetrics() - assert.Equal(t, 0, len(metrics)) + assert.Equal(t, 1, len(metrics)) + assert.Equal(t, "test-model", metrics[0].Model) + assert.Equal(t, 0, metrics[0].InputTokens) + assert.Equal(t, 0, metrics[0].OutputTokens) }) - t.Run("handles empty streaming response", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 10) + t.Run("handles empty streaming response records minimal metrics", func(t *testing.T) { + mm := newMetricsMonitor(testLogger, 10, 0) responseBody := `` @@ -814,15 +822,17 @@ data: [DONE] ginCtx, _ := gin.CreateTestContext(rec) err := mm.wrapHandler("test-model", ginCtx.Writer, req, nextHandler) - // Empty body should not trigger WrapHandler processing assert.NoError(t, err) metrics := mm.getMetrics() - assert.Equal(t, 0, len(metrics)) + assert.Equal(t, 1, len(metrics)) + assert.Equal(t, "test-model", metrics[0].Model) + assert.Equal(t, 0, metrics[0].InputTokens) + assert.Equal(t, 0, metrics[0].OutputTokens) }) t.Run("gracefully handles streaming response without usage data", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 10) + mm := newMetricsMonitor(testLogger, 10, 0) // vLLM streaming response without stream_options - no usage data responseBody := `data: {"choices":[{"text":"Hello"}]} @@ -860,7 +870,7 @@ data: [DONE] }) t.Run("gracefully handles non-streaming response without usage data", func(t *testing.T) { - mm := newMetricsMonitor(testLogger, 10) + mm := newMetricsMonitor(testLogger, 10, 0) // Valid JSON response but no usage or timings fields responseBody := `{"choices":[{"text":"Hello world"}],"model":"test-model"}` @@ -894,7 +904,7 @@ data: [DONE] // Benchmark tests func BenchmarkMetricsMonitor_AddMetrics(b *testing.B) { - mm := newMetricsMonitor(testLogger, 1000) + mm := newMetricsMonitor(testLogger, 1000, 0) metric := TokenMetrics{ Model: "test-model", @@ -915,7 +925,7 @@ func BenchmarkMetricsMonitor_AddMetrics(b *testing.B) { func BenchmarkMetricsMonitor_AddMetrics_SmallBuffer(b *testing.B) { // Test performance with a smaller buffer where wrapping occurs more frequently - mm := newMetricsMonitor(testLogger, 100) + mm := newMetricsMonitor(testLogger, 100, 0) metric := TokenMetrics{ Model: "test-model", @@ -933,3 +943,352 @@ func BenchmarkMetricsMonitor_AddMetrics_SmallBuffer(b *testing.B) { mm.addMetrics(metric) } } + +func TestMetricsMonitor_WrapHandler_Compression(t *testing.T) { + t.Run("gzip encoded response", func(t *testing.T) { + mm := newMetricsMonitor(testLogger, 10, 0) + + responseBody := `{"usage": {"prompt_tokens": 100, "completion_tokens": 50}}` + + // Compress with gzip + var buf bytes.Buffer + gzWriter := gzip.NewWriter(&buf) + gzWriter.Write([]byte(responseBody)) + gzWriter.Close() + compressedBody := buf.Bytes() + + nextHandler := func(modelID string, w http.ResponseWriter, r *http.Request) error { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Encoding", "gzip") + w.WriteHeader(http.StatusOK) + w.Write(compressedBody) + return nil + } + + req := httptest.NewRequest("POST", "/test", nil) + rec := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(rec) + + err := mm.wrapHandler("test-model", ginCtx.Writer, req, nextHandler) + assert.NoError(t, err) + + metrics := mm.getMetrics() + assert.Equal(t, 1, len(metrics)) + assert.Equal(t, "test-model", metrics[0].Model) + assert.Equal(t, 100, metrics[0].InputTokens) + assert.Equal(t, 50, metrics[0].OutputTokens) + }) + + t.Run("deflate encoded response", func(t *testing.T) { + mm := newMetricsMonitor(testLogger, 10, 0) + + responseBody := `{"usage": {"prompt_tokens": 200, "completion_tokens": 75}}` + + // Compress with deflate + var buf bytes.Buffer + flateWriter, _ := flate.NewWriter(&buf, flate.DefaultCompression) + flateWriter.Write([]byte(responseBody)) + flateWriter.Close() + compressedBody := buf.Bytes() + + nextHandler := func(modelID string, w http.ResponseWriter, r *http.Request) error { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Encoding", "deflate") + w.WriteHeader(http.StatusOK) + w.Write(compressedBody) + return nil + } + + req := httptest.NewRequest("POST", "/test", nil) + rec := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(rec) + + err := mm.wrapHandler("test-model", ginCtx.Writer, req, nextHandler) + assert.NoError(t, err) + + metrics := mm.getMetrics() + assert.Equal(t, 1, len(metrics)) + assert.Equal(t, "test-model", metrics[0].Model) + assert.Equal(t, 200, metrics[0].InputTokens) + assert.Equal(t, 75, metrics[0].OutputTokens) + }) + + t.Run("invalid gzip data records minimal metrics", func(t *testing.T) { + mm := newMetricsMonitor(testLogger, 10, 0) + + // Invalid compressed data + invalidData := []byte("this is not gzip data") + + nextHandler := func(modelID string, w http.ResponseWriter, r *http.Request) error { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Encoding", "gzip") + w.WriteHeader(http.StatusOK) + w.Write(invalidData) + return nil + } + + req := httptest.NewRequest("POST", "/test", nil) + rec := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(rec) + + err := mm.wrapHandler("test-model", ginCtx.Writer, req, nextHandler) + assert.NoError(t, err) // Should not return error, just log warning + + metrics := mm.getMetrics() + assert.Equal(t, 1, len(metrics)) + assert.Equal(t, "test-model", metrics[0].Model) + assert.Equal(t, 0, metrics[0].InputTokens) + assert.Equal(t, 0, metrics[0].OutputTokens) + }) + + t.Run("unknown encoding treated as uncompressed", func(t *testing.T) { + mm := newMetricsMonitor(testLogger, 10, 0) + + responseBody := `{"usage": {"prompt_tokens": 300, "completion_tokens": 100}}` + + nextHandler := func(modelID string, w http.ResponseWriter, r *http.Request) error { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Encoding", "unknown-encoding") + w.WriteHeader(http.StatusOK) + w.Write([]byte(responseBody)) + return nil + } + + req := httptest.NewRequest("POST", "/test", nil) + rec := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(rec) + + err := mm.wrapHandler("test-model", ginCtx.Writer, req, nextHandler) + assert.NoError(t, err) + + metrics := mm.getMetrics() + assert.Equal(t, 1, len(metrics)) + assert.Equal(t, 300, metrics[0].InputTokens) + assert.Equal(t, 100, metrics[0].OutputTokens) + }) +} + +func TestReqRespCapture_Size(t *testing.T) { + t.Run("calculates size correctly", func(t *testing.T) { + capture := ReqRespCapture{ + ID: 1, + ReqPath: "/v1/chat/completions", // 20 bytes + ReqHeaders: map[string]string{ + "Content-Type": "application/json", // 12 + 16 = 28 + }, + ReqBody: []byte("request body"), // 12 bytes + RespHeaders: map[string]string{ + "X-Test": "value", // 6 + 5 = 11 + }, + RespBody: []byte("response body"), // 13 bytes + } + + // Expected: 20 + 12 + 13 + 28 + 11 = 84 + assert.Equal(t, 84, capture.Size()) + }) + + t.Run("handles empty capture", func(t *testing.T) { + capture := ReqRespCapture{} + assert.Equal(t, 0, capture.Size()) + }) +} + +func TestMetricsMonitor_AddCapture(t *testing.T) { + t.Run("does nothing when captures disabled", func(t *testing.T) { + mm := newMetricsMonitor(testLogger, 10, 0) + + capture := ReqRespCapture{ + ID: 0, + ReqBody: []byte("test"), + } + mm.addCapture(capture) + + // Should not store capture + assert.Nil(t, mm.getCaptureByID(0)) + }) + + t.Run("adds capture when enabled", func(t *testing.T) { + mm := newMetricsMonitor(testLogger, 10, 5) + + capture := ReqRespCapture{ + ID: 0, + ReqBody: []byte("test request"), + RespBody: []byte("test response"), + } + mm.addCapture(capture) + + retrieved := mm.getCaptureByID(0) + assert.NotNil(t, retrieved) + assert.Equal(t, 0, retrieved.ID) + assert.Equal(t, []byte("test request"), retrieved.ReqBody) + assert.Equal(t, []byte("test response"), retrieved.RespBody) + }) + + t.Run("evicts oldest when exceeding max size", func(t *testing.T) { + mm := newMetricsMonitor(testLogger, 10, 5) + mm.maxCaptureSize = 100 // Set small limit for test + + // Add captures that will exceed the limit + capture1 := ReqRespCapture{ID: 0, ReqBody: make([]byte, 40)} + capture2 := ReqRespCapture{ID: 1, ReqBody: make([]byte, 40)} + capture3 := ReqRespCapture{ID: 2, ReqBody: make([]byte, 40)} + + mm.addCapture(capture1) + mm.addCapture(capture2) + // Adding capture3 should evict capture1 + mm.addCapture(capture3) + + assert.Nil(t, mm.getCaptureByID(0), "capture 0 should be evicted") + assert.NotNil(t, mm.getCaptureByID(1), "capture 1 should exist") + assert.NotNil(t, mm.getCaptureByID(2), "capture 2 should exist") + }) + + t.Run("skips capture larger than max size", func(t *testing.T) { + mm := newMetricsMonitor(testLogger, 10, 5) + mm.maxCaptureSize = 100 + + // Add a capture larger than max + largeCapture := ReqRespCapture{ID: 0, ReqBody: make([]byte, 200)} + mm.addCapture(largeCapture) + + assert.Nil(t, mm.getCaptureByID(0), "oversized capture should not be stored") + }) +} + +func TestMetricsMonitor_GetCaptureByID(t *testing.T) { + t.Run("returns nil for non-existent ID", func(t *testing.T) { + mm := newMetricsMonitor(testLogger, 10, 5) + + assert.Nil(t, mm.getCaptureByID(999)) + }) + + t.Run("returns capture by ID", func(t *testing.T) { + mm := newMetricsMonitor(testLogger, 10, 5) + + capture := ReqRespCapture{ + ID: 42, + ReqBody: []byte("test"), + } + mm.addCapture(capture) + + retrieved := mm.getCaptureByID(42) + assert.NotNil(t, retrieved) + assert.Equal(t, 42, retrieved.ID) + }) +} + +func TestRedactHeaders(t *testing.T) { + t.Run("redacts sensitive headers", func(t *testing.T) { + headers := map[string]string{ + "Authorization": "Bearer secret-token", + "Proxy-Authorization": "Basic creds", + "Cookie": "session=abc123", + "Set-Cookie": "session=xyz789", + "X-Api-Key": "sk-12345", + "Content-Type": "application/json", + "X-Custom": "safe-value", + } + + redactHeaders(headers) + + assert.Equal(t, "[REDACTED]", headers["Authorization"]) + assert.Equal(t, "[REDACTED]", headers["Proxy-Authorization"]) + assert.Equal(t, "[REDACTED]", headers["Cookie"]) + assert.Equal(t, "[REDACTED]", headers["Set-Cookie"]) + assert.Equal(t, "[REDACTED]", headers["X-Api-Key"]) + assert.Equal(t, "application/json", headers["Content-Type"]) + assert.Equal(t, "safe-value", headers["X-Custom"]) + }) + + t.Run("handles mixed case header names", func(t *testing.T) { + headers := map[string]string{ + "authorization": "Bearer token", + "COOKIE": "session=abc", + "x-api-key": "key123", + } + + redactHeaders(headers) + + assert.Equal(t, "[REDACTED]", headers["authorization"]) + assert.Equal(t, "[REDACTED]", headers["COOKIE"]) + assert.Equal(t, "[REDACTED]", headers["x-api-key"]) + }) + + t.Run("handles empty headers", func(t *testing.T) { + headers := map[string]string{} + redactHeaders(headers) + assert.Empty(t, headers) + }) +} + +func TestMetricsMonitor_WrapHandler_Capture(t *testing.T) { + t.Run("captures request and response when enabled", func(t *testing.T) { + mm := newMetricsMonitor(testLogger, 10, 5) + + requestBody := `{"model": "test", "prompt": "hello"}` + responseBody := `{"usage": {"prompt_tokens": 100, "completion_tokens": 50}}` + + nextHandler := func(modelID string, w http.ResponseWriter, r *http.Request) error { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Custom", "header-value") + w.WriteHeader(http.StatusOK) + w.Write([]byte(responseBody)) + return nil + } + + req := httptest.NewRequest("POST", "/test", bytes.NewBufferString(requestBody)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer secret") + rec := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(rec) + + err := mm.wrapHandler("test-model", ginCtx.Writer, req, nextHandler) + assert.NoError(t, err) + + // Check metric was recorded + metrics := mm.getMetrics() + assert.Equal(t, 1, len(metrics)) + metricID := metrics[0].ID + + // Check capture was stored with same ID + capture := mm.getCaptureByID(metricID) + assert.NotNil(t, capture) + assert.Equal(t, metricID, capture.ID) + assert.Equal(t, []byte(requestBody), capture.ReqBody) + assert.Equal(t, []byte(responseBody), capture.RespBody) + assert.Equal(t, "/test", capture.ReqPath) + assert.Equal(t, "application/json", capture.ReqHeaders["Content-Type"]) + assert.Equal(t, "[REDACTED]", capture.ReqHeaders["Authorization"]) + assert.Equal(t, "application/json", capture.RespHeaders["Content-Type"]) + assert.Equal(t, "header-value", capture.RespHeaders["X-Custom"]) + }) + + t.Run("does not capture when disabled", func(t *testing.T) { + mm := newMetricsMonitor(testLogger, 10, 0) + + requestBody := `{"model": "test"}` + responseBody := `{"usage": {"prompt_tokens": 100, "completion_tokens": 50}}` + + nextHandler := func(modelID string, w http.ResponseWriter, r *http.Request) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(responseBody)) + return nil + } + + req := httptest.NewRequest("POST", "/test", bytes.NewBufferString(requestBody)) + rec := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(rec) + + err := mm.wrapHandler("test-model", ginCtx.Writer, req, nextHandler) + assert.NoError(t, err) + + // Metrics should still be recorded + metrics := mm.getMetrics() + assert.Equal(t, 1, len(metrics)) + + // But no capture + capture := mm.getCaptureByID(metrics[0].ID) + assert.Nil(t, capture) + }) +} diff --git a/proxy/peerproxy.go b/proxy/peerproxy.go new file mode 100644 index 00000000..e2032d91 --- /dev/null +++ b/proxy/peerproxy.go @@ -0,0 +1,141 @@ +package proxy + +import ( + "fmt" + "net" + "net/http" + "net/http/httputil" + "runtime" + "sort" + "strings" + "time" + + "github.com/napmany/llmsnap/proxy/config" +) + +type peerProxyMember struct { + peerID string + reverseProxy *httputil.ReverseProxy + apiKey string +} + +type PeerProxy struct { + peers config.PeerDictionaryConfig + proxyMap map[string]*peerProxyMember +} + +func NewPeerProxy(peers config.PeerDictionaryConfig, proxyLogger *LogMonitor) (*PeerProxy, error) { + proxyMap := make(map[string]*peerProxyMember) + + // Sort peer IDs for consistent iteration order + peerIDs := make([]string, 0, len(peers)) + for peerID := range peers { + peerIDs = append(peerIDs, peerID) + } + sort.Strings(peerIDs) + + // Create a shared transport with reasonable timeouts for peer connections + // these can be tuned with feedback later + peerTransport := &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, // Connection timeout + KeepAlive: 30 * time.Second, + }).DialContext, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 60 * time.Second, // Time to wait for response headers + ExpectContinueTimeout: 1 * time.Second, + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + } + + for _, peerID := range peerIDs { + peer := peers[peerID] + // Create reverse proxy for this peer + reverseProxy := httputil.NewSingleHostReverseProxy(peer.ProxyURL) + reverseProxy.Transport = peerTransport + + // Wrap Director to set Host header for remote hosts (not localhost) + originalDirector := reverseProxy.Director + reverseProxy.Director = func(req *http.Request) { + originalDirector(req) + // Ensure Host header matches target URL for remote proxying + req.Host = req.URL.Host + } + + reverseProxy.ModifyResponse = func(resp *http.Response) error { + if strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/event-stream") { + resp.Header.Set("X-Accel-Buffering", "no") + } + return nil + } + + reverseProxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + proxyLogger.Warnf("peer %s: proxy error: %v", peerID, err) + errMsg := fmt.Sprintf("peer proxy error: %v", err) + if runtime.GOOS == "darwin" && strings.Contains(err.Error(), "connect: no route to host") { + errMsg += " (hint: on macOS, check System Settings > Privacy & Security > Local Network permissions)" + } + http.Error(w, errMsg, http.StatusBadGateway) + } + + pp := &peerProxyMember{ + peerID: peerID, + reverseProxy: reverseProxy, + apiKey: peer.ApiKey, + } + + // Map each model to this peer's proxy + for _, modelID := range peer.Models { + if _, found := proxyMap[modelID]; found { + proxyLogger.Warnf("peer %s: model %s already mapped to another peer, skipping", peerID, modelID) + continue + } + proxyMap[modelID] = pp + } + } + + return &PeerProxy{ + peers: peers, + proxyMap: proxyMap, + }, nil +} + +func (p *PeerProxy) HasPeerModel(modelID string) bool { + _, found := p.proxyMap[modelID] + return found +} + +// GetPeerFilters returns the filters for a peer model, or empty filters if not found +func (p *PeerProxy) GetPeerFilters(modelID string) config.Filters { + pp, found := p.proxyMap[modelID] + if !found { + return config.Filters{} + } + // Get the peer config using the peerID + peer, found := p.peers[pp.peerID] + if !found { + return config.Filters{} + } + return peer.Filters +} + +func (p *PeerProxy) ListPeers() config.PeerDictionaryConfig { + return p.peers +} + +func (p *PeerProxy) ProxyRequest(model_id string, writer http.ResponseWriter, request *http.Request) error { + pp, found := p.proxyMap[model_id] + if !found { + return fmt.Errorf("no peer proxy found for model %s", model_id) + } + + // Inject API key if configured for this peer + if pp.apiKey != "" { + request.Header.Set("Authorization", "Bearer "+pp.apiKey) + request.Header.Set("x-api-key", pp.apiKey) + } + + pp.reverseProxy.ServeHTTP(writer, request) + return nil +} diff --git a/proxy/peerproxy_test.go b/proxy/peerproxy_test.go new file mode 100644 index 00000000..6b15480f --- /dev/null +++ b/proxy/peerproxy_test.go @@ -0,0 +1,268 @@ +package proxy + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/napmany/llmsnap/proxy/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewPeerProxy_EmptyPeers(t *testing.T) { + peers := config.PeerDictionaryConfig{} + pm, err := NewPeerProxy(peers, testLogger) + require.NoError(t, err) + assert.NotNil(t, pm) + assert.Empty(t, pm.proxyMap) +} + +func TestNewPeerProxy_SinglePeer(t *testing.T) { + proxyURL, _ := url.Parse("http://peer1.example.com:8080") + peers := config.PeerDictionaryConfig{ + "peer1": config.PeerConfig{ + Proxy: "http://peer1.example.com:8080", + ProxyURL: proxyURL, + ApiKey: "test-key", + Models: []string{"model-a", "model-b"}, + }, + } + + pm, err := NewPeerProxy(peers, testLogger) + require.NoError(t, err) + assert.Len(t, pm.proxyMap, 2) + assert.True(t, pm.HasPeerModel("model-a")) + assert.True(t, pm.HasPeerModel("model-b")) + assert.False(t, pm.HasPeerModel("model-c")) +} + +func TestNewPeerProxy_MultiplePeers(t *testing.T) { + proxyURL1, _ := url.Parse("http://peer1.example.com:8080") + proxyURL2, _ := url.Parse("http://peer2.example.com:8080") + peers := config.PeerDictionaryConfig{ + "peer1": config.PeerConfig{ + Proxy: "http://peer1.example.com:8080", + ProxyURL: proxyURL1, + Models: []string{"model-a", "model-b"}, + }, + "peer2": config.PeerConfig{ + Proxy: "http://peer2.example.com:8080", + ProxyURL: proxyURL2, + Models: []string{"model-c", "model-d"}, + }, + } + + pm, err := NewPeerProxy(peers, testLogger) + require.NoError(t, err) + assert.Len(t, pm.proxyMap, 4) + assert.True(t, pm.HasPeerModel("model-a")) + assert.True(t, pm.HasPeerModel("model-b")) + assert.True(t, pm.HasPeerModel("model-c")) + assert.True(t, pm.HasPeerModel("model-d")) +} + +func TestNewPeerProxy_DuplicateModelWarning(t *testing.T) { + // When the same model is in multiple peers, only the first (lexicographically by peer ID) + // should be mapped, and a warning should be logged + proxyURL1, _ := url.Parse("http://peer1.example.com:8080") + proxyURL2, _ := url.Parse("http://peer2.example.com:8080") + peers := config.PeerDictionaryConfig{ + "alpha-peer": config.PeerConfig{ + Proxy: "http://peer1.example.com:8080", + ProxyURL: proxyURL1, + Models: []string{"duplicate-model"}, + }, + "beta-peer": config.PeerConfig{ + Proxy: "http://peer2.example.com:8080", + ProxyURL: proxyURL2, + Models: []string{"duplicate-model"}, + }, + } + + pm, err := NewPeerProxy(peers, testLogger) + require.NoError(t, err) + // Should only have one entry for the duplicate model + assert.Len(t, pm.proxyMap, 1) + assert.True(t, pm.HasPeerModel("duplicate-model")) +} + +func TestHasPeerModel(t *testing.T) { + proxyURL, _ := url.Parse("http://peer1.example.com:8080") + peers := config.PeerDictionaryConfig{ + "peer1": config.PeerConfig{ + Proxy: "http://peer1.example.com:8080", + ProxyURL: proxyURL, + Models: []string{"existing-model"}, + }, + } + + pm, err := NewPeerProxy(peers, testLogger) + require.NoError(t, err) + + assert.True(t, pm.HasPeerModel("existing-model")) + assert.False(t, pm.HasPeerModel("non-existing-model")) +} + +func TestProxyRequest_ModelNotFound(t *testing.T) { + peers := config.PeerDictionaryConfig{} + pm, err := NewPeerProxy(peers, testLogger) + require.NoError(t, err) + + req := httptest.NewRequest("POST", "/v1/chat/completions", nil) + w := httptest.NewRecorder() + + err = pm.ProxyRequest("non-existing-model", w, req) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no peer proxy found for model non-existing-model") +} + +func TestProxyRequest_Success(t *testing.T) { + // Create a test server to act as the peer + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("response from peer")) + })) + defer testServer.Close() + + proxyURL, _ := url.Parse(testServer.URL) + peers := config.PeerDictionaryConfig{ + "peer1": config.PeerConfig{ + Proxy: testServer.URL, + ProxyURL: proxyURL, + Models: []string{"test-model"}, + }, + } + + pm, err := NewPeerProxy(peers, testLogger) + require.NoError(t, err) + + req := httptest.NewRequest("POST", "/v1/chat/completions", nil) + w := httptest.NewRecorder() + + err = pm.ProxyRequest("test-model", w, req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "response from peer", w.Body.String()) +} + +func TestProxyRequest_ApiKeyInjection(t *testing.T) { + // Create a test server that checks for the Authorization header + var receivedAuthHeader string + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAuthHeader = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + })) + defer testServer.Close() + + proxyURL, _ := url.Parse(testServer.URL) + peers := config.PeerDictionaryConfig{ + "peer1": config.PeerConfig{ + Proxy: testServer.URL, + ProxyURL: proxyURL, + ApiKey: "secret-api-key", + Models: []string{"test-model"}, + }, + } + + pm, err := NewPeerProxy(peers, testLogger) + require.NoError(t, err) + + req := httptest.NewRequest("POST", "/v1/chat/completions", nil) + w := httptest.NewRecorder() + + err = pm.ProxyRequest("test-model", w, req) + assert.NoError(t, err) + assert.Equal(t, "Bearer secret-api-key", receivedAuthHeader) +} + +func TestProxyRequest_NoApiKey(t *testing.T) { + // Create a test server that checks for the Authorization header + var receivedAuthHeader string + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAuthHeader = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + })) + defer testServer.Close() + + proxyURL, _ := url.Parse(testServer.URL) + peers := config.PeerDictionaryConfig{ + "peer1": config.PeerConfig{ + Proxy: testServer.URL, + ProxyURL: proxyURL, + ApiKey: "", // No API key + Models: []string{"test-model"}, + }, + } + + pm, err := NewPeerProxy(peers, testLogger) + require.NoError(t, err) + + req := httptest.NewRequest("POST", "/v1/chat/completions", nil) + w := httptest.NewRecorder() + + err = pm.ProxyRequest("test-model", w, req) + assert.NoError(t, err) + assert.Empty(t, receivedAuthHeader) +} + +func TestProxyRequest_HostHeaderSet(t *testing.T) { + // Create a test server that checks the Host header + var receivedHost string + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedHost = r.Host + w.WriteHeader(http.StatusOK) + })) + defer testServer.Close() + + proxyURL, _ := url.Parse(testServer.URL) + peers := config.PeerDictionaryConfig{ + "peer1": config.PeerConfig{ + Proxy: testServer.URL, + ProxyURL: proxyURL, + Models: []string{"test-model"}, + }, + } + + pm, err := NewPeerProxy(peers, testLogger) + require.NoError(t, err) + + req := httptest.NewRequest("POST", "/v1/chat/completions", nil) + w := httptest.NewRecorder() + + err = pm.ProxyRequest("test-model", w, req) + assert.NoError(t, err) + // The Host header should be set to the target URL's host + assert.True(t, strings.HasPrefix(receivedHost, "127.0.0.1:")) +} + +func TestProxyRequest_SSEHeaderModification(t *testing.T) { + // Create a test server that returns SSE content type + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + })) + defer testServer.Close() + + proxyURL, _ := url.Parse(testServer.URL) + peers := config.PeerDictionaryConfig{ + "peer1": config.PeerConfig{ + Proxy: testServer.URL, + ProxyURL: proxyURL, + Models: []string{"test-model"}, + }, + } + + pm, err := NewPeerProxy(peers, testLogger) + require.NoError(t, err) + + req := httptest.NewRequest("POST", "/v1/chat/completions", nil) + w := httptest.NewRecorder() + + err = pm.ProxyRequest("test-model", w, req) + assert.NoError(t, err) + // The X-Accel-Buffering header should be set to "no" for SSE + assert.Equal(t, "no", w.Header().Get("X-Accel-Buffering")) +} diff --git a/proxy/process_test.go b/proxy/process_test.go index c8700af2..702baa93 100644 --- a/proxy/process_test.go +++ b/proxy/process_test.go @@ -407,6 +407,10 @@ func TestProcess_StopImmediately(t *testing.T) { // Test that SIGKILL is sent when gracefulStopTimeout is reached and properly terminates // the upstream command func TestProcess_ForceStopWithKill(t *testing.T) { + if testing.Short() { + t.Skip("skipping slow test") + } + if runtime.GOOS == "windows" { t.Skip("skipping SIGTERM test on Windows ") } diff --git a/proxy/processgroup_test.go b/proxy/processgroup_test.go index 1de6d5eb..9dce2268 100644 --- a/proxy/processgroup_test.go +++ b/proxy/processgroup_test.go @@ -49,6 +49,10 @@ func TestProcessGroup_HasMember(t *testing.T) { // TestProcessGroup_ProxyRequestSwapIsTrueParallel tests that when swap is true // and multiple requests are made in parallel, only one process is running at a time. func TestProcessGroup_ProxyRequestSwapIsTrueParallel(t *testing.T) { + if testing.Short() { + t.Skip("skipping slow test") + } + var processGroupTestConfig = config.AddDefaultGroupToConfig(config.Config{ HealthCheckTimeout: 15, Models: map[string]config.ModelConfig{ diff --git a/proxy/proxymanager.go b/proxy/proxymanager.go index 22239c1f..910c0411 100644 --- a/proxy/proxymanager.go +++ b/proxy/proxymanager.go @@ -3,6 +3,7 @@ package proxy import ( "bytes" "context" + "encoding/base64" "fmt" "io" "mime/multipart" @@ -50,6 +51,9 @@ type ProxyManager struct { buildDate string commit string version string + + // peer proxy see: #296, #433 + peerProxy *PeerProxy } func New(proxyConfig config.Config) *ProxyManager { @@ -133,6 +137,12 @@ func New(proxyConfig config.Config) *ProxyManager { maxMetrics = proxyConfig.MetricsMaxInMemory } + peerProxy, err := NewPeerProxy(proxyConfig.Peers, proxyLogger) + if err != nil { + proxyLogger.Errorf("Disabling Peering. Failed to create proxy peers: %v", err) + peerProxy = nil + } + pm := &ProxyManager{ config: proxyConfig, ginEngine: gin.New(), @@ -141,7 +151,7 @@ func New(proxyConfig config.Config) *ProxyManager { muxLogger: muxLogger, upstreamLogger: upstreamLogger, - metricsMonitor: newMetricsMonitor(proxyLogger, maxMetrics), + metricsMonitor: newMetricsMonitor(proxyLogger, maxMetrics, proxyConfig.CaptureBuffer), processGroups: make(map[string]*ProcessGroup), @@ -151,6 +161,8 @@ func New(proxyConfig config.Config) *ProxyManager { buildDate: "unknown", commit: "abcd1234", version: "0", + + peerProxy: peerProxy, } // create the process groups @@ -166,22 +178,29 @@ func New(proxyConfig config.Config) *ProxyManager { // do it in the background, don't block startup -- not sure if good idea yet go func() { discardWriter := &DiscardWriter{} - for _, realModelName := range proxyConfig.Hooks.OnStartup.Preload { - proxyLogger.Infof("Preloading model: %s", realModelName) - processGroup, _, err := pm.swapProcessGroup(realModelName) + for _, preloadModelName := range proxyConfig.Hooks.OnStartup.Preload { + modelID, ok := proxyConfig.RealModelName(preloadModelName) + + if !ok { + proxyLogger.Warnf("Preload model %s not found in config", preloadModelName) + continue + } + + proxyLogger.Infof("Preloading model: %s", modelID) + processGroup, err := pm.swapProcessGroup(modelID) if err != nil { event.Emit(ModelPreloadedEvent{ - ModelName: realModelName, + ModelName: modelID, Success: false, }) - proxyLogger.Errorf("Failed to preload model %s: %v", realModelName, err) + proxyLogger.Errorf("Failed to preload model %s: %v", modelID, err) continue } else { req, _ := http.NewRequest("GET", "/", nil) - processGroup.ProxyRequest(realModelName, discardWriter, req) + processGroup.ProxyRequest(modelID, discardWriter, req) event.Emit(ModelPreloadedEvent{ - ModelName: realModelName, + ModelName: modelID, Success: true, }) } @@ -256,37 +275,45 @@ func (pm *ProxyManager) setupGinEngine() { }) // Set up routes using the Gin engine - pm.ginEngine.POST("/v1/chat/completions", pm.proxyInferenceHandler) + // Protected routes use pm.apiKeyAuth() middleware + pm.ginEngine.POST("/v1/chat/completions", pm.apiKeyAuth(), pm.proxyInferenceHandler) + pm.ginEngine.POST("/v1/responses", pm.apiKeyAuth(), pm.proxyInferenceHandler) // Support legacy /v1/completions api, see issue #12 - pm.ginEngine.POST("/v1/completions", pm.proxyInferenceHandler) + pm.ginEngine.POST("/v1/completions", pm.apiKeyAuth(), pm.proxyInferenceHandler) // Support anthropic /v1/messages (added https://github.com/ggml-org/llama.cpp/pull/17570) - pm.ginEngine.POST("/v1/messages", pm.proxyInferenceHandler) + pm.ginEngine.POST("/v1/messages", pm.apiKeyAuth(), pm.proxyInferenceHandler) + // Support anthropic count_tokens API (Also added in the above PR) + pm.ginEngine.POST("/v1/messages/count_tokens", pm.apiKeyAuth(), pm.proxyInferenceHandler) // Support embeddings and reranking - pm.ginEngine.POST("/v1/embeddings", pm.proxyInferenceHandler) + pm.ginEngine.POST("/v1/embeddings", pm.apiKeyAuth(), pm.proxyInferenceHandler) // llama-server's /reranking endpoint + aliases - pm.ginEngine.POST("/reranking", pm.proxyInferenceHandler) - pm.ginEngine.POST("/rerank", pm.proxyInferenceHandler) - pm.ginEngine.POST("/v1/rerank", pm.proxyInferenceHandler) - pm.ginEngine.POST("/v1/reranking", pm.proxyInferenceHandler) + pm.ginEngine.POST("/reranking", pm.apiKeyAuth(), pm.proxyInferenceHandler) + pm.ginEngine.POST("/rerank", pm.apiKeyAuth(), pm.proxyInferenceHandler) + pm.ginEngine.POST("/v1/rerank", pm.apiKeyAuth(), pm.proxyInferenceHandler) + pm.ginEngine.POST("/v1/reranking", pm.apiKeyAuth(), pm.proxyInferenceHandler) // llama-server's /infill endpoint for code infilling - pm.ginEngine.POST("/infill", pm.proxyInferenceHandler) + pm.ginEngine.POST("/infill", pm.apiKeyAuth(), pm.proxyInferenceHandler) // llama-server's /completion endpoint - pm.ginEngine.POST("/completion", pm.proxyInferenceHandler) + pm.ginEngine.POST("/completion", pm.apiKeyAuth(), pm.proxyInferenceHandler) // Support audio/speech endpoint - pm.ginEngine.POST("/v1/audio/speech", pm.proxyInferenceHandler) - pm.ginEngine.POST("/v1/audio/transcriptions", pm.proxyOAIPostFormHandler) + pm.ginEngine.POST("/v1/audio/speech", pm.apiKeyAuth(), pm.proxyInferenceHandler) + pm.ginEngine.POST("/v1/audio/voices", pm.apiKeyAuth(), pm.proxyInferenceHandler) + pm.ginEngine.GET("/v1/audio/voices", pm.apiKeyAuth(), pm.proxyGETModelHandler) + pm.ginEngine.POST("/v1/audio/transcriptions", pm.apiKeyAuth(), pm.proxyOAIPostFormHandler) + pm.ginEngine.POST("/v1/images/generations", pm.apiKeyAuth(), pm.proxyInferenceHandler) + pm.ginEngine.POST("/v1/images/edits", pm.apiKeyAuth(), pm.proxyOAIPostFormHandler) - pm.ginEngine.GET("/v1/models", pm.listModelsHandler) + pm.ginEngine.GET("/v1/models", pm.apiKeyAuth(), pm.listModelsHandler) // in proxymanager_loghandlers.go - pm.ginEngine.GET("/logs", pm.sendLogsHandlers) - pm.ginEngine.GET("/logs/stream", pm.streamLogsHandler) - pm.ginEngine.GET("/logs/stream/*logMonitorID", pm.streamLogsHandler) + pm.ginEngine.GET("/logs", pm.apiKeyAuth(), pm.sendLogsHandlers) + pm.ginEngine.GET("/logs/stream", pm.apiKeyAuth(), pm.streamLogsHandler) + pm.ginEngine.GET("/logs/stream/*logMonitorID", pm.apiKeyAuth(), pm.streamLogsHandler) /** * User Interface Endpoints @@ -298,9 +325,9 @@ func (pm *ProxyManager) setupGinEngine() { pm.ginEngine.GET("/upstream", func(c *gin.Context) { c.Redirect(http.StatusFound, "/ui/models") }) - pm.ginEngine.Any("/upstream/*upstreamPath", pm.proxyToUpstream) - pm.ginEngine.GET("/unload", pm.unloadAllModelsHandler) - pm.ginEngine.GET("/running", pm.listRunningProcessesHandler) + pm.ginEngine.Any("/upstream/*upstreamPath", pm.apiKeyAuth(), pm.proxyToUpstream) + pm.ginEngine.GET("/unload", pm.apiKeyAuth(), pm.unloadAllModelsHandler) + pm.ginEngine.GET("/running", pm.apiKeyAuth(), pm.listRunningProcessesHandler) pm.ginEngine.GET("/health", func(c *gin.Context) { c.String(http.StatusOK, "OK") }) @@ -322,25 +349,35 @@ func (pm *ProxyManager) setupGinEngine() { if err != nil { pm.proxyLogger.Errorf("Failed to load React filesystem: %v", err) } else { + // Serve files with compression support under /ui/* + // This handler checks for pre-compressed .br and .gz files + pm.ginEngine.GET("/ui/*filepath", func(c *gin.Context) { + filepath := strings.TrimPrefix(c.Param("filepath"), "/") + // Default to index.html for directory-like paths + if filepath == "" { + filepath = "index.html" + } - // serve files that exist under /ui/* - pm.ginEngine.StaticFS("/ui", reactFS) + ServeCompressedFile(reactFS, c.Writer, c.Request, filepath) + }) - // server SPA for UI under /ui/* + // Serve SPA for UI under /ui/* - fallback to index.html for client-side routing pm.ginEngine.NoRoute(func(c *gin.Context) { if !strings.HasPrefix(c.Request.URL.Path, "/ui") { c.AbortWithStatus(http.StatusNotFound) return } - file, err := reactFS.Open("index.html") - if err != nil { - c.String(http.StatusInternalServerError, err.Error()) + // Check if this looks like a file request (has extension) + path := c.Request.URL.Path + if strings.Contains(path, ".") && !strings.HasSuffix(path, "/") { + // This was likely a file request that wasn't found + c.AbortWithStatus(http.StatusNotFound) return } - defer file.Close() - http.ServeContent(c.Writer, c.Request, "index.html", time.Now(), file) + // Serve index.html for SPA routing + ServeCompressedFile(reactFS, c.Writer, c.Request, "index.html") }) } @@ -398,16 +435,10 @@ func (pm *ProxyManager) Shutdown() { pm.shutdownCancel() } -func (pm *ProxyManager) swapProcessGroup(requestedModel string) (*ProcessGroup, string, error) { - // de-alias the real model name and get a real one - realModelName, found := pm.config.RealModelName(requestedModel) - if !found { - return nil, realModelName, fmt.Errorf("could not find real modelID for %s", requestedModel) - } - +func (pm *ProxyManager) swapProcessGroup(realModelName string) (*ProcessGroup, error) { processGroup := pm.findGroupByModelName(realModelName) if processGroup == nil { - return nil, realModelName, fmt.Errorf("could not find process group for model %s", requestedModel) + return nil, fmt.Errorf("could not find process group for model %s", realModelName) } if processGroup.exclusive { @@ -419,54 +450,71 @@ func (pm *ProxyManager) swapProcessGroup(requestedModel string) (*ProcessGroup, } } - return processGroup, realModelName, nil + return processGroup, nil } func (pm *ProxyManager) listModelsHandler(c *gin.Context) { data := make([]gin.H, 0, len(pm.config.Models)) createdTime := time.Now().Unix() - for id, modelConfig := range pm.config.Models { - if modelConfig.Unlisted { - continue + newRecord := func(modelId string, modelConfig config.ModelConfig) gin.H { + record := gin.H{ + "id": modelId, + "object": "model", + "created": createdTime, + "owned_by": "llmsnap", } - newRecord := func(modelId string) gin.H { - record := gin.H{ - "id": modelId, - "object": "model", - "created": createdTime, - "owned_by": "llmsnap", - } + if name := strings.TrimSpace(modelConfig.Name); name != "" { + record["name"] = name + } + if desc := strings.TrimSpace(modelConfig.Description); desc != "" { + record["description"] = desc + } - if name := strings.TrimSpace(modelConfig.Name); name != "" { - record["name"] = name - } - if desc := strings.TrimSpace(modelConfig.Description); desc != "" { - record["description"] = desc + // Add metadata if present + if len(modelConfig.Metadata) > 0 { + record["meta"] = gin.H{ + "llamaswap": modelConfig.Metadata, } + } + return record + } - // Add metadata if present - if len(modelConfig.Metadata) > 0 { - record["meta"] = gin.H{ - "llamaswap": modelConfig.Metadata, - } - } - return record + for id, modelConfig := range pm.config.Models { + if modelConfig.Unlisted { + continue } - data = append(data, newRecord(id)) + data = append(data, newRecord(id, modelConfig)) // Include aliases if pm.config.IncludeAliasesInList { for _, alias := range modelConfig.Aliases { if alias := strings.TrimSpace(alias); alias != "" { - data = append(data, newRecord(alias)) + data = append(data, newRecord(alias, modelConfig)) } } } } + if pm.peerProxy != nil { + for peerID, peer := range pm.peerProxy.ListPeers() { + // add peer models + for _, modelID := range peer.Models { + // Skip unlisted models if not showing them + record := newRecord(modelID, config.ModelConfig{ + Name: fmt.Sprintf("%s: %s", peerID, modelID), + Metadata: map[string]any{ + "peerID": peerID, + }, + }) + + data = append(data, record) + } + } + } + // Sort by the "id" key sort.Slice(data, func(i, j int) bool { si, _ := data[i]["id"].(string) @@ -505,8 +553,8 @@ func (pm *ProxyManager) findModelInPath(path string) (searchName string, realNam searchModelName = searchModelName + "/" + part } - if real, ok := pm.config.RealModelName(searchModelName); ok { - return searchModelName, real, "/" + strings.Join(parts[i+1:], "/"), true + if modelID, ok := pm.config.RealModelName(searchModelName); ok { + return searchModelName, modelID, "/" + strings.Join(parts[i+1:], "/"), true } } @@ -516,23 +564,22 @@ func (pm *ProxyManager) findModelInPath(path string) (searchName string, realNam func (pm *ProxyManager) proxyToUpstream(c *gin.Context) { upstreamPath := c.Param("upstreamPath") - searchModelName, modelName, remainingPath, modelFound := pm.findModelInPath(upstreamPath) + searchModelName, modelID, remainingPath, modelFound := pm.findModelInPath(upstreamPath) if !modelFound { pm.sendErrorResponse(c, http.StatusBadRequest, "model id required in path") return } - // Check if this is exactly a model name with no additional path - // and doesn't end with a trailing slash + // Redirect /upstream/modelname to /upstream/modelname/ for URL consistency. + // This ensures relative URLs in upstream responses resolve correctly and + // provides canonical URL form. Uses 308 for POST/PUT/etc to preserve the + // HTTP method (301 would downgrade to GET). if remainingPath == "/" && !strings.HasSuffix(upstreamPath, "/") { - // Build new URL with query parameters preserved newPath := "/upstream/" + searchModelName + "/" if c.Request.URL.RawQuery != "" { newPath += "?" + c.Request.URL.RawQuery } - - // Use 308 for non-GET/HEAD requests to preserve method if c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead { c.Redirect(http.StatusMovedPermanently, newPath) } else { @@ -541,7 +588,7 @@ func (pm *ProxyManager) proxyToUpstream(c *gin.Context) { return } - processGroup, realModelName, err := pm.swapProcessGroup(modelName) + processGroup, err := pm.swapProcessGroup(modelID) if err != nil { pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error swapping process group: %s", err.Error())) return @@ -553,15 +600,15 @@ func (pm *ProxyManager) proxyToUpstream(c *gin.Context) { // attempt to record metrics if it is a POST request if pm.metricsMonitor != nil && c.Request.Method == "POST" { - if err := pm.metricsMonitor.wrapHandler(realModelName, c.Writer, c.Request, processGroup.ProxyRequest); err != nil { + if err := pm.metricsMonitor.wrapHandler(modelID, c.Writer, c.Request, processGroup.ProxyRequest); err != nil { pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error proxying metrics wrapped request: %s", err.Error())) - pm.proxyLogger.Errorf("Error proxying wrapped upstream request for model %s, path=%s", realModelName, originalPath) + pm.proxyLogger.Errorf("Error proxying wrapped upstream request for model %s, path=%s", modelID, originalPath) return } } else { - if err := processGroup.ProxyRequest(realModelName, c.Writer, c.Request); err != nil { + if err := processGroup.ProxyRequest(modelID, c.Writer, c.Request); err != nil { pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error proxying request: %s", err.Error())) - pm.proxyLogger.Errorf("Error proxying upstream request for model %s, path=%s", realModelName, originalPath) + pm.proxyLogger.Errorf("Error proxying upstream request for model %s, path=%s", modelID, originalPath) return } } @@ -580,41 +627,90 @@ func (pm *ProxyManager) proxyInferenceHandler(c *gin.Context) { return } - realModelName, found := pm.config.RealModelName(requestedModel) - if !found { - pm.sendErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("could not find real modelID for %s", requestedModel)) - return - } + // Look for a matching local model first + var nextHandler func(modelID string, w http.ResponseWriter, r *http.Request) error - processGroup, _, err := pm.swapProcessGroup(realModelName) - if err != nil { - pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error swapping process group: %s", err.Error())) - return - } - - // issue #69 allow custom model names to be sent to upstream - useModelName := pm.config.Models[realModelName].UseModelName - if useModelName != "" { - bodyBytes, err = sjson.SetBytes(bodyBytes, "model", useModelName) + modelID, found := pm.config.RealModelName(requestedModel) + if found { + processGroup, err := pm.swapProcessGroup(modelID) if err != nil { - pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error rewriting model name in JSON: %s", err.Error())) + pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error swapping process group: %s", err.Error())) return } - } - // issue #174 strip parameters from the JSON body - stripParams, err := pm.config.Models[realModelName].Filters.SanitizedStripParams() - if err != nil { // just log it and continue - pm.proxyLogger.Errorf("Error sanitizing strip params string: %s, %s", pm.config.Models[realModelName].Filters.StripParams, err.Error()) - } else { + // issue #69 allow custom model names to be sent to upstream + useModelName := pm.config.Models[modelID].UseModelName + if useModelName != "" { + bodyBytes, err = sjson.SetBytes(bodyBytes, "model", useModelName) + if err != nil { + pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error rewriting model name in JSON: %s", err.Error())) + return + } + } + + // issue #174 strip parameters from the JSON body + stripParams, err := pm.config.Models[modelID].Filters.SanitizedStripParams() + if err != nil { // just log it and continue + pm.proxyLogger.Errorf("Error sanitizing strip params string: %s, %s", pm.config.Models[modelID].Filters.StripParams, err.Error()) + } else { + for _, param := range stripParams { + pm.proxyLogger.Debugf("<%s> stripping param: %s", modelID, param) + bodyBytes, err = sjson.DeleteBytes(bodyBytes, param) + if err != nil { + pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error deleting parameter %s from request", param)) + return + } + } + } + + // issue #453 set/override parameters in the JSON body + setParams, setParamKeys := pm.config.Models[modelID].Filters.SanitizedSetParams() + for _, key := range setParamKeys { + pm.proxyLogger.Debugf("<%s> setting param: %s", modelID, key) + bodyBytes, err = sjson.SetBytes(bodyBytes, key, setParams[key]) + if err != nil { + pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error setting parameter %s in request", key)) + return + } + } + + pm.proxyLogger.Debugf("ProxyManager using local Process for model: %s", requestedModel) + nextHandler = processGroup.ProxyRequest + } else if pm.peerProxy != nil && pm.peerProxy.HasPeerModel(requestedModel) { + pm.proxyLogger.Debugf("ProxyManager using ProxyPeer for model: %s", requestedModel) + modelID = requestedModel + + // issue #453 apply filters for peer requests + peerFilters := pm.peerProxy.GetPeerFilters(requestedModel) + + // Apply stripParams - remove specified parameters from request + stripParams := peerFilters.SanitizedStripParams() for _, param := range stripParams { - pm.proxyLogger.Debugf("<%s> stripping param: %s", realModelName, param) + pm.proxyLogger.Debugf("<%s> stripping param: %s", requestedModel, param) bodyBytes, err = sjson.DeleteBytes(bodyBytes, param) if err != nil { - pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error deleting parameter %s from request", param)) + pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error stripping parameter %s from request", param)) + return + } + } + + // Apply setParams - set/override specified parameters in request + setParams, setParamKeys := peerFilters.SanitizedSetParams() + for _, key := range setParamKeys { + pm.proxyLogger.Debugf("<%s> setting param: %s", requestedModel, key) + bodyBytes, err = sjson.SetBytes(bodyBytes, key, setParams[key]) + if err != nil { + pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error setting parameter %s in request", key)) return } } + + nextHandler = pm.peerProxy.ProxyRequest + } + + if nextHandler == nil { + pm.sendErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("could not find suitable inference handler for %s", requestedModel)) + return } c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) @@ -627,19 +723,19 @@ func (pm *ProxyManager) proxyInferenceHandler(c *gin.Context) { // issue #366 extract values that downstream handlers may need isStreaming := gjson.GetBytes(bodyBytes, "stream").Bool() ctx := context.WithValue(c.Request.Context(), proxyCtxKey("streaming"), isStreaming) - ctx = context.WithValue(ctx, proxyCtxKey("model"), realModelName) + ctx = context.WithValue(ctx, proxyCtxKey("model"), modelID) c.Request = c.Request.WithContext(ctx) if pm.metricsMonitor != nil && c.Request.Method == "POST" { - if err := pm.metricsMonitor.wrapHandler(realModelName, c.Writer, c.Request, processGroup.ProxyRequest); err != nil { + if err := pm.metricsMonitor.wrapHandler(modelID, c.Writer, c.Request, nextHandler); err != nil { pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error proxying metrics wrapped request: %s", err.Error())) - pm.proxyLogger.Errorf("Error Proxying Metrics Wrapped Request for processGroup %s and model %s", processGroup.id, realModelName) + pm.proxyLogger.Errorf("Error Proxying Metrics Wrapped Request model %s", modelID) return } } else { - if err := processGroup.ProxyRequest(realModelName, c.Writer, c.Request); err != nil { + if err := nextHandler(modelID, c.Writer, c.Request); err != nil { pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error proxying request: %s", err.Error())) - pm.proxyLogger.Errorf("Error Proxying Request for processGroup %s and model %s", processGroup.id, realModelName) + pm.proxyLogger.Errorf("Error Proxying Request for model %s", modelID) return } } @@ -659,9 +755,29 @@ func (pm *ProxyManager) proxyOAIPostFormHandler(c *gin.Context) { return } - processGroup, realModelName, err := pm.swapProcessGroup(requestedModel) - if err != nil { - pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error swapping process group: %s", err.Error())) + // Look for a matching local model first, then check peers + var nextHandler func(modelID string, w http.ResponseWriter, r *http.Request) error + var useModelName string + + modelID, found := pm.config.RealModelName(requestedModel) + if found { + processGroup, err := pm.swapProcessGroup(modelID) + if err != nil { + pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error swapping process group: %s", err.Error())) + return + } + + useModelName = pm.config.Models[modelID].UseModelName + pm.proxyLogger.Debugf("ProxyManager using local Process for model: %s", requestedModel) + nextHandler = processGroup.ProxyRequest + } else if pm.peerProxy != nil && pm.peerProxy.HasPeerModel(requestedModel) { + pm.proxyLogger.Debugf("ProxyManager using ProxyPeer for model: %s", requestedModel) + modelID = requestedModel + nextHandler = pm.peerProxy.ProxyRequest + } + + if nextHandler == nil { + pm.sendErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("could not find suitable handler for %s", requestedModel)) return } @@ -677,8 +793,6 @@ func (pm *ProxyManager) proxyOAIPostFormHandler(c *gin.Context) { // If this is the model field and we have a profile, use just the model name if key == "model" { // # issue #69 allow custom model names to be sent to upstream - useModelName := pm.config.Models[realModelName].UseModelName - if useModelName != "" { fieldValue = useModelName } else { @@ -748,9 +862,46 @@ func (pm *ProxyManager) proxyOAIPostFormHandler(c *gin.Context) { modifiedReq.ContentLength = int64(requestBuffer.Len()) // Use the modified request for proxying - if err := processGroup.ProxyRequest(realModelName, c.Writer, modifiedReq); err != nil { + if err := nextHandler(modelID, c.Writer, modifiedReq); err != nil { + pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error proxying request: %s", err.Error())) + pm.proxyLogger.Errorf("Error Proxying Request for model %s", modelID) + return + } +} + +func (pm *ProxyManager) proxyGETModelHandler(c *gin.Context) { + requestedModel := c.Query("model") + if requestedModel == "" { + pm.sendErrorResponse(c, http.StatusBadRequest, "missing required 'model' query parameter") + return + } + + var nextHandler func(modelID string, w http.ResponseWriter, r *http.Request) error + var modelID string + + if realModelID, found := pm.config.RealModelName(requestedModel); found { + processGroup, err := pm.swapProcessGroup(realModelID) + if err != nil { + pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error swapping process group: %s", err.Error())) + return + } + modelID = realModelID + pm.proxyLogger.Debugf("ProxyManager using local Process for model: %s", requestedModel) + nextHandler = processGroup.ProxyRequest + } else if pm.peerProxy != nil && pm.peerProxy.HasPeerModel(requestedModel) { + modelID = requestedModel + pm.proxyLogger.Debugf("ProxyManager using ProxyPeer for model: %s", requestedModel) + nextHandler = pm.peerProxy.ProxyRequest + } + + if nextHandler == nil { + pm.sendErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("could not find suitable handler for %s", requestedModel)) + return + } + + if err := nextHandler(modelID, c.Writer, c.Request); err != nil { pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error proxying request: %s", err.Error())) - pm.proxyLogger.Errorf("Error Proxying Request for processGroup %s and model %s", processGroup.id, realModelName) + pm.proxyLogger.Errorf("Error Proxying GET Request for model %s", modelID) return } } @@ -765,6 +916,67 @@ func (pm *ProxyManager) sendErrorResponse(c *gin.Context, statusCode int, messag } } +// apiKeyAuth returns a middleware that validates API keys if configured. +// Returns a pass-through handler if no API keys are configured. +func (pm *ProxyManager) apiKeyAuth() gin.HandlerFunc { + if len(pm.config.RequiredAPIKeys) == 0 { + return func(c *gin.Context) { c.Next() } + } + + return func(c *gin.Context) { + xApiKey := c.GetHeader("x-api-key") + + var bearerKey string + var basicKey string + if auth := c.GetHeader("Authorization"); auth != "" { + if strings.HasPrefix(auth, "Bearer ") { + bearerKey = strings.TrimPrefix(auth, "Bearer ") + } else if strings.HasPrefix(auth, "Basic ") { + // Basic Auth: base64(username:password), password is the API key + encoded := strings.TrimPrefix(auth, "Basic ") + if decoded, err := base64.StdEncoding.DecodeString(encoded); err == nil { + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) == 2 { + basicKey = parts[1] // password is the API key + } + } + } + } + + // Use first key found: Basic, then Bearer, then x-api-key + var providedKey string + if basicKey != "" { + providedKey = basicKey + } else if bearerKey != "" { + providedKey = bearerKey + } else { + providedKey = xApiKey + } + + // Validate key + valid := false + for _, key := range pm.config.RequiredAPIKeys { + if providedKey == key { + valid = true + break + } + } + + if !valid { + c.Header("WWW-Authenticate", `Basic realm="llmsnap"`) + pm.sendErrorResponse(c, http.StatusUnauthorized, "unauthorized: invalid or missing API key") + c.Abort() + return + } + + // Strip auth headers to prevent leakage to upstream + c.Request.Header.Del("Authorization") + c.Request.Header.Del("x-api-key") + + c.Next() + } +} + func (pm *ProxyManager) unloadAllModelsHandler(c *gin.Context) { pm.StopProcesses(StopImmediately) c.String(http.StatusOK, "OK") @@ -778,8 +990,13 @@ func (pm *ProxyManager) listRunningProcessesHandler(context *gin.Context) { for _, process := range processGroup.processes { if process.CurrentState() == StateReady { runningProcesses = append(runningProcesses, gin.H{ - "model": process.ID, - "state": process.state, + "model": process.ID, + "state": process.state, + "cmd": process.config.Cmd, + "proxy": process.config.Proxy, + "ttl": process.config.UnloadAfter, + "name": process.config.Name, + "description": process.config.Description, }) } } diff --git a/proxy/proxymanager_api.go b/proxy/proxymanager_api.go index fd3b1b56..e3093697 100644 --- a/proxy/proxymanager_api.go +++ b/proxy/proxymanager_api.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "sort" + "strconv" "strings" "github.com/gin-gonic/gin" @@ -19,11 +20,13 @@ type Model struct { State string `json:"state"` Unlisted bool `json:"unlisted"` SleepMode string `json:"sleepMode"` + PeerID string `json:"peerID"` } func addApiHandlers(pm *ProxyManager) { // Add API endpoints for React to consume - apiGroup := pm.ginEngine.Group("/api") + // Protected with API key authentication + apiGroup := pm.ginEngine.Group("/api", pm.apiKeyAuth()) { apiGroup.POST("/models/unload", pm.apiUnloadAllModels) apiGroup.POST("/models/unload/*model", pm.apiUnloadSingleModelHandler) @@ -31,6 +34,7 @@ func addApiHandlers(pm *ProxyManager) { apiGroup.GET("/events", pm.apiSendEvents) apiGroup.GET("/metrics", pm.apiGetMetrics) apiGroup.GET("/version", pm.apiGetVersion) + apiGroup.GET("/captures/:id", pm.apiGetCapture) } } @@ -91,6 +95,18 @@ func (pm *ProxyManager) getModelStatus() []Model { }) } + // Iterate over the peer models + if pm.peerProxy != nil { + for peerID, peer := range pm.peerProxy.ListPeers() { + for _, modelID := range peer.Models { + models = append(models, Model{ + Id: modelID, + PeerID: peerID, + }) + } + } + } + return models } @@ -267,3 +283,20 @@ func (pm *ProxyManager) apiGetVersion(c *gin.Context) { "build_date": pm.buildDate, }) } + +func (pm *ProxyManager) apiGetCapture(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid capture ID"}) + return + } + + capture := pm.metricsMonitor.getCaptureByID(id) + if capture == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "capture not found"}) + return + } + + c.JSON(http.StatusOK, capture) +} diff --git a/proxy/proxymanager_test.go b/proxy/proxymanager_test.go index d13db539..dbc0b46f 100644 --- a/proxy/proxymanager_test.go +++ b/proxy/proxymanager_test.go @@ -3,6 +3,7 @@ package proxy import ( "bytes" "context" + "encoding/base64" "encoding/json" "fmt" "math/rand" @@ -36,10 +37,6 @@ func (r *TestResponseRecorder) CloseNotify() <-chan bool { return r.closeChannel } -func (r *TestResponseRecorder) closeClient() { - r.closeChannel <- true -} - func CreateTestResponseRecorder() *TestResponseRecorder { return &TestResponseRecorder{ httptest.NewRecorder(), @@ -223,17 +220,23 @@ func TestProxyManager_ListModelsHandler(t *testing.T) { model2Config.Name = " " // empty whitespace only strings will get ignored model2Config.Description = " " - config := config.Config{ + cfg := config.Config{ HealthCheckTimeout: 15, Models: map[string]config.ModelConfig{ "model1": model1Config, "model2": model2Config, "model3": getTestSimpleResponderConfig("model3"), }, + Peers: map[string]config.PeerConfig{ + "peer1": { + Proxy: "http://peer1:8080", + Models: []string{"peer-model-a", "peer-model-b"}, + }, + }, LogLevel: "error", } - proxy := New(config) + proxy := New(cfg) // Create a test request req := httptest.NewRequest("GET", "/v1/models", nil) @@ -258,14 +261,16 @@ func TestProxyManager_ListModelsHandler(t *testing.T) { t.Fatalf("Failed to parse JSON response: %v", err) } - // Check the number of models returned - assert.Len(t, response.Data, 3) + // Check the number of models returned (3 local + 2 peer models) + assert.Len(t, response.Data, 5) // Check the details of each model expectedModels := map[string]struct{}{ - "model1": {}, - "model2": {}, - "model3": {}, + "model1": {}, + "model2": {}, + "model3": {}, + "peer-model-a": {}, + "peer-model-b": {}, } // make all models @@ -296,6 +301,19 @@ func TestProxyManager_ListModelsHandler(t *testing.T) { description, ok := model["description"].(string) assert.True(t, ok, "description should be a string") assert.Equal(t, "Model 1 description is used for testing", description) + } else if modelID == "peer-model-a" || modelID == "peer-model-b" { + // Peer models should have meta.llamaswap.peerID + meta, exists := model["meta"] + assert.True(t, exists, "peer model should have meta field") + metaMap, ok := meta.(map[string]interface{}) + assert.True(t, ok, "meta should be a map") + llamaswap, exists := metaMap["llamaswap"] + assert.True(t, exists, "meta should have llamaswap field") + llamaswapMap, ok := llamaswap.(map[string]interface{}) + assert.True(t, ok, "llamaswap should be a map") + peerID, exists := llamaswapMap["peerID"] + assert.True(t, exists, "llamaswap should have peerID field") + assert.Equal(t, "peer1", peerID) } else { _, exists := model["name"] assert.False(t, exists, "unexpected name field for model: %s", modelID) @@ -502,6 +520,10 @@ func TestProxyManager_ListModelsHandler_IncludeAliasesInList(t *testing.T) { } func TestProxyManager_Shutdown(t *testing.T) { + if testing.Short() { + t.Skip("skipping slow test") + } + // make broken model configurations model1Config := getTestSimpleResponderConfigPort("model1", 9991) model1Config.Proxy = "http://localhost:10001/" @@ -650,8 +672,13 @@ func TestProxyManager_RunningEndpoint(t *testing.T) { // Define a helper struct to parse the JSON response. type RunningResponse struct { Running []struct { - Model string `json:"model"` - State string `json:"state"` + Model string `json:"model"` + State string `json:"state"` + Cmd string `json:"cmd"` + Proxy string `json:"proxy"` + TTL int `json:"ttl"` + Name string `json:"name"` + Description string `json:"description"` } `json:"running"` } @@ -699,6 +726,11 @@ func TestProxyManager_RunningEndpoint(t *testing.T) { // Is the model loaded? assert.Equal(t, "ready", response.Running[0].State) + + // Verify extended fields are present + assert.NotEmpty(t, response.Running[0].Cmd, "cmd should be populated") + assert.NotEmpty(t, response.Running[0].Proxy, "proxy should be populated") + assert.Equal(t, 0, response.Running[0].TTL, "ttl should default to 0") }) } @@ -818,6 +850,43 @@ func TestProxyManager_UseModelName(t *testing.T) { }) } +func TestProxyManager_AudioVoicesGETHandler(t *testing.T) { + conf := config.AddDefaultGroupToConfig(config.Config{ + HealthCheckTimeout: 15, + Models: map[string]config.ModelConfig{ + "model1": getTestSimpleResponderConfig("model1"), + }, + LogLevel: "error", + }) + + proxy := New(conf) + defer proxy.StopProcesses(StopWaitForInflightRequest) + + t.Run("successful GET with model query param", func(t *testing.T) { + req := httptest.NewRequest("GET", "/v1/audio/voices?model=model1", nil) + w := CreateTestResponseRecorder() + proxy.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "voice1") + }) + + t.Run("missing model query param returns 400", func(t *testing.T) { + req := httptest.NewRequest("GET", "/v1/audio/voices", nil) + w := CreateTestResponseRecorder() + proxy.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "missing required 'model' query parameter") + }) + + t.Run("unknown model returns 400", func(t *testing.T) { + req := httptest.NewRequest("GET", "/v1/audio/voices?model=nonexistent", nil) + w := CreateTestResponseRecorder() + proxy.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "could not find suitable handler") + }) +} + func TestProxyManager_CORSOptionsHandler(t *testing.T) { config := config.AddDefaultGroupToConfig(config.Config{ HealthCheckTimeout: 15, @@ -944,7 +1013,9 @@ func TestProxyManager_ChatContentLength(t *testing.T) { func TestProxyManager_FiltersStripParams(t *testing.T) { modelConfig := getTestSimpleResponderConfig("model1") modelConfig.Filters = config.ModelFilters{ - StripParams: "temperature, model, stream", + Filters: config.Filters{ + StripParams: "temperature, model, stream", + }, } config := config.AddDefaultGroupToConfig(config.Config{ @@ -1187,3 +1258,349 @@ func TestProxyManager_ApiGetVersion(t *testing.T) { assert.Equal(t, value, response[key], "%s value %s should match response %s", key, value, response[key]) } } + +func TestProxyManager_APIKeyAuth(t *testing.T) { + testConfig := config.AddDefaultGroupToConfig(config.Config{ + HealthCheckTimeout: 15, + Models: map[string]config.ModelConfig{ + "model1": getTestSimpleResponderConfig("model1"), + }, + RequiredAPIKeys: []string{"valid-key-1", "valid-key-2"}, + LogLevel: "error", + }) + + proxy := New(testConfig) + defer proxy.StopProcesses(StopImmediately) + + t.Run("valid key in x-api-key header", func(t *testing.T) { + reqBody := `{"model":"model1"}` + req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewBufferString(reqBody)) + req.Header.Set("x-api-key", "valid-key-1") + w := CreateTestResponseRecorder() + + proxy.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("valid key in Authorization Bearer header", func(t *testing.T) { + reqBody := `{"model":"model1"}` + req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewBufferString(reqBody)) + req.Header.Set("Authorization", "Bearer valid-key-2") + w := CreateTestResponseRecorder() + + proxy.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("both headers with matching keys", func(t *testing.T) { + reqBody := `{"model":"model1"}` + req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewBufferString(reqBody)) + req.Header.Set("x-api-key", "valid-key-1") + req.Header.Set("Authorization", "Bearer valid-key-1") + w := CreateTestResponseRecorder() + + proxy.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("invalid key returns 401", func(t *testing.T) { + reqBody := `{"model":"model1"}` + req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewBufferString(reqBody)) + req.Header.Set("x-api-key", "invalid-key") + w := CreateTestResponseRecorder() + + proxy.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains(t, w.Body.String(), "unauthorized") + }) + + t.Run("missing key returns 401", func(t *testing.T) { + reqBody := `{"model":"model1"}` + req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewBufferString(reqBody)) + w := CreateTestResponseRecorder() + + proxy.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("valid key in Basic Auth header", func(t *testing.T) { + reqBody := `{"model":"model1"}` + req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewBufferString(reqBody)) + // Basic Auth: base64("anyuser:valid-key-1") + credentials := base64.StdEncoding.EncodeToString([]byte("anyuser:valid-key-1")) + req.Header.Set("Authorization", "Basic "+credentials) + w := CreateTestResponseRecorder() + + proxy.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("invalid key in Basic Auth header returns 401", func(t *testing.T) { + reqBody := `{"model":"model1"}` + req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewBufferString(reqBody)) + credentials := base64.StdEncoding.EncodeToString([]byte("anyuser:wrong-key")) + req.Header.Set("Authorization", "Basic "+credentials) + w := CreateTestResponseRecorder() + + proxy.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains(t, w.Body.String(), "unauthorized") + }) + + t.Run("x-api-key and Basic Auth with matching keys", func(t *testing.T) { + reqBody := `{"model":"model1"}` + req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewBufferString(reqBody)) + req.Header.Set("x-api-key", "valid-key-1") + credentials := base64.StdEncoding.EncodeToString([]byte("user:valid-key-1")) + req.Header.Set("Authorization", "Basic "+credentials) + w := CreateTestResponseRecorder() + + proxy.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("401 response includes WWW-Authenticate header", func(t *testing.T) { + reqBody := `{"model":"model1"}` + req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewBufferString(reqBody)) + w := CreateTestResponseRecorder() + + proxy.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Equal(t, `Basic realm="llmsnap"`, w.Header().Get("WWW-Authenticate")) + }) +} + +func TestProxyManager_APIKeyAuth_Disabled(t *testing.T) { + // Config without RequiredAPIKeys - auth should be disabled + testConfig := config.AddDefaultGroupToConfig(config.Config{ + HealthCheckTimeout: 15, + Models: map[string]config.ModelConfig{ + "model1": getTestSimpleResponderConfig("model1"), + }, + LogLevel: "error", + }) + + proxy := New(testConfig) + defer proxy.StopProcesses(StopImmediately) + + t.Run("requests pass without API key when not configured", func(t *testing.T) { + reqBody := `{"model":"model1"}` + req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewBufferString(reqBody)) + w := CreateTestResponseRecorder() + + proxy.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + }) +} + +// TestProxyManager_PeerProxy_InferenceHandler tests the peerProxy integration +// in proxyInferenceHandler for issue #433 +func TestProxyManager_PeerProxy_InferenceHandler(t *testing.T) { + t.Run("requests to peer models are proxied", func(t *testing.T) { + // Create a test server to act as the peer + peerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"response":"from-peer","model":"peer-model"}`)) + })) + defer peerServer.Close() + + // Create config with peers but no local model for "peer-model" + configStr := fmt.Sprintf(` +logLevel: error +peers: + test-peer: + proxy: %s + models: + - peer-model +models: + local-model: + cmd: %s -port ${PORT} -silent -respond local-model +`, peerServer.URL, getSimpleResponderPath()) + + testConfig, err := config.LoadConfigFromReader(strings.NewReader(configStr)) + assert.NoError(t, err) + + proxy := New(testConfig) + defer proxy.StopProcesses(StopImmediately) + + reqBody := `{"model":"peer-model"}` + req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewBufferString(reqBody)) + w := CreateTestResponseRecorder() + + proxy.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "from-peer") + }) + + t.Run("local models take precedence over peer models", func(t *testing.T) { + // Create a test server to act as the peer - should NOT be called + peerCalled := false + peerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + peerCalled = true + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"response":"from-peer"}`)) + })) + defer peerServer.Close() + + // Create config where "shared-model" exists both locally and on peer + configStr := fmt.Sprintf(` +logLevel: error +peers: + test-peer: + proxy: %s + models: + - shared-model +models: + shared-model: + cmd: %s -port ${PORT} -silent -respond local-response +`, peerServer.URL, getSimpleResponderPath()) + + testConfig, err := config.LoadConfigFromReader(strings.NewReader(configStr)) + assert.NoError(t, err) + + proxy := New(testConfig) + defer proxy.StopProcesses(StopImmediately) + + reqBody := `{"model":"shared-model"}` + req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewBufferString(reqBody)) + w := CreateTestResponseRecorder() + + proxy.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "local-response") + assert.False(t, peerCalled, "peer should not be called when local model exists") + }) + + t.Run("unknown model returns error", func(t *testing.T) { + // Create a test server to act as the peer + peerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer peerServer.Close() + + configStr := fmt.Sprintf(` +logLevel: error +peers: + test-peer: + proxy: %s + models: + - peer-model +models: + local-model: + cmd: %s -port ${PORT} -silent -respond local-model +`, peerServer.URL, getSimpleResponderPath()) + + testConfig, err := config.LoadConfigFromReader(strings.NewReader(configStr)) + assert.NoError(t, err) + + proxy := New(testConfig) + defer proxy.StopProcesses(StopImmediately) + + reqBody := `{"model":"unknown-model"}` + req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewBufferString(reqBody)) + w := CreateTestResponseRecorder() + + proxy.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "could not find suitable inference handler") + }) + + t.Run("peer API key is injected into request", func(t *testing.T) { + var receivedAuthHeader string + peerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAuthHeader = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"response":"ok"}`)) + })) + defer peerServer.Close() + + configStr := fmt.Sprintf(` +logLevel: error +peers: + test-peer: + proxy: %s + apiKey: secret-peer-key + models: + - peer-model +models: + local-model: + cmd: %s -port ${PORT} -silent -respond local-model +`, peerServer.URL, getSimpleResponderPath()) + + testConfig, err := config.LoadConfigFromReader(strings.NewReader(configStr)) + assert.NoError(t, err) + + proxy := New(testConfig) + defer proxy.StopProcesses(StopImmediately) + + reqBody := `{"model":"peer-model"}` + req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewBufferString(reqBody)) + w := CreateTestResponseRecorder() + + proxy.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "Bearer secret-peer-key", receivedAuthHeader) + }) + + t.Run("no peers configured - unknown model returns error", func(t *testing.T) { + testConfig := config.AddDefaultGroupToConfig(config.Config{ + HealthCheckTimeout: 15, + Models: map[string]config.ModelConfig{ + "local-model": getTestSimpleResponderConfig("local-model"), + }, + LogLevel: "error", + }) + + proxy := New(testConfig) + defer proxy.StopProcesses(StopImmediately) + + // peerProxy exists but has no peer models configured + assert.False(t, proxy.peerProxy.HasPeerModel("unknown-model")) + + reqBody := `{"model":"unknown-model"}` + req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewBufferString(reqBody)) + w := CreateTestResponseRecorder() + + proxy.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "could not find suitable inference handler") + }) + + t.Run("peer streaming response sets X-Accel-Buffering header", func(t *testing.T) { + peerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + w.Write([]byte("data: test\n\n")) + })) + defer peerServer.Close() + + configStr := fmt.Sprintf(` +logLevel: error +peers: + test-peer: + proxy: %s + models: + - peer-model +models: + local-model: + cmd: %s -port ${PORT} -silent -respond local-model +`, peerServer.URL, getSimpleResponderPath()) + + testConfig, err := config.LoadConfigFromReader(strings.NewReader(configStr)) + assert.NoError(t, err) + + proxy := New(testConfig) + defer proxy.StopProcesses(StopImmediately) + + reqBody := `{"model":"peer-model"}` + req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewBufferString(reqBody)) + w := CreateTestResponseRecorder() + + proxy.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "no", w.Header().Get("X-Accel-Buffering")) + }) +} diff --git a/proxy/ui_compress.go b/proxy/ui_compress.go new file mode 100644 index 00000000..43b36875 --- /dev/null +++ b/proxy/ui_compress.go @@ -0,0 +1,81 @@ +package proxy + +import ( + "net/http" + "strings" +) + +// selectEncoding chooses the best encoding based on Accept-Encoding header +// Returns the encoding ("br", "gzip", or "") and the corresponding file extension +func selectEncoding(acceptEncoding string) (encoding, ext string) { + if acceptEncoding == "" { + return "", "" + } + + for _, part := range strings.Split(acceptEncoding, ",") { + enc := strings.TrimSpace(strings.SplitN(part, ";", 2)[0]) + if enc == "br" { + return "br", ".br" + } + } + + for _, part := range strings.Split(acceptEncoding, ",") { + enc := strings.TrimSpace(strings.SplitN(part, ";", 2)[0]) + if enc == "gzip" { + return "gzip", ".gz" + } + } + + return "", "" +} + +// ServeCompressedFile serves a file with compression support. +// It checks for pre-compressed versions and serves them with proper headers. +func ServeCompressedFile(fs http.FileSystem, w http.ResponseWriter, r *http.Request, name string) { + encoding, ext := selectEncoding(r.Header.Get("Accept-Encoding")) + + // Try to serve compressed version if client supports it + if encoding != "" { + if cf, err := fs.Open(name + ext); err == nil { + defer cf.Close() + + // Verify it's a regular file (not a directory) + if stat, err := cf.Stat(); err == nil && !stat.IsDir() { + // Set the content encoding header + w.Header().Set("Content-Encoding", encoding) + w.Header().Add("Vary", "Accept-Encoding") + + // Get original file info for content type detection + origFile, err := fs.Open(name) + if err == nil { + origFile.Close() + } + + // Serve the compressed file + http.ServeContent(w, r, name, stat.ModTime(), cf) + return + } + } + } + + // Fall back to serving the uncompressed file + file, err := fs.Open(name) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + defer file.Close() + + stat, err := file.Stat() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if stat.IsDir() { + http.Error(w, "is a directory", http.StatusForbidden) + return + } + + http.ServeContent(w, r, name, stat.ModTime(), file) +} diff --git a/proxy/ui_compress_test.go b/proxy/ui_compress_test.go new file mode 100644 index 00000000..27445404 --- /dev/null +++ b/proxy/ui_compress_test.go @@ -0,0 +1,283 @@ +package proxy + +import ( + "bytes" + "compress/gzip" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" + "time" +) + +func TestServeCompressedFile_Brotli(t *testing.T) { + // Create test content + content := []byte("This is test content that should be compressed with brotli") + brContent := []byte("fake-brotli-compressed-data") + + // Create a test filesystem + mapFS := fstest.MapFS{ + "test.js": {Data: content, ModTime: time.Now()}, + "test.js.br": {Data: brContent, ModTime: time.Now()}, + "test.js.gz": {Data: []byte("fake-gzip-data"), ModTime: time.Now()}, + } + fs := http.FS(mapFS) + + req := httptest.NewRequest(http.MethodGet, "/test.js", nil) + req.Header.Set("Accept-Encoding", "br, gzip") + w := httptest.NewRecorder() + + ServeCompressedFile(fs, w, req, "test.js") + + resp := w.Result() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status 200, got %d", resp.StatusCode) + } + + // Check that brotli is used (preferred over gzip) + if encoding := resp.Header.Get("Content-Encoding"); encoding != "br" { + t.Errorf("Expected Content-Encoding 'br', got '%s'", encoding) + } + + if vary := resp.Header.Get("Vary"); vary != "Accept-Encoding" { + t.Errorf("Expected Vary 'Accept-Encoding', got '%s'", vary) + } + + if !bytes.Equal(body, brContent) { + t.Errorf("Expected brotli content, got %s", string(body)) + } +} + +func TestServeCompressedFile_Gzip(t *testing.T) { + // Create test content + content := []byte("This is test content that should be compressed with gzip") + gzContent := []byte("fake-gzip-compressed-data") + + // Create a test filesystem without brotli + mapFS := fstest.MapFS{ + "test.js": {Data: content, ModTime: time.Now()}, + "test.js.gz": {Data: gzContent, ModTime: time.Now()}, + } + fs := http.FS(mapFS) + + req := httptest.NewRequest(http.MethodGet, "/test.js", nil) + req.Header.Set("Accept-Encoding", "gzip") + w := httptest.NewRecorder() + + ServeCompressedFile(fs, w, req, "test.js") + + resp := w.Result() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status 200, got %d", resp.StatusCode) + } + + if encoding := resp.Header.Get("Content-Encoding"); encoding != "gzip" { + t.Errorf("Expected Content-Encoding 'gzip', got '%s'", encoding) + } + + if !bytes.Equal(body, gzContent) { + t.Errorf("Expected gzip content, got %s", string(body)) + } +} + +func TestServeCompressedFile_UncompressedFallback(t *testing.T) { + // Create test content + content := []byte("This is uncompressed test content") + + // Create a test filesystem without compressed versions + mapFS := fstest.MapFS{ + "test.js": {Data: content, ModTime: time.Now()}, + } + fs := http.FS(mapFS) + + req := httptest.NewRequest(http.MethodGet, "/test.js", nil) + req.Header.Set("Accept-Encoding", "br, gzip") + w := httptest.NewRecorder() + + ServeCompressedFile(fs, w, req, "test.js") + + resp := w.Result() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status 200, got %d", resp.StatusCode) + } + + // Should not have Content-Encoding header since we're serving uncompressed + if encoding := resp.Header.Get("Content-Encoding"); encoding != "" { + t.Errorf("Expected no Content-Encoding, got '%s'", encoding) + } + + if !bytes.Equal(body, content) { + t.Errorf("Expected original content, got %s", string(body)) + } +} + +func TestServeCompressedFile_NoAcceptEncoding(t *testing.T) { + // Create test content + content := []byte("This is test content") + + // Create a test filesystem with compressed versions + mapFS := fstest.MapFS{ + "test.js": {Data: content, ModTime: time.Now()}, + "test.js.br": {Data: []byte("brotli"), ModTime: time.Now()}, + "test.js.gz": {Data: []byte("gzip"), ModTime: time.Now()}, + } + fs := http.FS(mapFS) + + req := httptest.NewRequest(http.MethodGet, "/test.js", nil) + // No Accept-Encoding header + w := httptest.NewRecorder() + + ServeCompressedFile(fs, w, req, "test.js") + + resp := w.Result() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status 200, got %d", resp.StatusCode) + } + + // Should serve uncompressed content + if encoding := resp.Header.Get("Content-Encoding"); encoding != "" { + t.Errorf("Expected no Content-Encoding, got '%s'", encoding) + } + + if !bytes.Equal(body, content) { + t.Errorf("Expected original content, got %s", string(body)) + } +} + +func TestServeCompressedFile_NotFound(t *testing.T) { + mapFS := fstest.MapFS{} + fs := http.FS(mapFS) + + req := httptest.NewRequest(http.MethodGet, "/nonexistent.js", nil) + w := httptest.NewRecorder() + + ServeCompressedFile(fs, w, req, "nonexistent.js") + + resp := w.Result() + + if resp.StatusCode != http.StatusNotFound { + t.Errorf("Expected status 404, got %d", resp.StatusCode) + } +} + +func TestSelectEncoding(t *testing.T) { + tests := []struct { + acceptEncoding string + wantEncoding string + wantExt string + }{ + {"br, gzip", "br", ".br"}, + {"gzip, deflate", "gzip", ".gz"}, + {"gzip", "gzip", ".gz"}, + {"br", "br", ".br"}, + {"", "", ""}, + {"deflate", "", ""}, + {"br;q=1.0, gzip;q=0.5", "br", ".br"}, + {"gzip;q=1.0, br;q=0.5", "br", ".br"}, + {"browser", "", ""}, + {"compress, deflate", "", ""}, + } + + for _, tt := range tests { + gotEncoding, gotExt := selectEncoding(tt.acceptEncoding) + if gotEncoding != tt.wantEncoding || gotExt != tt.wantExt { + t.Errorf("selectEncoding(%q) = (%q, %q), want (%q, %q)", + tt.acceptEncoding, gotEncoding, gotExt, tt.wantEncoding, tt.wantExt) + } + } +} + +// Test with actual pre-compressed files from ui_dist +func TestServeCompressedFile_RealFiles(t *testing.T) { + // Check if ui_dist exists + if _, err := os.Stat("./ui_dist"); os.IsNotExist(err) { + t.Skip("ui_dist not found, skipping real file test") + } + + // Find a .js or .css file that has compressed versions + entries, err := os.ReadDir("./ui_dist/assets") + if err != nil { + t.Skipf("Could not read ui_dist/assets: %v", err) + } + + var testFile string + for _, entry := range entries { + name := entry.Name() + if strings.HasSuffix(name, ".js") && !strings.HasSuffix(name, ".js.gz") && !strings.HasSuffix(name, ".js.br") { + // Check if compressed versions exist + base := strings.TrimSuffix(name, ".js") + if _, err := os.Stat(filepath.Join("./ui_dist/assets", base+".js.gz")); err == nil { + testFile = "assets/" + name + break + } + } + } + + if testFile == "" { + t.Skip("No suitable test file found with compressed versions") + } + + fs := http.FS(os.DirFS("./ui_dist")) + + // Test brotli + t.Run("brotli", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/"+testFile, nil) + req.Header.Set("Accept-Encoding", "br") + w := httptest.NewRecorder() + + ServeCompressedFile(fs, w, req, testFile) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("Expected status 200, got %d", resp.StatusCode) + } + + if encoding := resp.Header.Get("Content-Encoding"); encoding != "br" { + t.Errorf("Expected Content-Encoding 'br', got '%s'", encoding) + } + }) + + // Test gzip + t.Run("gzip", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/"+testFile, nil) + req.Header.Set("Accept-Encoding", "gzip") + w := httptest.NewRecorder() + + ServeCompressedFile(fs, w, req, testFile) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("Expected status 200, got %d", resp.StatusCode) + } + + if encoding := resp.Header.Get("Content-Encoding"); encoding != "gzip" { + t.Errorf("Expected Content-Encoding 'gzip', got '%s'", encoding) + } + + // Verify it's valid gzip + reader, err := gzip.NewReader(resp.Body) + if err != nil { + t.Errorf("Expected valid gzip content: %v", err) + return + } + defer reader.Close() + + // Just read to verify it's valid + _, err = io.Copy(io.Discard, reader) + if err != nil { + t.Errorf("Failed to decompress gzip: %v", err) + } + }) +} diff --git a/ui-svelte/.gitignore b/ui-svelte/.gitignore new file mode 100644 index 00000000..b18b8708 --- /dev/null +++ b/ui-svelte/.gitignore @@ -0,0 +1,2 @@ +node_modules +.vite diff --git a/ui/index.html b/ui-svelte/index.html similarity index 85% rename from ui/index.html rename to ui-svelte/index.html index 38e60f6c..f7d80418 100644 --- a/ui/index.html +++ b/ui-svelte/index.html @@ -10,8 +10,8 @@ llmsnap - -
- + +
+ diff --git a/ui-svelte/package-lock.json b/ui-svelte/package-lock.json new file mode 100644 index 00000000..8c86b603 --- /dev/null +++ b/ui-svelte/package-lock.json @@ -0,0 +1,4119 @@ +{ + "name": "ui-svelte", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ui-svelte", + "version": "0.0.0", + "dependencies": { + "highlight.js": "^11.11.1", + "katex": "^0.16.28", + "lucide-svelte": "^0.563.0", + "rehype-katex": "^7.0.1", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "svelte-spa-router": "^4.0.1", + "unified": "^11.0.5", + "unist-util-visit": "^5.1.0" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@tailwindcss/vite": "^4.1.8", + "@tsconfig/svelte": "^5.0.4", + "@types/hast": "^3.0.4", + "@types/node": "^25.1.0", + "svelte": "^5.19.0", + "svelte-check": "^4.1.4", + "tailwindcss": "^4.1.8", + "typescript": "~5.8.3", + "vite": "^6.3.5", + "vite-plugin-compression2": "^2.4.0", + "vitest": "^4.0.18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "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==", + "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==", + "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==", + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz", + "integrity": "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.0.tgz", + "integrity": "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.0.tgz", + "integrity": "sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.0.tgz", + "integrity": "sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.0.tgz", + "integrity": "sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.0.tgz", + "integrity": "sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.0.tgz", + "integrity": "sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.0.tgz", + "integrity": "sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.0.tgz", + "integrity": "sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.0.tgz", + "integrity": "sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.0.tgz", + "integrity": "sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.0.tgz", + "integrity": "sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.0.tgz", + "integrity": "sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.0.tgz", + "integrity": "sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.0.tgz", + "integrity": "sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.0.tgz", + "integrity": "sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.0.tgz", + "integrity": "sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz", + "integrity": "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.0.tgz", + "integrity": "sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.0.tgz", + "integrity": "sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.0.tgz", + "integrity": "sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.0.tgz", + "integrity": "sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.0.tgz", + "integrity": "sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.0.tgz", + "integrity": "sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.0.tgz", + "integrity": "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==", + "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/@sveltejs/acorn-typescript": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", + "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.17", + "vitefu": "^1.0.6" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@tsconfig/svelte": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.6.tgz", + "integrity": "sha512-yGxYL0I9eETH1/DR9qVJey4DAsCdeau4a9wYPKuXfEhm8lFO8wg+LLYJjIpAm6Fw7HSlhepPhYPDop75485yWQ==", + "dev": true, + "license": "MIT" + }, + "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/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/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "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": "25.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.1.0.tgz", + "integrity": "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.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/@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/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "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.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "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==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "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/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "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/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.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/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/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz", + "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==", + "license": "MIT" + }, + "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/enhanced-resolve": { + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "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.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz", + "integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, + "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/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/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/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/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "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-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-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "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/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "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/katex": { + "version": "0.16.28", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.28.tgz", + "integrity": "sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "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/lucide-svelte": { + "version": "0.563.0", + "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.563.0.tgz", + "integrity": "sha512-pjZKw7TpQcamfQrx7YdbOHgmrcNeKiGGMD0tKZQaVktwSsbqw28CsKc2Q97ttwjytiCWkJyOa8ij2Q+Og0nPfQ==", + "license": "ISC", + "peerDependencies": { + "svelte": "^3 || ^4 || ^5.0.0-next.42" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.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/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-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.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-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.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-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@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-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-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/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-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^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-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^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-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^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-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.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-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.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-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/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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/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/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/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/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/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/regexparam": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-2.0.2.tgz", + "integrity": "sha512-A1PeDEYMrkLrfyOwv2jwihXbo9qxdGD3atBYQA9JJgreAx8/7rC6IUkWOw2NQlOxLp2wL0ifQbh1HuidDfYA6w==", + "license": "MIT", + "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/rehype-stringify": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.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/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rollup": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz", + "integrity": "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==", + "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.57.0", + "@rollup/rollup-android-arm64": "4.57.0", + "@rollup/rollup-darwin-arm64": "4.57.0", + "@rollup/rollup-darwin-x64": "4.57.0", + "@rollup/rollup-freebsd-arm64": "4.57.0", + "@rollup/rollup-freebsd-x64": "4.57.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", + "@rollup/rollup-linux-arm-musleabihf": "4.57.0", + "@rollup/rollup-linux-arm64-gnu": "4.57.0", + "@rollup/rollup-linux-arm64-musl": "4.57.0", + "@rollup/rollup-linux-loong64-gnu": "4.57.0", + "@rollup/rollup-linux-loong64-musl": "4.57.0", + "@rollup/rollup-linux-ppc64-gnu": "4.57.0", + "@rollup/rollup-linux-ppc64-musl": "4.57.0", + "@rollup/rollup-linux-riscv64-gnu": "4.57.0", + "@rollup/rollup-linux-riscv64-musl": "4.57.0", + "@rollup/rollup-linux-s390x-gnu": "4.57.0", + "@rollup/rollup-linux-x64-gnu": "4.57.0", + "@rollup/rollup-linux-x64-musl": "4.57.0", + "@rollup/rollup-openbsd-x64": "4.57.0", + "@rollup/rollup-openharmony-arm64": "4.57.0", + "@rollup/rollup-win32-arm64-msvc": "4.57.0", + "@rollup/rollup-win32-ia32-msvc": "4.57.0", + "@rollup/rollup-win32-x64-gnu": "4.57.0", + "@rollup/rollup-win32-x64-msvc": "4.57.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "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/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/svelte": { + "version": "5.48.5", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.48.5.tgz", + "integrity": "sha512-NB3o70OxfmnE5UPyLr8uH3IV02Q43qJVAuWigYmsSOYsS0s/rHxP0TF81blG0onF/xkhNvZw4G8NfzIX+By5ZQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "acorn": "^8.12.1", + "aria-query": "^5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.2", + "esm-env": "^1.2.1", + "esrap": "^2.2.1", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.5.tgz", + "integrity": "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte-spa-router": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/svelte-spa-router/-/svelte-spa-router-4.0.1.tgz", + "integrity": "sha512-2JkmUQ2f9jRluijL58LtdQBIpynSbem2eBGp4zXdi7aDY1znbR6yjw0KsonD0aq2QLwf4Yx4tBJQjxIjgjXHKg==", + "license": "MIT", + "dependencies": { + "regexparam": "2.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ItalyPaleAle" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar-mini": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/tar-mini/-/tar-mini-0.2.0.tgz", + "integrity": "sha512-+qfUHz700DWnRutdUsxRRVZ38G1Qr27OetwaMYTdg8hcPxf46U0S1Zf76dQMWRBmusOt2ZCK5kbIaiLkoGO7WQ==", + "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/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/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "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.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "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/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": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "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/vite-plugin-compression2": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/vite-plugin-compression2/-/vite-plugin-compression2-2.4.0.tgz", + "integrity": "sha512-8J4CBF1+dM1I06azba/eXJuJHinLF0Am7lUvRH8AZpu0otJoBaDEnxrIEr5iPZJSwH0AEglJGYCveh7pN52jCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0", + "tar-mini": "^0.2.0" + } + }, + "node_modules/vitefu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", + "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "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.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "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/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/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/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT" + }, + "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/ui-svelte/package.json b/ui-svelte/package.json new file mode 100644 index 00000000..7afbc757 --- /dev/null +++ b/ui-svelte/package.json @@ -0,0 +1,42 @@ +{ + "name": "ui-svelte", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "start": "vite", + "build": "vite build --emptyOutDir", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json", + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@tailwindcss/vite": "^4.1.8", + "@tsconfig/svelte": "^5.0.4", + "@types/hast": "^3.0.4", + "@types/node": "^25.1.0", + "svelte": "^5.19.0", + "svelte-check": "^4.1.4", + "tailwindcss": "^4.1.8", + "typescript": "~5.8.3", + "vite": "^6.3.5", + "vite-plugin-compression2": "^2.4.0", + "vitest": "^4.0.18" + }, + "dependencies": { + "highlight.js": "^11.11.1", + "katex": "^0.16.28", + "lucide-svelte": "^0.563.0", + "rehype-katex": "^7.0.1", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "svelte-spa-router": "^4.0.1", + "unified": "^11.0.5", + "unist-util-visit": "^5.1.0" + } +} diff --git a/ui/public/apple-touch-icon.png b/ui-svelte/public/apple-touch-icon.png similarity index 100% rename from ui/public/apple-touch-icon.png rename to ui-svelte/public/apple-touch-icon.png diff --git a/ui/public/favicon-96x96.png b/ui-svelte/public/favicon-96x96.png similarity index 100% rename from ui/public/favicon-96x96.png rename to ui-svelte/public/favicon-96x96.png diff --git a/ui/public/favicon.ico b/ui-svelte/public/favicon.ico similarity index 100% rename from ui/public/favicon.ico rename to ui-svelte/public/favicon.ico diff --git a/ui/public/favicon.svg b/ui-svelte/public/favicon.svg similarity index 100% rename from ui/public/favicon.svg rename to ui-svelte/public/favicon.svg diff --git a/ui/public/site.webmanifest b/ui-svelte/public/site.webmanifest similarity index 100% rename from ui/public/site.webmanifest rename to ui-svelte/public/site.webmanifest diff --git a/ui/public/web-app-manifest-192x192.png b/ui-svelte/public/web-app-manifest-192x192.png similarity index 100% rename from ui/public/web-app-manifest-192x192.png rename to ui-svelte/public/web-app-manifest-192x192.png diff --git a/ui/public/web-app-manifest-512x512.png b/ui-svelte/public/web-app-manifest-512x512.png similarity index 100% rename from ui/public/web-app-manifest-512x512.png rename to ui-svelte/public/web-app-manifest-512x512.png diff --git a/ui-svelte/src/App.svelte b/ui-svelte/src/App.svelte new file mode 100644 index 00000000..41ec576e --- /dev/null +++ b/ui-svelte/src/App.svelte @@ -0,0 +1,48 @@ + + +
+
+ +
+ +
+
diff --git a/ui/src/assets/logo.png b/ui-svelte/src/assets/logo.png similarity index 100% rename from ui/src/assets/logo.png rename to ui-svelte/src/assets/logo.png diff --git a/ui/src/assets/react.svg b/ui-svelte/src/assets/react.svg similarity index 100% rename from ui/src/assets/react.svg rename to ui-svelte/src/assets/react.svg diff --git a/ui-svelte/src/components/CaptureDialog.svelte b/ui-svelte/src/components/CaptureDialog.svelte new file mode 100644 index 00000000..088bbd8e --- /dev/null +++ b/ui-svelte/src/components/CaptureDialog.svelte @@ -0,0 +1,452 @@ + + + + {#if capture} +
+
+

Capture #{capture.id + 1}{#if capture.req_path} {capture.req_path}{/if}

+ +
+ +
+ +
+ + Request Headers + +
+ + + {#each Object.entries(capture.req_headers || {}) as [key, value]} + + + + + {/each} + +
{key}{value}
+
+
+ + +
+ + Request Body + + {#if requestBodyRaw} +
+
+ {#if isRequestJson} + + + {/if} +
+ +
+
+
{displayedRequestBody}
+
+ {:else} +
+
(empty)
+
+ {/if} +
+ + +
+ + Response Headers + +
+ + + {#each Object.entries(capture.resp_headers || {}) as [key, value]} + + + + + {/each} + +
{key}{value}
+
+
+ + +
+ + Response Body + + {#if isResponseImage && capture.resp_body} +
+
+ Response +
+
+ {:else if isSSE || isResponseText} +
+
+ {#if isSSE} + + {/if} + {#if isResponseJson} + + {/if} + {#if isSSE || isResponseJson} + + {/if} +
+ +
+
+ {#if respBodyTab === "chat"} +
+ {#if sseChat.reasoning} +
+
+ Reasoning +
+
{sseChat.reasoning}
+
+ {/if} + {#if sseChat.content} +
+ {#if sseChat.reasoning} +
+ Response +
+ {/if} +
{sseChat.content}
+
+ {/if} + {#if !sseChat.reasoning && !sseChat.content} +
(empty)
+ {/if} +
+ {:else} +
{displayedResponseBody || "(empty)"}
+ {/if} +
+ {:else if responseBodyRaw} +
+
+ (binary data - {responseContentType || "unknown content type"}) +
+
+ {:else} +
+
(empty)
+
+ {/if} +
+
+ +
+ +
+
+ {/if} +
+ + diff --git a/ui-svelte/src/components/ConnectionStatus.svelte b/ui-svelte/src/components/ConnectionStatus.svelte new file mode 100644 index 00000000..fc3372ca --- /dev/null +++ b/ui-svelte/src/components/ConnectionStatus.svelte @@ -0,0 +1,24 @@ + + +
+ +
diff --git a/ui-svelte/src/components/Header.svelte b/ui-svelte/src/components/Header.svelte new file mode 100644 index 00000000..f45fe898 --- /dev/null +++ b/ui-svelte/src/components/Header.svelte @@ -0,0 +1,98 @@ + + +
+ {#if $screenWidth !== "xs" && $screenWidth !== "sm"} +

+ {$appTitle} +

+ {/if} + + + + Playground + + + Models + + + Activity + + + Logs + + + + +
diff --git a/ui-svelte/src/components/LogPanel.svelte b/ui-svelte/src/components/LogPanel.svelte new file mode 100644 index 00000000..573950ff --- /dev/null +++ b/ui-svelte/src/components/LogPanel.svelte @@ -0,0 +1,132 @@ + + +
+
+
+

{title}

+ +
+ + + +
+
+ + {#if $showFilterStore} +
+ + +
+ {/if} +
+
+
{filteredLogs}
+
+
diff --git a/ui-svelte/src/components/ModelsPanel.svelte b/ui-svelte/src/components/ModelsPanel.svelte new file mode 100644 index 00000000..d630676c --- /dev/null +++ b/ui-svelte/src/components/ModelsPanel.svelte @@ -0,0 +1,216 @@ + + +
+
+
+

Models

+ {#if $isNarrow} +
+ + {#if menuOpen} +
+ + + +
+ {/if} +
+ {/if} +
+ {#if !$isNarrow} +
+
+ + + +
+ +
+ {/if} +
+ +
+ + + + + + + + + + {#each filteredModels.regularModels as model (model.id)} + + + + + + {/each} + +
{$showIdorNameStore === "id" ? "Model ID" : "Name"}State
+ + {getModelDisplay(model)} + + {#if model.description} +

{model.description}

+ {/if} +
+ {#if model.state === "stopped"} + + {:else if model.state === "asleep"} + + + {:else if model.state === "ready" && model.sleepMode === "enable"} + + + {:else if model.state === "ready"} + + {:else} + + {/if} + + {model.state} +
+ + {#if Object.keys(filteredModels.peerModelsByPeerId).length > 0} +

Peer Models

+ {#each Object.entries(filteredModels.peerModelsByPeerId).sort(([a], [b]) => a.localeCompare(b)) as [peerId, peerModels] (peerId)} +
+ + + + + + + + {#each peerModels as model (model.id)} + + + + {/each} + +
{peerId}
+ {model.id} +
+
+ {/each} + {/if} +
+
diff --git a/ui-svelte/src/components/ResizablePanels.svelte b/ui-svelte/src/components/ResizablePanels.svelte new file mode 100644 index 00000000..5d824ba4 --- /dev/null +++ b/ui-svelte/src/components/ResizablePanels.svelte @@ -0,0 +1,152 @@ + + +
+
+ {@render leftPanel()} +
+ + + + + +
+ {@render rightPanel()} +
+
diff --git a/ui-svelte/src/components/StatsPanel.svelte b/ui-svelte/src/components/StatsPanel.svelte new file mode 100644 index 00000000..2ef869a5 --- /dev/null +++ b/ui-svelte/src/components/StatsPanel.svelte @@ -0,0 +1,147 @@ + + +
+
+ + + + + + + + + + + + + + + + + + + + + +
Requests + Processed + + Generated + + Token Stats (tokens/sec) +
{stats.totalRequests} +
+ {nf.format(stats.totalInputTokens)} + tokens +
+
+
+ {nf.format(stats.totalOutputTokens)} + tokens +
+
+
+
+
+
P50
+
+ {stats.tokenStats.p50} +
+
+ +
+
P95
+
+ {stats.tokenStats.p95} +
+
+ +
+
P99
+
+ {stats.tokenStats.p99} +
+
+
+ {#if stats.histogramData} + + {/if} +
+
+
+
diff --git a/ui-svelte/src/components/TokenHistogram.svelte b/ui-svelte/src/components/TokenHistogram.svelte new file mode 100644 index 00000000..5523c571 --- /dev/null +++ b/ui-svelte/src/components/TokenHistogram.svelte @@ -0,0 +1,129 @@ + + +
+ + + + + + + + + {#each data.bins as count, i} + {@const barHeight = maxCount > 0 ? (count / maxCount) * chartHeight : 0} + {@const x = padding.left + i * barWidth} + {@const y = height - padding.bottom - barHeight} + {@const binStart = data.min + i * data.binSize} + {@const binEnd = binStart + data.binSize} + + + {`${binStart.toFixed(1)} - ${binEnd.toFixed(1)} tokens/sec\nCount: ${count}`} + + {/each} + + + + + + + + + + + {data.min.toFixed(1)} + + + + {data.max.toFixed(1)} + + + + + Tokens/Second Distribution + + +
diff --git a/ui-svelte/src/components/Tooltip.svelte b/ui-svelte/src/components/Tooltip.svelte new file mode 100644 index 00000000..97c76c96 --- /dev/null +++ b/ui-svelte/src/components/Tooltip.svelte @@ -0,0 +1,20 @@ + + +
+ +
+ {content} +
+
+
diff --git a/ui-svelte/src/components/playground/AudioInterface.svelte b/ui-svelte/src/components/playground/AudioInterface.svelte new file mode 100644 index 00000000..d22a5989 --- /dev/null +++ b/ui-svelte/src/components/playground/AudioInterface.svelte @@ -0,0 +1,251 @@ + + +
+ +
+ +
+ + + {#if !hasModels} +
+

No models configured. Add models to your configuration to transcribe audio.

+
+ {:else} + +
+ {#if isTranscribing} +
+
+

Transcribing audio...

+
+ {:else if error} +
+

Error

+

{error}

+
+ {:else if transcriptionResult} +
+
+

Transcription Result

+ +
+
+ {transcriptionResult} +
+
+ {:else if selectedFile} +
+

File Selected

+

{selectedFile.name}

+

{formatFileSize(selectedFile.size)}

+
+ {:else} +
+
+

Drag and drop an audio file here

+

or use the Browse button below

+

Accepted formats: MP3, WAV (max 25MB)

+
+
+ {/if} +
+ + +
+ + +
+ {#if isTranscribing} + + {:else} + + + {/if} +
+ {/if} +
diff --git a/ui-svelte/src/components/playground/ChatInterface.svelte b/ui-svelte/src/components/playground/ChatInterface.svelte new file mode 100644 index 00000000..4b2ba992 --- /dev/null +++ b/ui-svelte/src/components/playground/ChatInterface.svelte @@ -0,0 +1,424 @@ + + +
+ +
+ +
+ + +
+
+ + + {#if showSettings} +
+
+ + +
+
+ + +
+ Precise (0) + Creative (2) +
+
+
+ {/if} + + + {#if !hasModels} +
+

No models configured. Add models to your configuration to start chatting.

+
+ {:else} + +
+ {#if messages.length === 0} +
+

Start a conversation by typing a message below.

+
+ {:else} + {#each messages as message, idx (idx)} + editMessage(idx, newContent) : undefined} + onRegenerate={message.role === "assistant" && idx > 0 && messages[idx - 1].role === "user" + ? () => regenerateFromIndex(idx - 1) + : undefined} + /> + {/each} + {/if} +
+ + +
+ + {#if attachedImages.length > 0} +
+ {#each attachedImages as imageUrl, idx (idx)} +
+ Attached image {idx + 1} + +
+ {/each} +
+ {/if} + + + {#if imageError} +
+ {imageError} +
+ {/if} + +
+ + + + +
+ {#if isStreaming} + + {:else} + + + {/if} +
+
+
+ {/if} +
diff --git a/ui-svelte/src/components/playground/ChatMessage.svelte b/ui-svelte/src/components/playground/ChatMessage.svelte new file mode 100644 index 00000000..1f573826 --- /dev/null +++ b/ui-svelte/src/components/playground/ChatMessage.svelte @@ -0,0 +1,388 @@ + + +
+
+ {#if role === "assistant"} + {#if reasoning_content || isReasoning} +
+ + {#if showReasoning} +
+ {reasoning_content}{#if isReasoning}{/if} +
+ {/if} +
+ {/if} + {#if hasImages} +
+ {#each imageUrls as imageUrl, idx (idx)} + + {/each} +
+ {/if} + {#if showRaw} +
{textContent}
+ {:else} +
+ {@html renderedContent} + {#if isStreaming && !isReasoning} + + {/if} +
+ {/if} + {#if !isStreaming} +
+ {#if onRegenerate} + + {/if} + + +
+ {/if} + {:else} + {#if isEditing} +
+ +
+ + +
+
+ {:else} + {#if hasImages} +
+ {#each imageUrls as imageUrl, idx (idx)} + + {/each} +
+ {/if} +
{textContent}
+ {#if canEdit} + + {/if} + {/if} + {/if} +
+
+ + +{#if modalImageUrl} +
closeModal(e)} + onkeydown={handleModalKeyDown} + role="button" + tabindex="-1" + > + + +
+{/if} + + diff --git a/ui-svelte/src/components/playground/ExpandableTextarea.svelte b/ui-svelte/src/components/playground/ExpandableTextarea.svelte new file mode 100644 index 00000000..3bea4548 --- /dev/null +++ b/ui-svelte/src/components/playground/ExpandableTextarea.svelte @@ -0,0 +1,121 @@ + + +
+ + +
+ +{#if isExpanded} +
+
+ +
+

Edit Text

+ +
+ + +
+ +
+ + +
+ + +
+
+
+{/if} diff --git a/ui-svelte/src/components/playground/ImageInterface.svelte b/ui-svelte/src/components/playground/ImageInterface.svelte new file mode 100644 index 00000000..85e5d27d --- /dev/null +++ b/ui-svelte/src/components/playground/ImageInterface.svelte @@ -0,0 +1,229 @@ + + +
+ +
+ + +
+ + + {#if !hasModels} +
+

No models configured. Add models to your configuration to generate images.

+
+ {:else} + +
+ {#if isGenerating} +
+
+

Generating image...

+
+ {:else if error} +
+

Error

+

{error}

+
+ {:else if generatedImage} +
+ + +
+ {:else} +
+

Enter a prompt below to generate an image

+
+ {/if} +
+ + +
+ +
+ {#if isGenerating} + + {:else} + + + {/if} +
+
+ {/if} +
+ + +{#if showFullscreen && generatedImage} +
closeFullscreen(e)} + onkeydown={(e) => e.key === 'Escape' && closeFullscreen()} + role="dialog" + aria-modal="true" + tabindex="-1" + > + + AI generated content +
+{/if} diff --git a/ui-svelte/src/components/playground/ModelSelector.svelte b/ui-svelte/src/components/playground/ModelSelector.svelte new file mode 100644 index 00000000..e8d9b51c --- /dev/null +++ b/ui-svelte/src/components/playground/ModelSelector.svelte @@ -0,0 +1,39 @@ + + +{#if hasModels} + +{/if} diff --git a/ui-svelte/src/components/playground/PlaceholderTab.svelte b/ui-svelte/src/components/playground/PlaceholderTab.svelte new file mode 100644 index 00000000..ca93e435 --- /dev/null +++ b/ui-svelte/src/components/playground/PlaceholderTab.svelte @@ -0,0 +1,14 @@ + + +
+
+

{featureName}

+

To be implemented

+
+
diff --git a/ui-svelte/src/components/playground/SpeechInterface.svelte b/ui-svelte/src/components/playground/SpeechInterface.svelte new file mode 100644 index 00000000..e9ab20a8 --- /dev/null +++ b/ui-svelte/src/components/playground/SpeechInterface.svelte @@ -0,0 +1,359 @@ + + +
+ +
+ +
+ + {#if $selectedModelStore && !getVoicesCache()[$selectedModelStore]} + + {/if} +
+
+ + + {#if !hasModels} +
+

No models configured. Add models to your configuration to generate speech.

+
+ {:else} + +
+ {#if isGenerating} +
+
+
+

Generating speech...

+
+
+ {:else if error} +
+
+

Error

+

{error}

+
+
+ {:else if generatedAudioUrl} +
+ +
+
+ {#if generatedVoice} + + + + + {generatedVoice} + + {/if} + {#if generatedTimestamp} + + + + + {formatTimestamp(generatedTimestamp)} + + {/if} +
+ +
+ + +
+ +
+
+ {:else} +
+
+ + + +

Enter text below to convert to speech

+
+
+ {/if} +
+ + +
+ +
+ {#if isGenerating} + + {:else} + + + + {/if} +
+
+ {/if} +
diff --git a/ui/src/index.css b/ui-svelte/src/index.css similarity index 99% rename from ui/src/index.css rename to ui-svelte/src/index.css index 892983a1..110ea3e1 100644 --- a/ui/src/index.css +++ b/ui-svelte/src/index.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@import "katex/dist/katex.min.css"; @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); @theme { diff --git a/ui-svelte/src/lib/audioApi.ts b/ui-svelte/src/lib/audioApi.ts new file mode 100644 index 00000000..3c13b3d9 --- /dev/null +++ b/ui-svelte/src/lib/audioApi.ts @@ -0,0 +1,24 @@ +import type { AudioTranscriptionResponse } from "./types"; + +export async function transcribeAudio( + model: string, + file: File, + signal?: AbortSignal +): Promise { + const formData = new FormData(); + formData.append("file", file); + formData.append("model", model); + + const response = await fetch("/v1/audio/transcriptions", { + method: "POST", + body: formData, + signal, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Audio API error: ${response.status} - ${errorText}`); + } + + return response.json(); +} diff --git a/ui-svelte/src/lib/chatApi.ts b/ui-svelte/src/lib/chatApi.ts new file mode 100644 index 00000000..6f0f4707 --- /dev/null +++ b/ui-svelte/src/lib/chatApi.ts @@ -0,0 +1,108 @@ +import type { ChatMessage, ChatCompletionRequest } from "./types"; + +export interface StreamChunk { + content: string; + reasoning_content?: string; + done: boolean; +} + +export interface ChatOptions { + temperature?: number; +} + +function parseSSELine(line: string): StreamChunk | null { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith("data: ")) { + return null; + } + + const data = trimmed.slice(6); + if (data === "[DONE]") { + return { content: "", done: true }; + } + + try { + const parsed = JSON.parse(data); + const delta = parsed.choices?.[0]?.delta; + const content = delta?.content || ""; + const reasoning_content = delta?.reasoning_content || ""; + + if (content || reasoning_content) { + return { content, reasoning_content, done: false }; + } + return null; + } catch { + return null; + } +} + +export async function* streamChatCompletion( + model: string, + messages: ChatMessage[], + signal?: AbortSignal, + options?: ChatOptions +): AsyncGenerator { + const request: ChatCompletionRequest = { + model, + messages, + stream: true, + temperature: options?.temperature, + }; + + const response = await fetch("/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + signal, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Chat API error: ${response.status} - ${errorText}`); + } + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error("Response body is not readable"); + } + + 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 result = parseSSELine(line); + if (result?.done) { + yield result; + return; + } + if (result) { + yield result; + } + } + } + + // Process any remaining buffer + const result = parseSSELine(buffer); + if (result && !result.done) { + yield result; + } + + yield { content: "", done: true }; + } finally { + reader.releaseLock(); + } +} diff --git a/ui-svelte/src/lib/imageApi.ts b/ui-svelte/src/lib/imageApi.ts new file mode 100644 index 00000000..14199d79 --- /dev/null +++ b/ui-svelte/src/lib/imageApi.ts @@ -0,0 +1,31 @@ +import type { ImageGenerationRequest, ImageGenerationResponse } from "./types"; + +export async function generateImage( + model: string, + prompt: string, + size: string, + signal?: AbortSignal +): Promise { + const request: ImageGenerationRequest = { + model, + prompt, + n: 1, + size, + }; + + const response = await fetch("/v1/images/generations", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + signal, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Image API error: ${response.status} - ${errorText}`); + } + + return response.json(); +} diff --git a/ui-svelte/src/lib/markdown.test.ts b/ui-svelte/src/lib/markdown.test.ts new file mode 100644 index 00000000..8763b6fe --- /dev/null +++ b/ui-svelte/src/lib/markdown.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect } from "vitest"; +import { renderMarkdown, escapeHtml } from "./markdown"; + +describe("renderMarkdown", () => { + describe("basic markdown", () => { + it("renders plain text", () => { + const result = renderMarkdown("Hello world"); + expect(result).toContain("Hello world"); + }); + + it("renders bold text", () => { + const result = renderMarkdown("**bold**"); + expect(result).toContain("bold"); + }); + + it("renders italic text", () => { + const result = renderMarkdown("*italic*"); + expect(result).toContain("italic"); + }); + + it("renders code blocks", () => { + const result = renderMarkdown("```js\nconst x = 1;\n```"); + expect(result).toContain("hljs"); + expect(result).toContain("const"); + }); + + it("returns empty string for empty content", () => { + const result = renderMarkdown(""); + expect(result).toBe(""); + }); + + it("returns empty string for null/undefined content", () => { + // @ts-expect-error - testing null input + expect(renderMarkdown(null)).toBe(""); + // @ts-expect-error - testing undefined input + expect(renderMarkdown(undefined)).toBe(""); + }); + }); + + describe("KaTeX math rendering", () => { + it("renders inline math with $...$ syntax", () => { + const result = renderMarkdown("The equation $E = mc^2$ is famous."); + // KaTeX should convert this to HTML with katex class + expect(result).toContain("katex"); + expect(result).toContain("E"); + expect(result).toContain("="); + expect(result).toContain("mc"); + }); + + it("renders display math with $$...$$ syntax", () => { + const result = renderMarkdown("$$\\int_{a}^{b} f(x) dx$$"); + // Math should be rendered with KaTeX + expect(result).toContain("katex"); + expect(result).toContain("∫"); + expect(result).toContain("f(x)"); + }); + + it("renders complex LaTeX expressions", () => { + const result = renderMarkdown("$$\\sum_{i=1}^{n} x_i = \\frac{1}{n}\\sum_{i=1}^{n} x_i$$"); + expect(result).toContain("katex"); + expect(result).toContain("∑"); // or the MathML equivalent + }); + + it("renders LaTeX with Greek letters", () => { + const result = renderMarkdown("$\\alpha + \\beta = \\gamma$"); + expect(result).toContain("katex"); + // Greek letters should be rendered + expect(result).toMatch(/[αβγ]|alpha|beta|gamma/); + }); + + it("renders LaTeX with fractions", () => { + const result = renderMarkdown("$\\frac{a}{b}$"); + expect(result).toContain("katex"); + expect(result).toContain("frac"); + }); + + it("renders LaTeX with subscripts and superscripts", () => { + const result = renderMarkdown("$x^2 + y_3$"); + expect(result).toContain("katex"); + expect(result).toContain("sup"); // superscript + expect(result).toContain("sub"); // subscript + }); + + it("renders multiple inline math expressions in one paragraph", () => { + const result = renderMarkdown("First $x = 1$ and then $y = 2$."); + // Should contain multiple katex spans + const katexMatches = result.match(/katex/g); + expect(katexMatches?.length).toBeGreaterThanOrEqual(2); + }); + + it("renders math within a larger markdown document", () => { + const markdown = `# Heading + +This is a paragraph with $E = mc^2$ inline math. + +$$\\int_0^\\infty e^{-x} dx = 1$$ + +More text here. +`; + const result = renderMarkdown(markdown); + expect(result).toContain("

Heading

"); + expect(result).toContain("katex"); + // Both inline and display math should be rendered + expect(result).toContain("E = mc"); + expect(result).toContain("∫"); + expect(result).toContain("∞"); + }); + + it("handles escaped dollar signs", () => { + const result = renderMarkdown("This costs \\$5 and $x = 1$."); + // Should render the escaped $5 as text and the math + expect(result).toContain("katex"); + expect(result).toContain("$5"); + }); + + it("handles empty math expressions gracefully", () => { + // Empty math should not break the renderer + const result = renderMarkdown("$$$"); + expect(result).toBeTruthy(); + }); + + it("renders LaTeX matrices", () => { + const result = renderMarkdown("$$\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}$$"); + expect(result).toContain("katex"); + expect(result).toContain("pmatrix"); + }); + + it("renders LaTeX square roots", () => { + const result = renderMarkdown("$\\sqrt{x^2 + y^2}$"); + expect(result).toContain("katex"); + expect(result).toContain("sqrt"); + }); + }); + + describe("escapeHtml", () => { + it("escapes HTML entities", () => { + expect(escapeHtml(" + +
+

Activity

+ + {#if $metrics.length === 0} +
+

No metrics data available

+
+ {:else} +
+ + + + + + + + + + + + + + + + + {#each sortedMetrics as metric (metric.id)} + + + + + + + + + + + + + {/each} + +
IDTimeModel + Cached + + Prompt + GeneratedPrompt ProcessingGeneration SpeedDurationCapture
{metric.id + 1}{formatRelativeTime(metric.timestamp)}{metric.model}{metric.cache_tokens > 0 ? metric.cache_tokens.toLocaleString() : "-"}{metric.input_tokens.toLocaleString()}{metric.output_tokens.toLocaleString()}{formatSpeed(metric.prompt_per_second)}{formatSpeed(metric.tokens_per_second)}{formatDuration(metric.duration_ms)} + {#if metric.has_capture} + + {:else} + - + {/if} +
+
+ {/if} +
+ + diff --git a/ui-svelte/src/routes/LogViewer.svelte b/ui-svelte/src/routes/LogViewer.svelte new file mode 100644 index 00000000..ff91ef42 --- /dev/null +++ b/ui-svelte/src/routes/LogViewer.svelte @@ -0,0 +1,75 @@ + + +
+
+ +
+ +
+ {#if $viewModeStore === "panels"} + + {#snippet leftPanel()} + + {/snippet} + {#snippet rightPanel()} + + {/snippet} + + {:else if $viewModeStore === "proxy"} + + {:else} + + {/if} +
+
diff --git a/ui-svelte/src/routes/Models.svelte b/ui-svelte/src/routes/Models.svelte new file mode 100644 index 00000000..96a08414 --- /dev/null +++ b/ui-svelte/src/routes/Models.svelte @@ -0,0 +1,26 @@ + + + + {#snippet leftPanel()} + + {/snippet} + {#snippet rightPanel()} +
+ {#if direction === "horizontal"} + + {/if} +
+ +
+
+ {/snippet} +
diff --git a/ui-svelte/src/routes/Playground.svelte b/ui-svelte/src/routes/Playground.svelte new file mode 100644 index 00000000..4acc0c1a --- /dev/null +++ b/ui-svelte/src/routes/Playground.svelte @@ -0,0 +1,99 @@ + + +
+ +
+ +
+ + {#if mobileMenuOpen} +
+ {#each tabs as tab (tab.id)} + + {/each} +
+ {/if} +
+ + + +
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + diff --git a/ui-svelte/src/stores/api.ts b/ui-svelte/src/stores/api.ts new file mode 100644 index 00000000..d2b76d2f --- /dev/null +++ b/ui-svelte/src/stores/api.ts @@ -0,0 +1,204 @@ +import { writable } from "svelte/store"; +import type { Model, Metrics, VersionInfo, LogData, APIEventEnvelope, ReqRespCapture } from "../lib/types"; +import { connectionState } from "./theme"; + +const LOG_LENGTH_LIMIT = 1024 * 100; /* 100KB of log data */ + +// Stores +export const models = writable([]); +export const proxyLogs = writable(""); +export const upstreamLogs = writable(""); +export const metrics = writable([]); +export const versionInfo = writable({ + build_date: "unknown", + commit: "unknown", + version: "unknown", +}); + +let apiEventSource: EventSource | null = null; + +function appendLog(newData: string, store: typeof proxyLogs | typeof upstreamLogs): void { + store.update((prev) => { + const updatedLog = prev + newData; + return updatedLog.length > LOG_LENGTH_LIMIT ? updatedLog.slice(-LOG_LENGTH_LIMIT) : updatedLog; + }); +} + +export function enableAPIEvents(enabled: boolean): void { + if (!enabled) { + apiEventSource?.close(); + apiEventSource = null; + metrics.set([]); + return; + } + + let retryCount = 0; + const initialDelay = 1000; // 1 second + + const connect = () => { + apiEventSource?.close(); + apiEventSource = new EventSource("/api/events"); + + connectionState.set("connecting"); + + apiEventSource.onopen = () => { + // Clear everything on connect to keep things in sync + proxyLogs.set(""); + upstreamLogs.set(""); + metrics.set([]); + models.set([]); + retryCount = 0; + connectionState.set("connected"); + }; + + apiEventSource.onmessage = (e: MessageEvent) => { + try { + const message = JSON.parse(e.data) as APIEventEnvelope; + switch (message.type) { + case "modelStatus": { + const newModels = JSON.parse(message.data) as Model[]; + // Sort models by name and id + newModels.sort((a, b) => { + return (a.name + a.id).localeCompare(b.name + b.id); + }); + models.set(newModels); + break; + } + + case "logData": { + const logData = JSON.parse(message.data) as LogData; + switch (logData.source) { + case "proxy": + appendLog(logData.data, proxyLogs); + break; + case "upstream": + appendLog(logData.data, upstreamLogs); + break; + } + break; + } + + case "metrics": { + const newMetrics = JSON.parse(message.data) as Metrics[]; + metrics.update((prevMetrics) => [...newMetrics, ...prevMetrics]); + break; + } + } + } catch (err) { + console.error(e.data, err); + } + }; + + apiEventSource.onerror = () => { + apiEventSource?.close(); + retryCount++; + const delay = Math.min(initialDelay * Math.pow(2, retryCount - 1), 5000); + connectionState.set("disconnected"); + setTimeout(connect, delay); + }; + }; + + connect(); +} + +// Fetch version info when connected +connectionState.subscribe(async (status) => { + if (status === "connected") { + try { + const response = await fetch("/api/version"); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data: VersionInfo = await response.json(); + versionInfo.set(data); + } catch (error) { + console.error(error); + } + } +}); + +export async function listModels(): Promise { + try { + const response = await fetch("/api/models/"); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + return data || []; + } catch (error) { + console.error("Failed to fetch models:", error); + return []; + } +} + +export async function unloadAllModels(): Promise { + try { + const response = await fetch(`/api/models/unload`, { + method: "POST", + }); + if (!response.ok) { + throw new Error(`Failed to unload models: ${response.status}`); + } + } catch (error) { + console.error("Failed to unload models:", error); + throw error; + } +} + +export async function unloadSingleModel(model: string): Promise { + try { + const response = await fetch(`/api/models/unload/${model}`, { + method: "POST", + }); + if (!response.ok) { + throw new Error(`Failed to unload model: ${response.status}`); + } + } catch (error) { + console.error("Failed to unload model", model, error); + throw error; + } +} + +export async function sleepModel(model: string): Promise { + try { + const response = await fetch(`/api/models/sleep/${model}`, { + method: "POST", + }); + if (!response.ok) { + throw new Error(`Failed to sleep model: ${response.status}`); + } + } catch (error) { + console.error("Failed to sleep model", model, error); + throw error; + } +} + +export async function loadModel(model: string): Promise { + try { + const response = await fetch(`/upstream/${model}/`, { + method: "GET", + }); + if (!response.ok) { + throw new Error(`Failed to load model: ${response.status}`); + } + } catch (error) { + console.error("Failed to load model:", error); + throw error; + } +} + +export async function getCapture(id: number): Promise { + try { + const response = await fetch(`/api/captures/${id}`); + if (response.status === 404) { + return null; + } + if (!response.ok) { + throw new Error(`Failed to fetch capture: ${response.status}`); + } + return await response.json(); + } catch (error) { + console.error("Failed to fetch capture:", error); + return null; + } +} diff --git a/ui-svelte/src/stores/persistent.ts b/ui-svelte/src/stores/persistent.ts new file mode 100644 index 00000000..134aeaa0 --- /dev/null +++ b/ui-svelte/src/stores/persistent.ts @@ -0,0 +1,31 @@ +import { writable, type Writable } from "svelte/store"; + +export function persistentStore(key: string, initialValue: T): Writable { + // Get initial value from localStorage or use default + let storedValue = initialValue; + if (typeof window !== "undefined") { + try { + const saved = localStorage.getItem(key); + if (saved !== null) { + storedValue = JSON.parse(saved); + } + } catch (e) { + console.error(`Error parsing stored value for ${key}`, e); + } + } + + const store = writable(storedValue); + + // Subscribe to changes and save to localStorage + store.subscribe((value) => { + if (typeof window !== "undefined") { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (e) { + console.error(`Error saving value for ${key}`, e); + } + } + }); + + return store; +} diff --git a/ui-svelte/src/stores/theme.ts b/ui-svelte/src/stores/theme.ts new file mode 100644 index 00000000..e6b512be --- /dev/null +++ b/ui-svelte/src/stores/theme.ts @@ -0,0 +1,53 @@ +import { writable, derived } from "svelte/store"; +import { persistentStore } from "./persistent"; +import type { ScreenWidth } from "../lib/types"; + +// Persistent stores +export const isDarkMode = persistentStore("theme", false); +export const appTitle = persistentStore("appTitle", "llmsnap"); + +// Non-persistent stores +export const screenWidth = writable("md"); +export const connectionState = writable<"connected" | "connecting" | "disconnected">("disconnected"); + +// Derived store for narrow screens +export const isNarrow = derived(screenWidth, ($screenWidth) => { + return $screenWidth === "xs" || $screenWidth === "sm" || $screenWidth === "md"; +}); + +// Function to toggle theme +export function toggleTheme(): void { + isDarkMode.update((current) => !current); +} + +// Function to check and update screen width +export function checkScreenWidth(): void { + const innerWidth = window.innerWidth; + let newWidth: ScreenWidth; + + if (innerWidth < 640) { + newWidth = "xs"; + } else if (innerWidth < 768) { + newWidth = "sm"; + } else if (innerWidth < 1024) { + newWidth = "md"; + } else if (innerWidth < 1280) { + newWidth = "lg"; + } else if (innerWidth < 1536) { + newWidth = "xl"; + } else { + newWidth = "2xl"; + } + + screenWidth.set(newWidth); +} + +// Initialize screen width and set up resize listener +export function initScreenWidth(): () => void { + checkScreenWidth(); + window.addEventListener("resize", checkScreenWidth); + + return () => { + window.removeEventListener("resize", checkScreenWidth); + }; +} diff --git a/ui-svelte/svelte.config.js b/ui-svelte/svelte.config.js new file mode 100644 index 00000000..d0e64483 --- /dev/null +++ b/ui-svelte/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +export default { + preprocess: vitePreprocess(), +}; diff --git a/ui-svelte/tsconfig.json b/ui-svelte/tsconfig.json new file mode 100644 index 00000000..80fb577b --- /dev/null +++ b/ui-svelte/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "resolveJsonModule": true, + "allowJs": true, + "checkJs": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} diff --git a/ui/vite.config.ts b/ui-svelte/vite.config.ts similarity index 50% rename from ui/vite.config.ts rename to ui-svelte/vite.config.ts index dd0e4f36..1b0c1dce 100644 --- a/ui/vite.config.ts +++ b/ui-svelte/vite.config.ts @@ -1,10 +1,25 @@ import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; import tailwindcss from "@tailwindcss/vite"; +import { compression } from "vite-plugin-compression2"; // https://vite.dev/config/ export default defineConfig({ - plugins: [react(), tailwindcss()], + plugins: [ + svelte(), + tailwindcss(), + compression({ + algorithm: "gzip", + exclude: [/\.(br)$/, /\.(gz)$/], + threshold: 1024, + }), + compression({ + algorithm: "brotliCompress", + exclude: [/\.(br)$/, /\.(gz)$/], + threshold: 1024, + filename: "[path][base].br", + }), + ], base: "/ui/", build: { outDir: "../proxy/ui_dist", @@ -16,6 +31,7 @@ export default defineConfig({ "/logs": "http://localhost:8080", "/upstream": "http://localhost:8080", "/unload": "http://localhost:8080", + "/v1": "http://localhost:8080", }, }, }); diff --git a/ui/.gitignore b/ui/.gitignore deleted file mode 100644 index 5c1e4437..00000000 --- a/ui/.gitignore +++ /dev/null @@ -1,25 +0,0 @@ -.vite -# Logs -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/ui/README.md b/ui/README.md deleted file mode 100644 index da984443..00000000 --- a/ui/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# 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/) 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 - -## 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 tseslint.config({ - extends: [ - // 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, - ], - languageOptions: { - // other options... - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - }, -}) -``` - -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 tseslint.config({ - plugins: { - // Add the react-x and react-dom plugins - 'react-x': reactX, - 'react-dom': reactDom, - }, - rules: { - // other rules... - // Enable its recommended typescript rules - ...reactX.configs['recommended-typescript'].rules, - ...reactDom.configs.recommended.rules, - }, -}) -``` diff --git a/ui/eslint.config.js b/ui/eslint.config.js deleted file mode 100644 index 092408a9..00000000 --- a/ui/eslint.config.js +++ /dev/null @@ -1,28 +0,0 @@ -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' - -export default tseslint.config( - { ignores: ['dist'] }, - { - extends: [js.configs.recommended, ...tseslint.configs.recommended], - files: ['**/*.{ts,tsx}'], - languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, - }, - plugins: { - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh, - }, - rules: { - ...reactHooks.configs.recommended.rules, - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], - }, - }, -) diff --git a/ui/misc/logo.acorn b/ui/misc/logo.acorn deleted file mode 100644 index b68762d0..00000000 Binary files a/ui/misc/logo.acorn and /dev/null differ diff --git a/ui/package-lock.json b/ui/package-lock.json deleted file mode 100644 index 3fcfd10e..00000000 --- a/ui/package-lock.json +++ /dev/null @@ -1,4129 +0,0 @@ -{ - "name": "ui", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "ui", - "version": "0.0.0", - "dependencies": { - "react": "^19.1.0", - "react-dom": "^19.1.0", - "react-icons": "^5.5.0", - "react-resizable-panels": "^3.0.4", - "react-router-dom": "^7.6.2" - }, - "devDependencies": { - "@eslint/js": "^9.25.0", - "@tailwindcss/vite": "^4.1.8", - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", - "@vitejs/plugin-react": "^4.4.1", - "eslint": "^9.25.0", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.19", - "globals": "^16.0.0", - "tailwindcss": "^4.1.8", - "typescript": "~5.8.3", - "typescript-eslint": "^8.30.1", - "vite": "^6.3.5" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "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.27.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.5.tgz", - "integrity": "sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.27.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.4.tgz", - "integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.3", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.27.3", - "@babel/helpers": "^7.27.4", - "@babel/parser": "^7.27.4", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.27.4", - "@babel/types": "^7.27.3", - "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.27.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.5.tgz", - "integrity": "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.27.5", - "@babel/types": "^7.27.3", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", - "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-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.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", - "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.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.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "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.27.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", - "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.27.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.27.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz", - "integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.3" - }, - "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/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.27.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.4.tgz", - "integrity": "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.3", - "@babel/parser": "^7.27.4", - "@babel/template": "^7.27.2", - "@babel/types": "^7.27.3", - "debug": "^4.3.1", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/types": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.6.tgz", - "integrity": "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", - "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", - "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", - "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", - "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", - "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", - "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", - "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", - "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", - "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", - "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", - "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", - "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", - "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", - "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", - "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", - "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", - "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", - "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", - "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", - "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", - "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", - "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", - "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", - "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", - "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", - "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.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", - "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.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", - "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.0", - "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.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", - "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", - "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.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "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/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "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/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "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/@rolldown/pluginutils": { - "version": "1.0.0-beta.9", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.9.tgz", - "integrity": "sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.42.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.42.0.tgz", - "integrity": "sha512-gldmAyS9hpj+H6LpRNlcjQWbuKUtb94lodB9uCz71Jm+7BxK1VIOo7y62tZZwxhA7j1ylv/yQz080L5WkS+LoQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.42.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.42.0.tgz", - "integrity": "sha512-bpRipfTgmGFdCZDFLRvIkSNO1/3RGS74aWkJJTFJBH7h3MRV4UijkaEUeOMbi9wxtxYmtAbVcnMtHTPBhLEkaw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.42.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.42.0.tgz", - "integrity": "sha512-JxHtA081izPBVCHLKnl6GEA0w3920mlJPLh89NojpU2GsBSB6ypu4erFg/Wx1qbpUbepn0jY4dVWMGZM8gplgA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.42.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.42.0.tgz", - "integrity": "sha512-rv5UZaWVIJTDMyQ3dCEK+m0SAn6G7H3PRc2AZmExvbDvtaDc+qXkei0knQWcI3+c9tEs7iL/4I4pTQoPbNL2SA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.42.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.42.0.tgz", - "integrity": "sha512-fJcN4uSGPWdpVmvLuMtALUFwCHgb2XiQjuECkHT3lWLZhSQ3MBQ9pq+WoWeJq2PrNxr9rPM1Qx+IjyGj8/c6zQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.42.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.42.0.tgz", - "integrity": "sha512-CziHfyzpp8hJpCVE/ZdTizw58gr+m7Y2Xq5VOuCSrZR++th2xWAz4Nqk52MoIIrV3JHtVBhbBsJcAxs6NammOQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.42.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.42.0.tgz", - "integrity": "sha512-UsQD5fyLWm2Fe5CDM7VPYAo+UC7+2Px4Y+N3AcPh/LdZu23YcuGPegQly++XEVaC8XUTFVPscl5y5Cl1twEI4A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.42.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.42.0.tgz", - "integrity": "sha512-/i8NIrlgc/+4n1lnoWl1zgH7Uo0XK5xK3EDqVTf38KvyYgCU/Rm04+o1VvvzJZnVS5/cWSd07owkzcVasgfIkQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.42.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.42.0.tgz", - "integrity": "sha512-eoujJFOvoIBjZEi9hJnXAbWg+Vo1Ov8n/0IKZZcPZ7JhBzxh2A+2NFyeMZIRkY9iwBvSjloKgcvnjTbGKHE44Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.42.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.42.0.tgz", - "integrity": "sha512-/3NrcOWFSR7RQUQIuZQChLND36aTU9IYE4j+TB40VU78S+RA0IiqHR30oSh6P1S9f9/wVOenHQnacs/Byb824g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.42.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.42.0.tgz", - "integrity": "sha512-O8AplvIeavK5ABmZlKBq9/STdZlnQo7Sle0LLhVA7QT+CiGpNVe197/t8Aph9bhJqbDVGCHpY2i7QyfEDDStDg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.42.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.42.0.tgz", - "integrity": "sha512-6Qb66tbKVN7VyQrekhEzbHRxXXFFD8QKiFAwX5v9Xt6FiJ3BnCVBuyBxa2fkFGqxOCSGGYNejxd8ht+q5SnmtA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.42.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.42.0.tgz", - "integrity": "sha512-KQETDSEBamQFvg/d8jajtRwLNBlGc3aKpaGiP/LvEbnmVUKlFta1vqJqTrvPtsYsfbE/DLg5CC9zyXRX3fnBiA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.42.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.42.0.tgz", - "integrity": "sha512-qMvnyjcU37sCo/tuC+JqeDKSuukGAd+pVlRl/oyDbkvPJ3awk6G6ua7tyum02O3lI+fio+eM5wsVd66X0jQtxw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.42.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.42.0.tgz", - "integrity": "sha512-I2Y1ZUgTgU2RLddUHXTIgyrdOwljjkmcZ/VilvaEumtS3Fkuhbw4p4hgHc39Ypwvo2o7sBFNl2MquNvGCa55Iw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.42.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.42.0.tgz", - "integrity": "sha512-Gfm6cV6mj3hCUY8TqWa63DB8Mx3NADoFwiJrMpoZ1uESbK8FQV3LXkhfry+8bOniq9pqY1OdsjFWNsSbfjPugw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.42.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.42.0.tgz", - "integrity": "sha512-g86PF8YZ9GRqkdi0VoGlcDUb4rYtQKyTD1IVtxxN4Hpe7YqLBShA7oHMKU6oKTCi3uxwW4VkIGnOaH/El8de3w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.42.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.42.0.tgz", - "integrity": "sha512-+axkdyDGSp6hjyzQ5m1pgcvQScfHnMCcsXkx8pTgy/6qBmWVhtRVlgxjWwDp67wEXXUr0x+vD6tp5W4x6V7u1A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.42.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.42.0.tgz", - "integrity": "sha512-F+5J9pelstXKwRSDq92J0TEBXn2nfUrQGg+HK1+Tk7VOL09e0gBqUHugZv7SW4MGrYj41oNCUe3IKCDGVlis2g==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.42.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.42.0.tgz", - "integrity": "sha512-LpHiJRwkaVz/LqjHjK8LCi8osq7elmpwujwbXKNW88bM8eeGxavJIKKjkjpMHAh/2xfnrt1ZSnhTv41WYUHYmA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@tailwindcss/node": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.8.tgz", - "integrity": "sha512-OWwBsbC9BFAJelmnNcrKuf+bka2ZxCE2A4Ft53Tkg4uoiE67r/PMEYwCsourC26E+kmxfwE0hVzMdxqeW+xu7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.3.0", - "enhanced-resolve": "^5.18.1", - "jiti": "^2.4.2", - "lightningcss": "1.30.1", - "magic-string": "^0.30.17", - "source-map-js": "^1.2.1", - "tailwindcss": "4.1.8" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.8.tgz", - "integrity": "sha512-d7qvv9PsM5N3VNKhwVUhpK6r4h9wtLkJ6lz9ZY9aeZgrUWk1Z8VPyqyDT9MZlem7GTGseRQHkeB1j3tC7W1P+A==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.4", - "tar": "^7.4.3" - }, - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.8", - "@tailwindcss/oxide-darwin-arm64": "4.1.8", - "@tailwindcss/oxide-darwin-x64": "4.1.8", - "@tailwindcss/oxide-freebsd-x64": "4.1.8", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.8", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.8", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.8", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.8", - "@tailwindcss/oxide-linux-x64-musl": "4.1.8", - "@tailwindcss/oxide-wasm32-wasi": "4.1.8", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.8", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.8" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.8.tgz", - "integrity": "sha512-Fbz7qni62uKYceWYvUjRqhGfZKwhZDQhlrJKGtnZfuNtHFqa8wmr+Wn74CTWERiW2hn3mN5gTpOoxWKk0jRxjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.8.tgz", - "integrity": "sha512-RdRvedGsT0vwVVDztvyXhKpsU2ark/BjgG0huo4+2BluxdXo8NDgzl77qh0T1nUxmM11eXwR8jA39ibvSTbi7A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.8.tgz", - "integrity": "sha512-t6PgxjEMLp5Ovf7uMb2OFmb3kqzVTPPakWpBIFzppk4JE4ix0yEtbtSjPbU8+PZETpaYMtXvss2Sdkx8Vs4XRw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.8.tgz", - "integrity": "sha512-g8C8eGEyhHTqwPStSwZNSrOlyx0bhK/V/+zX0Y+n7DoRUzyS8eMbVshVOLJTDDC+Qn9IJnilYbIKzpB9n4aBsg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.8.tgz", - "integrity": "sha512-Jmzr3FA4S2tHhaC6yCjac3rGf7hG9R6Gf2z9i9JFcuyy0u79HfQsh/thifbYTF2ic82KJovKKkIB6Z9TdNhCXQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.8.tgz", - "integrity": "sha512-qq7jXtO1+UEtCmCeBBIRDrPFIVI4ilEQ97qgBGdwXAARrUqSn/L9fUrkb1XP/mvVtoVeR2bt/0L77xx53bPZ/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.8.tgz", - "integrity": "sha512-O6b8QesPbJCRshsNApsOIpzKt3ztG35gfX9tEf4arD7mwNinsoCKxkj8TgEE0YRjmjtO3r9FlJnT/ENd9EVefQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.8.tgz", - "integrity": "sha512-32iEXX/pXwikshNOGnERAFwFSfiltmijMIAbUhnNyjFr3tmWmMJWQKU2vNcFX0DACSXJ3ZWcSkzNbaKTdngH6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.8.tgz", - "integrity": "sha512-s+VSSD+TfZeMEsCaFaHTaY5YNj3Dri8rST09gMvYQKwPphacRG7wbuQ5ZJMIJXN/puxPcg/nU+ucvWguPpvBDg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.8.tgz", - "integrity": "sha512-CXBPVFkpDjM67sS1psWohZ6g/2/cd+cq56vPxK4JeawelxwK4YECgl9Y9TjkE2qfF+9/s1tHHJqrC4SS6cVvSg==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@emnapi/wasi-threads": "^1.0.2", - "@napi-rs/wasm-runtime": "^0.2.10", - "@tybys/wasm-util": "^0.9.0", - "tslib": "^2.8.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.8.tgz", - "integrity": "sha512-7GmYk1n28teDHUjPlIx4Z6Z4hHEgvP5ZW2QS9ygnDAdI/myh3HTHjDqtSqgu1BpRoI4OiLx+fThAyA1JePoENA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.8.tgz", - "integrity": "sha512-fou+U20j+Jl0EHwK92spoWISON2OBnCazIc038Xj2TdweYV33ZRkS9nwqiUi2d/Wba5xg5UoHfvynnb/UB49cQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/vite": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.8.tgz", - "integrity": "sha512-CQ+I8yxNV5/6uGaJjiuymgw0kEQiNKRinYbZXPdx1fk5WgiyReG0VaUx/Xq6aVNSUNJFzxm6o8FNKS5aMaim5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tailwindcss/node": "4.1.8", - "@tailwindcss/oxide": "4.1.8", - "tailwindcss": "4.1.8" - }, - "peerDependencies": { - "vite": "^5.2.0 || ^6" - } - }, - "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.20.7", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", - "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.20.7" - } - }, - "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==", - "dev": true, - "license": "MIT" - }, - "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/react": { - "version": "19.1.6", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.6.tgz", - "integrity": "sha512-JeG0rEWak0N6Itr6QUx+X60uQmN+5t3j9r/OVDtWzFXKaj6kD1BwJzOksD0FF6iWxZlbE1kB0q9vtnU2ekqa1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.0.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.1.6", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz", - "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.0.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.33.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.33.1.tgz", - "integrity": "sha512-TDCXj+YxLgtvxvFlAvpoRv9MAncDLBV2oT9Bd7YBGC/b/sEURoOYuIwLI99rjWOfY3QtDzO+mk0n4AmdFExW8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.33.1", - "@typescript-eslint/type-utils": "8.33.1", - "@typescript-eslint/utils": "8.33.1", - "@typescript-eslint/visitor-keys": "8.33.1", - "graphemer": "^1.4.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.33.1", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.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.33.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.33.1.tgz", - "integrity": "sha512-qwxv6dq682yVvgKKp2qWwLgRbscDAYktPptK4JPojCwwi3R9cwrvIxS4lvBpzmcqzR4bdn54Z0IG1uHFskW4dA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.33.1", - "@typescript-eslint/types": "8.33.1", - "@typescript-eslint/typescript-estree": "8.33.1", - "@typescript-eslint/visitor-keys": "8.33.1", - "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 <5.9.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.33.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.33.1.tgz", - "integrity": "sha512-DZR0efeNklDIHHGRpMpR5gJITQpu6tLr9lDJnKdONTC7vvzOlLAG/wcfxcdxEWrbiZApcoBCzXqU/Z458Za5Iw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.33.1", - "@typescript-eslint/types": "^8.33.1", - "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 <5.9.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.33.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.33.1.tgz", - "integrity": "sha512-dM4UBtgmzHR9bS0Rv09JST0RcHYearoEoo3pG5B6GoTR9XcyeqX87FEhPo+5kTvVfKCvfHaHrcgeJQc6mrDKrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.33.1", - "@typescript-eslint/visitor-keys": "8.33.1" - }, - "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.33.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.33.1.tgz", - "integrity": "sha512-STAQsGYbHCF0/e+ShUQ4EatXQ7ceh3fBCXkNU7/MZVKulrlq1usH7t2FhxvCpuCi5O5oi1vmVaAjrGeL71OK1g==", - "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 <5.9.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.33.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.33.1.tgz", - "integrity": "sha512-1cG37d9xOkhlykom55WVwG2QRNC7YXlxMaMzqw2uPeJixBFfKWZgaP/hjAObqMN/u3fr5BrTwTnc31/L9jQ2ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/typescript-estree": "8.33.1", - "@typescript-eslint/utils": "8.33.1", - "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 <5.9.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.33.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.33.1.tgz", - "integrity": "sha512-xid1WfizGhy/TKMTwhtVOgalHwPtV8T32MS9MaH50Cwvz6x6YqRIPdD2WvW0XaqOzTV9p5xdLY0h/ZusU5Lokg==", - "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.33.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.33.1.tgz", - "integrity": "sha512-+s9LYcT8LWjdYWu7IWs7FvUxpQ/DGkdjZeE/GGulHvv8rvYwQvVaUZ6DE+j5x/prADUgSbbCWZ2nPI3usuVeOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.33.1", - "@typescript-eslint/tsconfig-utils": "8.33.1", - "@typescript-eslint/types": "8.33.1", - "@typescript-eslint/visitor-keys": "8.33.1", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.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": ">=4.8.4 <5.9.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.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.33.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.33.1.tgz", - "integrity": "sha512-52HaBiEQUaRYqAXpfzWSR2U3gxk92Kw006+xZpElaPMg3C4PgM+A5LqwoQI1f9E5aZ/qlxAZxzm42WX+vn92SQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.33.1", - "@typescript-eslint/types": "8.33.1", - "@typescript-eslint/typescript-estree": "8.33.1" - }, - "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 <5.9.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.33.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.33.1.tgz", - "integrity": "sha512-3i8NrFcZeeDHJ+7ZUuDkGT+UHq+XoFGsymNK2jZCOHcfEzRQ0BdpRtdpSx/Iyf3MHLWIcLS0COuOPibKQboIiQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.33.1", - "eslint-visitor-keys": "^4.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.1.tgz", - "integrity": "sha512-uPZBqSI0YD4lpkIru6M35sIfylLGTyhGHvDZbNLuMA73lMlwJKz5xweH7FajfcCAc2HnINciejA9qTz0dr0M7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.26.10", - "@babel/plugin-transform-react-jsx-self": "^7.25.9", - "@babel/plugin-transform-react-jsx-source": "^7.25.9", - "@rolldown/pluginutils": "1.0.0-beta.9", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" - } - }, - "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/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-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/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/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.25.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz", - "integrity": "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==", - "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": { - "caniuse-lite": "^1.0.30001718", - "electron-to-chromium": "^1.5.160", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" - }, - "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.30001721", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001721.tgz", - "integrity": "sha512-cOuvmUVtKrtEaoKiO0rSc29jcjwMwX5tOHDy4MgVFEWiUXj4uBMJkwI8MDySkgXidpMiHUcviogAvFi4pA2hDQ==", - "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/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/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "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/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/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "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/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "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/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.165", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.165.tgz", - "integrity": "sha512-naiMx1Z6Nb2TxPU6fiFrUrDTjyPMLdTtaOd2oLmG8zVSg2hCWGkhPyxwk+qRmZ1ytwVqUv0u7ZcDA5+ALhaUtw==", - "dev": true, - "license": "ISC" - }, - "node_modules/enhanced-resolve": { - "version": "5.18.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", - "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/esbuild": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", - "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.5", - "@esbuild/android-arm": "0.25.5", - "@esbuild/android-arm64": "0.25.5", - "@esbuild/android-x64": "0.25.5", - "@esbuild/darwin-arm64": "0.25.5", - "@esbuild/darwin-x64": "0.25.5", - "@esbuild/freebsd-arm64": "0.25.5", - "@esbuild/freebsd-x64": "0.25.5", - "@esbuild/linux-arm": "0.25.5", - "@esbuild/linux-arm64": "0.25.5", - "@esbuild/linux-ia32": "0.25.5", - "@esbuild/linux-loong64": "0.25.5", - "@esbuild/linux-mips64el": "0.25.5", - "@esbuild/linux-ppc64": "0.25.5", - "@esbuild/linux-riscv64": "0.25.5", - "@esbuild/linux-s390x": "0.25.5", - "@esbuild/linux-x64": "0.25.5", - "@esbuild/netbsd-arm64": "0.25.5", - "@esbuild/netbsd-x64": "0.25.5", - "@esbuild/openbsd-arm64": "0.25.5", - "@esbuild/openbsd-x64": "0.25.5", - "@esbuild/sunos-x64": "0.25.5", - "@esbuild/win32-arm64": "0.25.5", - "@esbuild/win32-ia32": "0.25.5", - "@esbuild/win32-x64": "0.25.5" - } - }, - "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.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", - "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", - "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.1", - "@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": "5.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", - "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "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.20", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz", - "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "eslint": ">=8.40" - } - }, - "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/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/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/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/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.2.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.2.0.tgz", - "integrity": "sha512-O+7l9tPdHCU320IigZZPj5zmRCFG9xHmx9cU8FqU2Rp+JN714seHV+2S9+JslCpY4gJwU2vOGox0wzgae/MCEg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, - "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/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/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-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/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/jiti": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", - "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", - "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/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/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/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/lightningcss": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", - "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-darwin-arm64": "1.30.1", - "lightningcss-darwin-x64": "1.30.1", - "lightningcss-freebsd-x64": "1.30.1", - "lightningcss-linux-arm-gnueabihf": "1.30.1", - "lightningcss-linux-arm64-gnu": "1.30.1", - "lightningcss-linux-arm64-musl": "1.30.1", - "lightningcss-linux-x64-gnu": "1.30.1", - "lightningcss-linux-x64-musl": "1.30.1", - "lightningcss-win32-arm64-msvc": "1.30.1", - "lightningcss-win32-x64-msvc": "1.30.1" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", - "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", - "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", - "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", - "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", - "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", - "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", - "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", - "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", - "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", - "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "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/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/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.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/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/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/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minizlib": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", - "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "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==", - "dev": true, - "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.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", - "dev": true, - "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/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/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/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": "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/postcss": { - "version": "8.5.4", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.4.tgz", - "integrity": "sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w==", - "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/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.1.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", - "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", - "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.26.0" - }, - "peerDependencies": { - "react": "^19.1.0" - } - }, - "node_modules/react-icons": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", - "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", - "license": "MIT", - "peerDependencies": { - "react": "*" - } - }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-resizable-panels": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.4.tgz", - "integrity": "sha512-8Y4KNgV94XhUvI2LeByyPIjoUJb71M/0hyhtzkHaqpVHs+ZQs8b627HmzyhmVYi3C9YP6R+XD1KmG7hHjEZXFQ==", - "license": "MIT", - "peerDependencies": { - "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", - "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - } - }, - "node_modules/react-router": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.2.tgz", - "integrity": "sha512-U7Nv3y+bMimgWjhlT5CRdzHPu2/KVmqPwKUCChW8en5P3znxUqwlYFlbmyj8Rgp1SF6zs5X4+77kBVknkg6a0w==", - "license": "MIT", - "dependencies": { - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, - "node_modules/react-router-dom": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.2.tgz", - "integrity": "sha512-Q8zb6VlTbdYKK5JJBLQEN06oTUa/RAbG/oQS1auK1I0TbJOXktqm+QENEVJU6QvWynlXPRBXI3fiOQcSEA78rA==", - "license": "MIT", - "dependencies": { - "react-router": "7.6.2" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - } - }, - "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.42.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.42.0.tgz", - "integrity": "sha512-LW+Vse3BJPyGJGAJt1j8pWDKPd73QM8cRXYK1IxOBgL2AGLu7Xd2YOW0M2sLUBCkF5MshXXtMApyEAEzMVMsnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.7" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.42.0", - "@rollup/rollup-android-arm64": "4.42.0", - "@rollup/rollup-darwin-arm64": "4.42.0", - "@rollup/rollup-darwin-x64": "4.42.0", - "@rollup/rollup-freebsd-arm64": "4.42.0", - "@rollup/rollup-freebsd-x64": "4.42.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.42.0", - "@rollup/rollup-linux-arm-musleabihf": "4.42.0", - "@rollup/rollup-linux-arm64-gnu": "4.42.0", - "@rollup/rollup-linux-arm64-musl": "4.42.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.42.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.42.0", - "@rollup/rollup-linux-riscv64-gnu": "4.42.0", - "@rollup/rollup-linux-riscv64-musl": "4.42.0", - "@rollup/rollup-linux-s390x-gnu": "4.42.0", - "@rollup/rollup-linux-x64-gnu": "4.42.0", - "@rollup/rollup-linux-x64-musl": "4.42.0", - "@rollup/rollup-win32-arm64-msvc": "4.42.0", - "@rollup/rollup-win32-ia32-msvc": "4.42.0", - "@rollup/rollup-win32-x64-msvc": "4.42.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/rollup/node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", - "dev": true, - "license": "MIT" - }, - "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/scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", - "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/set-cookie-parser": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", - "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", - "license": "MIT" - }, - "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/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/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/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/tailwindcss": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.8.tgz", - "integrity": "sha512-kjeW8gjdxasbmFKpVGrGd5T4i40mV5J2Rasw48QARfYeQ8YS9x02ON9SFWax3Qf616rt4Cp3nVNIj6Hd1mP3og==", - "dev": true, - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", - "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/tar": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", - "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/tar/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.5", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.5.tgz", - "integrity": "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "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/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/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.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.33.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.33.1.tgz", - "integrity": "sha512-AgRnV4sKkWOiZ0Kjbnf5ytTJXMUZQ0qhSVdQtDNYLPLnjsATEYhaO94GlRQwi4t4gO8FfjM6NnikHeKjUm8D7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.33.1", - "@typescript-eslint/parser": "8.33.1", - "@typescript-eslint/utils": "8.33.1" - }, - "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 <5.9.0" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", - "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/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "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/vite/node_modules/fdir": { - "version": "6.4.5", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.5.tgz", - "integrity": "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "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/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/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" - } - } - } -} diff --git a/ui/package.json b/ui/package.json deleted file mode 100644 index f0e18d42..00000000 --- a/ui/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "ui", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "start": "vite", - "build": "tsc -b && vite build --emptyOutDir", - "lint": "eslint .", - "preview": "vite preview" - }, - "dependencies": { - "react": "^19.1.0", - "react-dom": "^19.1.0", - "react-icons": "^5.5.0", - "react-resizable-panels": "^3.0.4", - "react-router-dom": "^7.6.2" - }, - "devDependencies": { - "@eslint/js": "^9.25.0", - "@tailwindcss/vite": "^4.1.8", - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", - "@vitejs/plugin-react": "^4.4.1", - "eslint": "^9.25.0", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.19", - "globals": "^16.0.0", - "tailwindcss": "^4.1.8", - "typescript": "~5.8.3", - "typescript-eslint": "^8.30.1", - "vite": "^6.3.5" - } -} diff --git a/ui/src/App.css b/ui/src/App.css deleted file mode 100644 index 902778b7..00000000 --- a/ui/src/App.css +++ /dev/null @@ -1,6 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} diff --git a/ui/src/App.tsx b/ui/src/App.tsx deleted file mode 100644 index 9842b7b9..00000000 --- a/ui/src/App.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useEffect } from "react"; -import { Navigate, Route, BrowserRouter as Router, Routes } from "react-router-dom"; -import { Header } from "./components/Header"; -import { useAPI } from "./contexts/APIProvider"; -import { useTheme } from "./contexts/ThemeProvider"; -import ActivityPage from "./pages/Activity"; -import LogViewerPage from "./pages/LogViewer"; -import ModelPage from "./pages/Models"; - -function App() { - const { setConnectionState } = useTheme(); - - const { connectionStatus } = useAPI(); - - // Synchronize the window.title connections state with the actual connection state - useEffect(() => { - setConnectionState(connectionStatus); - }, [connectionStatus]); - - return ( - -
-
- -
- - } /> - } /> - } /> - } /> - -
-
-
- ); -} - -export default App; diff --git a/ui/src/components/ConnectionStatus.tsx b/ui/src/components/ConnectionStatus.tsx deleted file mode 100644 index e22c5f35..00000000 --- a/ui/src/components/ConnectionStatus.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useAPI } from "../contexts/APIProvider"; -import { useMemo } from "react"; - -const ConnectionStatusIcon = () => { - const { connectionStatus, versionInfo } = useAPI(); - - const eventStatusColor = useMemo(() => { - switch (connectionStatus) { - case "connected": - return "bg-emerald-500"; - case "connecting": - return "bg-amber-500"; - case "disconnected": - default: - return "bg-red-500"; - } - }, [connectionStatus]); - - return ( -
- -
- ); -}; - -export default ConnectionStatusIcon; diff --git a/ui/src/components/Header.tsx b/ui/src/components/Header.tsx deleted file mode 100644 index c325c072..00000000 --- a/ui/src/components/Header.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { useCallback } from "react"; -import { RiMoonFill, RiSunFill } from "react-icons/ri"; -import { NavLink, type NavLinkRenderProps } from "react-router-dom"; -import { useTheme } from "../contexts/ThemeProvider"; -import ConnectionStatusIcon from "./ConnectionStatus"; - -export function Header() { - const { screenWidth, toggleTheme, isDarkMode, appTitle, setAppTitle, isNarrow } = useTheme(); - const handleTitleChange = useCallback( - (newTitle: string) => { - setAppTitle(newTitle.replace(/\n/g, "").trim().substring(0, 64) || "llmsnap"); - }, - [setAppTitle] - ); - - const navLinkClass = ({ isActive }: NavLinkRenderProps) => - `text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 ${isActive ? "font-semibold" : ""}`; - - return ( -
- {screenWidth !== "xs" && screenWidth !== "sm" && ( -

handleTitleChange(e.currentTarget.textContent || "(set title)")} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - handleTitleChange(e.currentTarget.textContent || "(set title)"); - e.currentTarget.blur(); - } - }} - > - {appTitle} -

- )} - - - - Logs - - - Models - - - Activity - - - - -
- ); -} diff --git a/ui/src/contexts/APIProvider.tsx b/ui/src/contexts/APIProvider.tsx deleted file mode 100644 index 49b5a2e5..00000000 --- a/ui/src/contexts/APIProvider.tsx +++ /dev/null @@ -1,297 +0,0 @@ -import { createContext, useState, useContext, useEffect, useCallback, useMemo, type ReactNode } from "react"; -import type { ConnectionState } from "../lib/types"; - -type ModelStatus = "ready" | "starting" | "stopping" | "stopped" | "shutdown" | "sleepPending" | "asleep" | "waking" | "unknown"; -const LOG_LENGTH_LIMIT = 1024 * 100; /* 100KB of log data */ - -export interface Model { - id: string; - state: ModelStatus; - name: string; - description: string; - unlisted: boolean; - sleepMode: string; -} - -interface APIProviderType { - models: Model[]; - listModels: () => Promise; - unloadAllModels: () => Promise; - unloadSingleModel: (model: string) => Promise; - loadModel: (model: string) => Promise; - sleepModel: (model: string) => Promise; - enableAPIEvents: (enabled: boolean) => void; - proxyLogs: string; - upstreamLogs: string; - metrics: Metrics[]; - connectionStatus: ConnectionState; - versionInfo: VersionInfo; -} - -interface Metrics { - id: number; - timestamp: string; - model: string; - cache_tokens: number; - input_tokens: number; - output_tokens: number; - prompt_per_second: number; - tokens_per_second: number; - duration_ms: number; -} - -interface LogData { - source: "upstream" | "proxy"; - data: string; -} - -interface APIEventEnvelope { - type: "modelStatus" | "logData" | "metrics"; - data: string; -} - -interface VersionInfo { - build_date: string; - commit: string; - version: string; -} - -const APIContext = createContext(undefined); -type APIProviderProps = { - children: ReactNode; - autoStartAPIEvents?: boolean; -}; - -let apiEventSource: EventSource | null = null; - -export function APIProvider({ children, autoStartAPIEvents = true }: APIProviderProps) { - const [proxyLogs, setProxyLogs] = useState(""); - const [upstreamLogs, setUpstreamLogs] = useState(""); - const [metrics, setMetrics] = useState([]); - const [connectionStatus, setConnectionState] = useState("disconnected"); - const [versionInfo, setVersionInfo] = useState({ - build_date: "unknown", - commit: "unknown", - version: "unknown" - }); - //const apiEventSource = useRef(null); - - const [models, setModels] = useState([]); - - const appendLog = useCallback((newData: string, setter: React.Dispatch>) => { - setter((prev) => { - const updatedLog = prev + newData; - return updatedLog.length > LOG_LENGTH_LIMIT ? updatedLog.slice(-LOG_LENGTH_LIMIT) : updatedLog; - }); - }, []); - - const enableAPIEvents = useCallback((enabled: boolean) => { - if (!enabled) { - apiEventSource?.close(); - apiEventSource = null; - setMetrics([]); - return; - } - - let retryCount = 0; - const initialDelay = 1000; // 1 second - - const connect = () => { - apiEventSource?.close(); - apiEventSource = new EventSource("/api/events"); - - setConnectionState("connecting"); - - apiEventSource.onopen = () => { - // clear everything out on connect to keep things in sync - setProxyLogs(""); - setUpstreamLogs(""); - setMetrics([]); // clear metrics on reconnect - setModels([]); // clear models on reconnect - retryCount = 0; - setConnectionState("connected"); - }; - - apiEventSource.onmessage = (e: MessageEvent) => { - try { - const message = JSON.parse(e.data) as APIEventEnvelope; - switch (message.type) { - case "modelStatus": - { - const models = JSON.parse(message.data) as Model[]; - - // sort models by name and id - models.sort((a, b) => { - return (a.name + a.id).localeCompare(b.name + b.id); - }); - - setModels(models); - } - break; - - case "logData": - const logData = JSON.parse(message.data) as LogData; - switch (logData.source) { - case "proxy": - appendLog(logData.data, setProxyLogs); - break; - case "upstream": - appendLog(logData.data, setUpstreamLogs); - break; - } - break; - - case "metrics": - { - const newMetrics = JSON.parse(message.data) as Metrics[]; - setMetrics((prevMetrics) => { - return [...newMetrics, ...prevMetrics]; - }); - } - break; - } - } catch (err) { - console.error(e.data, err); - } - }; - - apiEventSource.onerror = () => { - apiEventSource?.close(); - retryCount++; - const delay = Math.min(initialDelay * Math.pow(2, retryCount - 1), 5000); - setConnectionState("disconnected"); - setTimeout(connect, delay); - }; - }; - - connect(); - }, []); - - useEffect(() => { - // fetch version - const fetchVersion = async () => { - try { - const response = await fetch("/api/version"); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const data: VersionInfo = await response.json(); - setVersionInfo(data); - } catch (error) { - console.error(error); - } - }; - - if (connectionStatus === 'connected') { - fetchVersion(); - } - }, [connectionStatus]); - - useEffect(() => { - if (autoStartAPIEvents) { - enableAPIEvents(true); - } - - return () => { - enableAPIEvents(false); - }; - }, [enableAPIEvents, autoStartAPIEvents]); - - const listModels = useCallback(async (): Promise => { - try { - const response = await fetch("/api/models/"); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const data = await response.json(); - return data || []; - } catch (error) { - console.error("Failed to fetch models:", error); - return []; // Return empty array as fallback - } - }, []); - - const unloadAllModels = useCallback(async () => { - try { - const response = await fetch(`/api/models/unload`, { - method: "POST", - }); - if (!response.ok) { - throw new Error(`Failed to unload models: ${response.status}`); - } - } catch (error) { - console.error("Failed to unload models:", error); - throw error; // Re-throw to let calling code handle it - } - }, []); - - const unloadSingleModel = useCallback(async (model: string) => { - try { - const response = await fetch(`/api/models/unload/${model}`, { - method: "POST", - }); - if (!response.ok) { - throw new Error(`Failed to unload model: ${response.status}`); - } - } catch (error) { - console.error("Failed to unload model", model, error); - throw error; - } - }, []); - - const loadModel = useCallback(async (model: string) => { - try { - const response = await fetch(`/upstream/${model}/`, { - method: "GET", - }); - if (!response.ok) { - throw new Error(`Failed to load model: ${response.status}`); - } - } catch (error) { - console.error("Failed to load model:", error); - throw error; // Re-throw to let calling code handle it - } - }, []); - - const sleepModel = useCallback(async (model: string) => { - try { - const response = await fetch(`/api/models/sleep/${model}`, { - method: "POST", - }); - if (!response.ok) { - throw new Error(`Failed to sleep model: ${response.status}`); - } - } catch (error) { - console.error("Failed to sleep model:", error); - throw error; - } - }, []); - - const value = useMemo( - () => ({ - models, - listModels, - unloadAllModels, - unloadSingleModel, - loadModel, - sleepModel, - enableAPIEvents, - proxyLogs, - upstreamLogs, - metrics, - connectionStatus, - versionInfo, - }), - [models, listModels, unloadAllModels, unloadSingleModel, loadModel, sleepModel, enableAPIEvents, proxyLogs, upstreamLogs, metrics, connectionStatus, versionInfo] - ); - - return {children}; -} - -export function useAPI() { - const context = useContext(APIContext); - if (context === undefined) { - throw new Error("useAPI must be used within an APIProvider"); - } - return context; -} diff --git a/ui/src/contexts/ThemeProvider.tsx b/ui/src/contexts/ThemeProvider.tsx deleted file mode 100644 index 7727e5ad..00000000 --- a/ui/src/contexts/ThemeProvider.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { createContext, useContext, useEffect, type ReactNode, useMemo, useState } from "react"; -import { usePersistentState } from "../hooks/usePersistentState"; -import type { ConnectionState } from "../lib/types"; - -type ScreenWidth = "xs" | "sm" | "md" | "lg" | "xl" | "2xl"; -type ThemeContextType = { - isDarkMode: boolean; - screenWidth: ScreenWidth; - isNarrow: boolean; - toggleTheme: () => void; - - // for managing the window title and connection state information - appTitle: string; - setAppTitle: (title: string) => void; - setConnectionState: (state: ConnectionState) => void; -}; - -const ThemeContext = createContext(undefined); - -type ThemeProviderProps = { - children: ReactNode; -}; - -export function ThemeProvider({ children }: ThemeProviderProps) { - const [appTitle, setAppTitle] = usePersistentState("appTitle", "llmsnap"); - const [connectionState, setConnectionState] = useState("disconnected"); - - /** - * Set the document.title with informative information - */ - useEffect(() => { - const connectionIcon = connectionState === "connecting" ? "🟡" : connectionState === "connected" ? "🟢" : "🔴"; - document.title = connectionIcon + " " + appTitle; // Set initial title - }, [appTitle, connectionState]); - - const [isDarkMode, setIsDarkMode] = usePersistentState("theme", false); - const [screenWidth, setScreenWidth] = useState("md"); // Default to md - - // matches tailwind classes - // https://tailwindcss.com/docs/responsive-design - useEffect(() => { - const checkInnerWidth = () => { - const innerWidth = window.innerWidth; - if (innerWidth < 640) { - setScreenWidth("xs"); - } else if (innerWidth < 768) { - setScreenWidth("sm"); - } else if (innerWidth < 1024) { - setScreenWidth("md"); - } else if (innerWidth < 1280) { - setScreenWidth("lg"); - } else if (innerWidth < 1536) { - setScreenWidth("xl"); - } else { - setScreenWidth("2xl"); - } - }; - - checkInnerWidth(); - window.addEventListener("resize", checkInnerWidth); - - return () => window.removeEventListener("resize", checkInnerWidth); - }, []); - - useEffect(() => { - document.documentElement.setAttribute("data-theme", isDarkMode ? "dark" : "light"); - }, [isDarkMode]); - - const toggleTheme = () => setIsDarkMode((prev) => !prev); - const isNarrow = useMemo(() => { - return screenWidth === "xs" || screenWidth === "sm" || screenWidth === "md"; - }, [screenWidth]); - - return ( - - {children} - - ); -} - -export function useTheme(): ThemeContextType { - const context = useContext(ThemeContext); - if (context === undefined) { - throw new Error("useTheme must be used within a ThemeProvider"); - } - return context; -} diff --git a/ui/src/hooks/usePersistentState.ts b/ui/src/hooks/usePersistentState.ts deleted file mode 100644 index 8320977c..00000000 --- a/ui/src/hooks/usePersistentState.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useState, useEffect, useCallback } from "react"; - -export function usePersistentState(key: string, initialValue: T): [T, (value: T | ((prevState: T) => T)) => void] { - const [state, setState] = useState(() => { - if (typeof window === "undefined") return initialValue; - try { - const saved = localStorage.getItem(key); - return saved !== null ? JSON.parse(saved) : initialValue; - } catch (e) { - console.error(`Error parsing stored value for ${key}`, e); - return initialValue; - } - }); - - const setPersistentState = useCallback( - (value: T | ((prevState: T) => T)) => { - setState((prev) => { - const nextValue = typeof value === "function" ? (value as (prevState: T) => T)(prev) : value; - try { - localStorage.setItem(key, JSON.stringify(nextValue)); - } catch (e) { - console.error(`Error saving value for ${key}`, e); - } - return nextValue; - }); - }, - [key] - ); - - useEffect(() => { - try { - localStorage.setItem(key, JSON.stringify(state)); - } catch (e) { - console.error(`Error saving value for ${key}`, e); - } - }, [key, state]); - - return [state, setPersistentState]; -} diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts deleted file mode 100644 index 1d55c13c..00000000 --- a/ui/src/lib/types.ts +++ /dev/null @@ -1 +0,0 @@ -export type ConnectionState = "connected" | "connecting" | "disconnected"; diff --git a/ui/src/main.tsx b/ui/src/main.tsx deleted file mode 100644 index f26d521a..00000000 --- a/ui/src/main.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import "./index.css"; -import App from "./App.tsx"; -import { ThemeProvider } from "./contexts/ThemeProvider"; -import { APIProvider } from "./contexts/APIProvider"; - -createRoot(document.getElementById("root")!).render( - - - - - - - -); diff --git a/ui/src/pages/Activity.tsx b/ui/src/pages/Activity.tsx deleted file mode 100644 index d1faf032..00000000 --- a/ui/src/pages/Activity.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useMemo } from "react"; -import { useAPI } from "../contexts/APIProvider"; - -const formatSpeed = (speed: number): string => { - return speed < 0 ? "unknown" : speed.toFixed(2) + " t/s"; -}; - -const formatDuration = (ms: number): string => { - return (ms / 1000).toFixed(2) + "s"; -}; - -const formatRelativeTime = (timestamp: string): string => { - const now = new Date(); - const date = new Date(timestamp); - const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); - - // Handle future dates by returning "just now" - if (diffInSeconds < 5) { - return "now"; - } - - if (diffInSeconds < 60) { - return `${diffInSeconds}s ago`; - } - - const diffInMinutes = Math.floor(diffInSeconds / 60); - if (diffInMinutes < 60) { - return `${diffInMinutes}m ago`; - } - - const diffInHours = Math.floor(diffInMinutes / 60); - if (diffInHours < 24) { - return `${diffInHours}h ago`; - } - - return "a while ago"; -}; - -const ActivityPage = () => { - const { metrics } = useAPI(); - const sortedMetrics = useMemo(() => { - return [...metrics].sort((a, b) => b.id - a.id); - }, [metrics]); - - return ( -
-

Activity

- - {metrics.length === 0 && ( -
-

No metrics data available

-
- )} - {metrics.length > 0 && ( -
- - - - - - - - - - - - - - - - {sortedMetrics.map((metric) => ( - - - - - - - - - - - - ))} - -
IDTimeModel - Cached - - Prompt - GeneratedPrompt ProcessingGeneration SpeedDuration
{metric.id + 1 /* un-zero index */}{formatRelativeTime(metric.timestamp)}{metric.model}{metric.cache_tokens > 0 ? metric.cache_tokens.toLocaleString() : "-"}{metric.input_tokens.toLocaleString()}{metric.output_tokens.toLocaleString()}{formatSpeed(metric.prompt_per_second)}{formatSpeed(metric.tokens_per_second)}{formatDuration(metric.duration_ms)}
-
- )} -
- ); -}; - -interface TooltipProps { - content: string; -} - -const Tooltip: React.FC = ({ content }) => { - return ( -
- ⓘ -
- {content} -
-
-
- ); -}; - -export default ActivityPage; diff --git a/ui/src/pages/LogViewer.tsx b/ui/src/pages/LogViewer.tsx deleted file mode 100644 index 24bc3407..00000000 --- a/ui/src/pages/LogViewer.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { useState, useEffect, useRef, useMemo, useCallback } from "react"; -import { useAPI } from "../contexts/APIProvider"; -import { usePersistentState } from "../hooks/usePersistentState"; -import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; -import { - RiTextWrap, - RiAlignJustify, - RiFontSize, - RiMenuSearchLine, - RiMenuSearchFill, - RiCloseCircleFill, -} from "react-icons/ri"; -import { useTheme } from "../contexts/ThemeProvider"; - -const LogViewer = () => { - const { proxyLogs, upstreamLogs } = useAPI(); - const { screenWidth } = useTheme(); - const direction = screenWidth === "xs" || screenWidth === "sm" ? "vertical" : "horizontal"; - - return ( - - - - - - - - - - ); -}; - -interface LogPanelProps { - id: string; - title: string; - logData: string; -} -export const LogPanel = ({ id, title, logData }: LogPanelProps) => { - const [filterRegex, setFilterRegex] = useState(""); - const [fontSize, setFontSize] = usePersistentState<"xxs" | "xs" | "small" | "normal">( - `logPanel-${id}-fontSize`, - "normal" - ); - const [wrapText, setTextWrap] = usePersistentState(`logPanel-${id}-wrapText`, false); - const [showFilter, setShowFilter] = usePersistentState(`logPanel-${id}-showFilter`, false); - - const textWrapClass = useMemo(() => { - return wrapText ? "whitespace-pre-wrap" : "whitespace-pre"; - }, [wrapText]); - - const toggleFontSize = useCallback(() => { - setFontSize((prev) => { - switch (prev) { - case "xxs": - return "xs"; - case "xs": - return "small"; - case "small": - return "normal"; - case "normal": - return "xxs"; - } - }); - }, []); - - const toggleWrapText = useCallback(() => { - setTextWrap((prev) => !prev); - }, []); - - const toggleFilter = useCallback(() => { - if (showFilter) { - setShowFilter(false); - setFilterRegex(""); // Clear filter when closing - } else { - setShowFilter(true); - } - }, [filterRegex, setFilterRegex, showFilter]); - - const fontSizeClass = useMemo(() => { - switch (fontSize) { - case "xxs": - return "text-[0.5rem]"; // 0.5rem (8px) - case "xs": - return "text-[0.75rem]"; // 0.75rem (12px) - case "small": - return "text-[0.875rem]"; // 0.875rem (14px) - case "normal": - return "text-base"; // 1rem (16px) - } - }, [fontSize]); - - const filteredLogs = useMemo(() => { - if (!filterRegex) return logData; - try { - const regex = new RegExp(filterRegex, "i"); - const lines = logData.split("\n"); - const filtered = lines.filter((line) => regex.test(line)); - return filtered.join("\n"); - } catch (e) { - return logData; // Return unfiltered if regex is invalid - } - }, [logData, filterRegex]); - - // auto scroll to bottom - const preTagRef = useRef(null); - useEffect(() => { - if (!preTagRef.current) return; - preTagRef.current.scrollTop = preTagRef.current.scrollHeight; - }, [filteredLogs]); - - return ( -
-
-
-

{title}

- -
- - - -
-
- - {/* Filtering Options - Full width on mobile, normal on desktop */} - {showFilter && ( -
-
- setFilterRegex(e.target.value)} - /> - -
-
- )} -
-
-
-          {filteredLogs}
-        
-
-
- ); -}; -export default LogViewer; diff --git a/ui/src/pages/Models.tsx b/ui/src/pages/Models.tsx deleted file mode 100644 index 1c363e6e..00000000 --- a/ui/src/pages/Models.tsx +++ /dev/null @@ -1,512 +0,0 @@ -import { useState, useCallback, useMemo } from "react"; -import { useAPI } from "../contexts/APIProvider"; -import { LogPanel } from "./LogViewer"; -import { usePersistentState } from "../hooks/usePersistentState"; -import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; -import { useTheme } from "../contexts/ThemeProvider"; -import { RiEyeFill, RiEyeOffFill, RiSwapBoxFill, RiEjectLine, RiMenuFill } from "react-icons/ri"; - -export default function ModelsPage() { - const { isNarrow } = useTheme(); - const direction = isNarrow ? "vertical" : "horizontal"; - const { upstreamLogs } = useAPI(); - - return ( - - - - - - - -
- {direction === "horizontal" && } -
- -
-
-
-
- ); -} - -function ModelsPanel() { - const { models, loadModel, unloadAllModels, unloadSingleModel, sleepModel } = useAPI(); - const { isNarrow } = useTheme(); - const [isUnloading, setIsUnloading] = useState(false); - const [showUnlisted, setShowUnlisted] = usePersistentState("showUnlisted", true); - const [showIdorName, setShowIdorName] = usePersistentState<"id" | "name">("showIdorName", "id"); // true = show ID, false = show name - const [menuOpen, setMenuOpen] = useState(false); - - const filteredModels = useMemo(() => { - return models.filter((model) => showUnlisted || !model.unlisted); - }, [models, showUnlisted]); - - const handleUnloadAllModels = useCallback(async () => { - setIsUnloading(true); - try { - await unloadAllModels(); - } catch (e) { - console.error(e); - } finally { - setTimeout(() => { - setIsUnloading(false); - }, 1000); - } - }, [unloadAllModels]); - - const toggleIdorName = useCallback(() => { - setShowIdorName((prev) => (prev === "name" ? "id" : "name")); - }, [showIdorName]); - - return ( -
-
-
-

Models

- {isNarrow && ( -
- - {menuOpen && ( -
- - - -
- )} -
- )} -
- {!isNarrow && ( -
-
- - - -
- -
- )} -
- -
- - - - - - - - - - {filteredModels.map((model) => ( - - - - - - ))} - -
{showIdorName === "id" ? "Model ID" : "Name"}ActionsState
- - {showIdorName === "id" ? model.id : model.name !== "" ? model.name : model.id} - - - {!!model.description && ( -

- {model.description} -

- )} -
-
- {model.state === "stopped" ? ( - - ) : model.state === "asleep" ? ( - <> - - - - ) : model.state === "ready" ? ( - <> - {model.sleepMode === "enable" && ( - - )} - - - ) : ( - - )} -
-
- {model.state} -
-
-
- ); -} - -interface HistogramData { - bins: number[]; - min: number; - max: number; - binSize: number; - p99: number; - p95: number; - p50: number; -} - -function TokenHistogram({ data }: { data: HistogramData }) { - const { bins, min, max, p50, p95, p99 } = data; - const maxCount = Math.max(...bins); - - const height = 120; - const padding = { top: 10, right: 15, bottom: 25, left: 45 }; - - // Use viewBox for responsive sizing - const viewBoxWidth = 600; - const chartWidth = viewBoxWidth - padding.left - padding.right; - const chartHeight = height - padding.top - padding.bottom; - - const barWidth = chartWidth / bins.length; - const range = max - min; - - // Calculate x position for a given value - const getXPosition = (value: number) => { - return padding.left + ((value - min) / range) * chartWidth; - }; - - return ( -
- - {/* Y-axis */} - - - {/* X-axis */} - - - {/* Histogram bars */} - {bins.map((count, i) => { - const barHeight = maxCount > 0 ? (count / maxCount) * chartHeight : 0; - const x = padding.left + i * barWidth; - const y = height - padding.bottom - barHeight; - const binStart = min + i * data.binSize; - const binEnd = binStart + data.binSize; - - return ( - - - {`${binStart.toFixed(1)} - ${binEnd.toFixed(1)} tokens/sec\nCount: ${count}`} - - ); - })} - - {/* Percentile lines */} - - - - - - - {/* X-axis labels */} - - {min.toFixed(1)} - - - - {max.toFixed(1)} - - - {/* X-axis label */} - - Tokens/Second Distribution - - -
- ); -} - -function StatsPanel() { - const { metrics } = useAPI(); - - const [totalRequests, totalInputTokens, totalOutputTokens, tokenStats, histogramData] = useMemo(() => { - const totalRequests = metrics.length; - if (totalRequests === 0) { - return [0, 0, 0, { p99: 0, p95: 0, p50: 0 }, null]; - } - const totalInputTokens = metrics.reduce((sum, m) => sum + m.input_tokens, 0); - const totalOutputTokens = metrics.reduce((sum, m) => sum + m.output_tokens, 0); - - // Calculate token statistics using output_tokens and duration_ms - // Filter out metrics with invalid duration or output tokens - const validMetrics = metrics.filter((m) => m.duration_ms > 0 && m.output_tokens > 0); - if (validMetrics.length === 0) { - return [totalRequests, totalInputTokens, totalOutputTokens, { p99: 0, p95: 0, p50: 0 }, null]; - } - - // Calculate tokens/second for each valid metric - const tokensPerSecond = validMetrics.map((m) => m.output_tokens / (m.duration_ms / 1000)); - - // Sort for percentile calculation - const sortedTokensPerSecond = [...tokensPerSecond].sort((a, b) => a - b); - - // Calculate percentiles - showing speed thresholds where X% of requests are SLOWER (below) - // P99: 99% of requests are slower than this speed (99th percentile - fast requests) - // P95: 95% of requests are slower than this speed (95th percentile) - // P50: 50% of requests are slower than this speed (median) - const p99 = sortedTokensPerSecond[Math.floor(sortedTokensPerSecond.length * 0.99)]; - const p95 = sortedTokensPerSecond[Math.floor(sortedTokensPerSecond.length * 0.95)]; - const p50 = sortedTokensPerSecond[Math.floor(sortedTokensPerSecond.length * 0.5)]; - - // Create histogram data - const min = Math.min(...tokensPerSecond); - const max = Math.max(...tokensPerSecond); - const binCount = Math.min(30, Math.max(10, Math.floor(tokensPerSecond.length / 5))); // Adaptive bin count - const binSize = (max - min) / binCount; - - const bins = Array(binCount).fill(0); - tokensPerSecond.forEach((value) => { - const binIndex = Math.min(Math.floor((value - min) / binSize), binCount - 1); - bins[binIndex]++; - }); - - const histogramData = { - bins, - min, - max, - binSize, - p99, - p95, - p50, - }; - - return [ - totalRequests, - totalInputTokens, - totalOutputTokens, - { - p99: p99.toFixed(2), - p95: p95.toFixed(2), - p50: p50.toFixed(2), - }, - histogramData, - ]; - }, [metrics]); - - const nf = new Intl.NumberFormat(); - - return ( -
-
- - - - - - - - - - - - - - - - - - - - - -
- Requests - - Processed - - Generated - - Token Stats (tokens/sec) -
{totalRequests} -
- {nf.format(totalInputTokens)} - tokens -
-
-
- {nf.format(totalOutputTokens)} - tokens -
-
-
-
-
-
P50
-
- {tokenStats.p50} -
-
- -
-
P95
-
- {tokenStats.p95} -
-
- -
-
P99
-
- {tokenStats.p99} -
-
-
- {histogramData && } -
-
-
-
- ); -} diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts deleted file mode 100644 index 11f02fe2..00000000 --- a/ui/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/ui/tsconfig.app.json b/ui/tsconfig.app.json deleted file mode 100644 index c9ccbd4c..00000000 --- a/ui/tsconfig.app.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true - }, - "include": ["src"] -} diff --git a/ui/tsconfig.json b/ui/tsconfig.json deleted file mode 100644 index 1ffef600..00000000 --- a/ui/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] -} diff --git a/ui/tsconfig.node.json b/ui/tsconfig.node.json deleted file mode 100644 index 9728af2d..00000000 --- a/ui/tsconfig.node.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "target": "ES2022", - "lib": ["ES2023"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true - }, - "include": ["vite.config.ts"] -}