mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-22 15:49:12 -04:00
Compare commits
11 Commits
feat/recon
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e2b69e895 | ||
|
|
63bcbf6c12 | ||
|
|
95b058e1c5 | ||
|
|
f2abcc7503 | ||
|
|
62c99c10b3 | ||
|
|
7226bb9f30 | ||
|
|
569d9bbd9e | ||
|
|
682fb2718c | ||
|
|
20c643e1f6 | ||
|
|
64a4351f3a | ||
|
|
b7d67f5779 |
97
.github/workflows/tests-pii-ner-e2e.yml
vendored
Normal file
97
.github/workflows/tests-pii-ner-e2e.yml
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: 'PII NER tier E2E (live GGUF, CPU)'
|
||||
|
||||
# Runs the real privacy-filter GGUF NER tier end-to-end on CPU — the gap the
|
||||
# hermetic tests/e2e suite cannot cover (it only exercises the in-process
|
||||
# pattern tier). Heavy (builds the C++ backend image + downloads a ~2.7 GB
|
||||
# GGUF), so it is path-filtered on PRs and otherwise runs nightly / on demand.
|
||||
#
|
||||
# This drives the container-level harness (tests/e2e-backends) via
|
||||
# `make test-extra-backend-privacy-filter`: it builds the privacy-filter image,
|
||||
# downloads the model, loads it on CPU, and asserts byte-correct, UTF-8-aligned
|
||||
# TokenClassify spans. The complementary HTTP-path specs in tests/e2e
|
||||
# (e2e_pii_ner_test.go) Skip unless PII_NER_MODEL_GGUF is wired.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 3 * * *'
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- 'backend/cpp/privacy-filter/**'
|
||||
- 'backend/Dockerfile.privacy-filter'
|
||||
- 'core/services/routing/pii/**'
|
||||
- 'core/services/routing/piidetector/**'
|
||||
- 'core/backend/token_classify.go'
|
||||
- 'core/http/endpoints/localai/pii.go'
|
||||
- 'core/schema/pii.go'
|
||||
- 'tests/e2e-backends/**'
|
||||
- 'tests/e2e/e2e_pii_ner_test.go'
|
||||
- 'tests/e2e/e2e_suite_test.go'
|
||||
- '.github/workflows/tests-pii-ner-e2e.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'backend/cpp/privacy-filter/**'
|
||||
- 'backend/Dockerfile.privacy-filter'
|
||||
- 'core/services/routing/pii/**'
|
||||
- 'core/services/routing/piidetector/**'
|
||||
- 'core/backend/token_classify.go'
|
||||
- 'core/http/endpoints/localai/pii.go'
|
||||
- 'core/schema/pii.go'
|
||||
- 'tests/e2e-backends/**'
|
||||
- 'tests/e2e/e2e_pii_ner_test.go'
|
||||
- 'tests/e2e/e2e_suite_test.go'
|
||||
- '.github/workflows/tests-pii-ner-e2e.yml'
|
||||
|
||||
concurrency:
|
||||
group: ci-tests-pii-ner-e2e-${{ github.event.pull_request.number || github.sha }}-${{ github.repository }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
tests-pii-ner-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
go-version: ['1.25.x']
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
- name: Free disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL || true
|
||||
sudo docker image prune --all --force || true
|
||||
df -h
|
||||
- name: Configure apt mirror on runner
|
||||
uses: ./.github/actions/configure-apt-mirror
|
||||
- name: Setup Go ${{ matrix.go-version }}
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
cache: false
|
||||
- name: Proto Dependencies
|
||||
run: |
|
||||
curl -L -s https://github.com/protocolbuffers/protobuf/releases/download/v26.1/protoc-26.1-linux-x86_64.zip -o protoc.zip && \
|
||||
unzip -j -d /usr/local/bin protoc.zip bin/protoc && \
|
||||
rm protoc.zip
|
||||
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.2
|
||||
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@1958fcbe2ca8bd93af633f11e97d44e567e945af
|
||||
PATH="$PATH:$HOME/go/bin" make protogen-go
|
||||
- name: Dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential
|
||||
# Builds local-ai-backend:privacy-filter, downloads the GGUF, loads it on
|
||||
# CPU and runs the token_classify capability spec (byte-offset contract).
|
||||
- name: Run live PII NER backend E2E
|
||||
run: PATH="$PATH:$HOME/go/bin" make test-extra-backend-privacy-filter
|
||||
- name: Setup tmate session if tests fail
|
||||
if: ${{ failure() }}
|
||||
uses: mxschmitt/action-tmate@v3.23
|
||||
with:
|
||||
detached: true
|
||||
connect-timeout-seconds: 180
|
||||
limit-access-to-actor: true
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -91,3 +91,6 @@ core/http/react-ui/test-results/
|
||||
|
||||
# Local worktrees
|
||||
.worktrees/
|
||||
|
||||
# SDD / brainstorm scratch (agent-driven development)
|
||||
.superpowers/
|
||||
|
||||
10
Makefile
10
Makefile
@@ -690,6 +690,16 @@ test-extra-backend-llama-cpp-transcription: docker-build-llama-cpp
|
||||
BACKEND_TEST_CTX_SIZE=2048 \
|
||||
$(MAKE) test-extra-backend
|
||||
|
||||
## privacy-filter: the PII/NER token-classification backend. Exercises the
|
||||
## TokenClassify RPC and asserts byte-correct, UTF-8-aligned span offsets
|
||||
## against the openai-privacy-filter multilingual GGUF (CPU-runnable, ~50M
|
||||
## active params). This is the live-backend coverage for the PII NER tier.
|
||||
test-extra-backend-privacy-filter: docker-build-privacy-filter
|
||||
BACKEND_IMAGE=local-ai-backend:privacy-filter \
|
||||
BACKEND_TEST_MODEL_URL=https://huggingface.co/LocalAI-io/privacy-filter-multilingual-GGUF/resolve/main/privacy-filter-multilingual-f16.gguf \
|
||||
BACKEND_TEST_CAPS=health,load,token_classify \
|
||||
$(MAKE) test-extra-backend
|
||||
|
||||
## vllm is resolved from a HuggingFace model id (no file download) and
|
||||
## exercises Predict + streaming + tool-call extraction via the hermes parser.
|
||||
## Requires a host CPU with the SIMD instructions the prebuilt vllm CPU
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
LLAMA_VERSION?=e475fa2b5f9fb50c3d6fc3e7c6fdf1e004465b62
|
||||
LLAMA_VERSION?=7c082bc417bbe53210a83df4ba5b49e18ce6193c
|
||||
LLAMA_REPO?=https://github.com/ggerganov/llama.cpp
|
||||
|
||||
CMAKE_ARGS?=
|
||||
|
||||
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
|
||||
|
||||
# CrispASR version (release tag)
|
||||
CRISPASR_REPO?=https://github.com/CrispStrobe/CrispASR
|
||||
CRISPASR_VERSION?=d745bda4386ae0f9d1d2f23fff8ec95d76428221
|
||||
CRISPASR_VERSION?=7a8cb80907341c0204bd0488c1244764f4163883
|
||||
SO_TARGET?=libgocrispasr.so
|
||||
|
||||
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
--extra-index-url https://download.pytorch.org/whl/cpu
|
||||
git+https://github.com/huggingface/diffusers
|
||||
diffusers==0.38.0
|
||||
opencv-python
|
||||
transformers
|
||||
transformers==4.57.6
|
||||
torchvision==0.22.1
|
||||
accelerate
|
||||
git+https://github.com/xhinker/sd_embed
|
||||
@@ -10,9 +10,15 @@ sentencepiece
|
||||
torch==2.7.1
|
||||
optimum-quanto
|
||||
ftfy
|
||||
# TODO: re-add compel once it supports transformers >= 5.
|
||||
# Tracking: https://github.com/damian0815/compel/pull/129
|
||||
# https://github.com/damian0815/compel/issues/128
|
||||
# compel currently pins transformers~=4.25, which forced pip into multi-hour
|
||||
# resolver backtracking storms in CI. backend.py imports it lazily and gates
|
||||
# the COMPEL=1 env var on the import succeeding, so dropping it here is safe.
|
||||
# diffusers and transformers are pinned together on purpose. transformers v5
|
||||
# restructured CLIPTextModel and dropped the `.text_model` attribute, which
|
||||
# breaks single-file Stable Diffusion loading on every released diffusers
|
||||
# (<=0.38.0); only unreleased diffusers main supports transformers v5. Tracking
|
||||
# main via git froze whichever broken pair existed at image-build time. Pin the
|
||||
# last known-good released pair so builds are reproducible and can't drift into
|
||||
# the broken window. See https://github.com/mudler/LocalAI/issues/9979
|
||||
#
|
||||
# compel is intentionally omitted: it pins transformers~=4.25, which conflicts
|
||||
# with this pin and previously forced pip into multi-hour resolver backtracking
|
||||
# storms in CI. backend.py imports it lazily and gates the COMPEL=1 env var on
|
||||
# the import succeeding, so dropping it here is safe.
|
||||
@@ -1,7 +1,7 @@
|
||||
--extra-index-url https://download.pytorch.org/whl/cu121
|
||||
git+https://github.com/huggingface/diffusers
|
||||
diffusers==0.38.0
|
||||
opencv-python
|
||||
transformers
|
||||
transformers==4.57.6
|
||||
torchvision
|
||||
accelerate
|
||||
git+https://github.com/xhinker/sd_embed
|
||||
@@ -10,9 +10,15 @@ sentencepiece
|
||||
torch
|
||||
ftfy
|
||||
optimum-quanto
|
||||
# TODO: re-add compel once it supports transformers >= 5.
|
||||
# Tracking: https://github.com/damian0815/compel/pull/129
|
||||
# https://github.com/damian0815/compel/issues/128
|
||||
# compel currently pins transformers~=4.25, which forced pip into multi-hour
|
||||
# resolver backtracking storms in CI. backend.py imports it lazily and gates
|
||||
# the COMPEL=1 env var on the import succeeding, so dropping it here is safe.
|
||||
# diffusers and transformers are pinned together on purpose. transformers v5
|
||||
# restructured CLIPTextModel and dropped the `.text_model` attribute, which
|
||||
# breaks single-file Stable Diffusion loading on every released diffusers
|
||||
# (<=0.38.0); only unreleased diffusers main supports transformers v5. Tracking
|
||||
# main via git froze whichever broken pair existed at image-build time. Pin the
|
||||
# last known-good released pair so builds are reproducible and can't drift into
|
||||
# the broken window. See https://github.com/mudler/LocalAI/issues/9979
|
||||
#
|
||||
# compel is intentionally omitted: it pins transformers~=4.25, which conflicts
|
||||
# with this pin and previously forced pip into multi-hour resolver backtracking
|
||||
# storms in CI. backend.py imports it lazily and gates the COMPEL=1 env var on
|
||||
# the import succeeding, so dropping it here is safe.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
--extra-index-url https://download.pytorch.org/whl/cu130
|
||||
git+https://github.com/huggingface/diffusers
|
||||
diffusers==0.38.0
|
||||
opencv-python
|
||||
transformers
|
||||
transformers==4.57.6
|
||||
torchvision
|
||||
accelerate
|
||||
git+https://github.com/xhinker/sd_embed
|
||||
@@ -10,9 +10,15 @@ sentencepiece
|
||||
torch
|
||||
ftfy
|
||||
optimum-quanto
|
||||
# TODO: re-add compel once it supports transformers >= 5.
|
||||
# Tracking: https://github.com/damian0815/compel/pull/129
|
||||
# https://github.com/damian0815/compel/issues/128
|
||||
# compel currently pins transformers~=4.25, which forced pip into multi-hour
|
||||
# resolver backtracking storms in CI. backend.py imports it lazily and gates
|
||||
# the COMPEL=1 env var on the import succeeding, so dropping it here is safe.
|
||||
# diffusers and transformers are pinned together on purpose. transformers v5
|
||||
# restructured CLIPTextModel and dropped the `.text_model` attribute, which
|
||||
# breaks single-file Stable Diffusion loading on every released diffusers
|
||||
# (<=0.38.0); only unreleased diffusers main supports transformers v5. Tracking
|
||||
# main via git froze whichever broken pair existed at image-build time. Pin the
|
||||
# last known-good released pair so builds are reproducible and can't drift into
|
||||
# the broken window. See https://github.com/mudler/LocalAI/issues/9979
|
||||
#
|
||||
# compel is intentionally omitted: it pins transformers~=4.25, which conflicts
|
||||
# with this pin and previously forced pip into multi-hour resolver backtracking
|
||||
# storms in CI. backend.py imports it lazily and gates the COMPEL=1 env var on
|
||||
# the import succeeding, so dropping it here is safe.
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
--extra-index-url https://download.pytorch.org/whl/rocm7.0
|
||||
torch==2.10.0+rocm7.0
|
||||
torchvision==0.25.0+rocm7.0
|
||||
git+https://github.com/huggingface/diffusers
|
||||
diffusers==0.38.0
|
||||
opencv-python
|
||||
transformers
|
||||
transformers==4.57.6
|
||||
accelerate
|
||||
peft
|
||||
sentencepiece
|
||||
optimum-quanto
|
||||
ftfy
|
||||
# TODO: re-add compel once it supports transformers >= 5.
|
||||
# Tracking: https://github.com/damian0815/compel/pull/129
|
||||
# https://github.com/damian0815/compel/issues/128
|
||||
# compel currently pins transformers~=4.25, which forced pip into multi-hour
|
||||
# resolver backtracking storms in CI. backend.py imports it lazily and gates
|
||||
# the COMPEL=1 env var on the import succeeding, so dropping it here is safe.
|
||||
# diffusers and transformers are pinned together on purpose. transformers v5
|
||||
# restructured CLIPTextModel and dropped the `.text_model` attribute, which
|
||||
# breaks single-file Stable Diffusion loading on every released diffusers
|
||||
# (<=0.38.0); only unreleased diffusers main supports transformers v5. Tracking
|
||||
# main via git froze whichever broken pair existed at image-build time. Pin the
|
||||
# last known-good released pair so builds are reproducible and can't drift into
|
||||
# the broken window. See https://github.com/mudler/LocalAI/issues/9979
|
||||
#
|
||||
# compel is intentionally omitted: it pins transformers~=4.25, which conflicts
|
||||
# with this pin and previously forced pip into multi-hour resolver backtracking
|
||||
# storms in CI. backend.py imports it lazily and gates the COMPEL=1 env var on
|
||||
# the import succeeding, so dropping it here is safe.
|
||||
@@ -3,18 +3,24 @@ torch
|
||||
torchvision
|
||||
optimum[openvino]
|
||||
setuptools
|
||||
git+https://github.com/huggingface/diffusers
|
||||
diffusers==0.38.0
|
||||
opencv-python
|
||||
transformers
|
||||
transformers==4.57.6
|
||||
accelerate
|
||||
git+https://github.com/xhinker/sd_embed
|
||||
peft
|
||||
sentencepiece
|
||||
optimum-quanto
|
||||
ftfy
|
||||
# TODO: re-add compel once it supports transformers >= 5.
|
||||
# Tracking: https://github.com/damian0815/compel/pull/129
|
||||
# https://github.com/damian0815/compel/issues/128
|
||||
# compel currently pins transformers~=4.25, which forced pip into multi-hour
|
||||
# resolver backtracking storms in CI. backend.py imports it lazily and gates
|
||||
# the COMPEL=1 env var on the import succeeding, so dropping it here is safe.
|
||||
# diffusers and transformers are pinned together on purpose. transformers v5
|
||||
# restructured CLIPTextModel and dropped the `.text_model` attribute, which
|
||||
# breaks single-file Stable Diffusion loading on every released diffusers
|
||||
# (<=0.38.0); only unreleased diffusers main supports transformers v5. Tracking
|
||||
# main via git froze whichever broken pair existed at image-build time. Pin the
|
||||
# last known-good released pair so builds are reproducible and can't drift into
|
||||
# the broken window. See https://github.com/mudler/LocalAI/issues/9979
|
||||
#
|
||||
# compel is intentionally omitted: it pins transformers~=4.25, which conflicts
|
||||
# with this pin and previously forced pip into multi-hour resolver backtracking
|
||||
# storms in CI. backend.py imports it lazily and gates the COMPEL=1 env var on
|
||||
# the import succeeding, so dropping it here is safe.
|
||||
@@ -1,7 +1,7 @@
|
||||
--extra-index-url https://pypi.jetson-ai-lab.io/jp6/cu129/
|
||||
torch
|
||||
git+https://github.com/huggingface/diffusers
|
||||
transformers
|
||||
diffusers==0.38.0
|
||||
transformers==4.57.6
|
||||
accelerate
|
||||
peft
|
||||
optimum-quanto
|
||||
@@ -9,9 +9,15 @@ numpy<2
|
||||
sentencepiece
|
||||
torchvision
|
||||
ftfy
|
||||
# TODO: re-add compel once it supports transformers >= 5.
|
||||
# Tracking: https://github.com/damian0815/compel/pull/129
|
||||
# https://github.com/damian0815/compel/issues/128
|
||||
# compel currently pins transformers~=4.25, which forced pip into multi-hour
|
||||
# resolver backtracking storms in CI. backend.py imports it lazily and gates
|
||||
# the COMPEL=1 env var on the import succeeding, so dropping it here is safe.
|
||||
# diffusers and transformers are pinned together on purpose. transformers v5
|
||||
# restructured CLIPTextModel and dropped the `.text_model` attribute, which
|
||||
# breaks single-file Stable Diffusion loading on every released diffusers
|
||||
# (<=0.38.0); only unreleased diffusers main supports transformers v5. Tracking
|
||||
# main via git froze whichever broken pair existed at image-build time. Pin the
|
||||
# last known-good released pair so builds are reproducible and can't drift into
|
||||
# the broken window. See https://github.com/mudler/LocalAI/issues/9979
|
||||
#
|
||||
# compel is intentionally omitted: it pins transformers~=4.25, which conflicts
|
||||
# with this pin and previously forced pip into multi-hour resolver backtracking
|
||||
# storms in CI. backend.py imports it lazily and gates the COMPEL=1 env var on
|
||||
# the import succeeding, so dropping it here is safe.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
--extra-index-url https://download.pytorch.org/whl/cu130
|
||||
torch
|
||||
git+https://github.com/huggingface/diffusers
|
||||
transformers
|
||||
diffusers==0.38.0
|
||||
transformers==4.57.6
|
||||
accelerate
|
||||
peft
|
||||
optimum-quanto
|
||||
@@ -10,9 +10,15 @@ sentencepiece
|
||||
torchvision
|
||||
ftfy
|
||||
chardet
|
||||
# TODO: re-add compel once it supports transformers >= 5.
|
||||
# Tracking: https://github.com/damian0815/compel/pull/129
|
||||
# https://github.com/damian0815/compel/issues/128
|
||||
# compel currently pins transformers~=4.25, which forced pip into multi-hour
|
||||
# resolver backtracking storms in CI. backend.py imports it lazily and gates
|
||||
# the COMPEL=1 env var on the import succeeding, so dropping it here is safe.
|
||||
# diffusers and transformers are pinned together on purpose. transformers v5
|
||||
# restructured CLIPTextModel and dropped the `.text_model` attribute, which
|
||||
# breaks single-file Stable Diffusion loading on every released diffusers
|
||||
# (<=0.38.0); only unreleased diffusers main supports transformers v5. Tracking
|
||||
# main via git froze whichever broken pair existed at image-build time. Pin the
|
||||
# last known-good released pair so builds are reproducible and can't drift into
|
||||
# the broken window. See https://github.com/mudler/LocalAI/issues/9979
|
||||
#
|
||||
# compel is intentionally omitted: it pins transformers~=4.25, which conflicts
|
||||
# with this pin and previously forced pip into multi-hour resolver backtracking
|
||||
# storms in CI. backend.py imports it lazily and gates the COMPEL=1 env var on
|
||||
# the import succeeding, so dropping it here is safe.
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
torch==2.7.1
|
||||
torchvision==0.22.1
|
||||
git+https://github.com/huggingface/diffusers
|
||||
diffusers==0.38.0
|
||||
opencv-python
|
||||
transformers
|
||||
transformers==4.57.6
|
||||
accelerate
|
||||
peft
|
||||
sentencepiece
|
||||
optimum-quanto
|
||||
ftfy
|
||||
# TODO: re-add compel once it supports transformers >= 5.
|
||||
# Tracking: https://github.com/damian0815/compel/pull/129
|
||||
# https://github.com/damian0815/compel/issues/128
|
||||
# compel currently pins transformers~=4.25, which forced pip into multi-hour
|
||||
# resolver backtracking storms in CI. backend.py imports it lazily and gates
|
||||
# the COMPEL=1 env var on the import succeeding, so dropping it here is safe.
|
||||
# diffusers and transformers are pinned together on purpose. transformers v5
|
||||
# restructured CLIPTextModel and dropped the `.text_model` attribute, which
|
||||
# breaks single-file Stable Diffusion loading on every released diffusers
|
||||
# (<=0.38.0); only unreleased diffusers main supports transformers v5. Tracking
|
||||
# main via git froze whichever broken pair existed at image-build time. Pin the
|
||||
# last known-good released pair so builds are reproducible and can't drift into
|
||||
# the broken window. See https://github.com/mudler/LocalAI/issues/9979
|
||||
#
|
||||
# compel is intentionally omitted: it pins transformers~=4.25, which conflicts
|
||||
# with this pin and previously forced pip into multi-hour resolver backtracking
|
||||
# storms in CI. backend.py imports it lazily and gates the COMPEL=1 env var on
|
||||
# the import succeeding, so dropping it here is safe.
|
||||
@@ -1,6 +1,6 @@
|
||||
--extra-index-url https://download.pytorch.org/whl/cpu
|
||||
accelerate
|
||||
torch==2.9.1+cpu
|
||||
torch==2.12.1+xpu
|
||||
torchvision
|
||||
torchaudio
|
||||
transformers
|
||||
|
||||
@@ -341,11 +341,9 @@ func (a *Application) ResolvePIIPolicy(cfg *config.ModelConfig) (enabled bool, d
|
||||
}
|
||||
appCfg := a.ApplicationConfig()
|
||||
|
||||
if cfg.PII.Enabled != nil {
|
||||
enabled = *cfg.PII.Enabled
|
||||
} else {
|
||||
enabled = cfg.PIIIsEnabled() // backend default (cloud-proxy)
|
||||
}
|
||||
// PIIIsEnabled already encodes "explicit pii.enabled wins, else backend
|
||||
// default (cloud-proxy)" — the single source of that rule.
|
||||
enabled = cfg.PIIIsEnabled()
|
||||
if !enabled {
|
||||
return false, nil
|
||||
}
|
||||
@@ -354,7 +352,7 @@ func (a *Application) ResolvePIIPolicy(cfg *config.ModelConfig) (enabled bool, d
|
||||
if len(detectors) == 0 {
|
||||
detectors = append([]string(nil), appCfg.PIIDefaultDetectors...)
|
||||
}
|
||||
return enabled, detectors
|
||||
return true, detectors // enabled is necessarily true past the !enabled guard
|
||||
}
|
||||
|
||||
// PIIPolicyResolver adapts ResolvePIIPolicy to pii.PolicyResolver for
|
||||
|
||||
@@ -357,6 +357,15 @@ func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB, configLoade
|
||||
Pressure: pressure,
|
||||
})
|
||||
|
||||
// Wire staging-progress broadcasting so file-staging shows up on every
|
||||
// replica, not just the one performing the transfer. Without this, a
|
||||
// /api/operations poll that round-robins onto a peer sees no staging row and
|
||||
// the progress flickers. The origin publishes; peers mirror via the wildcard.
|
||||
router.StagingTracker().SetPublisher(natsClient)
|
||||
if _, err := router.StagingTracker().SubscribeBroadcasts(natsClient); err != nil {
|
||||
xlog.Warn("Failed to subscribe to staging progress broadcasts", "error", err)
|
||||
}
|
||||
|
||||
// Create ReplicaReconciler for auto-scaling model replicas. Adapter +
|
||||
// RegistrationToken feed the state-reconciliation passes: pending op
|
||||
// drain uses the adapter, and model health probes use the token to auth
|
||||
|
||||
@@ -385,6 +385,23 @@ func GetNodeModelsEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// ListAllNodeModelsEndpoint returns all loaded models across all healthy nodes.
|
||||
// @Summary List all loaded models cluster-wide
|
||||
// @Tags Nodes
|
||||
// @Success 200 {array} nodes.NodeModel
|
||||
// @Router /api/nodes/models [get]
|
||||
func ListAllNodeModelsEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
models, err := registry.ListAllLoadedModels(ctx)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to list all node models", "error", err)
|
||||
return c.JSON(http.StatusInternalServerError, nodeError(http.StatusInternalServerError, "failed to list node models"))
|
||||
}
|
||||
return c.JSON(http.StatusOK, models)
|
||||
}
|
||||
}
|
||||
|
||||
// DrainNodeEndpoint sets a node to draining status (no new requests).
|
||||
func DrainNodeEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
|
||||
@@ -407,4 +407,44 @@ var _ = Describe("Node HTTP handlers", func() {
|
||||
Expect(names).To(ConsistOf("alpha", "beta"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ListAllNodeModelsEndpoint", func() {
|
||||
It("returns an empty list when no models are loaded", func() {
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
handler := ListAllNodeModelsEndpoint(registry)
|
||||
Expect(handler(c)).To(Succeed())
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var list []nodes.NodeModel
|
||||
Expect(json.Unmarshal(rec.Body.Bytes(), &list)).To(Succeed())
|
||||
Expect(list).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns loaded models across healthy nodes", func() {
|
||||
ctx := context.Background()
|
||||
Expect(registry.Register(ctx, &nodes.BackendNode{
|
||||
ID: "n1", Name: "alpha", Address: "10.0.0.1:50051", Status: nodes.StatusHealthy,
|
||||
}, true)).To(Succeed())
|
||||
Expect(registry.SetNodeModel(ctx, "n1", "llama-3.3", 0, "loaded", "10.0.0.1:50051", 0)).To(Succeed())
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
handler := ListAllNodeModelsEndpoint(registry)
|
||||
Expect(handler(c)).To(Succeed())
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var list []nodes.NodeModel
|
||||
Expect(json.Unmarshal(rec.Body.Bytes(), &list)).To(Succeed())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].ModelName).To(Equal("llama-3.3"))
|
||||
Expect(list[0].NodeID).To(Equal("n1"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -288,6 +288,21 @@ test.describe('Model Editor - Interactive Tab', () => {
|
||||
await expect(page.locator('input[placeholder^="match,"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('pattern min_len clamps a directly-typed negative to 0', async ({ page }) => {
|
||||
const searchInput = page.locator('input[placeholder="Search fields to add..."]')
|
||||
await searchInput.fill('Custom Secret Patterns')
|
||||
const dropdown = searchInput.locator('..').locator('..')
|
||||
await dropdown.locator('div', { hasText: 'Custom Secret Patterns' }).first().click()
|
||||
|
||||
await page.locator('button', { hasText: 'Add pattern' }).click()
|
||||
// The number input's min={0} only limits the spinner arrows, not keyboard
|
||||
// entry; the editor must sanitise a typed negative so a meaningless
|
||||
// negative length floor never reaches the saved config.
|
||||
const minLen = page.locator('input[aria-label="Minimum length"]')
|
||||
await minLen.fill('-5')
|
||||
await expect(minLen).toHaveValue('0')
|
||||
})
|
||||
|
||||
// Regression: a map-typed field (entity_actions) present in the loaded YAML
|
||||
// must render WITH its values. flattenConfig used to recurse into the map,
|
||||
// scattering it across pii_detection.entity_actions.<GROUP> paths that match
|
||||
@@ -329,4 +344,37 @@ test.describe('Model Editor - Interactive Tab', () => {
|
||||
await expect(page.getByText(/block —/i).first()).toBeVisible()
|
||||
})
|
||||
|
||||
// A map cannot hold two values for one key, so renaming a row to an existing
|
||||
// group must collapse to a single row (Object.fromEntries, last write wins)
|
||||
// rather than rendering two conflicting rows that silently lose one on save.
|
||||
test('entity_actions collapses a duplicate group to a single row', async ({ page }) => {
|
||||
await page.route('**/api/models/edit/ner-model', (route) => {
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
name: 'ner-model',
|
||||
config: [
|
||||
'name: ner-model',
|
||||
'backend: llama-cpp',
|
||||
'pii_detection:',
|
||||
' entity_actions:',
|
||||
' SSN: block',
|
||||
' EMAIL: mask',
|
||||
'',
|
||||
].join('\n'),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await page.goto('/app/model-editor/ner-model')
|
||||
|
||||
const groupInputs = page.locator('input[aria-label="Entity group"]')
|
||||
await expect(groupInputs).toHaveCount(2)
|
||||
|
||||
// Rename the EMAIL row to duplicate SSN; the editor collapses to one SSN row.
|
||||
await groupInputs.nth(1).fill('SSN')
|
||||
await expect(groupInputs).toHaveCount(1)
|
||||
await expect(groupInputs.nth(0)).toHaveValue('SSN')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
34
core/http/react-ui/e2e/nodes-detail.spec.js
Normal file
34
core/http/react-ui/e2e/nodes-detail.spec.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
const ID = 'n1'
|
||||
async function mockNode(page) {
|
||||
await page.route(`**/api/nodes/${ID}`, r => r.fulfill({ status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify({ id: ID, name: 'alpha', node_type: 'backend', address: '10.0.0.1:50051', status: 'healthy', total_vram: 24e9, available_vram: 12e9, max_replicas_per_model: 1, labels: { env: 'prod' } }) }))
|
||||
await page.route(`**/api/nodes/${ID}/models`, r => r.fulfill({ status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify([{ node_id: ID, model_name: 'llama-3.3', state: 'loaded', in_flight: 0, replica_index: 0 }]) }))
|
||||
await page.route(`**/api/nodes/${ID}/backends`, r => r.fulfill({ status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify([{ name: 'llama-cpp', is_system: true, installed_at: '2026-06-01T00:00:00Z' }]) }))
|
||||
}
|
||||
|
||||
test.describe('Node detail page', () => {
|
||||
test('renders sections for a node', async ({ page }) => {
|
||||
await mockNode(page)
|
||||
await page.goto(`/app/nodes/${ID}`)
|
||||
await expect(page.locator('.page-title').first()).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByText('alpha')).toBeVisible()
|
||||
await expect(page.getByText('llama-3.3')).toBeVisible()
|
||||
await expect(page.getByText('llama-cpp')).toBeVisible()
|
||||
await expect(page.getByText('env=prod')).toBeVisible()
|
||||
})
|
||||
|
||||
test('is reachable by clicking a roster panel', async ({ page }) => {
|
||||
await page.route('**/api/nodes', r => r.fulfill({ status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify([{ id: ID, name: 'alpha', node_type: 'backend', address: '10.0.0.1:50051', status: 'healthy' }]) }))
|
||||
await page.route('**/api/nodes/models', r => r.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
|
||||
await page.route('**/api/nodes/scheduling', r => r.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
|
||||
await mockNode(page)
|
||||
await page.goto('/app/nodes')
|
||||
await page.locator('.node-panel').filter({ hasText: 'alpha' }).getByText('alpha').click()
|
||||
await expect(page).toHaveURL(new RegExp(`/app/nodes/${ID}$`))
|
||||
})
|
||||
})
|
||||
@@ -12,28 +12,37 @@ const NODE_NAME = 'worker-test'
|
||||
const BACKEND_NAME = 'cuda12-vllm-development'
|
||||
|
||||
async function mockDistributedNodes(page, { onDelete } = {}) {
|
||||
const nodeRecord = {
|
||||
id: NODE_ID,
|
||||
name: NODE_NAME,
|
||||
node_type: 'backend',
|
||||
address: '10.0.0.1:50051',
|
||||
http_address: '10.0.0.1:8090',
|
||||
status: 'healthy',
|
||||
total_vram: 0,
|
||||
available_vram: 0,
|
||||
total_ram: 8_000_000_000,
|
||||
available_ram: 4_000_000_000,
|
||||
gpu_vendor: '',
|
||||
last_heartbeat: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
|
||||
await page.route('**/api/nodes', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: NODE_ID,
|
||||
name: NODE_NAME,
|
||||
node_type: 'backend',
|
||||
address: '10.0.0.1:50051',
|
||||
http_address: '10.0.0.1:8090',
|
||||
status: 'healthy',
|
||||
total_vram: 0,
|
||||
available_vram: 0,
|
||||
total_ram: 8_000_000_000,
|
||||
available_ram: 4_000_000_000,
|
||||
gpu_vendor: '',
|
||||
last_heartbeat: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
body: JSON.stringify([nodeRecord]),
|
||||
})
|
||||
})
|
||||
|
||||
// The detail page fetches the single node via nodesApi.get(id).
|
||||
await page.route(`**/api/nodes/${NODE_ID}`, (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(nodeRecord),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -80,24 +89,18 @@ async function mockDistributedNodes(page, { onDelete } = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
async function expandNodeAndWaitForBackends(page) {
|
||||
await page.goto('/app/nodes')
|
||||
// Click the row to expand it. The chevron toggle and the row both work,
|
||||
// but clicking the name cell is the most user-like.
|
||||
await page.getByText(NODE_NAME).first().click()
|
||||
// Backends, Capacity and Labels live behind a "Manage" <details>
|
||||
// disclosure (the drawer was distilled to keep at-a-glance content
|
||||
// lean — see distill refactor in the multi-replica branch). Open it
|
||||
// by clicking the summary inside the .node-manage scope so the
|
||||
// per-node backend table is in the DOM before assertions run.
|
||||
await page.locator('.node-manage > summary').first().click()
|
||||
async function openNodeDetail(page) {
|
||||
// The per-node backend table now lives on the deep-linkable detail page
|
||||
// at /app/nodes/:id (the old expand-row + "Manage" disclosure was removed
|
||||
// when the roster was restructured). Navigate straight there.
|
||||
await page.goto(`/app/nodes/${NODE_ID}`)
|
||||
await expect(page.getByRole('cell', { name: BACKEND_NAME, exact: true })).toBeVisible({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
test.describe('Nodes page — per-node backend actions', () => {
|
||||
test('upgrade affordance is self-explanatory (not "Reinstall backend" with a sync icon)', async ({ page }) => {
|
||||
await mockDistributedNodes(page)
|
||||
await expandNodeAndWaitForBackends(page)
|
||||
await openNodeDetail(page)
|
||||
|
||||
// Negative: the old, ambiguous wording must not be used.
|
||||
await expect(page.locator('button[title="Reinstall backend"]')).toHaveCount(0)
|
||||
@@ -114,7 +117,7 @@ test.describe('Nodes page — per-node backend actions', () => {
|
||||
|
||||
test('per-node backend row shows a delete (trash) button next to upgrade', async ({ page }) => {
|
||||
await mockDistributedNodes(page)
|
||||
await expandNodeAndWaitForBackends(page)
|
||||
await openNodeDetail(page)
|
||||
|
||||
const deleteBtn = page.locator('button[title="Delete backend from this node"]')
|
||||
await expect(deleteBtn).toBeVisible()
|
||||
@@ -128,7 +131,7 @@ test.describe('Nodes page — per-node backend actions', () => {
|
||||
postedBody = route.request().postDataJSON()
|
||||
},
|
||||
})
|
||||
await expandNodeAndWaitForBackends(page)
|
||||
await openNodeDetail(page)
|
||||
|
||||
await page.locator('button[title="Delete backend from this node"]').click()
|
||||
|
||||
@@ -150,7 +153,7 @@ test.describe('Nodes page — per-node backend actions', () => {
|
||||
deleteCalls += 1
|
||||
},
|
||||
})
|
||||
await expandNodeAndWaitForBackends(page)
|
||||
await openNodeDetail(page)
|
||||
|
||||
await page.locator('button[title="Delete backend from this node"]').click()
|
||||
|
||||
|
||||
47
core/http/react-ui/e2e/nodes-roster.spec.js
Normal file
47
core/http/react-ui/e2e/nodes-roster.spec.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
async function mockCluster(page, nodes) {
|
||||
await page.route('**/api/nodes', r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(nodes) }))
|
||||
await page.route('**/api/nodes/models', r => r.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
|
||||
await page.route('**/api/nodes/scheduling', r => r.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
|
||||
}
|
||||
|
||||
test.describe('Nodes roster header', () => {
|
||||
test('shows a cluster pulse line and no stat-card grid', async ({ page }) => {
|
||||
await mockCluster(page, [
|
||||
{ id: 'n1', name: 'alpha', node_type: 'backend', address: '10.0.0.1:50051', status: 'healthy' },
|
||||
{ id: 'n2', name: 'beta', node_type: 'backend', address: '10.0.0.2:50051', status: 'draining' },
|
||||
])
|
||||
await page.goto('/app/nodes')
|
||||
await expect(page.locator('.cluster-pulse')).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.locator('.cluster-pulse')).toContainText('2 nodes')
|
||||
await expect(page.locator('.stat-grid')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('shows an approval callout for pending nodes', async ({ page }) => {
|
||||
await mockCluster(page, [{ id: 'n3', name: 'gamma', node_type: 'backend', address: '10.0.0.3:50051', status: 'pending' }])
|
||||
await page.goto('/app/nodes')
|
||||
await expect(page.locator('.attention-callout')).toContainText('approval', { timeout: 15_000 })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Nodes roster panels', () => {
|
||||
test('shows model chips without clicking and filters by type', async ({ page }) => {
|
||||
await page.route('**/api/nodes', r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([
|
||||
{ id: 'n1', name: 'alpha', node_type: 'backend', address: '10.0.0.1:50051', status: 'healthy' },
|
||||
{ id: 'a1', name: 'agent-1', node_type: 'agent', address: '10.0.0.9:50051', status: 'healthy' },
|
||||
]) }))
|
||||
await page.route('**/api/nodes/models', r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([
|
||||
{ node_id: 'n1', model_name: 'llama-3.3', state: 'loaded', in_flight: 2, replica_index: 0 },
|
||||
]) }))
|
||||
await page.route('**/api/nodes/scheduling', r => r.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
|
||||
|
||||
await page.goto('/app/nodes')
|
||||
// model chip visible without any expand click
|
||||
await expect(page.locator('.node-panel').filter({ hasText: 'alpha' }).getByText('llama-3.3')).toBeVisible({ timeout: 15_000 })
|
||||
// segmented filter: Agent shows the agent node, hides the backend node
|
||||
await page.getByRole('radio', { name: /Agent/ }).click()
|
||||
await expect(page.getByText('agent-1')).toBeVisible()
|
||||
await expect(page.getByText('alpha')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -21,6 +21,7 @@ const PAGES = [
|
||||
['/app/backends', 'Backends'],
|
||||
['/app/settings', 'Settings'],
|
||||
['/app/nodes', 'Nodes'],
|
||||
['/app/scheduling', 'Scheduling'],
|
||||
['/app/face', 'Face recognition'],
|
||||
['/app/voice', 'Voice recognition'],
|
||||
['/app/fine-tune', 'Fine-tuning'],
|
||||
|
||||
16
core/http/react-ui/e2e/scheduling.spec.js
Normal file
16
core/http/react-ui/e2e/scheduling.spec.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
test.describe('Scheduling page', () => {
|
||||
test('renders at /app/scheduling with rules from the API', async ({ page }) => {
|
||||
await page.route('**/api/nodes/scheduling', (route) => {
|
||||
route.fulfill({
|
||||
status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify([{ model_name: 'llama-3.3', spread_all: true, min_replicas: 0, max_replicas: 0 }]),
|
||||
})
|
||||
})
|
||||
await page.goto('/app/scheduling')
|
||||
await expect(page.locator('.page-title').first()).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page).toHaveURL(/\/app\/scheduling$/)
|
||||
await expect(page.getByText('llama-3.3')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -43,6 +43,10 @@
|
||||
"title": "Verteilte Knoten",
|
||||
"subtitle": "Backend- und Agenten-Worker-Knoten verwalten"
|
||||
},
|
||||
"scheduling": {
|
||||
"title": "Planung",
|
||||
"subtitle": "Modellplatzierung und Replikat-Regeln im gesamten Cluster"
|
||||
},
|
||||
"p2p": {
|
||||
"title": "Verteilte KI-Berechnung",
|
||||
"subtitle": "Skalieren Sie Ihre KI-Workloads über mehrere Geräte mit Peer-to-Peer-Verteilung"
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"backends": "Backends",
|
||||
"traces": "Traces",
|
||||
"nodes": "Knoten",
|
||||
"scheduling": "Planung",
|
||||
"swarm": "Swarm",
|
||||
"system": "System",
|
||||
"settings": "Einstellungen",
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
"title": "Distributed Nodes",
|
||||
"subtitle": "Manage backend and agent worker nodes"
|
||||
},
|
||||
"scheduling": {
|
||||
"title": "Scheduling",
|
||||
"subtitle": "Model placement and replica rules across the cluster"
|
||||
},
|
||||
"p2p": {
|
||||
"title": "Distributed AI Computing",
|
||||
"subtitle": "Scale your AI workloads across multiple devices with peer-to-peer distribution"
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"backends": "Backends",
|
||||
"traces": "Traces",
|
||||
"nodes": "Nodes",
|
||||
"scheduling": "Scheduling",
|
||||
"swarm": "Swarm",
|
||||
"system": "System",
|
||||
"settings": "Settings",
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
"title": "Nodos distribuidos",
|
||||
"subtitle": "Administra nodos worker de backends y agentes"
|
||||
},
|
||||
"scheduling": {
|
||||
"title": "Planificación",
|
||||
"subtitle": "Reglas de ubicación de modelos y réplicas en el clúster"
|
||||
},
|
||||
"p2p": {
|
||||
"title": "Computación de IA distribuida",
|
||||
"subtitle": "Escala tus cargas de trabajo de IA en múltiples dispositivos con distribución peer-to-peer"
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"backends": "Backends",
|
||||
"traces": "Trazas",
|
||||
"nodes": "Nodos",
|
||||
"scheduling": "Planificación",
|
||||
"swarm": "Swarm",
|
||||
"system": "Sistema",
|
||||
"settings": "Configuración",
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
"title": "Node Terdistribusi",
|
||||
"subtitle": "Kelola node backend dan node worker"
|
||||
},
|
||||
"scheduling": {
|
||||
"title": "Penjadwalan",
|
||||
"subtitle": "Aturan penempatan model dan replika di seluruh klaster"
|
||||
},
|
||||
"p2p": {
|
||||
"title": "Komputasi AI Terdistribusi",
|
||||
"subtitle": "Skalakan beban kerja AI Anda ke beberapa perangkat dengan distribusi peer-to-peer"
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"backends": "Backend",
|
||||
"traces": "Trace",
|
||||
"nodes": "Node",
|
||||
"scheduling": "Penjadwalan",
|
||||
"swarm": "Swarm",
|
||||
"system": "Sistem",
|
||||
"settings": "Pengaturan",
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
"title": "Nodi distribuiti",
|
||||
"subtitle": "Gestisci i nodi worker dei backend e degli agenti"
|
||||
},
|
||||
"scheduling": {
|
||||
"title": "Pianificazione",
|
||||
"subtitle": "Regole di posizionamento dei modelli e delle repliche nel cluster"
|
||||
},
|
||||
"p2p": {
|
||||
"title": "Calcolo AI distribuito",
|
||||
"subtitle": "Scala i tuoi carichi di lavoro AI su più dispositivi con la distribuzione peer-to-peer"
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"backends": "Backend",
|
||||
"traces": "Tracce",
|
||||
"nodes": "Nodi",
|
||||
"scheduling": "Pianificazione",
|
||||
"swarm": "Swarm",
|
||||
"system": "Sistema",
|
||||
"settings": "Impostazioni",
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
"title": "분산 노드",
|
||||
"subtitle": "백엔드 및 에이전트 워커 노드를 관리합니다"
|
||||
},
|
||||
"scheduling": {
|
||||
"title": "스케줄링",
|
||||
"subtitle": "클러스터 전반의 모델 배치 및 복제본 규칙"
|
||||
},
|
||||
"p2p": {
|
||||
"title": "분산 AI 컴퓨팅",
|
||||
"subtitle": "피어 투 피어 분산으로 여러 기기에 걸쳐 AI 워크로드를 확장합니다"
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"backends": "백엔드",
|
||||
"traces": "트레이스",
|
||||
"nodes": "노드",
|
||||
"scheduling": "스케줄링",
|
||||
"swarm": "Swarm",
|
||||
"system": "시스템",
|
||||
"settings": "설정",
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
"title": "分布式节点",
|
||||
"subtitle": "管理后端和智能体工作节点"
|
||||
},
|
||||
"scheduling": {
|
||||
"title": "调度",
|
||||
"subtitle": "集群中的模型放置和副本规则"
|
||||
},
|
||||
"p2p": {
|
||||
"title": "分布式 AI 计算",
|
||||
"subtitle": "通过点对点分发将您的 AI 工作负载扩展到多个设备"
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"backends": "后端",
|
||||
"traces": "追踪",
|
||||
"nodes": "节点",
|
||||
"scheduling": "调度",
|
||||
"swarm": "Swarm",
|
||||
"system": "系统",
|
||||
"settings": "设置",
|
||||
|
||||
@@ -8471,3 +8471,56 @@ select.input {
|
||||
.status-pill--error .status-pill__dot { background: var(--color-error); }
|
||||
.status-pill--info .status-pill__dot { background: var(--color-info); }
|
||||
.status-pill--muted .status-pill__dot { background: var(--color-text-muted); }
|
||||
|
||||
/* Nodes: cluster pulse + attention callout (replaces the stat-card strip) */
|
||||
.cluster-pulse {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 var(--spacing-lg);
|
||||
}
|
||||
.cluster-pulse__strong { color: var(--color-text-primary); font-weight: 600; }
|
||||
|
||||
.attention-callout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.attention-callout--warn {
|
||||
background: var(--color-warning-light);
|
||||
border: 1px solid var(--color-warning-border);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.attention-callout--error {
|
||||
background: var(--color-error-light);
|
||||
border: 1px solid var(--color-error-border);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Node roster panels (Nodes page) */
|
||||
.node-roster { display: flex; flex-direction: column; gap: var(--spacing-sm); }
|
||||
.node-panel {
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.node-panel__main { padding: var(--spacing-md) var(--spacing-lg); cursor: pointer; }
|
||||
.node-panel:hover { border-color: var(--color-border); }
|
||||
.node-panel__head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--spacing-md); }
|
||||
.node-panel__id { display: flex; align-items: center; gap: var(--spacing-sm); flex-wrap: wrap; }
|
||||
.node-panel__name { font-weight: 600; }
|
||||
.node-panel__meta { display: flex; gap: var(--spacing-lg); margin-top: var(--spacing-sm); color: var(--color-text-muted); font-size: var(--text-xs); }
|
||||
.node-panel__models { display: flex; flex-wrap: wrap; gap: 6px; margin-top: var(--spacing-sm); }
|
||||
.model-chip {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
font-family: var(--font-mono); font-size: 0.6875rem;
|
||||
padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid;
|
||||
}
|
||||
.model-chip__dot { width: 6px; height: 6px; border-radius: 50%; }
|
||||
.model-chip__state { opacity: 0.85; font-style: normal; }
|
||||
.node-filter { margin-bottom: var(--spacing-lg); }
|
||||
.node-detail__metrics { display: flex; gap: var(--spacing-xl); margin: var(--spacing-md) 0 var(--spacing-lg); flex-wrap: wrap; }
|
||||
|
||||
@@ -74,7 +74,18 @@ export default function PatternListEditor({ value, onChange }) {
|
||||
min={0}
|
||||
value={r.min_len || 0}
|
||||
title="Minimum match length (0 = no floor)"
|
||||
onChange={e => update(i, { min_len: parseInt(e.target.value, 10) || 0 })}
|
||||
// min={0} only constrains the spinner, not keyboard entry. Clamp a
|
||||
// typed negative to 0 (a negative floor is meaningless and would
|
||||
// disable the length filter). When we clamp, force the DOM value
|
||||
// too: the resulting 0->0 state change is a no-op, so React's
|
||||
// controlled input would otherwise keep displaying the rejected
|
||||
// "-5" even though the saved value is 0.
|
||||
onChange={e => {
|
||||
const parsed = parseInt(e.target.value, 10)
|
||||
const n = Math.max(0, parsed || 0)
|
||||
if (parsed < 0) e.target.value = String(n)
|
||||
update(i, { min_len: n })
|
||||
}}
|
||||
style={{ width: 80, fontSize: '0.8125rem' }}
|
||||
aria-label="Minimum length"
|
||||
/>
|
||||
|
||||
@@ -59,6 +59,7 @@ export const operateConsole = {
|
||||
titleKey: 'operate.cluster',
|
||||
items: [
|
||||
{ path: '/app/nodes', icon: 'fas fa-network-wired', labelKey: 'items.nodes', adminOnly: true, feature: 'distributed' },
|
||||
{ path: '/app/scheduling', icon: 'fas fa-calendar-alt', labelKey: 'items.scheduling', adminOnly: true, feature: 'distributed' },
|
||||
{ path: '/app/p2p', icon: 'fas fa-circle-nodes', labelKey: 'items.swarm', adminOnly: true },
|
||||
],
|
||||
},
|
||||
|
||||
31
core/http/react-ui/src/components/nodes/AttentionCallout.jsx
Normal file
31
core/http/react-ui/src/components/nodes/AttentionCallout.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
export default function AttentionCallout({ nodes, onApprove }) {
|
||||
const pending = nodes.filter(n => n.status === 'pending')
|
||||
const unhealthy = nodes.filter(n => n.status === 'unhealthy' || n.status === 'offline')
|
||||
if (pending.length === 0 && unhealthy.length === 0) return null
|
||||
|
||||
if (pending.length > 0) {
|
||||
const first = pending[0]
|
||||
const extra = pending.length - 1
|
||||
return (
|
||||
<div className="attention-callout attention-callout--warn">
|
||||
<span>
|
||||
<i className="fas fa-exclamation-circle" />{' '}
|
||||
<strong>{pending.length} node{pending.length > 1 ? 's' : ''} awaiting approval</strong>
|
||||
{' - '}{first.name}{extra > 0 ? ` +${extra} more` : ''}
|
||||
</span>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => onApprove(first.id)}>
|
||||
<i className="fas fa-check" /> Approve {first.name}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="attention-callout attention-callout--error">
|
||||
<span>
|
||||
<i className="fas fa-exclamation-triangle" />{' '}
|
||||
<strong>{unhealthy.length} node{unhealthy.length > 1 ? 's' : ''} unhealthy</strong>
|
||||
{' - '}{unhealthy.map(n => n.name).slice(0, 3).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
196
core/http/react-ui/src/components/nodes/CapacityEditor.jsx
Normal file
196
core/http/react-ui/src/components/nodes/CapacityEditor.jsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { nodesApi } from '../../utils/api'
|
||||
import LoadingSpinner from '../LoadingSpinner'
|
||||
|
||||
/**
|
||||
* Inline editor for a node's per-model replica capacity.
|
||||
*
|
||||
* UX intent: discoverable affordance (pencil icon) that opens an inline
|
||||
* input - never a modal for a single field. Source-of-truth note is shown
|
||||
* inline so operators understand a worker re-registration will overwrite
|
||||
* their override; surfacing this in a tooltip would hide too important a
|
||||
* caveat.
|
||||
*
|
||||
* `confirmShrink` is a hook the parent provides so the page can render its
|
||||
* own confirm dialog (it has access to all nodes and can phrase the message
|
||||
* with full context).
|
||||
*/
|
||||
export default function CapacityEditor({ node, loadedModelCounts, onUpdate, confirmShrink, addToast }) {
|
||||
const current = node.max_replicas_per_model || 1
|
||||
const isOverride = !!node.max_replicas_per_model_manually_set
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState(String(current))
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [resetting, setResetting] = useState(false)
|
||||
|
||||
// Reset draft when current value changes (server response, etc.)
|
||||
useEffect(() => {
|
||||
if (!editing) setDraft(String(current))
|
||||
}, [current, editing])
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setEditing(false)
|
||||
setDraft(String(current))
|
||||
}, [current])
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const value = parseInt(draft, 10)
|
||||
if (!Number.isFinite(value) || value < 1) {
|
||||
addToast('Replica capacity must be 1 or higher', 'error')
|
||||
return
|
||||
}
|
||||
if (value === current) {
|
||||
setEditing(false)
|
||||
return
|
||||
}
|
||||
// Reducing the cap below current loaded replicas: confirm so the operator
|
||||
// sees the consequence (running replicas keep going until idle eviction).
|
||||
const maxLoadedAcrossModels = Math.max(0, ...Object.values(loadedModelCounts || {}))
|
||||
if (value < maxLoadedAcrossModels) {
|
||||
const proceed = await confirmShrink({ node, newValue: value, currentLoaded: maxLoadedAcrossModels })
|
||||
if (!proceed) return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await nodesApi.updateMaxReplicasPerModel(node.id, value)
|
||||
addToast(`Replica capacity set to ${value} on ${node.name}`, 'success')
|
||||
setEditing(false)
|
||||
onUpdate?.(value)
|
||||
} catch (err) {
|
||||
addToast(`Could not change replica capacity: ${err.message || err}`, 'error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [draft, current, node, loadedModelCounts, confirmShrink, onUpdate, addToast])
|
||||
|
||||
const onKeyDown = (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); save() }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); cancel() }
|
||||
}
|
||||
|
||||
const reset = useCallback(async () => {
|
||||
setResetting(true)
|
||||
try {
|
||||
await nodesApi.resetMaxReplicasPerModel(node.id)
|
||||
addToast(`Override cleared on ${node.name}; worker flag will apply on next re-registration`, 'success')
|
||||
onUpdate?.(null)
|
||||
} catch (err) {
|
||||
addToast(`Could not reset override: ${err.message || err}`, 'error')
|
||||
} finally {
|
||||
setResetting(false)
|
||||
}
|
||||
}, [node, onUpdate, addToast])
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 'var(--spacing-md)',
|
||||
}}>
|
||||
<i className="fas fa-layer-group" style={{ color: 'var(--color-text-muted)', marginTop: 3 }} aria-hidden="true" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', flexWrap: 'wrap' }}>
|
||||
<label
|
||||
htmlFor={`capacity-${node.id}`}
|
||||
style={{ fontSize: '0.8125rem', fontWeight: 600, color: 'var(--color-text-primary)' }}
|
||||
>
|
||||
Max replicas per model
|
||||
</label>
|
||||
{editing ? (
|
||||
<>
|
||||
<input
|
||||
id={`capacity-${node.id}`}
|
||||
type="number"
|
||||
min={1}
|
||||
value={draft}
|
||||
disabled={saving}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
autoFocus
|
||||
aria-describedby={`capacity-hint-${node.id}`}
|
||||
style={{
|
||||
width: 72, padding: '4px 8px', borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--color-border)', background: 'var(--color-bg-primary)',
|
||||
fontFamily: 'var(--font-mono)', fontSize: '0.8125rem',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
style={{ minHeight: 32 }}
|
||||
aria-label="Save replica capacity"
|
||||
>
|
||||
{saving ? <LoadingSpinner size="xs" /> : <><i className="fas fa-check" /> Save</>}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={cancel}
|
||||
disabled={saving}
|
||||
style={{ minHeight: 32 }}
|
||||
aria-label="Cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className="cell-mono"
|
||||
style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
{current}
|
||||
</span>
|
||||
{isOverride && (
|
||||
<span
|
||||
title="This value was set from the UI. It will persist across worker restarts until you click Reset."
|
||||
style={{
|
||||
display: 'inline-block', fontSize: '0.6875rem', padding: '1px 6px',
|
||||
borderRadius: 'var(--radius-sm)', fontWeight: 500,
|
||||
background: 'var(--color-bg-primary)',
|
||||
border: '1px solid var(--color-warning, #d97706)',
|
||||
color: 'var(--color-warning, #d97706)',
|
||||
}}
|
||||
>
|
||||
override
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
aria-label={`Edit replica capacity (currently ${current})`}
|
||||
title="Change replica capacity for this node"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
minWidth: 32, minHeight: 32, padding: 4, borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--color-border-subtle)',
|
||||
background: 'transparent', color: 'var(--color-text-muted)', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<i className="fas fa-pencil-alt" />
|
||||
</button>
|
||||
{isOverride && (
|
||||
<button
|
||||
onClick={reset}
|
||||
disabled={resetting}
|
||||
aria-label="Clear admin override and let the worker flag apply"
|
||||
title="Clear override; the worker's --max-replicas-per-model flag will apply on the next re-registration"
|
||||
className="btn btn-secondary btn-sm"
|
||||
style={{ minHeight: 32 }}
|
||||
>
|
||||
{resetting ? <LoadingSpinner size="xs" /> : <><i className="fas fa-undo" /> Reset</>}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
id={`capacity-hint-${node.id}`}
|
||||
style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: 4, lineHeight: 1.4 }}
|
||||
>
|
||||
{isOverride
|
||||
? <>Set from here. <strong>Reset</strong> to use the worker's default.</>
|
||||
: <>Saved values stick across worker restarts.</>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
core/http/react-ui/src/components/nodes/ClusterPulse.jsx
Normal file
18
core/http/react-ui/src/components/nodes/ClusterPulse.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { formatVRAM } from './nodeStatus'
|
||||
|
||||
export default function ClusterPulse({ nodes }) {
|
||||
const total = nodes.length
|
||||
const healthy = nodes.filter(n => n.status === 'healthy').length
|
||||
const draining = nodes.filter(n => n.status === 'draining').length
|
||||
const usedVRAM = nodes.reduce((s, n) =>
|
||||
(n.total_vram && n.available_vram != null) ? s + (n.total_vram - n.available_vram) : s, 0)
|
||||
const vramStr = formatVRAM(usedVRAM)
|
||||
return (
|
||||
<p className="cluster-pulse">
|
||||
<span className="cluster-pulse__strong">{total} {total === 1 ? 'node' : 'nodes'}</span>
|
||||
{' · '}<span style={{ color: 'var(--color-success)' }}>{healthy} healthy</span>
|
||||
{draining > 0 && <>{' · '}<span style={{ color: 'var(--color-warning)' }}>{draining} draining</span></>}
|
||||
{vramStr && <>{' · '}{vramStr} VRAM in use</>}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
98
core/http/react-ui/src/components/nodes/KeyValueChips.jsx
Normal file
98
core/http/react-ui/src/components/nodes/KeyValueChips.jsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
/**
|
||||
* Controlled chip-builder for { key: value } maps. Replaces the prior
|
||||
* comma-separated-string Node Selector input AND the bespoke Labels editor
|
||||
* in the node drawer - both were rendering the same chip pattern with
|
||||
* subtly different markup.
|
||||
*
|
||||
* Fully controlled: parent owns the map and decides what onAdd/onRemove
|
||||
* does (form state for the scheduling form; API calls for the live
|
||||
* labels editor). The component just renders chips and a key/value input
|
||||
* row.
|
||||
*
|
||||
* Props:
|
||||
* pairs - current map of key -> value
|
||||
* onAdd(k,v) - called when the user adds a pair (parent handles dedup
|
||||
* and persistence side effects)
|
||||
* onRemove(k) - called when a chip's × is clicked
|
||||
* placeholderKey, placeholderValue - input hints
|
||||
* ariaLabel - accessible name for the section
|
||||
*/
|
||||
export default function KeyValueChips({ pairs, onAdd, onRemove, placeholderKey = 'key', placeholderValue = 'value', ariaLabel }) {
|
||||
const [k, setK] = useState('')
|
||||
const [v, setV] = useState('')
|
||||
|
||||
const add = () => {
|
||||
const key = k.trim()
|
||||
if (!key) return
|
||||
onAdd(key, v.trim())
|
||||
setK(''); setV('')
|
||||
}
|
||||
const onKeyDown = (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); add() }
|
||||
}
|
||||
|
||||
const entries = pairs ? Object.entries(pairs) : []
|
||||
return (
|
||||
<div aria-label={ariaLabel}>
|
||||
{entries.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 'var(--spacing-xs)' }}>
|
||||
{entries.map(([key, val]) => (
|
||||
<span key={key} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
fontSize: '0.75rem', padding: '2px 8px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--color-bg-tertiary)',
|
||||
border: '1px solid var(--color-border-subtle)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
}}>
|
||||
{key}={val}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(key) }}
|
||||
aria-label={`Remove ${key}`}
|
||||
title="Remove"
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--color-text-muted)', fontSize: '0.625rem', padding: 0,
|
||||
}}
|
||||
>
|
||||
<i className="fas fa-times" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', alignItems: 'stretch' }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder={placeholderKey}
|
||||
value={k}
|
||||
onChange={e => setK(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder={placeholderValue}
|
||||
value={v}
|
||||
onChange={e => setV(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={add}
|
||||
disabled={!k.trim()}
|
||||
style={{ minHeight: 36 }}
|
||||
>
|
||||
<i className="fas fa-plus" /> Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
core/http/react-ui/src/components/nodes/ModelChip.jsx
Normal file
12
core/http/react-ui/src/components/nodes/ModelChip.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { modelStateConfig } from './nodeStatus'
|
||||
|
||||
export default function ModelChip({ model }) {
|
||||
const cfg = modelStateConfig[model.state] || modelStateConfig.idle
|
||||
return (
|
||||
<span className="model-chip" style={{ background: cfg.bg, color: cfg.color, borderColor: cfg.border }}>
|
||||
<span className="model-chip__dot" style={{ background: cfg.color }} />
|
||||
{model.model_name}
|
||||
{model.state !== 'loaded' && <span className="model-chip__state"> {model.state}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
60
core/http/react-ui/src/components/nodes/NodePanel.jsx
Normal file
60
core/http/react-ui/src/components/nodes/NodePanel.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import StatusPill from './StatusPill'
|
||||
import ModelChip from './ModelChip'
|
||||
import ActionMenu from '../ActionMenu'
|
||||
import { formatVRAM } from './nodeStatus'
|
||||
|
||||
export default function NodePanel({ node, models = [], onApprove, onDrain, onResume, onRemove }) {
|
||||
const navigate = useNavigate()
|
||||
const isAgent = node.node_type === 'agent'
|
||||
const open = () => navigate(`/app/nodes/${node.id}`)
|
||||
const usedVRAM = node.total_vram && node.available_vram != null ? node.total_vram - node.available_vram : null
|
||||
|
||||
return (
|
||||
<div className="node-panel">
|
||||
<div className="node-panel__main" onClick={open} role="button" tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') open() }}>
|
||||
<div className="node-panel__head">
|
||||
<div className="node-panel__id">
|
||||
<StatusPill status={node.status} />
|
||||
<span className="node-panel__name">{node.name}</span>
|
||||
<span className="cell-mono cell-muted">{node.address}</span>
|
||||
</div>
|
||||
<div className="node-panel__actions" onClick={(e) => e.stopPropagation()}>
|
||||
{node.status === 'pending' && (
|
||||
<button className="btn btn-primary btn-sm" onClick={() => onApprove(node.id)}>
|
||||
<i className="fas fa-check" /> Approve
|
||||
</button>
|
||||
)}
|
||||
<ActionMenu
|
||||
ariaLabel={`Actions for ${node.name}`}
|
||||
triggerLabel={`Actions for ${node.name}`}
|
||||
items={[
|
||||
{ key: 'resume', icon: 'fa-play', label: 'Resume', hidden: node.status !== 'draining', onClick: () => onResume(node.id) },
|
||||
{ key: 'drain', icon: 'fa-pause', label: 'Drain', hidden: node.status === 'draining' || node.status === 'pending', onClick: () => onDrain(node.id) },
|
||||
{ divider: true, hidden: node.status === 'pending' },
|
||||
{ key: 'remove', icon: 'fa-trash', label: 'Remove from cluster', danger: true, onClick: () => onRemove(node) },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isAgent && (
|
||||
<>
|
||||
<div className="node-panel__meta">
|
||||
{node.total_vram > 0 && (
|
||||
<span className="cell-mono">VRAM {formatVRAM(usedVRAM) || '0'} / {formatVRAM(node.total_vram)}</span>
|
||||
)}
|
||||
<span className="cell-mono">{node.in_flight_count || 0} in-flight</span>
|
||||
</div>
|
||||
<div className="node-panel__models">
|
||||
{models.length === 0
|
||||
? <span className="cell-muted">No models loaded</span>
|
||||
: models.map(m => <ModelChip key={`${m.model_name}-${m.replica_index ?? 0}`} model={m} />)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
core/http/react-ui/src/components/nodes/StatusPill.jsx
Normal file
11
core/http/react-ui/src/components/nodes/StatusPill.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { statusConfig } from './nodeStatus'
|
||||
|
||||
export default function StatusPill({ status }) {
|
||||
const cfg = statusConfig[status] || statusConfig.unhealthy
|
||||
return (
|
||||
<span className="node-status" style={{ color: cfg.color }}>
|
||||
<span className="node-status__dot" style={{ background: cfg.color }} />
|
||||
{cfg.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
34
core/http/react-ui/src/components/nodes/nodeStatus.js
vendored
Normal file
34
core/http/react-ui/src/components/nodes/nodeStatus.js
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
export const statusConfig = {
|
||||
healthy: { color: 'var(--color-success)', label: 'Healthy' },
|
||||
unhealthy: { color: 'var(--color-error)', label: 'Unhealthy' },
|
||||
offline: { color: 'var(--color-error)', label: 'Offline' },
|
||||
registering: { color: 'var(--color-primary)', label: 'Registering' },
|
||||
draining: { color: 'var(--color-warning)', label: 'Draining' },
|
||||
pending: { color: 'var(--color-warning)', label: 'Pending Approval' },
|
||||
}
|
||||
|
||||
export const modelStateConfig = {
|
||||
loaded: { bg: 'var(--color-success-light)', color: 'var(--color-success)', border: 'var(--color-success-border)' },
|
||||
loading: { bg: 'var(--color-primary-light)', color: 'var(--color-primary)', border: 'var(--color-primary-border)' },
|
||||
unloading: { bg: 'var(--color-warning-light)', color: 'var(--color-warning)', border: 'var(--color-warning-border)' },
|
||||
idle: { bg: 'var(--color-bg-tertiary)', color: 'var(--color-text-muted)', border: 'var(--color-border-subtle)' },
|
||||
}
|
||||
|
||||
export function formatVRAM(bytes) {
|
||||
if (!bytes || bytes === 0) return null
|
||||
const gb = bytes / (1024 * 1024 * 1024)
|
||||
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(bytes / (1024 * 1024)).toFixed(0)} MB`
|
||||
}
|
||||
|
||||
export function timeAgo(dateString) {
|
||||
if (!dateString) return 'never'
|
||||
const seconds = Math.floor((Date.now() - new Date(dateString).getTime()) / 1000)
|
||||
if (seconds < 0) return 'just now'
|
||||
if (seconds < 60) return `${seconds}s ago`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
352
core/http/react-ui/src/pages/NodeDetail.jsx
Normal file
352
core/http/react-ui/src/pages/NodeDetail.jsx
Normal file
@@ -0,0 +1,352 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, useNavigate, useOutletContext } from 'react-router-dom'
|
||||
import { nodesApi } from '../utils/api'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
import LoadingSpinner from '../components/LoadingSpinner'
|
||||
import ConfirmDialog from '../components/ConfirmDialog'
|
||||
import StatusPill from '../components/nodes/StatusPill'
|
||||
import CapacityEditor from '../components/nodes/CapacityEditor'
|
||||
import KeyValueChips from '../components/nodes/KeyValueChips'
|
||||
import { formatVRAM, modelStateConfig, timeAgo } from '../components/nodes/nodeStatus'
|
||||
|
||||
// Deep-linkable node management home. Reached by clicking a roster panel on
|
||||
// /app/nodes. Surfaces what's running here plus the management affordances
|
||||
// (capacity, backends, labels, drain/resume/remove) that previously lived in
|
||||
// the expanded-row "Manage" drawer.
|
||||
export default function NodeDetail() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { addToast } = useOutletContext()
|
||||
const [node, setNode] = useState(null)
|
||||
const [models, setModels] = useState([])
|
||||
const [backends, setBackends] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [confirmRemove, setConfirmRemove] = useState(false)
|
||||
const [confirmUnload, setConfirmUnload] = useState(null)
|
||||
const [confirmDeleteBackend, setConfirmDeleteBackend] = useState(null)
|
||||
// Promise-based shrink confirmation: CapacityEditor awaits this hook so the
|
||||
// page owns the dialog (it can phrase the message with full node context).
|
||||
const [confirmShrinkState, setConfirmShrinkState] = useState(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const n = await nodesApi.get(id)
|
||||
setNode(n)
|
||||
const [m, b] = await Promise.all([nodesApi.getModels(id), nodesApi.getBackends(id)])
|
||||
setModels(Array.isArray(m) ? m : [])
|
||||
setBackends(Array.isArray(b) ? b : [])
|
||||
} catch (err) {
|
||||
addToast(`Failed to load node: ${err.message}`, 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [id, addToast])
|
||||
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
|
||||
const confirmShrink = useCallback((ctx) => new Promise((resolve) => {
|
||||
setConfirmShrinkState({ ...ctx, resolve })
|
||||
}), [])
|
||||
|
||||
if (loading) return <div className="page page--wide" style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}><LoadingSpinner size="lg" /></div>
|
||||
if (!node) return <div className="page page--wide"><PageHeader title="Node not found" /></div>
|
||||
|
||||
const drain = async () => { try { await nodesApi.drain(id); addToast('Node set to draining', 'success'); refresh() } catch (e) { addToast(e.message, 'error') } }
|
||||
const resume = async () => { try { await nodesApi.resume(id); addToast('Node resumed', 'success'); refresh() } catch (e) { addToast(e.message, 'error') } }
|
||||
const remove = async () => { try { await nodesApi.delete(id); addToast('Node removed', 'success'); navigate('/app/nodes') } catch (e) { addToast(e.message, 'error') } }
|
||||
const unload = async (name) => { try { await nodesApi.unloadModel(id, name); addToast(`Model "${name}" unloaded`, 'success'); refresh() } catch (e) { addToast(e.message, 'error') } }
|
||||
const upgradeBackend = async (name) => { try { await nodesApi.installBackend(id, name); addToast(`Backend "${name}" upgraded`, 'success'); refresh() } catch (e) { addToast(e.message, 'error') } }
|
||||
const deleteBackend = async (name) => { try { await nodesApi.deleteBackend(id, name); addToast(`Backend "${name}" deleted`, 'success'); refresh() } catch (e) { addToast(e.message, 'error') } }
|
||||
const addLabel = async (k, v) => { try { await nodesApi.mergeLabels(id, { [k]: v }); refresh() } catch (e) { addToast(e.message, 'error') } }
|
||||
const delLabel = async (k) => { try { await nodesApi.deleteLabel(id, k); refresh() } catch (e) { addToast(e.message, 'error') } }
|
||||
|
||||
const usedVRAM = node.total_vram && node.available_vram != null ? node.total_vram - node.available_vram : 0
|
||||
// {modelName: replicaCount} of loaded models so the shrink confirm can warn
|
||||
// if the new cap is below the actual count of any single model on this node.
|
||||
const loadedModelCounts = (() => {
|
||||
const counts = {}
|
||||
models.forEach(m => { if (m.state === 'loaded') counts[m.model_name] = (counts[m.model_name] || 0) + 1 })
|
||||
return counts
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="page page--wide">
|
||||
<PageHeader
|
||||
eyebrow={<a onClick={() => navigate('/app/nodes')} style={{ cursor: 'pointer', color: 'var(--color-primary)' }}><i className="fas fa-arrow-left" style={{ marginRight: 6 }} aria-hidden="true" />Cluster</a>}
|
||||
title={<><StatusPill status={node.status} /> {node.name}</>}
|
||||
supporting={node.address}
|
||||
actions={
|
||||
<>
|
||||
{node.status === 'draining'
|
||||
? <button className="btn btn-secondary btn-sm" onClick={resume}><i className="fas fa-play" /> Resume</button>
|
||||
: <button className="btn btn-secondary btn-sm" onClick={drain}><i className="fas fa-pause" /> Drain</button>}
|
||||
<button className="btn btn-danger btn-sm" onClick={() => setConfirmRemove(true)}><i className="fas fa-trash" /> Remove</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Inline metrics row: VRAM / in-flight - no boxes, just labelled values. */}
|
||||
<div className="node-detail__metrics">
|
||||
{node.total_vram > 0 && (
|
||||
<div>
|
||||
<div className="drawer-eyebrow">VRAM</div>
|
||||
<span className="cell-mono">{formatVRAM(usedVRAM) || '0'} / {formatVRAM(node.total_vram)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="drawer-eyebrow">In-flight</div>
|
||||
<span className="cell-mono">{node.in_flight_count || 0}</span>
|
||||
</div>
|
||||
{node.node_type !== 'agent' && (
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="drawer-eyebrow">Capacity</div>
|
||||
<CapacityEditor
|
||||
node={node}
|
||||
loadedModelCounts={loadedModelCounts}
|
||||
confirmShrink={confirmShrink}
|
||||
addToast={addToast}
|
||||
onUpdate={() => refresh()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Running models */}
|
||||
<div style={{ marginTop: 'var(--spacing-lg)' }}>
|
||||
<div className="drawer-eyebrow">Running models</div>
|
||||
{models.length === 0 ? (
|
||||
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', margin: '0 0 var(--spacing-md) 0' }}>
|
||||
<i className="fas fa-cube" style={{ marginRight: 6, opacity: 0.6 }} aria-hidden="true" />
|
||||
No models loaded yet - they'll appear here when scheduled to this node.
|
||||
</p>
|
||||
) : (
|
||||
<table className="table" style={{ margin: 0 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Model</th>
|
||||
<th>State</th>
|
||||
<th>In-Flight</th>
|
||||
<th style={{ width: 40 }}>Logs</th>
|
||||
<th style={{ textAlign: 'right' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(() => {
|
||||
// Pre-compute per-model replica counts so the disambiguation
|
||||
// pill only renders when this node actually hosts >1 replica
|
||||
// of the same model. Single-replica deployments stay clean.
|
||||
const replicaCounts = {}
|
||||
models.forEach(m => { replicaCounts[m.model_name] = (replicaCounts[m.model_name] || 0) + 1 })
|
||||
return models.map(m => {
|
||||
const stCfg = modelStateConfig[m.state] || modelStateConfig.idle
|
||||
const showReplica = (replicaCounts[m.model_name] || 0) > 1
|
||||
// Per-replica process key - what the worker stores logs under and what the
|
||||
// store's GetLines/Subscribe match on for replica-scoped filtering.
|
||||
const processKey = `${m.model_name}#${m.replica_index ?? 0}`
|
||||
return (
|
||||
<tr key={m.id || `${m.model_name}#${m.replica_index ?? 0}`}>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8125rem' }}>
|
||||
{m.model_name}
|
||||
{showReplica && (
|
||||
<span
|
||||
className="cell-mono"
|
||||
aria-label={`replica ${m.replica_index ?? 0}`}
|
||||
title={`Replica ${m.replica_index ?? 0} on this node`}
|
||||
style={{
|
||||
marginLeft: 8, padding: '1px 6px', borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--color-bg-tertiary)',
|
||||
border: '1px solid var(--color-border-subtle)',
|
||||
fontSize: '0.6875rem', fontWeight: 500,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
rep {m.replica_index ?? 0}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span style={{
|
||||
display: 'inline-block', padding: '2px 8px', borderRadius: 'var(--radius-sm)',
|
||||
fontSize: '0.75rem', fontWeight: 500,
|
||||
background: stCfg.bg, color: stCfg.color, border: `1px solid ${stCfg.border}`,
|
||||
}}>
|
||||
{m.state}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8125rem' }}>
|
||||
{m.in_flight ?? 0}
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
// Send the replica-scoped process key (modelName#replicaIndex).
|
||||
navigate(`/app/node-backend-logs/${id}/${encodeURIComponent(processKey)}`)
|
||||
}}
|
||||
style={{ fontSize: '0.75rem', color: 'var(--color-primary)' }}
|
||||
title={showReplica ? `View backend logs for replica ${m.replica_index ?? 0}` : 'View backend logs'}
|
||||
>
|
||||
<i className="fas fa-terminal" />
|
||||
</a>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
title={m.in_flight > 0 ? 'Unload model (has in-flight requests)' : 'Unload model'}
|
||||
onClick={() => setConfirmUnload({ modelName: m.model_name, inFlight: m.in_flight ?? 0 })}
|
||||
>
|
||||
<i className="fas fa-stop" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Installed backends */}
|
||||
<div style={{ marginTop: 'var(--spacing-lg)' }}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
marginBottom: 'var(--spacing-sm)',
|
||||
}}>
|
||||
<div className="drawer-eyebrow" style={{ margin: 0 }}>Installed backends</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => navigate(`/app/backends?target=${encodeURIComponent(id)}`)}
|
||||
title={`Install a backend on ${node.name}`}
|
||||
>
|
||||
<i className="fas fa-plus" /> Add backend
|
||||
</button>
|
||||
</div>
|
||||
{backends.length === 0 ? (
|
||||
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', margin: 0 }}>
|
||||
None installed. <a href="#" style={{ color: 'var(--color-primary)' }} onClick={(e) => { e.preventDefault(); navigate(`/app/backends?target=${encodeURIComponent(id)}`) }}>Install one from the gallery</a> to schedule models here.
|
||||
</p>
|
||||
) : (
|
||||
<table className="table" style={{ margin: 0 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Installed At</th>
|
||||
<th style={{ textAlign: 'right' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{backends.map(b => (
|
||||
<tr key={b.name}>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8125rem' }}>
|
||||
{b.name}
|
||||
</td>
|
||||
<td>
|
||||
<span style={{
|
||||
display: 'inline-block', padding: '2px 8px', borderRadius: 'var(--radius-sm)',
|
||||
fontSize: '0.75rem', fontWeight: 500,
|
||||
background: b.is_system ? 'var(--color-bg-tertiary)' : 'var(--color-primary-light)',
|
||||
color: b.is_system ? 'var(--color-text-muted)' : 'var(--color-primary)',
|
||||
border: `1px solid ${b.is_system ? 'var(--color-border-subtle)' : 'var(--color-primary-border)'}`,
|
||||
}}>
|
||||
{b.is_system ? 'system' : 'gallery'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>
|
||||
{b.installed_at ? timeAgo(b.installed_at) : '-'}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
{!b.is_system && (
|
||||
<div style={{ display: 'inline-flex', gap: 'var(--spacing-xs)' }}>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => upgradeBackend(b.name)}
|
||||
title="Upgrade backend on this node"
|
||||
>
|
||||
<i className="fas fa-arrow-up" />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger-ghost btn-sm"
|
||||
onClick={() => setConfirmDeleteBackend({ backend: b.name })}
|
||||
title="Delete backend from this node"
|
||||
>
|
||||
<i className="fas fa-trash" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Labels - node.replica-slots is filtered out so the Capacity editor
|
||||
stays the single source of truth for that label. */}
|
||||
<div style={{ marginTop: 'var(--spacing-lg)' }}>
|
||||
<div className="drawer-eyebrow">Labels</div>
|
||||
<KeyValueChips
|
||||
pairs={Object.fromEntries(Object.entries(node.labels || {}).filter(([k]) => k !== 'node.replica-slots'))}
|
||||
onAdd={addLabel}
|
||||
onRemove={delLabel}
|
||||
placeholderKey="key"
|
||||
placeholderValue="value"
|
||||
ariaLabel="Node labels"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmRemove}
|
||||
title="Remove node"
|
||||
message={`Remove "${node.name}" from the cluster? This will deregister it.`}
|
||||
confirmLabel="Remove"
|
||||
danger
|
||||
onConfirm={() => { remove(); setConfirmRemove(false) }}
|
||||
onCancel={() => setConfirmRemove(false)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirmUnload}
|
||||
title="Unload Model"
|
||||
message={
|
||||
confirmUnload
|
||||
? confirmUnload.inFlight > 0
|
||||
? `"${confirmUnload.modelName}" currently has ${confirmUnload.inFlight} in-flight request(s). Unloading will interrupt them. Continue?`
|
||||
: `Unload "${confirmUnload.modelName}" from ${node.name}?`
|
||||
: ''
|
||||
}
|
||||
confirmLabel="Unload"
|
||||
danger={confirmUnload?.inFlight > 0}
|
||||
onConfirm={() => { if (confirmUnload) unload(confirmUnload.modelName); setConfirmUnload(null) }}
|
||||
onCancel={() => setConfirmUnload(null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirmDeleteBackend}
|
||||
title="Delete Backend"
|
||||
message={confirmDeleteBackend ? `Delete "${confirmDeleteBackend.backend}" from ${node.name}? This removes the backend files from this node only.` : ''}
|
||||
confirmLabel="Delete"
|
||||
danger
|
||||
onConfirm={() => { if (confirmDeleteBackend) deleteBackend(confirmDeleteBackend.backend); setConfirmDeleteBackend(null) }}
|
||||
onCancel={() => setConfirmDeleteBackend(null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirmShrinkState}
|
||||
title="Reduce replica capacity"
|
||||
message={
|
||||
confirmShrinkState
|
||||
? `${node.name} currently has ${confirmShrinkState.currentLoaded} replica(s) of at least one model loaded. Reducing the cap to ${confirmShrinkState.newValue} won't evict anything immediately - running replicas keep going, but the reconciler will trim down on the next idle window. Continue?`
|
||||
: ''
|
||||
}
|
||||
confirmLabel="Reduce"
|
||||
onConfirm={() => { confirmShrinkState?.resolve(true); setConfirmShrinkState(null) }}
|
||||
onCancel={() => { confirmShrinkState?.resolve(false); setConfirmShrinkState(null) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
438
core/http/react-ui/src/pages/Scheduling.jsx
Normal file
438
core/http/react-ui/src/pages/Scheduling.jsx
Normal file
@@ -0,0 +1,438 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useOutletContext } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { nodesApi } from '../utils/api'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
import ConfirmDialog from '../components/ConfirmDialog'
|
||||
import ResponsiveTable from '../components/ResponsiveTable'
|
||||
import SearchableModelSelect from '../components/SearchableModelSelect'
|
||||
import KeyValueChips from '../components/nodes/KeyValueChips'
|
||||
|
||||
// Numeric input with quick-pick preset chips. Picked over a slider because
|
||||
// replica counts are exact specs (operator math), not fuzzy estimates. The
|
||||
// chips give one-click access to common values without the slider's
|
||||
// precision/special-value problems (e.g. MaxReplicas=0 = "no limit").
|
||||
function ReplicaInput({ id, label, value, onChange, presets }) {
|
||||
return (
|
||||
<div style={{ flex: 1 }}>
|
||||
<label className="form-label" htmlFor={id}>{label}</label>
|
||||
<input
|
||||
id={id}
|
||||
className="input"
|
||||
type="number"
|
||||
min={0}
|
||||
value={value}
|
||||
onChange={e => onChange(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginTop: 6 }}>
|
||||
{presets.map(({ v, l }) => {
|
||||
const active = value === v
|
||||
return (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => onChange(v)}
|
||||
aria-pressed={active}
|
||||
className="cell-mono"
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: '0.6875rem',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
background: active ? 'var(--color-primary-light)' : 'transparent',
|
||||
border: `1px solid ${active ? 'var(--color-primary-border)' : 'var(--color-border-subtle)'}`,
|
||||
color: active ? 'var(--color-primary)' : 'var(--color-text-muted)',
|
||||
}}
|
||||
>{l || v}</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SchedulingForm({ onSave, onCancel }) {
|
||||
const [mode, setMode] = useState('placement')
|
||||
const [modelName, setModelName] = useState('')
|
||||
// Selector is now a chip-builder map instead of a comma-separated string.
|
||||
// Operators were copying syntax from docs and missing commas; the chip UI
|
||||
// makes the key=value structure self-documenting.
|
||||
const [selector, setSelector] = useState({})
|
||||
const [minReplicas, setMinReplicas] = useState(1)
|
||||
const [maxReplicas, setMaxReplicas] = useState(0)
|
||||
// Prefix-cache routing controls. Empty routePolicy means "inherit the
|
||||
// cluster default"; the three thresholds at 0 likewise inherit, so they
|
||||
// stay out of the POST body's effective override only when explicitly set.
|
||||
const [routePolicy, setRoutePolicy] = useState('')
|
||||
const [balanceAbsThreshold, setBalanceAbsThreshold] = useState(0)
|
||||
const [balanceRelThreshold, setBalanceRelThreshold] = useState(0)
|
||||
const [minPrefixMatch, setMinPrefixMatch] = useState(0)
|
||||
|
||||
const hasSelector = Object.keys(selector).length > 0
|
||||
|
||||
const isValid = () => {
|
||||
if (!modelName) return false
|
||||
if (mode === 'placement') return hasSelector
|
||||
if (mode === 'spread') return true
|
||||
return minReplicas > 0 || maxReplicas > 0
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSave({
|
||||
model_name: modelName,
|
||||
node_selector: hasSelector ? selector : undefined,
|
||||
min_replicas: mode === 'autoscaling' ? minReplicas : 0,
|
||||
max_replicas: mode === 'autoscaling' ? maxReplicas : 0,
|
||||
spread_all: mode === 'spread',
|
||||
route_policy: routePolicy,
|
||||
balance_abs_threshold: balanceAbsThreshold,
|
||||
balance_rel_threshold: balanceRelThreshold,
|
||||
min_prefix_match: minPrefixMatch,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 'var(--spacing-lg)', marginBottom: 'var(--spacing-md)' }}>
|
||||
{/* Mode selector — uses the project's segmented control instead of two
|
||||
50%-width filled buttons that competed visually with the actual
|
||||
primary action (Save). */}
|
||||
<div role="radiogroup" aria-label="Scheduling mode" className="segmented" style={{ marginBottom: 'var(--spacing-xs)' }}>
|
||||
<button
|
||||
type="button" role="radio" aria-checked={mode === 'placement'}
|
||||
className={`segmented__item${mode === 'placement' ? ' is-active' : ''}`}
|
||||
onClick={() => setMode('placement')}
|
||||
>
|
||||
<i className="fas fa-thumbtack" aria-hidden="true" /> Pin to nodes
|
||||
</button>
|
||||
<button
|
||||
type="button" role="radio" aria-checked={mode === 'autoscaling'}
|
||||
className={`segmented__item${mode === 'autoscaling' ? ' is-active' : ''}`}
|
||||
onClick={() => setMode('autoscaling')}
|
||||
>
|
||||
<i className="fas fa-arrows-up-down" aria-hidden="true" /> Auto-scale
|
||||
</button>
|
||||
<button
|
||||
type="button" role="radio" aria-checked={mode === 'spread'}
|
||||
className={`segmented__item${mode === 'spread' ? ' is-active' : ''}`}
|
||||
onClick={() => setMode('spread')}
|
||||
>
|
||||
<i className="fas fa-network-wired" aria-hidden="true" /> Spread to all
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', margin: '0 0 var(--spacing-lg) 0' }}>
|
||||
{mode === 'placement'
|
||||
? 'Restrict this model to specific nodes. Loaded on demand, evictable when idle.'
|
||||
: mode === 'spread'
|
||||
? 'Run one replica on every node matching the selector (all healthy nodes when empty). Tracks nodes joining and leaving.'
|
||||
: 'Maintain a target replica count across the cluster. Min ≥ 1 protects from eviction.'}
|
||||
</p>
|
||||
|
||||
{/* Linear vertical flow — model picker is the visual focus, then the
|
||||
mode-specific fields below. No 2-column grid (the mismatched widths
|
||||
made the form look raw). */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-md)' }}>
|
||||
<div>
|
||||
<label className="form-label" htmlFor="sched-model">Model</label>
|
||||
{/* Searchable combobox so a long gallery doesn't force the operator
|
||||
to scroll through hundreds of entries. Free-text is allowed —
|
||||
you can pre-create a rule for a model that hasn't been
|
||||
installed yet, which is a real workflow when standing up a new
|
||||
node and pre-staging its scheduling policy. */}
|
||||
<SearchableModelSelect
|
||||
value={modelName}
|
||||
onChange={setModelName}
|
||||
placeholder="Type to search models, or paste a name..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label">
|
||||
Node selector{mode === 'placement' ? '' : ' (optional)'}
|
||||
</label>
|
||||
<KeyValueChips
|
||||
pairs={selector}
|
||||
onAdd={(k, v) => setSelector(prev => ({ ...prev, [k]: v }))}
|
||||
onRemove={(k) => setSelector(prev => { const n = { ...prev }; delete n[k]; return n })}
|
||||
placeholderKey="key (e.g. gpu.vendor)"
|
||||
placeholderValue="value (e.g. nvidia)"
|
||||
ariaLabel="Node selector"
|
||||
/>
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', display: 'block', marginTop: 6 }}>
|
||||
{mode === 'placement'
|
||||
? 'Models will load only on nodes that match all listed labels.'
|
||||
: (hasSelector ? 'Replicas land only on matching nodes.' : 'Empty = any healthy node.')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{mode === 'autoscaling' && (
|
||||
<div style={{ display: 'flex', gap: 'var(--spacing-md)' }}>
|
||||
<ReplicaInput
|
||||
id="sched-min"
|
||||
label="Min replicas"
|
||||
value={minReplicas}
|
||||
onChange={setMinReplicas}
|
||||
presets={[{ v: 1 }, { v: 2 }, { v: 3 }, { v: 4 }]}
|
||||
/>
|
||||
<ReplicaInput
|
||||
id="sched-max"
|
||||
label="Max replicas"
|
||||
value={maxReplicas}
|
||||
onChange={setMaxReplicas}
|
||||
presets={[{ v: 0, l: 'no limit' }, { v: 2 }, { v: 4 }, { v: 8 }]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Per-model routing policy. Left empty/zero these inherit the
|
||||
cluster-wide defaults; set them to override how requests for this
|
||||
model are spread across replicas. */}
|
||||
<div>
|
||||
<label className="form-label" htmlFor="sched-route-policy">Routing policy</label>
|
||||
<select
|
||||
id="sched-route-policy"
|
||||
className="input"
|
||||
value={routePolicy}
|
||||
onChange={e => setRoutePolicy(e.target.value)}
|
||||
>
|
||||
<option value="">Default (cluster setting)</option>
|
||||
<option value="round_robin">Round Robin</option>
|
||||
<option value="prefix_cache">Prefix Cache</option>
|
||||
</select>
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', display: 'block', marginTop: 6 }}>
|
||||
Prefix Cache routes shared-prefix requests to the same replica to reuse its KV cache, falling back to round-robin when replicas are imbalanced.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{routePolicy === 'prefix_cache' && (
|
||||
<div style={{ display: 'flex', gap: 'var(--spacing-md)' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label className="form-label" htmlFor="sched-min-prefix-match">Min prefix match</label>
|
||||
<input
|
||||
id="sched-min-prefix-match"
|
||||
className="input"
|
||||
type="number"
|
||||
step="0.05"
|
||||
min="0"
|
||||
max="1"
|
||||
value={minPrefixMatch}
|
||||
onChange={e => setMinPrefixMatch(parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', display: 'block', marginTop: 6 }}>
|
||||
Fraction of the prompt (0..1) that must match a cached prefix before affinity kicks in. 0 inherits the default.
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label className="form-label" htmlFor="sched-balance-abs">Balance abs threshold</label>
|
||||
<input
|
||||
id="sched-balance-abs"
|
||||
className="input"
|
||||
type="number"
|
||||
min="0"
|
||||
value={balanceAbsThreshold}
|
||||
onChange={e => setBalanceAbsThreshold(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', display: 'block', marginTop: 6 }}>
|
||||
Max absolute in-flight gap allowed before falling back to round-robin. 0 inherits the default.
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label className="form-label" htmlFor="sched-balance-rel">Balance rel threshold</label>
|
||||
<input
|
||||
id="sched-balance-rel"
|
||||
className="input"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
value={balanceRelThreshold}
|
||||
onChange={e => setBalanceRelThreshold(parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', display: 'block', marginTop: 6 }}>
|
||||
Max relative in-flight ratio (>= 1) allowed before falling back to round-robin. 0 inherits the default.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hairline divider above the actions, matching the project's form pattern. */}
|
||||
<div style={{
|
||||
display: 'flex', gap: 'var(--spacing-sm)', justifyContent: 'flex-end',
|
||||
marginTop: 'var(--spacing-lg)', paddingTop: 'var(--spacing-md)',
|
||||
borderTop: '1px solid var(--color-border-subtle)',
|
||||
}}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={onCancel}>Cancel</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleSubmit} disabled={!isValid()}>Save rule</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Scheduling() {
|
||||
const { addToast } = useOutletContext()
|
||||
const { t } = useTranslation('admin')
|
||||
const [schedulingConfigs, setSchedulingConfigs] = useState([])
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [confirmDelete, setConfirmDelete] = useState(null)
|
||||
|
||||
const fetchScheduling = useCallback(async () => {
|
||||
try {
|
||||
const data = await nodesApi.listScheduling()
|
||||
setSchedulingConfigs(Array.isArray(data) ? data : [])
|
||||
} catch { setSchedulingConfigs([]) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchScheduling() }, [fetchScheduling])
|
||||
|
||||
const handleSave = async (config) => {
|
||||
try {
|
||||
await nodesApi.setScheduling(config)
|
||||
addToast('Scheduling rule saved', 'success')
|
||||
setShowForm(false)
|
||||
fetchScheduling()
|
||||
} catch (err) { addToast(`Failed to save rule: ${err.message}`, 'error') }
|
||||
}
|
||||
|
||||
const handleDelete = async (model) => {
|
||||
try {
|
||||
await nodesApi.deleteScheduling(model)
|
||||
addToast('Scheduling rule removed', 'success')
|
||||
setConfirmDelete(null)
|
||||
fetchScheduling()
|
||||
} catch (err) { addToast(`Failed to remove rule: ${err.message}`, 'error') }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page page--wide">
|
||||
<PageHeader
|
||||
title={<><i className="fas fa-calendar-alt" style={{ marginRight: 'var(--spacing-sm)' }} />{t('scheduling.title')}</>}
|
||||
supporting={t('scheduling.subtitle')}
|
||||
/>
|
||||
<div>
|
||||
<button className="btn btn-primary btn-sm" style={{ marginBottom: 'var(--spacing-md)' }}
|
||||
onClick={() => setShowForm(f => !f)}>
|
||||
<i className="fas fa-plus" style={{ marginRight: 6 }} />
|
||||
Add Scheduling Rule
|
||||
</button>
|
||||
{showForm && <SchedulingForm onSave={handleSave} onCancel={() => setShowForm(false)} />}
|
||||
{schedulingConfigs.length === 0 && !showForm ? (
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--color-text-muted)', textAlign: 'center', padding: 'var(--spacing-xl) 0' }}>
|
||||
No scheduling rules configured. Add a rule to control how models are placed on nodes.
|
||||
</p>
|
||||
) : schedulingConfigs.length > 0 && (
|
||||
<ResponsiveTable>
|
||||
<thead><tr>
|
||||
<th>Model</th>
|
||||
<th>Mode</th>
|
||||
<th>Node Selector</th>
|
||||
<th>Min Replicas</th>
|
||||
<th>Max Replicas</th>
|
||||
<th>Routing</th>
|
||||
<th>Thresholds</th>
|
||||
<th>Status</th>
|
||||
<th style={{ textAlign: 'right' }}>Actions</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{schedulingConfigs.map(cfg => {
|
||||
const isSpread = !!cfg.spread_all
|
||||
const isAutoScaling = !isSpread && (cfg.min_replicas > 0 || cfg.max_replicas > 0)
|
||||
const hasSelector = !!cfg.node_selector
|
||||
const modeLabel = isSpread ? 'Spread' : isAutoScaling ? 'Auto-scaling' : hasSelector ? 'Placement' : 'Inactive'
|
||||
const modeColor = isSpread ? 'var(--color-warning)' : isAutoScaling ? 'var(--color-success)' : hasSelector ? 'var(--color-primary)' : 'var(--color-text-muted)'
|
||||
// Cooldown: reconciler tripped the circuit breaker because cluster
|
||||
// capacity is exhausted. Surface so the operator sees it instead
|
||||
// of the model silently failing to scale.
|
||||
const unsatisfiableUntil = cfg.unsatisfiable_until ? new Date(cfg.unsatisfiable_until) : null
|
||||
const isUnsatisfiable = unsatisfiableUntil && unsatisfiableUntil.getTime() > Date.now()
|
||||
return (
|
||||
<tr key={cfg.id || cfg.model_name}>
|
||||
<td style={{ fontWeight: 600, fontSize: '0.875rem' }}>{cfg.model_name}</td>
|
||||
<td>
|
||||
<span style={{
|
||||
display: 'inline-block', fontSize: '0.75rem', padding: '2px 8px', borderRadius: "var(--radius-sm)",
|
||||
background: 'var(--color-bg-tertiary)', border: `1px solid ${modeColor}`,
|
||||
color: modeColor, fontWeight: 600,
|
||||
}}>{modeLabel}</span>
|
||||
</td>
|
||||
<td>
|
||||
{cfg.node_selector ? (() => {
|
||||
try {
|
||||
const sel = typeof cfg.node_selector === 'string' ? JSON.parse(cfg.node_selector) : cfg.node_selector
|
||||
return Object.entries(sel).map(([k,v]) => (
|
||||
<span key={k} style={{
|
||||
display: 'inline-block', fontSize: '0.75rem', padding: '2px 6px', borderRadius: "var(--radius-sm)",
|
||||
background: 'var(--color-bg-tertiary)', border: '1px solid var(--color-border-subtle)',
|
||||
fontFamily: 'var(--font-mono)', marginRight: 4,
|
||||
}}>{k}={v}</span>
|
||||
))
|
||||
} catch { return <span style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>{cfg.node_selector}</span> }
|
||||
})() : <span style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>Any node</span>}
|
||||
</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)' }}>
|
||||
{isSpread
|
||||
? <span style={{
|
||||
display: 'inline-block', fontSize: '0.75rem', padding: '2px 8px', borderRadius: "var(--radius-sm)",
|
||||
background: 'var(--color-bg-tertiary)', border: '1px solid var(--color-warning)',
|
||||
color: 'var(--color-warning)', fontWeight: 600, fontFamily: 'var(--font-sans)',
|
||||
}}>Spread: all matching nodes</span>
|
||||
: isAutoScaling ? cfg.min_replicas : '-'}
|
||||
</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)' }}>
|
||||
{isSpread ? '-' : isAutoScaling ? (cfg.max_replicas || 'no limit') : '-'}
|
||||
</td>
|
||||
<td style={{ fontSize: '0.8125rem' }}>
|
||||
{cfg.route_policy || 'default'}
|
||||
</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>
|
||||
{cfg.route_policy === 'prefix_cache' ? (
|
||||
<>
|
||||
<div>match: {cfg.min_prefix_match ? cfg.min_prefix_match : 'inherit'}</div>
|
||||
<div>abs: {cfg.balance_abs_threshold ? cfg.balance_abs_threshold : 'inherit'}</div>
|
||||
<div>rel: {cfg.balance_rel_threshold ? cfg.balance_rel_threshold : 'inherit'}</div>
|
||||
</>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td>
|
||||
{isUnsatisfiable ? (
|
||||
<span
|
||||
title={`Reconciler couldn't satisfy this rule (capacity exhausted). Will retry by ${unsatisfiableUntil.toLocaleString()}, or sooner on a node lifecycle change.`}
|
||||
style={{
|
||||
display: 'inline-block', fontSize: '0.75rem', padding: '2px 8px',
|
||||
borderRadius: 'var(--radius-sm)', fontWeight: 600,
|
||||
background: 'var(--color-bg-tertiary)',
|
||||
border: '1px solid var(--color-warning, #d97706)',
|
||||
color: 'var(--color-warning, #d97706)',
|
||||
}}
|
||||
>
|
||||
<i className="fas fa-exclamation-triangle" style={{ marginRight: 4 }} />
|
||||
Unsatisfiable until {unsatisfiableUntil.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>OK</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<button className="btn btn-danger btn-sm" onClick={() => setConfirmDelete(cfg.model_name)}>
|
||||
<i className="fas fa-trash" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</ResponsiveTable>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirmDelete}
|
||||
title="Remove scheduling rule"
|
||||
message={confirmDelete ? `Remove the scheduling rule for "${confirmDelete}"?` : ''}
|
||||
confirmLabel="Remove"
|
||||
danger
|
||||
onConfirm={() => confirmDelete && handleDelete(confirmDelete)}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -69,7 +69,9 @@ const Studio = page('studio', () => import('./pages/Studio'))
|
||||
const FaceRecognition = page('face', () => import('./pages/FaceRecognition'))
|
||||
const VoiceRecognition = page('voice', () => import('./pages/VoiceRecognition'))
|
||||
const Nodes = page('nodes', () => import('./pages/Nodes'))
|
||||
const Scheduling = page('scheduling', () => import('./pages/Scheduling'))
|
||||
const NodeBackendLogs = page(null, () => import('./pages/NodeBackendLogs'))
|
||||
const NodeDetail = page(null, () => import('./pages/NodeDetail'))
|
||||
const NotFound = page(null, () => import('./pages/NotFound'))
|
||||
const Usage = page('usage', () => import('./pages/Usage'))
|
||||
const Users = page('users', () => import('./pages/Users'))
|
||||
@@ -152,6 +154,8 @@ const appChildren = [
|
||||
{ path: 'backend-logs/:modelId', element: <Admin><BackendLogs /></Admin> },
|
||||
{ path: 'p2p', element: <Admin><P2P /></Admin> },
|
||||
{ path: 'nodes', element: <Admin><Nodes /></Admin> },
|
||||
{ path: 'nodes/:id', element: <Admin><NodeDetail /></Admin> },
|
||||
{ path: 'scheduling', element: <Admin><Scheduling /></Admin> },
|
||||
{ path: 'node-backend-logs/:nodeId/:modelId', element: <Admin><NodeBackendLogs /></Admin> },
|
||||
{ path: 'usage', element: <Usage /> },
|
||||
{ path: 'users', element: <RequireAuthEnabled><Admin><Users /></Admin></RequireAuthEnabled> },
|
||||
|
||||
1
core/http/react-ui/src/utils/api.js
vendored
1
core/http/react-ui/src/utils/api.js
vendored
@@ -568,6 +568,7 @@ export const nodesApi = {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
listScheduling: () => fetchJSON(API_CONFIG.endpoints.nodesScheduling),
|
||||
allModels: () => fetchJSON(API_CONFIG.endpoints.nodesModels),
|
||||
setScheduling: (config) => postJSON(API_CONFIG.endpoints.nodesScheduling, config),
|
||||
deleteScheduling: (model) => fetchJSON(API_CONFIG.endpoints.nodesSchedulingModel(model), { method: 'DELETE' }),
|
||||
}
|
||||
|
||||
1
core/http/react-ui/src/utils/config.js
vendored
1
core/http/react-ui/src/utils/config.js
vendored
@@ -144,6 +144,7 @@ export const API_CONFIG = {
|
||||
nodeLabelKey: (id, key) => `/api/nodes/${id}/labels/${key}`,
|
||||
nodeMaxReplicasPerModel: (id) => `/api/nodes/${id}/max-replicas-per-model`,
|
||||
nodesScheduling: '/api/nodes/scheduling',
|
||||
nodesModels: '/api/nodes/models',
|
||||
nodesSchedulingModel: (model) => `/api/nodes/scheduling/${encodeURIComponent(model)}`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -71,6 +71,9 @@ func RegisterNodeAdminRoutes(e *echo.Echo, registry *nodes.NodeRegistry, unloade
|
||||
admin := e.Group("/api/nodes", readyMw, adminMw)
|
||||
admin.GET("", localai.ListNodesEndpoint(registry))
|
||||
|
||||
// Cluster-wide loaded models (registered before /:id to avoid route conflicts)
|
||||
admin.GET("/models", localai.ListAllNodeModelsEndpoint(registry))
|
||||
|
||||
// Model scheduling (registered before /:id to avoid route conflicts)
|
||||
admin.GET("/scheduling", localai.ListSchedulingEndpoint(registry))
|
||||
admin.GET("/scheduling/:model", localai.GetSchedulingEndpoint(registry))
|
||||
|
||||
@@ -64,6 +64,22 @@ func SubjectGalleryProgress(opID string) string {
|
||||
return subjectGalleryPrefix + sanitizeSubjectToken(opID) + ".progress"
|
||||
}
|
||||
|
||||
// SubjectStagingProgress returns the NATS subject a frontend replica publishes
|
||||
// file-staging progress on. Staging progress is otherwise per-process state
|
||||
// (the SmartRouter's in-memory StagingTracker), so without this broadcast a
|
||||
// /api/operations poll that round-robins onto a replica that did not originate
|
||||
// the staging op sees nothing - the progress row flickers in multi-replica
|
||||
// deployments. Peers subscribe to the wildcard and merge.
|
||||
func SubjectStagingProgress(modelID string) string {
|
||||
return subjectStagingPrefix + sanitizeSubjectToken(modelID) + ".progress"
|
||||
}
|
||||
|
||||
const subjectStagingPrefix = "staging."
|
||||
|
||||
// SubjectStagingProgressWildcard matches every replica's staging-progress
|
||||
// broadcasts so a peer can mirror staging ops it did not originate.
|
||||
const SubjectStagingProgressWildcard = "staging.*.progress"
|
||||
|
||||
// SubjectGalleryOpStart and SubjectGalleryOpEnd are broadcast subjects for the
|
||||
// in-memory OpCache lifecycle. Frontend replicas publish to these when an
|
||||
// admin admits a new install/delete (Start) and when an operation is
|
||||
|
||||
@@ -359,8 +359,21 @@ func (r *SmartRouter) Route(ctx context.Context, modelID, modelName, backendType
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Model not loaded — schedule loading with distributed lock to prevent duplicates
|
||||
loadModel := func() (*RouteResult, error) {
|
||||
// Step 2: Model not loaded — schedule loading with distributed lock to prevent duplicates.
|
||||
//
|
||||
// Detach the cold-load from the caller's context. Staging a model can
|
||||
// transfer multiple GB to a worker, which takes far longer than any client
|
||||
// keeps its HTTP request open — a browser refresh, an ingress/LB idle
|
||||
// timeout, or a round-robined retry landing on another replica all cancel
|
||||
// the request context. If staging were bound to it, the multi-GB upload
|
||||
// aborts with "context canceled" mid-transfer and large models can never
|
||||
// finish staging (the model-load outage). WithoutCancel keeps the request's
|
||||
// values (prefix chain, etc.) but drops its cancellation/deadline. Each
|
||||
// long step still has its own bound (the file stager's resume budget,
|
||||
// LoadModel's 5m timeout), and the per-model advisory lock below de-dupes
|
||||
// concurrent loaders across replicas.
|
||||
loadCtx := context.WithoutCancel(ctx)
|
||||
loadModel := func(ctx context.Context) (*RouteResult, error) {
|
||||
// Re-check after acquiring lock — another request may have loaded it
|
||||
node, nm, err := r.registry.FindAndLockNodeWithModel(ctx, trackingKey, candidateNodeIDs, pref)
|
||||
if err == nil && node != nil {
|
||||
@@ -433,9 +446,9 @@ func (r *SmartRouter) Route(ctx context.Context, modelID, modelName, backendType
|
||||
if r.db != nil {
|
||||
lockKey := advisorylock.KeyFromString("model-load:" + trackingKey)
|
||||
var result *RouteResult
|
||||
lockErr := advisorylock.WithLockCtx(ctx, r.db, lockKey, func() error {
|
||||
lockErr := advisorylock.WithLockCtx(loadCtx, r.db, lockKey, func() error {
|
||||
var err error
|
||||
result, err = loadModel()
|
||||
result, err = loadModel(loadCtx)
|
||||
return err
|
||||
})
|
||||
if lockErr != nil {
|
||||
@@ -444,7 +457,7 @@ func (r *SmartRouter) Route(ctx context.Context, modelID, modelName, backendType
|
||||
return result, nil
|
||||
}
|
||||
// No DB (non-distributed) — proceed without lock
|
||||
return loadModel()
|
||||
return loadModel(loadCtx)
|
||||
}
|
||||
|
||||
// parseSelectorJSON decodes a JSON node selector string into a map.
|
||||
|
||||
80
core/services/nodes/router_staging_context_test.go
Normal file
80
core/services/nodes/router_staging_context_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package nodes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/messaging"
|
||||
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
||||
)
|
||||
|
||||
// cancelOnStageStager simulates the triggering HTTP request being abandoned
|
||||
// (client disconnect, ingress idle-timeout) the moment a multi-GB file starts
|
||||
// staging. It cancels the request context and records whether the context the
|
||||
// stager itself received was cancelled as a result.
|
||||
type cancelOnStageStager struct {
|
||||
fakeFileStager
|
||||
cancelRequest context.CancelFunc
|
||||
staged bool
|
||||
ctxErrOnStage error
|
||||
}
|
||||
|
||||
func (s *cancelOnStageStager) EnsureRemote(ctx context.Context, _, _, key string) (string, error) {
|
||||
s.staged = true
|
||||
// Mid-transfer: the client gives up on the (minutes-long) request.
|
||||
if s.cancelRequest != nil {
|
||||
s.cancelRequest()
|
||||
}
|
||||
// A multi-GB upload must survive this. If staging were bound to the
|
||||
// request context, ctx is now cancelled and the real HTTP stager would
|
||||
// abort with "context canceled" — exactly the production outage.
|
||||
s.ctxErrOnStage = ctx.Err()
|
||||
return "/remote/" + key, nil
|
||||
}
|
||||
|
||||
var _ = Describe("Route cold-load staging context", func() {
|
||||
It("detaches staging from the request context so a client disconnect cannot abort a multi-GB transfer", func() {
|
||||
// A real model file so stageModelFiles actually calls the stager
|
||||
// (non-existent paths are skipped).
|
||||
tmp := GinkgoT().TempDir()
|
||||
modelFile := filepath.Join(tmp, "big.gguf")
|
||||
Expect(os.WriteFile(modelFile, []byte("weights"), 0o644)).To(Succeed())
|
||||
|
||||
reg := &fakeModelRouter{
|
||||
findAndLockErr: errors.New("not loaded"),
|
||||
findIdleNode: &BackendNode{ID: "n1", Name: "worker-1", Address: "10.0.0.1:50051"},
|
||||
}
|
||||
backend := &stubBackend{loadResult: &pb.Result{Success: true}}
|
||||
factory := &stubClientFactory{client: backend}
|
||||
unloader := &fakeUnloader{installReply: &messaging.BackendInstallReply{
|
||||
Success: true,
|
||||
Address: "10.0.0.1:9001",
|
||||
}}
|
||||
stager := &cancelOnStageStager{}
|
||||
|
||||
router := NewSmartRouter(reg, SmartRouterOptions{
|
||||
Unloader: unloader,
|
||||
ClientFactory: factory,
|
||||
FileStager: stager,
|
||||
// DB nil: no advisory lock, exercises the same detached load ctx.
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
stager.cancelRequest = cancel
|
||||
defer cancel()
|
||||
|
||||
result, err := router.Route(ctx, "big-model", filepath.Join("models", "big.gguf"), "llama-cpp",
|
||||
&pb.ModelOptions{Model: "big.gguf", ModelFile: modelFile}, false)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).ToNot(BeNil())
|
||||
Expect(stager.staged).To(BeTrue(), "staging must have been attempted")
|
||||
Expect(stager.ctxErrOnStage).ToNot(HaveOccurred(),
|
||||
"staging context must survive cancellation of the triggering request")
|
||||
})
|
||||
})
|
||||
@@ -5,58 +5,138 @@ import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/messaging"
|
||||
)
|
||||
|
||||
// StagingStatus represents the current progress of a model staging operation.
|
||||
type StagingStatus struct {
|
||||
ModelID string `json:"model_id"`
|
||||
NodeName string `json:"node_name"`
|
||||
FileName string `json:"file_name"`
|
||||
BytesSent int64 `json:"bytes_sent"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
Progress float64 `json:"progress"` // 0-100 overall progress
|
||||
Speed string `json:"speed"`
|
||||
FileIndex int `json:"file_index"`
|
||||
TotalFiles int `json:"total_files"`
|
||||
Message string `json:"message"`
|
||||
ModelID string `json:"model_id"`
|
||||
NodeName string `json:"node_name"`
|
||||
FileName string `json:"file_name"`
|
||||
BytesSent int64 `json:"bytes_sent"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
Progress float64 `json:"progress"` // 0-100 overall progress
|
||||
Speed string `json:"speed"`
|
||||
FileIndex int `json:"file_index"`
|
||||
TotalFiles int `json:"total_files"`
|
||||
Message string `json:"message"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
}
|
||||
|
||||
const (
|
||||
// stagingBroadcastInterval bounds how often byte-level UpdateFile ticks are
|
||||
// re-broadcast to peers (leading-edge debounce). State transitions (Start,
|
||||
// FileComplete, Complete) always publish so peers never miss them.
|
||||
stagingBroadcastInterval = time.Second
|
||||
// stagingRemoteTTL drops a mirrored (remote) op whose last update is older
|
||||
// than this. NATS pub/sub is fire-and-forget, so a missed Done event would
|
||||
// otherwise leave a phantom staging row on a peer forever; a live op
|
||||
// refreshes its mirror at least every stagingBroadcastInterval.
|
||||
stagingRemoteTTL = 60 * time.Second
|
||||
)
|
||||
|
||||
// stagingEntry wraps a StagingStatus with the bookkeeping needed to keep peer
|
||||
// replicas consistent: whether this op is mirrored from a peer (remote) vs.
|
||||
// owned locally, when it was last updated (for remote-mirror expiry), and when
|
||||
// its byte progress was last broadcast (for debounce).
|
||||
type stagingEntry struct {
|
||||
status StagingStatus
|
||||
remote bool
|
||||
updatedAt time.Time
|
||||
lastPub time.Time
|
||||
}
|
||||
|
||||
// StagingTracker tracks active file staging operations in-memory.
|
||||
// Used by SmartRouter to publish progress and by /api/operations to surface it.
|
||||
//
|
||||
// In distributed mode each frontend replica runs its own tracker. The replica
|
||||
// performing a transfer owns the op locally and broadcasts progress over NATS
|
||||
// (SetPublisher); peers mirror it via ApplyRemote (SubscribeBroadcasts) so a
|
||||
// /api/operations poll that round-robins onto any replica surfaces the op.
|
||||
type StagingTracker struct {
|
||||
mu sync.RWMutex
|
||||
active map[string]*StagingStatus
|
||||
mu sync.RWMutex
|
||||
active map[string]*stagingEntry
|
||||
publisher messaging.Publisher
|
||||
}
|
||||
|
||||
// StagingProgressEvent is the wire payload a frontend replica broadcasts on
|
||||
// SubjectStagingProgress so peer replicas can mirror a staging op they did not
|
||||
// originate. Done signals the op finished (peers drop their mirrored copy).
|
||||
type StagingProgressEvent struct {
|
||||
ModelID string `json:"model_id"`
|
||||
Status *StagingStatus `json:"status,omitempty"`
|
||||
Done bool `json:"done"`
|
||||
}
|
||||
|
||||
// NewStagingTracker creates a new tracker.
|
||||
func NewStagingTracker() *StagingTracker {
|
||||
return &StagingTracker{
|
||||
active: make(map[string]*StagingStatus),
|
||||
active: make(map[string]*stagingEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// SetPublisher wires the NATS publisher used to broadcast staging progress to
|
||||
// peer replicas. No-op publisher (nil) keeps the tracker standalone.
|
||||
func (t *StagingTracker) SetPublisher(p messaging.Publisher) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.publisher = p
|
||||
}
|
||||
|
||||
// SubscribeBroadcasts subscribes to peer replicas' staging-progress broadcasts
|
||||
// and mirrors them into this tracker, so /api/operations on any replica surfaces
|
||||
// staging ops it did not originate. Returns the subscription for cleanup.
|
||||
func (t *StagingTracker) SubscribeBroadcasts(nc messaging.MessagingClient) (messaging.Subscription, error) {
|
||||
return messaging.SubscribeJSON(nc, messaging.SubjectStagingProgressWildcard, func(evt StagingProgressEvent) {
|
||||
if evt.ModelID == "" {
|
||||
return
|
||||
}
|
||||
t.ApplyRemote(evt)
|
||||
})
|
||||
}
|
||||
|
||||
// publishStaging emits an event to the per-model staging subject. The publisher
|
||||
// is captured by the caller under the lock and passed in, so publishing happens
|
||||
// outside the lock (a slow NATS link must not stall the staging copy loop).
|
||||
func publishStaging(p messaging.Publisher, evt StagingProgressEvent) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
_ = p.Publish(messaging.SubjectStagingProgress(evt.ModelID), evt)
|
||||
}
|
||||
|
||||
// Start registers a new staging operation for the given model.
|
||||
func (t *StagingTracker) Start(modelID, nodeName string, totalFiles int) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.active[modelID] = &StagingStatus{
|
||||
ModelID: modelID,
|
||||
NodeName: nodeName,
|
||||
TotalFiles: totalFiles,
|
||||
StartedAt: time.Now(),
|
||||
Message: "Preparing to stage model files",
|
||||
e := &stagingEntry{
|
||||
status: StagingStatus{
|
||||
ModelID: modelID,
|
||||
NodeName: nodeName,
|
||||
TotalFiles: totalFiles,
|
||||
StartedAt: time.Now(),
|
||||
Message: "Preparing to stage model files",
|
||||
},
|
||||
updatedAt: time.Now(),
|
||||
// lastPub stays zero so the first UpdateFile tick always broadcasts.
|
||||
}
|
||||
t.active[modelID] = e
|
||||
pub := t.publisher
|
||||
snap := e.status
|
||||
t.mu.Unlock()
|
||||
|
||||
publishStaging(pub, StagingProgressEvent{ModelID: modelID, Status: &snap})
|
||||
}
|
||||
|
||||
// UpdateFile updates the tracker with current file transfer progress.
|
||||
func (t *StagingTracker) UpdateFile(modelID, fileName string, fileIndex int, bytesSent, totalBytes int64, speed string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
s, ok := t.active[modelID]
|
||||
e, ok := t.active[modelID]
|
||||
if !ok {
|
||||
t.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s := &e.status
|
||||
s.FileName = fileName
|
||||
s.FileIndex = fileIndex
|
||||
s.BytesSent = bytesSent
|
||||
@@ -79,52 +159,121 @@ func (t *StagingTracker) UpdateFile(modelID, fileName string, fileIndex int, byt
|
||||
} else {
|
||||
s.Message = fmt.Sprintf("Staging %s", fileName)
|
||||
}
|
||||
|
||||
e.updatedAt = time.Now()
|
||||
// Leading-edge debounce: byte ticks fire many times per second; only
|
||||
// re-broadcast at most once per stagingBroadcastInterval.
|
||||
var pub messaging.Publisher
|
||||
var snap StagingStatus
|
||||
if time.Since(e.lastPub) >= stagingBroadcastInterval {
|
||||
e.lastPub = time.Now()
|
||||
pub = t.publisher
|
||||
snap = e.status
|
||||
}
|
||||
t.mu.Unlock()
|
||||
|
||||
if pub != nil {
|
||||
publishStaging(pub, StagingProgressEvent{ModelID: modelID, Status: &snap})
|
||||
}
|
||||
}
|
||||
|
||||
// FileComplete marks a single file as done within a staging operation.
|
||||
func (t *StagingTracker) FileComplete(modelID string, fileIndex, totalFiles int) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
s, ok := t.active[modelID]
|
||||
e, ok := t.active[modelID]
|
||||
if !ok {
|
||||
t.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s := &e.status
|
||||
if totalFiles > 0 {
|
||||
s.Progress = float64(fileIndex) / float64(totalFiles) * 100
|
||||
}
|
||||
s.BytesSent = 0
|
||||
s.TotalBytes = 0
|
||||
s.Speed = ""
|
||||
e.updatedAt = time.Now()
|
||||
e.lastPub = time.Now()
|
||||
pub := t.publisher
|
||||
snap := e.status
|
||||
t.mu.Unlock()
|
||||
|
||||
// Always broadcast a per-file completion so peers' progress bars advance.
|
||||
publishStaging(pub, StagingProgressEvent{ModelID: modelID, Status: &snap})
|
||||
}
|
||||
|
||||
// Complete removes a staging operation (it's done).
|
||||
func (t *StagingTracker) Complete(modelID string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
_, ok := t.active[modelID]
|
||||
delete(t.active, modelID)
|
||||
pub := t.publisher
|
||||
t.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
// Tell peers to drop their mirrored copy.
|
||||
publishStaging(pub, StagingProgressEvent{ModelID: modelID, Done: true})
|
||||
}
|
||||
}
|
||||
|
||||
// GetAll returns a snapshot of all active staging operations.
|
||||
// ApplyRemote merges a peer replica's staging broadcast into this tracker. It
|
||||
// never re-broadcasts (no echo loop). A locally-owned op is authoritative: a
|
||||
// remote event for the same model is ignored, so the origin replica receiving
|
||||
// its own broadcast (and any stray peer event) cannot clobber or delete it.
|
||||
func (t *StagingTracker) ApplyRemote(evt StagingProgressEvent) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if existing, ok := t.active[evt.ModelID]; ok && !existing.remote {
|
||||
// We own this op locally — ignore peer chatter about it.
|
||||
return
|
||||
}
|
||||
if evt.Done {
|
||||
delete(t.active, evt.ModelID)
|
||||
return
|
||||
}
|
||||
if evt.Status == nil {
|
||||
return
|
||||
}
|
||||
t.active[evt.ModelID] = &stagingEntry{
|
||||
status: *evt.Status,
|
||||
remote: true,
|
||||
updatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetAll returns a snapshot of all active staging operations. Stale remote
|
||||
// mirrors (a peer op whose Done event was missed) are pruned here so they don't
|
||||
// linger in the UI.
|
||||
func (t *StagingTracker) GetAll() map[string]StagingStatus {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
now := time.Now()
|
||||
result := make(map[string]StagingStatus, len(t.active))
|
||||
for k, v := range t.active {
|
||||
result[k] = *v
|
||||
for k, e := range t.active {
|
||||
if e.remote && now.Sub(e.updatedAt) > stagingRemoteTTL {
|
||||
delete(t.active, k)
|
||||
continue
|
||||
}
|
||||
result[k] = e.status
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Get returns the status of a specific staging operation, or nil if not active.
|
||||
// Get returns the status of a specific staging operation, or nil if not active
|
||||
// (or a stale remote mirror).
|
||||
func (t *StagingTracker) Get(modelID string) *StagingStatus {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
s, ok := t.active[modelID]
|
||||
e, ok := t.active[modelID]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
copy := *s
|
||||
return ©
|
||||
if e.remote && time.Since(e.updatedAt) > stagingRemoteTTL {
|
||||
return nil
|
||||
}
|
||||
s := e.status
|
||||
return &s
|
||||
}
|
||||
|
||||
// StagingProgressCallback is called by file stagers to report byte-level progress.
|
||||
|
||||
109
core/services/nodes/staging_progress_broadcast_test.go
Normal file
109
core/services/nodes/staging_progress_broadcast_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package nodes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/messaging"
|
||||
)
|
||||
|
||||
// decodeStagingEvents extracts every StagingProgressEvent the fake messaging
|
||||
// client captured, in publish order.
|
||||
func decodeStagingEvents(mc *fakeMessagingClient) []StagingProgressEvent {
|
||||
mc.mu.Lock()
|
||||
defer mc.mu.Unlock()
|
||||
var out []StagingProgressEvent
|
||||
for _, p := range mc.published {
|
||||
var evt StagingProgressEvent
|
||||
if err := json.Unmarshal(p.Data, &evt); err != nil {
|
||||
continue
|
||||
}
|
||||
if evt.ModelID == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, evt)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var _ = Describe("StagingTracker cross-replica broadcast", func() {
|
||||
Context("when a publisher is wired (distributed mode)", func() {
|
||||
It("broadcasts staging progress so a peer replica surfaces an op it did not originate", func() {
|
||||
mc := &fakeMessagingClient{}
|
||||
origin := NewStagingTracker()
|
||||
origin.SetPublisher(mc)
|
||||
|
||||
origin.Start("model-x", "worker-1", 1)
|
||||
origin.UpdateFile("model-x", "weights.gguf", 1, 5<<30, 10<<30, "100 MiB/s")
|
||||
|
||||
events := decodeStagingEvents(mc)
|
||||
Expect(events).ToNot(BeEmpty(), "writes must be broadcast over NATS")
|
||||
Expect(mc.published[0].Subject).To(Equal(messaging.SubjectStagingProgress("model-x")))
|
||||
|
||||
// A peer replica that never ran the op merges the broadcast.
|
||||
peer := NewStagingTracker()
|
||||
for _, evt := range events {
|
||||
peer.ApplyRemote(evt)
|
||||
}
|
||||
|
||||
all := peer.GetAll()
|
||||
Expect(all).To(HaveKey("model-x"))
|
||||
Expect(all["model-x"].NodeName).To(Equal("worker-1"))
|
||||
Expect(all["model-x"].FileName).To(Equal("weights.gguf"))
|
||||
Expect(all["model-x"].TotalBytes).To(Equal(int64(10 << 30)))
|
||||
})
|
||||
|
||||
It("removes the op from the peer when the origin completes it", func() {
|
||||
mc := &fakeMessagingClient{}
|
||||
origin := NewStagingTracker()
|
||||
origin.SetPublisher(mc)
|
||||
|
||||
origin.Start("model-x", "worker-1", 1)
|
||||
origin.Complete("model-x")
|
||||
|
||||
peer := NewStagingTracker()
|
||||
for _, evt := range decodeStagingEvents(mc) {
|
||||
peer.ApplyRemote(evt)
|
||||
}
|
||||
Expect(peer.GetAll()).ToNot(HaveKey("model-x"))
|
||||
})
|
||||
|
||||
It("does not let a peer broadcast clobber an op this replica is itself running", func() {
|
||||
local := NewStagingTracker()
|
||||
local.Start("model-x", "worker-local", 2)
|
||||
local.UpdateFile("model-x", "weights.gguf", 1, 9<<30, 10<<30, "")
|
||||
|
||||
// A stray/older remote event for the SAME modelID must not overwrite
|
||||
// the authoritative local state, nor delete it.
|
||||
local.ApplyRemote(StagingProgressEvent{
|
||||
ModelID: "model-x",
|
||||
Status: &StagingStatus{ModelID: "model-x", NodeName: "worker-other", FileName: "stale.gguf"},
|
||||
})
|
||||
local.ApplyRemote(StagingProgressEvent{ModelID: "model-x", Done: true})
|
||||
|
||||
all := local.GetAll()
|
||||
Expect(all).To(HaveKey("model-x"))
|
||||
Expect(all["model-x"].NodeName).To(Equal("worker-local"))
|
||||
Expect(all["model-x"].FileName).To(Equal("weights.gguf"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when no publisher is wired (standalone mode)", func() {
|
||||
It("does not broadcast", func() {
|
||||
mc := &fakeMessagingClient{}
|
||||
t := NewStagingTracker()
|
||||
t.Start("model-x", "worker-1", 1)
|
||||
t.UpdateFile("model-x", "weights.gguf", 1, 1<<30, 10<<30, "")
|
||||
Expect(mc.published).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("SubjectStagingProgress", func() {
|
||||
It("namespaces by model id and matches the wildcard prefix", func() {
|
||||
Expect(messaging.SubjectStagingProgress("model-x")).To(Equal("staging.model-x.progress"))
|
||||
Expect(messaging.SubjectStagingProgressWildcard).To(Equal("staging.*.progress"))
|
||||
})
|
||||
})
|
||||
@@ -44,7 +44,7 @@ func applyAnyText(v any, elem int, text string) any {
|
||||
if elem < 0 {
|
||||
return text
|
||||
}
|
||||
if arr, ok := v.([]any); ok && elem >= 0 && elem < len(arr) {
|
||||
if arr, ok := v.([]any); ok && elem < len(arr) {
|
||||
arr[elem] = text
|
||||
}
|
||||
return v
|
||||
|
||||
@@ -39,8 +39,9 @@ type patternDetector struct {
|
||||
// When tracing is enabled it records a pattern_pii BackendTrace so the matches
|
||||
// (group, byte range, text) show in the Traces UI alongside NER detections.
|
||||
func (d *patternDetector) Detect(_ context.Context, text string) ([]pii.NEREntity, error) {
|
||||
tracing := d.appConfig != nil && d.appConfig.EnableTracing
|
||||
var start time.Time
|
||||
if d.appConfig != nil && d.appConfig.EnableTracing {
|
||||
if tracing {
|
||||
trace.InitBackendTracingIfEnabled(d.appConfig.TracingMaxItems, d.appConfig.TracingMaxBodyBytes)
|
||||
start = time.Now()
|
||||
}
|
||||
@@ -50,12 +51,12 @@ func (d *patternDetector) Detect(_ context.Context, text string) ([]pii.NEREntit
|
||||
var traceEnts []backend.TokenEntity
|
||||
for _, mt := range matches {
|
||||
out = append(out, pii.NEREntity{Group: mt.Group, Start: mt.Start, End: mt.End, Score: 1.0, Text: mt.Text})
|
||||
if d.appConfig != nil && d.appConfig.EnableTracing {
|
||||
if tracing {
|
||||
traceEnts = append(traceEnts, backend.TokenEntity{Group: mt.Group, Start: mt.Start, End: mt.End, Score: 1.0, Text: mt.Text})
|
||||
}
|
||||
}
|
||||
|
||||
if d.appConfig != nil && d.appConfig.EnableTracing {
|
||||
if tracing {
|
||||
trace.RecordBackendTrace(patternPIITrace(d.modelName, text, traceEnts, start))
|
||||
}
|
||||
return out, nil
|
||||
|
||||
@@ -28,10 +28,16 @@ const (
|
||||
// credential shape, small enough that the compiled program stays tiny.
|
||||
MaxPatternLen = 256
|
||||
// MaxQuantifier caps an explicit {n,m} upper bound. RE2 expands a bounded
|
||||
// repeat into that many copies, so an uncapped {0,1000000} would blow up
|
||||
// the compiled program's memory. Unbounded {n,} (no upper) is a loop, not
|
||||
// an expansion, and is allowed.
|
||||
MaxQuantifier = 4096
|
||||
// repeat into that many copies, so a large bound inflates the compiled
|
||||
// program. Go's regexp/syntax independently rejects any bound above 1000
|
||||
// at Parse time, so this cap MUST stay strictly below 1000 to be a live
|
||||
// guard rather than dead code shadowed by the parser: a bound in
|
||||
// (MaxQuantifier, 1000] reaches walk and is rejected here with an
|
||||
// actionable error, while >1000 is caught earlier by Parse. 512 is far
|
||||
// larger than any real credential token yet keeps the guard meaningful and
|
||||
// is defence in depth should the stdlib cap ever rise. Unbounded {n,} (no
|
||||
// upper) is a loop, not an expansion, and is allowed.
|
||||
MaxQuantifier = 512
|
||||
// MaxAlternation caps the arms of a single `a|b|c` alternation.
|
||||
MaxAlternation = 64
|
||||
// MaxAST bounds recursion depth so a pathologically nested pattern can't
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package piipattern
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -36,6 +37,45 @@ var _ = Describe("ValidatePattern", func() {
|
||||
)
|
||||
})
|
||||
|
||||
var _ = Describe("MaxQuantifier guard (must stay live, not dead code)", func() {
|
||||
// Go's regexp/syntax hard-caps repeat bounds at 1000 and rejects anything
|
||||
// larger at Parse time, before walk() runs. So the walk() {n,m} guard only
|
||||
// fires for bounds in (MaxQuantifier, 1000]; if MaxQuantifier ever creeps
|
||||
// to >= 1000 the guard becomes unreachable dead code. These specs pin the
|
||||
// relationship and prove the guard is the binding constraint in that band.
|
||||
const stdlibRepeatCap = 1000
|
||||
|
||||
It("is strictly below the stdlib repeat cap so the guard is reachable", func() {
|
||||
Expect(MaxQuantifier).To(BeNumerically("<", stdlibRepeatCap),
|
||||
"MaxQuantifier must be < %d or walk()'s {n,m} guard is dead code (Parse rejects larger bounds first)", stdlibRepeatCap)
|
||||
})
|
||||
|
||||
It("accepts a bound at exactly MaxQuantifier", func() {
|
||||
Expect(ValidatePattern(fmt.Sprintf(`sk-ant-[A-Za-z0-9]{%d}`, MaxQuantifier))).To(Succeed())
|
||||
})
|
||||
|
||||
It("rejects a bound just above MaxQuantifier with our actionable error (proves the guard runs)", func() {
|
||||
// MaxQuantifier+1 is still parseable (<= stdlib cap), so it reaches
|
||||
// walk(), where our guard — not the parser — rejects it.
|
||||
err := ValidatePattern(fmt.Sprintf(`sk-ant-[A-Za-z0-9]{%d}`, MaxQuantifier+1))
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("bound is too large"),
|
||||
"a bound in (MaxQuantifier, stdlib cap] must be rejected by walk(), not the parser")
|
||||
})
|
||||
|
||||
It("rejects an unbounded {n,} whose lower bound exceeds MaxQuantifier", func() {
|
||||
err := ValidatePattern(fmt.Sprintf(`sk-ant-[A-Za-z0-9]{%d,}`, MaxQuantifier+1))
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("bound is too large"))
|
||||
})
|
||||
|
||||
It("still fails closed above the stdlib cap (Parse rejects before walk)", func() {
|
||||
// >1000: caught by syntax.Parse; the message is the parser's, but it
|
||||
// still fails closed — defence in depth.
|
||||
Expect(ValidatePattern(fmt.Sprintf(`sk-ant-[A-Za-z0-9]{%d}`, stdlibRepeatCap+1))).NotTo(Succeed())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("Compile", func() {
|
||||
It("compiles a valid pattern with leftmost-longest semantics", func() {
|
||||
re, err := Compile(`sk-ant-[A-Za-z0-9_-]{4,}`)
|
||||
|
||||
@@ -311,7 +311,7 @@ Used by the WebUI and admin API consumers. Requires admin authentication.
|
||||
| `POST` | `/api/nodes/:id/models/unload` | Unload a model from a worker |
|
||||
| `POST` | `/api/nodes/:id/models/delete` | Delete model files from a worker |
|
||||
|
||||
The **Nodes** page in the React WebUI provides a visual overview of all registered workers, their statuses, and loaded models.
|
||||
The **Nodes** page in the React WebUI provides a visual overview of all registered workers, their statuses, and loaded models. The page opens with a one-line **cluster pulse** summarising node health and an **attention callout** that surfaces nodes needing action (for example pending approvals). Below that, a roster of **node panels** lists each worker with its inline model chips (no expand click needed), filtered by an **All / Backend / Agent** segmented control. Selecting a panel opens a dedicated **node detail page** at `/app/nodes/:id` with per-node metrics, models, and backend actions. Model scheduling lives on its own **Scheduling** page (separate nav item), not as a tab on the Nodes page.
|
||||
|
||||
## Node Approval
|
||||
|
||||
@@ -554,7 +554,7 @@ local-ai worker \
|
||||
|
||||
## Model Scheduling
|
||||
|
||||
Model scheduling controls where models are placed and how many replicas are maintained. It combines two optional features:
|
||||
Model scheduling controls where models are placed and how many replicas are maintained. In the React WebUI it has its own **Scheduling** page (a top-level nav item, separate from the Nodes page). It combines two optional features:
|
||||
|
||||
### Node Selectors
|
||||
|
||||
|
||||
@@ -131,6 +131,10 @@ local-ai run ollama://gemma:2b
|
||||
local-ai run oci://localai/phi-2:latest
|
||||
```
|
||||
|
||||
{{% notice note %}}
|
||||
When pulling models from Ollama or OCI registries, LocalAI identifies itself with a `LocalAI/<version>` `User-Agent` header so registry operators can attribute usage to LocalAI.
|
||||
{{% /notice %}}
|
||||
|
||||
### Run Models via URI
|
||||
|
||||
To run models via URI, specify a URI to a model file or a configuration file when starting LocalAI. Valid syntax includes:
|
||||
|
||||
@@ -1,4 +1,142 @@
|
||||
---
|
||||
- name: "qwythos-9b-claude-mythos-5-1m"
|
||||
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
|
||||
urls:
|
||||
- https://huggingface.co/empero-ai/Qwythos-9B-Claude-Mythos-5-1M-GGUF
|
||||
description: |
|
||||
# Qwythos-9B
|
||||
|
||||
**Developed by Empero**
|
||||
|
||||
**Qwythos-9B** is a full-parameter reasoning model built on top of a **deeply uncensored Qwen3.5-9B base** and post-trained on **over 500 million tokens** of high-quality Claude Mythos and Claude Fable traces, with chain-of-thought generated in-house by Empero AI's internal tool **rethink**.
|
||||
|
||||
The result is a compact, fast, **dramatically more capable** 9B reasoning model. Headline capabilities:
|
||||
|
||||
...
|
||||
license: "apache-2.0"
|
||||
tags:
|
||||
- llm
|
||||
- gguf
|
||||
- vision
|
||||
- multimodal
|
||||
- reasoning
|
||||
overrides:
|
||||
backend: llama-cpp
|
||||
function:
|
||||
automatic_tool_parsing_fallback: true
|
||||
grammar:
|
||||
disable: true
|
||||
known_usecases:
|
||||
- chat
|
||||
mmproj: llama-cpp/mmproj/Qwythos-9B-Claude-Mythos-5-1M-GGUF/mmproj-Qwythos-9B-Claude-Mythos-5-1M-f16.gguf
|
||||
options:
|
||||
- use_jinja:true
|
||||
- spec_type:draft-mtp
|
||||
- spec_n_max:6
|
||||
- spec_p_min:0.75
|
||||
parameters:
|
||||
model: llama-cpp/models/Qwythos-9B-Claude-Mythos-5-1M-GGUF/Qwythos-9B-Claude-Mythos-5-1M-MTP-Q4_K_M.gguf
|
||||
template:
|
||||
use_tokenizer_template: true
|
||||
files:
|
||||
- filename: llama-cpp/models/Qwythos-9B-Claude-Mythos-5-1M-GGUF/Qwythos-9B-Claude-Mythos-5-1M-MTP-Q4_K_M.gguf
|
||||
sha256: 24ee22e0f5d9f0d3d615809607f365c728d9b0c3f3fb6eb19d8bd83a1c2933d8
|
||||
uri: https://huggingface.co/empero-ai/Qwythos-9B-Claude-Mythos-5-1M-GGUF/resolve/main/Qwythos-9B-Claude-Mythos-5-1M-MTP-Q4_K_M.gguf
|
||||
- filename: llama-cpp/mmproj/Qwythos-9B-Claude-Mythos-5-1M-GGUF/mmproj-Qwythos-9B-Claude-Mythos-5-1M-f16.gguf
|
||||
sha256: f70dc3509053962b0d0d3ee8a7eacebf5d60aa560cad78254ae8698516ae029f
|
||||
uri: https://huggingface.co/empero-ai/Qwythos-9B-Claude-Mythos-5-1M-GGUF/resolve/main/mmproj-Qwythos-9B-Claude-Mythos-5-1M-f16.gguf
|
||||
- name: "glm-5.2"
|
||||
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
|
||||
urls:
|
||||
- https://huggingface.co/unsloth/GLM-5.2-GGUF
|
||||
description: |
|
||||
# GLM-5.2
|
||||
|
||||
👋 Join our WeChat or Discord community.
|
||||
|
||||
📖 Check out the GLM-5.2 blog and GLM-5 Technical report.
|
||||
|
||||
📍 Use GLM-5.2 API services on Z.ai API Platform.
|
||||
|
||||
🔜 Try GLM-5.2 here.
|
||||
|
||||
[Paper]
|
||||
[GitHub]
|
||||
|
||||
## Introduction
|
||||
|
||||
We're introducing GLM-5.2, our latest flagship model for long-horizon tasks. It marks a substantial leap in long-horizon task capability over its predecessor GLM-5.1 and, for the first time, delivers that capability on a **solid 1M-token context**. GLM-5.2's new capabilities include:
|
||||
- **Solid 1M Context:** A solid 1M-token context that stably sustains long-horizon work
|
||||
- **Advanced Coding with Flexible Effort**: Stronger coding capabilities with multiple thinking effort levels to balance performance and latency
|
||||
- **Improved Architecture**: We propose IndexShare, which reuses the same indexer across every four sparse attention layers, reducing per-token FLOPs by 2.9× at a 1M context length. We also improve GLM-5.2’s MTP layer for speculative decoding, increasing the acceptance length by up to 20%
|
||||
- **Pure Open**: An MIT open-source license — no regional limits, technical access without borders
|
||||
|
||||
## Benchmark
|
||||
|
||||
## Serve GLM-5.2 Locally
|
||||
|
||||
...
|
||||
license: "mit"
|
||||
tags:
|
||||
- llm
|
||||
- gguf
|
||||
icon: https://raw.githubusercontent.com/zai-org/GLM-5/refs/heads/main/resources/bench_52.png
|
||||
overrides:
|
||||
backend: llama-cpp
|
||||
function:
|
||||
automatic_tool_parsing_fallback: true
|
||||
grammar:
|
||||
disable: true
|
||||
known_usecases:
|
||||
- chat
|
||||
options:
|
||||
- use_jinja:true
|
||||
- spec_type:draft-mtp
|
||||
- spec_n_max:6
|
||||
- spec_p_min:0.75
|
||||
parameters:
|
||||
min_p: 0.01
|
||||
model: llama-cpp/models/GLM-5.2-GGUF/GLM-5.2-UD-Q4_K_M-00001-of-00011.gguf
|
||||
repeat_penalty: 1
|
||||
temperature: 1
|
||||
top_k: -1
|
||||
top_p: 0.95
|
||||
template:
|
||||
use_tokenizer_template: true
|
||||
files:
|
||||
- filename: llama-cpp/models/GLM-5.2-GGUF/GLM-5.2-UD-Q4_K_M-00001-of-00011.gguf
|
||||
sha256: 3256ac8c290273f0965ff39e93a8bcd07dc99bcd23e923bd4b7306ef39061038
|
||||
uri: https://huggingface.co/unsloth/GLM-5.2-GGUF/resolve/main/UD-Q4_K_M/GLM-5.2-UD-Q4_K_M-00001-of-00011.gguf
|
||||
- filename: llama-cpp/models/GLM-5.2-GGUF/GLM-5.2-UD-Q4_K_M-00002-of-00011.gguf
|
||||
sha256: 1020105e78d862988a6cabb3a78eafa75f29666ab8a5fd10de1b9b8c8a6bc5e8
|
||||
uri: https://huggingface.co/unsloth/GLM-5.2-GGUF/resolve/main/UD-Q4_K_M/GLM-5.2-UD-Q4_K_M-00002-of-00011.gguf
|
||||
- filename: llama-cpp/models/GLM-5.2-GGUF/GLM-5.2-UD-Q4_K_M-00003-of-00011.gguf
|
||||
sha256: 0b36f406e120759290894ea4960d5086f9b362a8c8f9c7fcaad24b4471172efb
|
||||
uri: https://huggingface.co/unsloth/GLM-5.2-GGUF/resolve/main/UD-Q4_K_M/GLM-5.2-UD-Q4_K_M-00003-of-00011.gguf
|
||||
- filename: llama-cpp/models/GLM-5.2-GGUF/GLM-5.2-UD-Q4_K_M-00004-of-00011.gguf
|
||||
sha256: 04b19199f52ba29e7f9966b15df3fbc2d1e5c56cd6343c405076be7174d49d32
|
||||
uri: https://huggingface.co/unsloth/GLM-5.2-GGUF/resolve/main/UD-Q4_K_M/GLM-5.2-UD-Q4_K_M-00004-of-00011.gguf
|
||||
- filename: llama-cpp/models/GLM-5.2-GGUF/GLM-5.2-UD-Q4_K_M-00005-of-00011.gguf
|
||||
sha256: 5cb76d724ee16e80c1cb6aba29aacd76161e7a6f147079be3447501c06d95f2c
|
||||
uri: https://huggingface.co/unsloth/GLM-5.2-GGUF/resolve/main/UD-Q4_K_M/GLM-5.2-UD-Q4_K_M-00005-of-00011.gguf
|
||||
- filename: llama-cpp/models/GLM-5.2-GGUF/GLM-5.2-UD-Q4_K_M-00006-of-00011.gguf
|
||||
sha256: ec2c65255c834b686f066e350bc5b8d8a7020cd1133f0ee9e819d2fb5d3afad0
|
||||
uri: https://huggingface.co/unsloth/GLM-5.2-GGUF/resolve/main/UD-Q4_K_M/GLM-5.2-UD-Q4_K_M-00006-of-00011.gguf
|
||||
- filename: llama-cpp/models/GLM-5.2-GGUF/GLM-5.2-UD-Q4_K_M-00007-of-00011.gguf
|
||||
sha256: 53c8328852ca0b6791a9a9243bcc56157305adca8526a646054389845e7445a9
|
||||
uri: https://huggingface.co/unsloth/GLM-5.2-GGUF/resolve/main/UD-Q4_K_M/GLM-5.2-UD-Q4_K_M-00007-of-00011.gguf
|
||||
- filename: llama-cpp/models/GLM-5.2-GGUF/GLM-5.2-UD-Q4_K_M-00008-of-00011.gguf
|
||||
sha256: 9a23bfb21c5f6fcc94b0329c108ec1ef3fdbd815c57eeb0bf105d26861d7271e
|
||||
uri: https://huggingface.co/unsloth/GLM-5.2-GGUF/resolve/main/UD-Q4_K_M/GLM-5.2-UD-Q4_K_M-00008-of-00011.gguf
|
||||
- filename: llama-cpp/models/GLM-5.2-GGUF/GLM-5.2-UD-Q4_K_M-00009-of-00011.gguf
|
||||
sha256: 71088054fb1a09a4f38e2ee8a726526790660a4f77ead817f75cb7a484bdb0b8
|
||||
uri: https://huggingface.co/unsloth/GLM-5.2-GGUF/resolve/main/UD-Q4_K_M/GLM-5.2-UD-Q4_K_M-00009-of-00011.gguf
|
||||
- filename: llama-cpp/models/GLM-5.2-GGUF/GLM-5.2-UD-Q4_K_M-00010-of-00011.gguf
|
||||
sha256: 848db99658faf24971df23638281305a15bdc187cbcaed968952ed9e9c835b50
|
||||
uri: https://huggingface.co/unsloth/GLM-5.2-GGUF/resolve/main/UD-Q4_K_M/GLM-5.2-UD-Q4_K_M-00010-of-00011.gguf
|
||||
- filename: llama-cpp/models/GLM-5.2-GGUF/GLM-5.2-UD-Q4_K_M-00011-of-00011.gguf
|
||||
sha256: 629e23bce250fb500d9a190de7249c2882af524aacc112ce507a871ed5bebf90
|
||||
uri: https://huggingface.co/unsloth/GLM-5.2-GGUF/resolve/main/UD-Q4_K_M/GLM-5.2-UD-Q4_K_M-00011-of-00011.gguf
|
||||
- name: "qwen3.6-35b-a3b-nvfp4-mtp"
|
||||
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
|
||||
urls:
|
||||
@@ -1114,6 +1252,98 @@
|
||||
- filename: privacy-filter/models/privacy-filter-multilingual/privacy-filter-multilingual-f16.gguf
|
||||
sha256: 01b76572f80b7d2ebee80a27cb9c3699c26b04cae1c402eee7664fc17a4b5ce6
|
||||
uri: https://huggingface.co/LocalAI-io/privacy-filter-multilingual-GGUF/resolve/main/privacy-filter-multilingual-f16.gguf
|
||||
- name: "privacy-filter-nemotron"
|
||||
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
|
||||
icon: https://cdn-avatars.huggingface.co/v1/production/uploads/5fd5e18a90b6dc4633f6d292/QPiv8pt4JNxr0FdGnpFef.png
|
||||
urls:
|
||||
- https://huggingface.co/OpenMed/privacy-filter-nemotron
|
||||
- https://huggingface.co/LocalAI-io/privacy-filter-nemotron-GGUF
|
||||
description: |
|
||||
A fine-grained English PII token-classification model: a fine-tune of
|
||||
openai/privacy-filter by OpenMed on NVIDIA's Nemotron-PII dataset. It labels
|
||||
every token with a BIOES tag over 55 PII categories (221 classes), trading
|
||||
the multilingual sibling's language breadth for category depth - identity,
|
||||
contact, address, dates, government IDs, financial, healthcare, enterprise,
|
||||
vehicle and digital entities (including api_key, ipv4/ipv6 and mac_address).
|
||||
For multilingual text prefer privacy-filter-multilingual instead.
|
||||
|
||||
In LocalAI this is a PII detector for the NER redactor tier: set
|
||||
known_usecases to [token_classify] (as below), and any model opts into
|
||||
redaction by listing this one under pii.detectors. The detection policy
|
||||
(which categories to mask vs block, and the score threshold) lives on this
|
||||
model's own pii_detection block - see the overrides below. It runs locally
|
||||
with no Python, served by the standalone privacy-filter backend's
|
||||
TokenClassify RPC (constrained BIOES Viterbi decode into UTF-8 byte-offset
|
||||
entity spans).
|
||||
|
||||
Architecture: gpt-oss-style sparse MoE (8 layers, d_model 640, 128 experts
|
||||
top-4, ~1.5B total / ~50M active per token), bidirectional banded attention,
|
||||
o200k tokenizer and a 221-way token-classification head; served via the
|
||||
openai-privacy-filter architecture. F16, ~2.8 GB. (A smaller Q8_0 quant
|
||||
exists on the GGUF repo for RAM-constrained use - validate it on your own
|
||||
data, since for PII a single dropped span is a leak.)
|
||||
license: apache-2.0
|
||||
tags:
|
||||
- token-classification
|
||||
- ner
|
||||
- pii
|
||||
- privacy
|
||||
- nemotron
|
||||
- gguf
|
||||
overrides:
|
||||
backend: privacy-filter
|
||||
embeddings: true
|
||||
known_usecases:
|
||||
- token_classify
|
||||
parameters:
|
||||
model: privacy-filter/models/privacy-filter-nemotron/privacy-filter-nemotron-f16.gguf
|
||||
pii_detection:
|
||||
min_score: 0.5
|
||||
default_action: mask
|
||||
files:
|
||||
- filename: privacy-filter/models/privacy-filter-nemotron/privacy-filter-nemotron-f16.gguf
|
||||
sha256: 70dfe91ff220ff04594168a83e296dcc2054449cde77f98d0e782edbb6a31f5a
|
||||
uri: https://huggingface.co/LocalAI-io/privacy-filter-nemotron-GGUF/resolve/main/privacy-filter-nemotron-f16.gguf
|
||||
- name: "privacy-filter-nemotron-q8"
|
||||
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
|
||||
icon: https://cdn-avatars.huggingface.co/v1/production/uploads/5fd5e18a90b6dc4633f6d292/QPiv8pt4JNxr0FdGnpFef.png
|
||||
urls:
|
||||
- https://huggingface.co/OpenMed/privacy-filter-nemotron
|
||||
- https://huggingface.co/LocalAI-io/privacy-filter-nemotron-GGUF
|
||||
description: |
|
||||
Q8_0 quant of privacy-filter-nemotron (~1.64 GB, vs ~2.8 GB for F16) for
|
||||
RAM-constrained / edge use (e.g. a 4 GB Raspberry Pi 5). The MoE expert
|
||||
weights are stored 8-bit; attention, embeddings and the classifier head
|
||||
stay F16. Same model, policy and runtime as the F16 entry - see
|
||||
privacy-filter-nemotron for the full description.
|
||||
|
||||
Prefer the F16 entry when you can afford it: it is the reference artifact.
|
||||
On a mixed-PII document the publisher measured q8 matching F16 on 99.93% of
|
||||
token labels with an identical span set at threshold 0.5 - but one token
|
||||
flipped, and for PII a single dropped span is a leak. Treat q8 as a
|
||||
deliberate size/speed tradeoff and validate it on your own data.
|
||||
license: apache-2.0
|
||||
tags:
|
||||
- token-classification
|
||||
- ner
|
||||
- pii
|
||||
- privacy
|
||||
- nemotron
|
||||
- gguf
|
||||
overrides:
|
||||
backend: privacy-filter
|
||||
embeddings: true
|
||||
known_usecases:
|
||||
- token_classify
|
||||
parameters:
|
||||
model: privacy-filter/models/privacy-filter-nemotron/privacy-filter-nemotron-q8.gguf
|
||||
pii_detection:
|
||||
min_score: 0.5
|
||||
default_action: mask
|
||||
files:
|
||||
- filename: privacy-filter/models/privacy-filter-nemotron/privacy-filter-nemotron-q8.gguf
|
||||
sha256: 2ec11c154e572a2686f4d77e861b7f74e6917e09638fe9bd27156d48bd99e21a
|
||||
uri: https://huggingface.co/LocalAI-io/privacy-filter-nemotron-GGUF/resolve/main/privacy-filter-nemotron-q8.gguf
|
||||
- name: "secret-filter"
|
||||
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
|
||||
description: |
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
|
||||
oras "oras.land/oras-go/v2"
|
||||
"oras.land/oras-go/v2/registry/remote"
|
||||
"oras.land/oras-go/v2/registry/remote/auth"
|
||||
"oras.land/oras-go/v2/registry/remote/retry"
|
||||
)
|
||||
|
||||
func FetchImageBlob(ctx context.Context, r, reference, dst string, statusReader func(ocispec.Descriptor) io.Writer) error {
|
||||
@@ -28,6 +30,16 @@ func FetchImageBlob(ctx context.Context, r, reference, dst string, statusReader
|
||||
}
|
||||
repo.SkipReferrersGC = true
|
||||
|
||||
// Identify LocalAI to the registry. This mirrors oras' auth.DefaultClient
|
||||
// (same retry policy) but advertises a LocalAI User-Agent instead of the
|
||||
// library default.
|
||||
client := &auth.Client{
|
||||
Client: retry.DefaultClient,
|
||||
Cache: auth.NewCache(),
|
||||
}
|
||||
client.SetUserAgent(UserAgent())
|
||||
repo.Client = client
|
||||
|
||||
// https://github.com/oras-project/oras/blob/main/cmd/oras/internal/option/remote.go#L364
|
||||
// https://github.com/oras-project/oras/blob/main/cmd/oras/root/blob/fetch.go#L136
|
||||
desc, reader, err := oras.Fetch(ctx, repo.Blobs(), reference, oras.DefaultFetchOptions)
|
||||
|
||||
@@ -176,6 +176,7 @@ func GetImage(targetImage, targetPlatform string, auth *registrytypes.AuthConfig
|
||||
opts := []remote.Option{
|
||||
remote.WithTransport(tr),
|
||||
remote.WithPlatform(*platform),
|
||||
remote.WithUserAgent(UserAgent()),
|
||||
}
|
||||
if auth != nil {
|
||||
opts = append(opts, remote.WithAuth(staticAuth{auth}))
|
||||
@@ -223,6 +224,7 @@ func GetImageDigest(targetImage, targetPlatform string, auth *registrytypes.Auth
|
||||
opts := []remote.Option{
|
||||
remote.WithTransport(tr),
|
||||
remote.WithPlatform(*platform),
|
||||
remote.WithUserAgent(UserAgent()),
|
||||
}
|
||||
if auth != nil {
|
||||
opts = append(opts, remote.WithAuth(staticAuth{auth}))
|
||||
|
||||
@@ -47,6 +47,7 @@ func OllamaModelManifest(image string) (*Manifest, error) {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json")
|
||||
req.Header.Set("User-Agent", UserAgent())
|
||||
client := httpclient.New(httpclient.WithFollowRedirects())
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
|
||||
19
pkg/oci/useragent.go
Normal file
19
pkg/oci/useragent.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mudler/LocalAI/internal"
|
||||
)
|
||||
|
||||
// UserAgent returns the User-Agent string LocalAI sends on outbound registry
|
||||
// requests (OCI registries and Ollama). It identifies the client as LocalAI
|
||||
// and, when the binary was built with a version stamp, appends it so registries
|
||||
// can attribute client-side usage to LocalAI rather than to the generic
|
||||
// User-Agent of the underlying transport library.
|
||||
func UserAgent() string {
|
||||
if internal.Version == "" {
|
||||
return "LocalAI"
|
||||
}
|
||||
return fmt.Sprintf("LocalAI/%s", internal.Version)
|
||||
}
|
||||
32
pkg/oci/useragent_test.go
Normal file
32
pkg/oci/useragent_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package oci_test
|
||||
|
||||
import (
|
||||
"github.com/mudler/LocalAI/internal"
|
||||
. "github.com/mudler/LocalAI/pkg/oci"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("OCI", func() {
|
||||
Context("UserAgent", func() {
|
||||
var savedVersion string
|
||||
|
||||
BeforeEach(func() {
|
||||
savedVersion = internal.Version
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
internal.Version = savedVersion
|
||||
})
|
||||
|
||||
It("identifies as LocalAI when no version is stamped", func() {
|
||||
internal.Version = ""
|
||||
Expect(UserAgent()).To(Equal("LocalAI"))
|
||||
})
|
||||
|
||||
It("appends the build version when one is stamped", func() {
|
||||
internal.Version = "v3.2.1"
|
||||
Expect(UserAgent()).To(Equal("LocalAI/v3.2.1"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1021,6 +1021,25 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/nodes/models": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Nodes"
|
||||
],
|
||||
"summary": "List all loaded models cluster-wide",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/nodes.NodeModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/nodes/{id}/max-replicas-per-model": {
|
||||
"put": {
|
||||
"tags": [
|
||||
@@ -3754,6 +3773,52 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"nodes.NodeModel": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"address": {
|
||||
"description": "gRPC address for this replica's backend process",
|
||||
"type": "string"
|
||||
},
|
||||
"backend_type": {
|
||||
"description": "e.g. \"llama-cpp\"; used by reconciler to replicate loads",
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"in_flight": {
|
||||
"description": "number of active requests on this replica",
|
||||
"type": "integer"
|
||||
},
|
||||
"last_used": {
|
||||
"type": "string"
|
||||
},
|
||||
"loading_by": {
|
||||
"description": "frontend ID that triggered loading",
|
||||
"type": "string"
|
||||
},
|
||||
"model_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"node_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"replica_index": {
|
||||
"type": "integer"
|
||||
},
|
||||
"state": {
|
||||
"description": "loading, loaded, unloading, idle",
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"proto.MemoryUsageData": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1018,6 +1018,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/nodes/models": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Nodes"
|
||||
],
|
||||
"summary": "List all loaded models cluster-wide",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/nodes.NodeModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/nodes/{id}/max-replicas-per-model": {
|
||||
"put": {
|
||||
"tags": [
|
||||
@@ -3751,6 +3770,52 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"nodes.NodeModel": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"address": {
|
||||
"description": "gRPC address for this replica's backend process",
|
||||
"type": "string"
|
||||
},
|
||||
"backend_type": {
|
||||
"description": "e.g. \"llama-cpp\"; used by reconciler to replicate loads",
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"in_flight": {
|
||||
"description": "number of active requests on this replica",
|
||||
"type": "integer"
|
||||
},
|
||||
"last_used": {
|
||||
"type": "string"
|
||||
},
|
||||
"loading_by": {
|
||||
"description": "frontend ID that triggered loading",
|
||||
"type": "string"
|
||||
},
|
||||
"model_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"node_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"replica_index": {
|
||||
"type": "integer"
|
||||
},
|
||||
"state": {
|
||||
"description": "loading, loaded, unloading, idle",
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"proto.MemoryUsageData": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -422,6 +422,38 @@ definitions:
|
||||
vram_display:
|
||||
type: string
|
||||
type: object
|
||||
nodes.NodeModel:
|
||||
properties:
|
||||
address:
|
||||
description: gRPC address for this replica's backend process
|
||||
type: string
|
||||
backend_type:
|
||||
description: e.g. "llama-cpp"; used by reconciler to replicate loads
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
in_flight:
|
||||
description: number of active requests on this replica
|
||||
type: integer
|
||||
last_used:
|
||||
type: string
|
||||
loading_by:
|
||||
description: frontend ID that triggered loading
|
||||
type: string
|
||||
model_name:
|
||||
type: string
|
||||
node_id:
|
||||
type: string
|
||||
replica_index:
|
||||
type: integer
|
||||
state:
|
||||
description: loading, loaded, unloading, idle
|
||||
type: string
|
||||
updated_at:
|
||||
type: string
|
||||
type: object
|
||||
proto.MemoryUsageData:
|
||||
properties:
|
||||
breakdown:
|
||||
@@ -3221,6 +3253,18 @@ paths:
|
||||
summary: Update a node's max replicas per model
|
||||
tags:
|
||||
- Nodes
|
||||
/api/nodes/models:
|
||||
get:
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/nodes.NodeModel'
|
||||
type: array
|
||||
summary: List all loaded models cluster-wide
|
||||
tags:
|
||||
- Nodes
|
||||
/api/p2p:
|
||||
get:
|
||||
responses:
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@@ -85,27 +86,28 @@ import (
|
||||
// file path to LoadModel, so GGUF, ONNX, safetensors, .bin etc. all work so
|
||||
// long as the backend under test accepts that format.
|
||||
const (
|
||||
capHealth = "health"
|
||||
capLoad = "load"
|
||||
capPredict = "predict"
|
||||
capStream = "stream"
|
||||
capEmbeddings = "embeddings"
|
||||
capTools = "tools"
|
||||
capTranscription = "transcription"
|
||||
capTTS = "tts"
|
||||
capImage = "image"
|
||||
capFaceDetect = "face_detect"
|
||||
capFaceEmbed = "face_embed"
|
||||
capFaceVerify = "face_verify"
|
||||
capFaceAnalyze = "face_analyze"
|
||||
capFaceAntispoof = "face_antispoof"
|
||||
capVoiceEmbed = "voice_embed"
|
||||
capVoiceVerify = "voice_verify"
|
||||
capVoiceAnalyze = "voice_analyze"
|
||||
capHealth = "health"
|
||||
capLoad = "load"
|
||||
capPredict = "predict"
|
||||
capStream = "stream"
|
||||
capEmbeddings = "embeddings"
|
||||
capTools = "tools"
|
||||
capTranscription = "transcription"
|
||||
capTTS = "tts"
|
||||
capImage = "image"
|
||||
capFaceDetect = "face_detect"
|
||||
capFaceEmbed = "face_embed"
|
||||
capFaceVerify = "face_verify"
|
||||
capFaceAnalyze = "face_analyze"
|
||||
capFaceAntispoof = "face_antispoof"
|
||||
capVoiceEmbed = "voice_embed"
|
||||
capVoiceVerify = "voice_verify"
|
||||
capVoiceAnalyze = "voice_analyze"
|
||||
capAudioTransform = "audio_transform"
|
||||
capLogprobs = "logprobs"
|
||||
capLogitBias = "logit_bias"
|
||||
capTokenize = "tokenize"
|
||||
capLogprobs = "logprobs"
|
||||
capLogitBias = "logit_bias"
|
||||
capTokenize = "tokenize"
|
||||
capTokenClassify = "token_classify"
|
||||
|
||||
defaultPrompt = "The capital of France is"
|
||||
streamPrompt = "Once upon a time"
|
||||
@@ -550,6 +552,45 @@ var _ = Describe("Backend container", Ordered, func() {
|
||||
GinkgoWriter.Printf("Embedding: %d dims\n", len(res.GetEmbeddings()))
|
||||
})
|
||||
|
||||
// TokenClassify is the PII-NER RPC (privacy-filter backend). The crown-jewel
|
||||
// invariant is byte-offset correctness: Start/End are half-open BYTE offsets
|
||||
// into the original UTF-8 text, and the backend's emitted text for a span must
|
||||
// equal text[Start:End]. We run at Threshold 0 (raw, unfiltered) and assert
|
||||
// every returned span is in range, rune-aligned, and self-consistent. The
|
||||
// prompt carries multibyte runes BEFORE the PII so a rune/byte confusion in
|
||||
// the engine would surface as a shifted slice here. Override the text with
|
||||
// BACKEND_TEST_TOKEN_CLASSIFY_TEXT for a model that detects a different class.
|
||||
It("classifies PII spans with byte-correct offsets via TokenClassify", func() {
|
||||
if !caps[capTokenClassify] {
|
||||
Skip("token_classify capability not enabled")
|
||||
}
|
||||
text := os.Getenv("BACKEND_TEST_TOKEN_CLASSIFY_TEXT")
|
||||
if text == "" {
|
||||
text = "Müller paid at café in Zürich; reach john.doe@example.com tomorrow."
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
res, err := client.TokenClassify(ctx, &pb.TokenClassifyRequest{Text: text, Threshold: 0})
|
||||
Expect(err).NotTo(HaveOccurred(), "TokenClassify RPC failed")
|
||||
ents := res.GetEntities()
|
||||
Expect(ents).NotTo(BeEmpty(), "TokenClassify returned no entities for an obvious-PII sentence")
|
||||
for _, e := range ents {
|
||||
start, end := int(e.GetStart()), int(e.GetEnd())
|
||||
Expect(start).To(BeNumerically(">=", 0))
|
||||
Expect(end).To(BeNumerically(">", start))
|
||||
Expect(end).To(BeNumerically("<=", len(text)))
|
||||
Expect(utf8.RuneStart(text[start])).To(BeTrue(), "start %d is mid-rune in %q", start, text)
|
||||
if end < len(text) {
|
||||
Expect(utf8.RuneStart(text[end])).To(BeTrue(), "end %d is mid-rune in %q", end, text)
|
||||
}
|
||||
slice := text[start:end]
|
||||
Expect(utf8.ValidString(slice)).To(BeTrue(), "span %q is not valid UTF-8", slice)
|
||||
Expect(e.GetText()).To(Equal(slice), "entity text must equal text[start:end]")
|
||||
GinkgoWriter.Printf("TokenClassify: %q [%d:%d] %s score=%.3f\n",
|
||||
slice, start, end, e.GetEntityGroup(), e.GetScore())
|
||||
}
|
||||
})
|
||||
|
||||
It("generates an image via GenerateImage", func() {
|
||||
if !caps[capImage] {
|
||||
Skip("image capability not enabled")
|
||||
|
||||
186
tests/e2e/e2e_pii_ner_test.go
Normal file
186
tests/e2e/e2e_pii_ner_test.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package e2e_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/mudler/LocalAI/core/backend"
|
||||
"github.com/mudler/LocalAI/core/schema"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Live PII NER tier e2e. These specs run the real privacy-filter GGUF on CPU
|
||||
// through the full TokenClassify path — the gap the hermetic suite cannot
|
||||
// cover (it only exercises the in-process pattern tier). They Skip unless
|
||||
// PII_NER_MODEL_GGUF is wired in BeforeSuite, so the default PR suite is
|
||||
// unaffected; the dedicated CI job sets it.
|
||||
//
|
||||
// The crown-jewel invariant is byte-offset correctness: entity Start/End are
|
||||
// half-open BYTE offsets into the original UTF-8 text, and the model's emitted
|
||||
// text for a span must equal the corresponding byte slice. We assert that two
|
||||
// ways — directly against ModelTokenClassify (raw, Threshold 0, no redactor
|
||||
// merge) and against the /api/pii/analyze HTTP contract (post-merge,
|
||||
// post-MinScore). The multibyte case proves offsets are bytes, not runes.
|
||||
var _ = Describe("PII NER tier (live privacy-filter GGUF)", func() {
|
||||
const (
|
||||
// Reliable, unambiguous PII the multilingual NER model detects.
|
||||
emailText = "Please contact John Doe at john.doe@example.com about invoice 4421."
|
||||
// Multibyte chars BEFORE the email push its byte offset past its rune
|
||||
// offset, so a rune/byte confusion in the engine or the Go bridge would
|
||||
// surface as a mismatched slice here but not in the ASCII case above.
|
||||
multibyteText = "Müller paid at café in Zürich; reach john.doe@example.com tomorrow."
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
if piiNERModel == "" {
|
||||
Skip("live PII NER model not wired (set PII_NER_MODEL_GGUF + REALTIME_BACKENDS_PATH; see tests-pii-ner-e2e.yml)")
|
||||
}
|
||||
})
|
||||
|
||||
Context("raw TokenClassify (byte-offset contract)", func() {
|
||||
It("returns byte-correct, rune-aligned spans for an ASCII email", func() {
|
||||
ents := tokenClassify(emailText)
|
||||
Expect(ents).NotTo(BeEmpty(), "model must detect at least one entity in an obvious-PII sentence")
|
||||
for _, e := range ents {
|
||||
assertByteCorrectSpan(emailText, e.Start, e.End, e.Text)
|
||||
}
|
||||
Expect(spanCoversSubstring(emailText, ents, "john.doe@example.com")).To(BeTrue(),
|
||||
"some detected span must cover the email address")
|
||||
})
|
||||
|
||||
It("keeps byte offsets correct when multibyte runes precede the PII", func() {
|
||||
ents := tokenClassify(multibyteText)
|
||||
Expect(ents).NotTo(BeEmpty())
|
||||
for _, e := range ents {
|
||||
// This is the assertion that fails if offsets were computed in
|
||||
// runes rather than bytes: the slice would be shifted left.
|
||||
assertByteCorrectSpan(multibyteText, e.Start, e.End, e.Text)
|
||||
}
|
||||
Expect(spanCoversSubstring(multibyteText, ents, "john.doe@example.com")).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Context("HTTP /api/pii/analyze", func() {
|
||||
It("reports ner-source entities with byte-correct offsets", func() {
|
||||
status, resp := analyze(schema.PIIAnalyzeRequest{
|
||||
Text: emailText,
|
||||
Detectors: []string{piiNERModel},
|
||||
})
|
||||
Expect(status).To(Equal(http.StatusOK))
|
||||
Expect(resp.Entities).NotTo(BeEmpty())
|
||||
for _, e := range resp.Entities {
|
||||
Expect(e.Source).To(Equal("ner"), "privacy-filter detections must be tagged source=ner")
|
||||
Expect(e.Action).To(Equal("mask"), "default_action mask must propagate to each entity")
|
||||
assertByteCorrectSpan(emailText, e.Start, e.End, emailText[e.Start:e.End])
|
||||
Expect(e.Score).To(BeNumerically(">=", 0.5), "below-MinScore spans are dropped before the response")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Context("HTTP /api/pii/redact", func() {
|
||||
It("masks detected PII out of the returned text", func() {
|
||||
status, body := redact(schema.PIIAnalyzeRequest{
|
||||
Text: emailText,
|
||||
Detectors: []string{piiNERModel},
|
||||
})
|
||||
Expect(status).To(Equal(http.StatusOK))
|
||||
var resp schema.PIIRedactResponse
|
||||
Expect(json.Unmarshal(body, &resp)).To(Succeed())
|
||||
Expect(resp.Masked).To(BeTrue())
|
||||
Expect(resp.RedactedText).NotTo(Equal(emailText))
|
||||
Expect(resp.RedactedText).NotTo(ContainSubstring("john.doe@example.com"),
|
||||
"the masked email must not survive in the redacted body")
|
||||
})
|
||||
|
||||
It("rejects the request with pii_blocked when an entity action is block", func() {
|
||||
status, body := redact(schema.PIIAnalyzeRequest{
|
||||
Text: emailText,
|
||||
Detectors: []string{piiNERBlockModel},
|
||||
})
|
||||
Expect(status).To(Equal(http.StatusBadRequest))
|
||||
Expect(string(body)).To(ContainSubstring("pii_blocked"))
|
||||
Expect(string(body)).NotTo(ContainSubstring("john.doe@example.com"),
|
||||
"a blocked response must never echo the raw secret")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// tokenClassify drives core/backend.ModelTokenClassify against the live model
|
||||
// with the loader/config the running server uses — the same path the NER
|
||||
// detector takes, but at Threshold 0 so we see the raw, unmerged spans.
|
||||
func tokenClassify(text string) []backend.TokenEntity {
|
||||
GinkgoHelper()
|
||||
cfg, ok := localAIApp.ModelConfigLoader().GetModelConfig(piiNERModel)
|
||||
Expect(ok).To(BeTrue(), "model config %q must be loaded", piiNERModel)
|
||||
fn, err := backend.ModelTokenClassify(text, backend.TokenClassifyOptions{},
|
||||
localAIApp.ModelLoader(), cfg, localAIApp.ApplicationConfig())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
ents, err := fn(context.TODO())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
return ents
|
||||
}
|
||||
|
||||
// assertByteCorrectSpan is the shared byte-offset invariant: a half-open byte
|
||||
// range within text, aligned to UTF-8 rune boundaries, whose slice equals the
|
||||
// entity's own reported text.
|
||||
func assertByteCorrectSpan(text string, start, end int, got string) {
|
||||
GinkgoHelper()
|
||||
Expect(start).To(BeNumerically(">=", 0))
|
||||
Expect(end).To(BeNumerically(">", start))
|
||||
Expect(end).To(BeNumerically("<=", len(text)))
|
||||
Expect(utf8.RuneStart(text[start])).To(BeTrue(), "start %d is mid-rune in %q", start, text)
|
||||
if end < len(text) {
|
||||
Expect(utf8.RuneStart(text[end])).To(BeTrue(), "end %d is mid-rune in %q", end, text)
|
||||
}
|
||||
slice := text[start:end]
|
||||
Expect(utf8.ValidString(slice)).To(BeTrue(), "span %q is not valid UTF-8", slice)
|
||||
Expect(slice).To(Equal(got), "entity text must equal text[start:end]")
|
||||
}
|
||||
|
||||
func spanCoversSubstring(text string, ents []backend.TokenEntity, sub string) bool {
|
||||
lo := bytes.Index([]byte(text), []byte(sub))
|
||||
if lo < 0 {
|
||||
return false
|
||||
}
|
||||
hi := lo + len(sub)
|
||||
for _, e := range ents {
|
||||
// any overlap with [lo,hi)
|
||||
if e.Start < hi && e.End > lo {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func analyze(req schema.PIIAnalyzeRequest) (int, schema.PIIAnalyzeResponse) {
|
||||
GinkgoHelper()
|
||||
status, body := postJSON("/api/pii/analyze", req)
|
||||
var resp schema.PIIAnalyzeResponse
|
||||
if status == http.StatusOK {
|
||||
Expect(json.Unmarshal(body, &resp)).To(Succeed())
|
||||
}
|
||||
return status, resp
|
||||
}
|
||||
|
||||
func redact(req schema.PIIAnalyzeRequest) (int, []byte) {
|
||||
GinkgoHelper()
|
||||
return postJSON("/api/pii/redact", req)
|
||||
}
|
||||
|
||||
func postJSON(path string, payload any) (int, []byte) {
|
||||
GinkgoHelper()
|
||||
data, err := json.Marshal(payload)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
httpResp, err := http.Post(anthropicBaseURL+path, "application/json", bytes.NewReader(data))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
body, err := io.ReadAll(httpResp.Body)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
return httpResp.StatusCode, body
|
||||
}
|
||||
@@ -47,6 +47,15 @@ var (
|
||||
// cloud-proxy model YAMLs can point at their URLs at startup time.
|
||||
cpOpenAIUpstream *fakeOpenAIUpstreamServer
|
||||
cpAnthropicUpstream *fakeAnthropicUpstreamServer
|
||||
|
||||
// Live PII NER tier. Set only when PII_NER_MODEL_GGUF points at a
|
||||
// privacy-filter GGUF and the privacy-filter backend is discoverable
|
||||
// (REALTIME_BACKENDS_PATH). Empty => the NER specs Skip, exactly like the
|
||||
// cloud-proxy specs Skip without their binary. This is what the hermetic
|
||||
// suite cannot do (e2e_suite_test.go comment at the cp-translate detector):
|
||||
// run the real GGUF NER tier instead of only the in-process pattern tier.
|
||||
piiNERModel string
|
||||
piiNERBlockModel string
|
||||
)
|
||||
|
||||
var _ = BeforeSuite(func() {
|
||||
@@ -535,6 +544,40 @@ var _ = BeforeSuite(func() {
|
||||
}
|
||||
}
|
||||
|
||||
// Live PII NER tier. When PII_NER_MODEL_GGUF points at a downloaded
|
||||
// privacy-filter GGUF, register two detector models that drive the real
|
||||
// gRPC TokenClassify path on the privacy-filter backend (discovered via
|
||||
// REALTIME_BACKENDS_PATH). Two models so we can exercise both policy
|
||||
// outcomes against the same weights: mask (redact) and block (reject).
|
||||
// NOTE: no pii_detection.builtins/patterns here — that would flip the
|
||||
// detector to the in-process regex tier instead of the GGUF NER tier.
|
||||
if gguf := os.Getenv("PII_NER_MODEL_GGUF"); gguf != "" {
|
||||
piiNERModel = "privacy-filter-ner"
|
||||
piiNERBlockModel = "privacy-filter-ner-block"
|
||||
nerModelConfig := func(name, defaultAction string) map[string]any {
|
||||
return map[string]any{
|
||||
"name": name,
|
||||
"backend": "privacy-filter",
|
||||
"embeddings": true, // required: TOKEN_CLS pooling loads via the embeddings flag
|
||||
"known_usecases": []string{"token_classify"},
|
||||
"parameters": map[string]any{"model": gguf},
|
||||
"pii_detection": map[string]any{
|
||||
"min_score": 0.5,
|
||||
"default_action": defaultAction,
|
||||
},
|
||||
}
|
||||
}
|
||||
for _, cfg := range []map[string]any{
|
||||
nerModelConfig(piiNERModel, "mask"),
|
||||
nerModelConfig(piiNERBlockModel, "block"),
|
||||
} {
|
||||
data, err := yaml.Marshal(cfg)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(os.WriteFile(filepath.Join(modelsPath, cfg["name"].(string)+".yaml"), data, 0644)).To(Succeed())
|
||||
}
|
||||
xlog.Info("wired live PII NER models", "gguf", gguf, "models", []string{piiNERModel, piiNERBlockModel})
|
||||
}
|
||||
|
||||
systemState, err := system.GetSystemState(systemOpts...)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user