mirror of
https://github.com/mudler/LocalAI.git
synced 2026-08-04 12:22:22 -04:00
Compare commits
70 Commits
ci/self-ho
...
docs/prese
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81fab6a22b | ||
|
|
f447faf08d | ||
|
|
6e7c0a4df8 | ||
|
|
e2311045d3 | ||
|
|
6bdb04ab5d | ||
|
|
bd076376be | ||
|
|
d28ccf32b5 | ||
|
|
95bd59d78e | ||
|
|
1741df0bf1 | ||
|
|
b6d2e94153 | ||
|
|
a0f7faaa2a | ||
|
|
133c546c3f | ||
|
|
8a68f3571c | ||
|
|
fd4ec083b9 | ||
|
|
8f74f74b10 | ||
|
|
cd62e8ff18 | ||
|
|
af98e76f84 | ||
|
|
7f9ffd9f54 | ||
|
|
5cb0c1a872 | ||
|
|
cd890b6a26 | ||
|
|
1c0380ad44 | ||
|
|
cb6e4d4391 | ||
|
|
cba54c5ea1 | ||
|
|
f951419207 | ||
|
|
58ea2f5d79 | ||
|
|
b89b0f73e5 | ||
|
|
45cd47cb99 | ||
|
|
1aa97381f3 | ||
|
|
74b7ea2829 | ||
|
|
8a80830f33 | ||
|
|
7621939028 | ||
|
|
cff69a05bf | ||
|
|
896b4b6785 | ||
|
|
0990be35b7 | ||
|
|
d0119bf62c | ||
|
|
359bd4850d | ||
|
|
a49f115b0d | ||
|
|
0d6b38e709 | ||
|
|
bef30732cd | ||
|
|
5b7ca31bd1 | ||
|
|
21ecc799e5 | ||
|
|
9fe1165f61 | ||
|
|
ad2be8a856 | ||
|
|
3c02d2aa4d | ||
|
|
c0a9c42771 | ||
|
|
cedcbf97a9 | ||
|
|
7e4a60c701 | ||
|
|
76927ccde3 | ||
|
|
fca7ab2df4 | ||
|
|
04764bbe89 | ||
|
|
2f3dd404b5 | ||
|
|
a7440f032d | ||
|
|
4a6cd227a3 | ||
|
|
740d8684b5 | ||
|
|
cb432c4c99 | ||
|
|
a4cd387100 | ||
|
|
c089caf320 | ||
|
|
9584377a50 | ||
|
|
51c9cc1934 | ||
|
|
22e401b43d | ||
|
|
11403f4797 | ||
|
|
aa5a9c483a | ||
|
|
4b3978dcba | ||
|
|
3f4e446adc | ||
|
|
e6b235baf2 | ||
|
|
0bedc75921 | ||
|
|
dad4d5956a | ||
|
|
42541dd4f6 | ||
|
|
fb54d0faab | ||
|
|
314a824039 |
@@ -304,7 +304,9 @@ React pages that want to filter the ModelSelector by capability import this symb
|
||||
|
||||
### 4. `docs/content/` (user-facing documentation)
|
||||
|
||||
A new capability deserves its own page under `docs/content/features/`, plus cross-links from related features and an entry in `docs/content/whats-new.md`. See the pattern used by `face-recognition.md` / `object-detection.md`.
|
||||
A new capability deserves its own page under `docs/content/features/`, plus cross-links from related features. See the pattern used by `face-recognition.md` / `object-detection.md`.
|
||||
|
||||
Announcing it is the release's job, not this page's: the capability gets covered in the release blog post under `website/content/blog/`. See [preparing-a-release.md](preparing-a-release.md). `docs/content/whats-new.md` is only a pointer at the blog and GitHub Releases, so there is nothing to add there.
|
||||
|
||||
## Path protection rules
|
||||
|
||||
@@ -334,7 +336,7 @@ When adding a new endpoint:
|
||||
- [ ] Swagger block on the handler: `@Summary`, `@Tags`, `@Param`, `@Success`, `@Router`
|
||||
- [ ] If new capability area (new swagger tag): entry in `instructionDefs` in `core/http/endpoints/localai/api_instructions.go` + test count bumped in `api_instructions_test.go`
|
||||
- [ ] If new `FLAG_*` usecase flag: matching `CAP_*` symbol exported from `core/http/react-ui/src/utils/capabilities.js`
|
||||
- [ ] `docs/content/features/<feature>.md` created; cross-links from related feature pages; entry in `docs/content/whats-new.md`
|
||||
- [ ] `docs/content/features/<feature>.md` created; cross-links from related feature pages; capability covered in the release blog post (see [preparing-a-release.md](preparing-a-release.md))
|
||||
|
||||
**Quality**
|
||||
- [ ] Error responses use `schema.ErrorResponse` format (or `echo.NewHTTPError` with a mapped gRPC status — see the `mapBackendError` helper in `core/http/endpoints/localai/images.go`)
|
||||
|
||||
@@ -125,7 +125,7 @@ The per-backend prefix match only sees files under a backend's own directory, so
|
||||
| `backend/backend.proto` | nothing if the edit is additive-only, otherwise everything (see below) |
|
||||
| `backend/Dockerfile.<x>` | the Linux entries whose `dockerfile:` names it |
|
||||
| `backend/python/common/` | Python, Linux + Darwin |
|
||||
| `scripts/build/package-gpu-libs.sh` | Python, Linux only |
|
||||
| `scripts/build/package-gpu-libs.sh` | every Linux entry (Python, Go and C++ all run it) |
|
||||
| `scripts/build/<lang>-darwin.sh` | the Darwin entries that build target routes to |
|
||||
| `.github/workflows/backend_build[_darwin].yml` | everything on that OS |
|
||||
| anything else under `scripts/build/` (except `*_test.sh`) | everything — conservative default for unclassified packaging inputs |
|
||||
@@ -359,6 +359,26 @@ GitHub Actions caches are limited to 10 GB per repo. Steady-state worst case: ~8
|
||||
|
||||
One residual self-hosted reference remains in `test-extra.yml` (`tests-vibevoice-cpp-grpc-transcription` uses `bigger-runner` for the 30s JFK-decode timeout headroom). That's a separate concern.
|
||||
|
||||
### Small always-on jobs routed to `arc-runner-set`
|
||||
|
||||
The hosted pool is shared across the whole *account*, not per repo, so a burst in one repo starves the others. On 2026-07-31 it went to **zero scheduled jobs for 35 consecutive minutes** with 39 jobs queued, while `arc-runner-set` completed 12 jobs without interruption over the same window. Actions was healthy globally at the time (other public repos were scheduling normally), so this is an account-level throttle, not an outage.
|
||||
|
||||
`gh-pages.yml` (`build` + `deploy`) is therefore routed to `arc-runner-set` when `github.repository == 'mudler/LocalAI'`. It needs no fork-safety clause because it only triggers on push-to-master and `workflow_dispatch`, so it never executes pull-request code. The repository guard keeps forks (which have no such runner label) from queueing forever. It fetches its own toolchains via `setup-go` / `actions-hugo` and uses no `sudo`/`apt`.
|
||||
|
||||
#### What the `arc-runner-set` image actually contains
|
||||
|
||||
Measured 2026-07-31 on run `30637392862` by a preflight step, not assumed:
|
||||
|
||||
| present | **absent** |
|
||||
|---|---|
|
||||
| `git`, `curl`, `unzip`, `tar`, `ldd`, `python3` | **`make`**, **`gcc`** |
|
||||
|
||||
That is why `lint.yml` is **not** on the self-hosted pool. Both of its jobs were routed there and both failed in one second: `golangci-lint` needs `make` (for `make protogen-go`, itself needing `curl`+`unzip` to fetch protoc, and for `make lint`), and `build-scripts` additionally needs a C toolchain because the packaging-script tests compile a throwaway binary and inspect it with `ldd`. Both jobs are back on `ubuntu-latest`.
|
||||
|
||||
The preflight steps were deliberately left in place. They cost about a second on the hosted pool and mean that whenever the runner image gains `make` + `gcc`, re-routing is one `runs-on:` line per job and any remaining gap reports itself by name rather than as an opaque mid-build failure.
|
||||
|
||||
Note for any future re-route: `lint.yml` also triggers on `pull_request`, and a fork PR runs untrusted contributor code. That must never reach a persistent self-hosted runner, so any re-route has to stay push-only, e.g. `${{ (github.event_name == 'push' && github.repository == 'mudler/LocalAI') && 'arc-runner-set' || 'ubuntu-latest' }}`.
|
||||
|
||||
## Touching the cache pipeline
|
||||
|
||||
When changing `image_build.yml`, `backend_build.yml`, any of the `backend/Dockerfile.*` files, `Dockerfile.base-grpc-builder`, `.docker/install-base-deps.sh`, `.docker/<backend>-compile.sh`, or `scripts/changed-backends.js`:
|
||||
|
||||
@@ -113,6 +113,54 @@ if [ "${BUILD_TYPE:-}" = "vulkan" ] && [ "${SKIP_DRIVERS:-false}" = "false" ]; t
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
fi
|
||||
|
||||
# --- 2b. Intel graphics driver (BUILD_TYPE=sycl*) ---
|
||||
# The Intel oneAPI base image brings the compilers and the oneAPI libraries, but
|
||||
# not the driver that talks to the graphics card. The packaging step copies that
|
||||
# driver into the backend, so that the backend works on a machine which has no
|
||||
# Intel graphics packages of its own, for the same reason the Vulkan section
|
||||
# above installs the Mesa drivers. Install it here so there is something to copy.
|
||||
#
|
||||
# Only the sycl builds are covered, because those are the ones whose packaging
|
||||
# copies the driver. See package_intel_libs in scripts/build/package-gpu-libs.sh.
|
||||
#
|
||||
# The driver comes from Intel's own package repository, not from the Ubuntu
|
||||
# archive. The archive has 23.43 from late 2023, which does not know any card
|
||||
# released since, so a machine with a recent Intel GPU would end up carrying a
|
||||
# driver that cannot drive it. Intel's repository has 25.18 for the same Ubuntu
|
||||
# release.
|
||||
#
|
||||
# Anything that goes wrong here fails the build, on purpose. An unreachable
|
||||
# repository is a passing problem that a retry fixes, whereas carrying a
|
||||
# different driver than intended, or none, is a difference nobody would notice
|
||||
# until a user reports an idle GPU.
|
||||
if case "${BUILD_TYPE:-}" in sycl*) true;; *) false;; esac \
|
||||
&& [ "${SKIP_DRIVERS:-false}" = "false" ]; then
|
||||
# Ubuntu release name, which is what the repository is indexed by.
|
||||
ubuntu_codename=$(. /etc/os-release && echo "${VERSION_CODENAME:-}")
|
||||
if [ -z "$ubuntu_codename" ]; then
|
||||
echo "ERROR: cannot tell which Ubuntu release this image is, so cannot pick the Intel driver repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The key is armored text, which apt reads directly from a .asc file, so
|
||||
# there is no need for gnupg here. "unified" is the component Intel ships
|
||||
# its current driver in.
|
||||
mkdir -p /usr/share/keyrings
|
||||
curl -fsSL https://repositories.intel.com/gpu/intel-graphics.key \
|
||||
-o /usr/share/keyrings/intel-graphics.asc
|
||||
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/intel-graphics.asc] https://repositories.intel.com/gpu/ubuntu ${ubuntu_codename} unified" \
|
||||
> /etc/apt/sources.list.d/intel-graphics.list
|
||||
apt-get update
|
||||
# The first package holds the driver OpenCL talks to, the second the driver
|
||||
# Level Zero talks to. Between them they pull in the compiler and the memory
|
||||
# manager that both need.
|
||||
apt-get install -y --no-install-recommends \
|
||||
intel-opencl-icd \
|
||||
libze-intel-gpu1
|
||||
apt-get clean
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
fi
|
||||
|
||||
# --- 3. CUDA toolkit (BUILD_TYPE=cublas|l4t) ---
|
||||
if { [ "${BUILD_TYPE:-}" = "cublas" ] || [ "${BUILD_TYPE:-}" = "l4t" ]; } && [ "${SKIP_DRIVERS:-false}" = "false" ]; then
|
||||
apt-get update
|
||||
|
||||
25
.docker/llama-cpp-build-target.sh
Executable file
25
.docker/llama-cpp-build-target.sh
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
arch=${1:?target architecture is required}
|
||||
build_type=${2-}
|
||||
|
||||
# SYCL compiles the whole tree with icpx -fsycl, and icpx never finishes
|
||||
# ggml-cpu/arch/x86/repack.cpp at -march=sapphirerapids: the job sits on that one
|
||||
# translation unit until GitHub kills it at 6h. gcc builds the same file in
|
||||
# seconds, so only the SYCL images have to give up the CPU variant matrix.
|
||||
case "$build_type" in
|
||||
sycl*)
|
||||
echo llama-cpp-fallback
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# GPU arm64 base images do not consistently provide the gcc-14 toolchain needed
|
||||
# to compile ggml's armv9.2 CPU variants. Keep their portable fallback until the
|
||||
# builder images can supply that compiler.
|
||||
if [ "$arch" = "arm64" ] && [ -n "$build_type" ]; then
|
||||
echo llama-cpp-fallback
|
||||
else
|
||||
echo llama-cpp-cpu-all
|
||||
fi
|
||||
@@ -18,10 +18,12 @@ if [[ -n "${CUDA_DOCKER_ARCH:-}" ]]; then
|
||||
fi
|
||||
|
||||
cd /LocalAI/backend/cpp/llama-cpp
|
||||
if [ -z "${BUILD_TYPE:-}" ]; then
|
||||
# Pure CPU image (BUILD_TYPE empty): one build with ggml CPU_ALL_VARIANTS replaces the
|
||||
# per-microarch binaries (x86: avx/avx2/avx512/fallback; arm64: armv8.x/armv9.x). ggml
|
||||
# dlopens the best libggml-cpu-*.so at runtime by probing host CPU features.
|
||||
BUILD_TARGET=$(/LocalAI/.docker/llama-cpp-build-target.sh "${TARGETARCH}" "${BUILD_TYPE:-}")
|
||||
if [ "$BUILD_TARGET" = "llama-cpp-cpu-all" ]; then
|
||||
# One build with ggml CPU_ALL_VARIANTS replaces the per-microarch binaries (x86:
|
||||
# avx/avx2/avx512/fallback; arm64: armv8.x/armv9.x). BUILD_TYPE remains in the
|
||||
# environment, so GPU builds retain their accelerator backend while ggml dlopens the
|
||||
# best CPU library when work is offloaded to the host.
|
||||
#
|
||||
# arm64: the CPU_ALL_VARIANTS table includes armv9.2 SME variants whose -march=...+sme is
|
||||
# rejected by the Ubuntu 24.04 default gcc-13. gcc-14 accepts it, so build the arm64
|
||||
@@ -35,14 +37,8 @@ if [ -z "${BUILD_TYPE:-}" ]; then
|
||||
apt-get update -qq && apt-get install -y -qq gcc-14 g++-14
|
||||
export CC=gcc-14 CXX=g++-14
|
||||
fi
|
||||
make llama-cpp-cpu-all
|
||||
else
|
||||
# GPU build (cublas/hipblas/sycl/vulkan/...): the accelerator does the compute, so a
|
||||
# single fallback CPU build is enough - no per-microarch CPU variants needed. (This also
|
||||
# keeps the heavy GPU backend compile from also building the whole CPU variant matrix,
|
||||
# and avoids the gcc-14 apt step on GPU base images such as nvidia l4t.)
|
||||
make llama-cpp-fallback
|
||||
fi
|
||||
make "$BUILD_TARGET"
|
||||
make llama-cpp-grpc
|
||||
make llama-cpp-rpc-server
|
||||
|
||||
|
||||
25
.docker/turboquant-build-target.sh
Executable file
25
.docker/turboquant-build-target.sh
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
arch=${1:?target architecture is required}
|
||||
build_type=${2-}
|
||||
|
||||
# SYCL compiles the whole tree with icpx -fsycl, and icpx never finishes
|
||||
# ggml-cpu/arch/x86/repack.cpp at -march=sapphirerapids: the job sits on that one
|
||||
# translation unit until GitHub kills it at 6h. gcc builds the same file in
|
||||
# seconds, so only the SYCL images have to give up the CPU variant matrix.
|
||||
case "$build_type" in
|
||||
sycl*)
|
||||
echo turboquant-fallback
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# GPU arm64 base images do not consistently provide the gcc-14 toolchain needed
|
||||
# to compile ggml's armv9.2 CPU variants. Keep their portable fallback until the
|
||||
# builder images can supply that compiler.
|
||||
if [ "$arch" = "arm64" ] && [ -n "$build_type" ]; then
|
||||
echo turboquant-fallback
|
||||
else
|
||||
echo turboquant-cpu-all
|
||||
fi
|
||||
@@ -19,20 +19,18 @@ fi
|
||||
|
||||
cd /LocalAI/backend/cpp/turboquant
|
||||
|
||||
if [ -z "${BUILD_TYPE:-}" ]; then
|
||||
# Pure CPU image: one ggml CPU_ALL_VARIANTS build replaces the per-microarch binaries.
|
||||
BUILD_TARGET=$(/LocalAI/.docker/turboquant-build-target.sh "${TARGETARCH}" "${BUILD_TYPE:-}")
|
||||
if [ "$BUILD_TARGET" = "turboquant-cpu-all" ]; then
|
||||
# BUILD_TYPE remains in the environment, so GPU builds retain their accelerator while
|
||||
# ggml selects the best CPU library when model work is offloaded to the host.
|
||||
# arm64: the armv9.2 SME variants need gcc-14 (gcc-13 rejects +sme).
|
||||
if [ "${TARGETARCH}" = "arm64" ]; then
|
||||
sh /LocalAI/.docker/apt-mirror.sh || true
|
||||
apt-get update -qq && apt-get install -y -qq gcc-14 g++-14
|
||||
export CC=gcc-14 CXX=g++-14
|
||||
fi
|
||||
make turboquant-cpu-all
|
||||
else
|
||||
# GPU build (cublas/hipblas/sycl/vulkan/...): single fallback CPU build, the accelerator
|
||||
# does the compute. Keeps the GPU compile from also building the CPU variant matrix and
|
||||
# avoids the gcc-14 apt step on GPU base images such as nvidia l4t.
|
||||
make turboquant-fallback
|
||||
fi
|
||||
make "$BUILD_TARGET"
|
||||
make turboquant-grpc
|
||||
make turboquant-rpc-server
|
||||
|
||||
|
||||
64
.github/ci/refresh-site-counters.sh
vendored
Executable file
64
.github/ci/refresh-site-counters.sh
vendored
Executable file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# Refreshes the counters shown on the landing page from the GitHub API.
|
||||
#
|
||||
# The numbers used to be typed into the templates by hand, which meant they
|
||||
# only moved when somebody remembered, and a stale star count on the front
|
||||
# page is worse than no star count. Everything the API can answer for lives
|
||||
# in website/data/stats.yaml and is rewritten wholesale by this script.
|
||||
#
|
||||
# Anything the API cannot answer for (the Discord member count) is read back
|
||||
# out of the existing file and carried through untouched.
|
||||
set -euo pipefail
|
||||
|
||||
REPO="${REPO:-mudler/LocalAI}"
|
||||
OUT="${OUT:-website/data/stats.yaml}"
|
||||
|
||||
# The contributors and releases endpoints are paginated and never report a
|
||||
# total. Asking for one item per page makes the last page number equal to the
|
||||
# item count, which the Link header hands over.
|
||||
count_via_link_header() {
|
||||
local path="$1" link last
|
||||
link=$(gh api -i "${path}?per_page=1" 2>/dev/null | tr -d '\r' | grep -i '^link:' || true)
|
||||
if [ -z "$link" ]; then
|
||||
# No Link header means a single page, so count that page directly.
|
||||
gh api "${path}?per_page=100" --jq 'length'
|
||||
return
|
||||
fi
|
||||
last=$(sed -n 's/.*[?&]page=\([0-9]*\)>; rel="last".*/\1/p' <<<"$link")
|
||||
[ -n "$last" ] || { gh api "${path}?per_page=100" --jq 'length'; return; }
|
||||
printf '%s\n' "$last"
|
||||
}
|
||||
|
||||
read -r stars forks < <(gh api "repos/${REPO}" --jq '"\(.stargazers_count) \(.forks_count)"')
|
||||
contributors=$(count_via_link_header "repos/${REPO}/contributors")
|
||||
releases=$(count_via_link_header "repos/${REPO}/releases")
|
||||
|
||||
# Not derivable from the GitHub API, so keep whatever is already on disk.
|
||||
discord=$(sed -n 's/^discord: *\([0-9]*\).*/\1/p' "$OUT" 2>/dev/null | head -1)
|
||||
discord="${discord:-0}"
|
||||
|
||||
for n in stars forks contributors releases; do
|
||||
v="${!n}"
|
||||
[[ "$v" =~ ^[0-9]+$ ]] && [ "$v" -gt 0 ] || {
|
||||
echo "refusing to write: ${n} came back as '${v}'" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
cat > "$OUT" <<YAML
|
||||
# Counters shown on the landing page.
|
||||
#
|
||||
# The four GitHub fields are rewritten by .github/ci/refresh-site-counters.sh,
|
||||
# which runs weekly from .github/workflows/refresh-site-counters.yml. Editing
|
||||
# them by hand works but will be overwritten on the next run.
|
||||
stars: ${stars}
|
||||
forks: ${forks}
|
||||
contributors: ${contributors}
|
||||
releases: ${releases}
|
||||
|
||||
# The GitHub API cannot answer for this one, so it is maintained by hand and
|
||||
# the refresh script carries it through untouched.
|
||||
discord: ${discord}
|
||||
YAML
|
||||
|
||||
echo "stars=${stars} forks=${forks} contributors=${contributors} releases=${releases} discord=${discord}"
|
||||
12
.github/workflows/bump_deps.yaml
vendored
12
.github/workflows/bump_deps.yaml
vendored
@@ -110,10 +110,14 @@ jobs:
|
||||
variable: "LOCATEANYTHING_VERSION"
|
||||
branch: "master"
|
||||
file: "backend/go/locate-anything-cpp/Makefile"
|
||||
- repository: "ServeurpersoCom/qwentts.cpp"
|
||||
variable: "QWEN3TTS_CPP_VERSION"
|
||||
branch: "master"
|
||||
file: "backend/go/qwen3-tts-cpp/Makefile"
|
||||
# qwentts.cpp is held, not tracked: upstream master hangs in synthesis
|
||||
# (see the comment on QWEN3TTS_CPP_VERSION in the backend Makefile).
|
||||
# Leaving it here would re-bump the pin back onto the hang every night.
|
||||
# Restore this entry once the upstream fix lands.
|
||||
# - repository: "ServeurpersoCom/qwentts.cpp"
|
||||
# variable: "QWEN3TTS_CPP_VERSION"
|
||||
# branch: "master"
|
||||
# file: "backend/go/qwen3-tts-cpp/Makefile"
|
||||
- repository: "ServeurpersoCom/omnivoice.cpp"
|
||||
variable: "OMNIVOICE_VERSION"
|
||||
branch: "master"
|
||||
|
||||
32
.github/workflows/gh-pages.yml
vendored
32
.github/workflows/gh-pages.yml
vendored
@@ -25,7 +25,20 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
# Self-hosted. This workflow is push-to-master + workflow_dispatch only, so
|
||||
# it never executes pull-request code and a fork cannot reach the runner
|
||||
# with untrusted changes. The repository guard keeps forks (whose own master
|
||||
# pushes would otherwise queue forever against a label they do not have) on
|
||||
# the hosted pool.
|
||||
#
|
||||
# Why: the GitHub-hosted pool is shared account-wide and has repeatedly
|
||||
# starved (2026-07-31: 35 consecutive minutes at zero scheduled jobs, while
|
||||
# arc-runner-set kept completing work throughout). Publishing the site is
|
||||
# small, frequent, and must not sit behind a saturated hosted queue.
|
||||
#
|
||||
# Needs only git, tar and curl on the runner: setup-go and actions-hugo
|
||||
# fetch their own toolchains, and no step uses sudo, apt, make or unzip.
|
||||
runs-on: ${{ github.repository == 'mudler/LocalAI' && 'arc-runner-set' || 'ubuntu-latest' }}
|
||||
env:
|
||||
HUGO_VERSION: "0.146.3"
|
||||
steps:
|
||||
@@ -38,7 +51,16 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
# Track go.mod rather than a literal. Pinned at 1.22 this installed a
|
||||
# toolchain older than the module's `go 1.26.0`, so the `go run` below
|
||||
# downloaded the real one from proxy.golang.org on every run. That
|
||||
# fetch is not always reachable from the runner and the deploy failed
|
||||
# on five of eight consecutive master pushes with:
|
||||
# go: download go1.26.0: ... connect: network is unreachable
|
||||
# ##[error]Command failed: go env GOPATH
|
||||
# Installing the version the module asks for removes the download
|
||||
# instead of depending on it succeeding.
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
|
||||
- name: Setup Hugo
|
||||
@@ -86,7 +108,11 @@ jobs:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
# Same routing as build: a hosted slot for a ~10s deploy is exactly the kind
|
||||
# of job that should not block on a starved pool. deploy-pages authenticates
|
||||
# with the job's OIDC token (id-token: write above), which self-hosted
|
||||
# runners issue the same way hosted ones do.
|
||||
runs-on: ${{ github.repository == 'mudler/LocalAI' && 'arc-runner-set' || 'ubuntu-latest' }}
|
||||
needs: build
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
|
||||
55
.github/workflows/lint.yml
vendored
55
.github/workflows/lint.yml
vendored
@@ -21,8 +21,41 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
golangci-lint:
|
||||
# Self-hosted for PUSH only, and only in the canonical repo.
|
||||
#
|
||||
# This workflow also runs on pull_request, which for a fork PR means
|
||||
# executing untrusted contributor code. That must never land on a
|
||||
# self-hosted runner, so anything that is not a push to mudler/LocalAI stays
|
||||
# on the ephemeral hosted pool. Pushes to master are trusted code that has
|
||||
# already been reviewed and merged.
|
||||
#
|
||||
# Why at all: the hosted pool is shared account-wide and starved for 35
|
||||
# straight minutes on 2026-07-31 while arc-runner-set kept completing jobs.
|
||||
# Lint is small and runs on every commit, so it is a good candidate to move
|
||||
# off the contended pool.
|
||||
# REVERTED to hosted: the arc-runner-set image has git, curl, unzip, tar,
|
||||
# ldd and python3, but NOT make (nor gcc). Measured on run 30637392862,
|
||||
# where the preflight below named both. Re-route here once the runner image
|
||||
# ships a C toolchain and make; the preflight stays so the next attempt
|
||||
# fails by name in one second instead of opaquely mid-build.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Preflight - required host tools
|
||||
# The hosted images ship these; a self-hosted container image may not.
|
||||
# Check up front so a missing tool reports itself by name instead of
|
||||
# surfacing as an opaque failure inside `make protogen-go` (which needs
|
||||
# curl + unzip for protoc) or `make lint`.
|
||||
run: |
|
||||
missing=""
|
||||
for t in git curl unzip make tar; do
|
||||
command -v "$t" >/dev/null 2>&1 || missing="$missing $t"
|
||||
done
|
||||
echo "runner: ${RUNNER_NAME:-unknown} os: $(uname -sm)"
|
||||
if [ -n "$missing" ]; then
|
||||
echo "::error::missing required tools on this runner:$missing"
|
||||
exit 1
|
||||
fi
|
||||
echo "all required tools present"
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
# Full history so golangci-lint's new-from-merge-base can reach
|
||||
@@ -55,8 +88,30 @@ jobs:
|
||||
# container build (a missing transitive dep, a partial cuDNN family). Their
|
||||
# shell tests need nothing but bash + gcc + ldd, so run them on every PR
|
||||
# rather than waiting on a multi-GB cross-arch backend image build.
|
||||
#
|
||||
# Push-only self-hosted routing, same fork-safety reasoning as
|
||||
# golangci-lint above.
|
||||
# REVERTED to hosted: the arc-runner-set image has git, curl, unzip, tar,
|
||||
# ldd and python3, but NOT make (nor gcc). Measured on run 30637392862,
|
||||
# where the preflight below named both. Re-route here once the runner image
|
||||
# ships a C toolchain and make; the preflight stays so the next attempt
|
||||
# fails by name in one second instead of opaquely mid-build.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Preflight - required host tools
|
||||
# This job additionally needs a C toolchain: the packaging-script tests
|
||||
# compile a throwaway binary and inspect it with ldd.
|
||||
run: |
|
||||
missing=""
|
||||
for t in git make gcc ldd python3; do
|
||||
command -v "$t" >/dev/null 2>&1 || missing="$missing $t"
|
||||
done
|
||||
echo "runner: ${RUNNER_NAME:-unknown} os: $(uname -sm)"
|
||||
if [ -n "$missing" ]; then
|
||||
echo "::error::missing required tools on this runner:$missing"
|
||||
exit 1
|
||||
fi
|
||||
echo "all required tools present"
|
||||
- uses: actions/checkout@v7
|
||||
- name: run packaging script tests
|
||||
run: make test-build-scripts
|
||||
|
||||
44
.github/workflows/refresh-site-counters.yml
vendored
Normal file
44
.github/workflows/refresh-site-counters.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Refresh site counters
|
||||
|
||||
# The landing page shows a star count, a contributor count and a release
|
||||
# count. They were typed in by hand, so they drifted the moment somebody
|
||||
# forgot. This pulls the real numbers once a week and commits them only when
|
||||
# they have actually moved, which in turn triggers the usual Pages deploy.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Mondays, 06:17 UTC. Off the hour on purpose, since the scheduler queues
|
||||
# everything that asks for :00 and drops what it cannot run.
|
||||
- cron: '17 6 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: refresh-site-counters
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
refresh:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Read the counts off the GitHub API
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: ./.github/ci/refresh-site-counters.sh
|
||||
|
||||
- name: Commit only if something moved
|
||||
run: |
|
||||
if git diff --quiet -- website/data/stats.yaml; then
|
||||
echo "counters unchanged, nothing to commit"
|
||||
exit 0
|
||||
fi
|
||||
git diff --unified=0 -- website/data/stats.yaml
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add website/data/stats.yaml
|
||||
git commit -m "chore(website): refresh the counters"
|
||||
git push
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -124,3 +124,8 @@ formal-verification/out/
|
||||
# package directory itself and untrack the source.
|
||||
/apexentries
|
||||
/.github/ci/apexentries/apexentries
|
||||
|
||||
# Runtime state written by `local-ai run` when it is started from the repo
|
||||
# root, which is what a contributor testing a build does. Nothing under here is
|
||||
# source: it is the instance's own models, outputs, traces and identity.
|
||||
/data/
|
||||
|
||||
@@ -161,7 +161,7 @@ local-ai run https://gist.githubusercontent.com/.../phi-2.yaml
|
||||
local-ai run oci://localai/phi-2:latest
|
||||
```
|
||||
|
||||
To test a running LocalAI server from the terminal, open an interactive chat session from another shell. Inside the prompt, `/models` lists installed models and `/model <name>` switches between them.
|
||||
To work with a running LocalAI server from the terminal, start the built-in agent from another shell. It answers questions, reads your files and runs commands on your machine, asking you to approve anything that changes state. Inside a session, `/models` lists installed models and `/model <name>` switches between them. See the [Terminal agent](https://localai.io/docs/features/terminal-agent/) docs.
|
||||
|
||||
```bash
|
||||
# Terminal 1
|
||||
@@ -195,7 +195,7 @@ For more details, see the [Getting Started guide](https://localai.io/basics/gett
|
||||
- **August 2025**: MLX, MLX-VLM, Diffusers, llama.cpp now supported on Apple Silicon
|
||||
- **July 2025**: All backends migrated outside the main binary — [lightweight, modular architecture](https://github.com/mudler/LocalAI/releases/tag/v3.2.0)
|
||||
|
||||
For older news and full release notes, see [GitHub Releases](https://github.com/mudler/LocalAI/releases) and the [News page](https://localai.io/basics/news/).
|
||||
For older news and full release notes, see [GitHub Releases](https://github.com/mudler/LocalAI/releases) and the [blog](https://localai.io/blog/).
|
||||
|
||||
## Features
|
||||
|
||||
@@ -260,7 +260,7 @@ We also maintain [apex-quant](https://github.com/localai-org/apex-quant), a per-
|
||||
- [Kubernetes installation](https://localai.io/basics/getting_started/#run-localai-in-kubernetes)
|
||||
- [Integrations & community projects](https://localai.io/docs/integrations/)
|
||||
- [Installation video walkthrough](https://www.youtube.com/watch?v=cMVNnlqwfw4)
|
||||
- [Media & blog posts](https://localai.io/basics/news/#media-blogs-social)
|
||||
- [Blog: release write-ups, benchmarks and engineering notes](https://localai.io/blog/)
|
||||
- [Examples](https://github.com/mudler/LocalAI-examples) — including the [realtime voice assistant demo](https://github.com/localai-org/localai-realtime-demo) (Go client for the Realtime API with tool calling)
|
||||
|
||||
## Team
|
||||
|
||||
@@ -15,6 +15,7 @@ service Backend {
|
||||
rpc PredictStream(PredictOptions) returns (stream Reply) {}
|
||||
rpc Embedding(PredictOptions) returns (EmbeddingResult) {}
|
||||
rpc GenerateImage(GenerateImageRequest) returns (Result) {}
|
||||
rpc UpscaleImage(UpscaleImageRequest) returns (Result) {}
|
||||
rpc GenerateVideo(GenerateVideoRequest) returns (Result) {}
|
||||
rpc Generate3D(Generate3DRequest) returns (Result) {}
|
||||
rpc AudioTranscription(TranscriptRequest) returns (TranscriptResult) {}
|
||||
@@ -637,6 +638,12 @@ message GenerateImageRequest {
|
||||
string ModelIdentity = 13;
|
||||
}
|
||||
|
||||
message UpscaleImageRequest {
|
||||
string src = 1; // input image path
|
||||
string dst = 2; // output image path
|
||||
int32 scale = 3; // upscale factor (e.g. 2 or 4)
|
||||
}
|
||||
|
||||
message GenerateVideoRequest {
|
||||
string prompt = 1;
|
||||
string negative_prompt = 2; // Negative prompt for video generation
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
# recipe is a make target (not a prepare.sh) so 'make purge && make' is a clean
|
||||
# rebuild and so the bump bot can see the pin.
|
||||
|
||||
AUDIO_CPP_VERSION?=f32876cfb45732dd4f43264e9104d229e95b0bc3
|
||||
AUDIO_CPP_VERSION?=5a8312ef7b8aa7cf14e9a24ac568cabd8725d68a
|
||||
AUDIO_CPP_REPO?=https://github.com/0xShug0/audio.cpp
|
||||
|
||||
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
# Pinned to the HEAD of the `prism` branch on https://github.com/PrismML-Eng/llama.cpp.
|
||||
# Auto-bumped nightly by .github/workflows/bump_deps.yaml.
|
||||
BONSAI_VERSION?=7529fdaaf99ffdc5ca71ace9c7409a56b27ad92f
|
||||
BONSAI_VERSION?=9ca265a57f85f2117942490f421f64a226dd9847
|
||||
LLAMA_REPO?=https://github.com/PrismML-Eng/llama.cpp
|
||||
|
||||
CMAKE_ARGS?=
|
||||
|
||||
@@ -40,6 +40,27 @@ else
|
||||
if [ -d "$CURDIR/lib/hipblaslt/library" ]; then
|
||||
export HIPBLASLT_TENSILE_LIBPATH="$CURDIR"/lib/hipblaslt/library
|
||||
fi
|
||||
# Backends built for Intel GPUs carry a copy of the Intel graphics driver,
|
||||
# and libze_loader is only there in those builds. Level Zero looks for a
|
||||
# driver on its own, so point it at the copy that came with this backend: it
|
||||
# was built against the same C library, while the machine's own driver may
|
||||
# not have been, and loading that one can crash on start.
|
||||
#
|
||||
# Anything the user set is left alone, so a machine with a graphics card
|
||||
# newer than the driver carried here can still be told to use its own.
|
||||
# Nothing is said about OpenCL: no OpenCL driver is carried, so anything we
|
||||
# set there would leave OpenCL worse off than the machine's own setup.
|
||||
if [ -e "$CURDIR/lib/libze_loader.so.1" ]; then
|
||||
if [ -e "$CURDIR/lib/libze_intel_gpu.so.1" ] && [ -z "${ZE_ENABLE_ALT_DRIVERS:-}" ]; then
|
||||
export ZE_ENABLE_ALT_DRIVERS="$CURDIR"/lib/libze_intel_gpu.so.1
|
||||
fi
|
||||
# Ask the driver how much graphics memory is free. Without this, the
|
||||
# backend reads zero on an integrated graphics chip, because such a chip
|
||||
# shares the system memory instead of having its own.
|
||||
if [ -z "${ZES_ENABLE_SYSMAN:-}" ]; then
|
||||
export ZES_ENABLE_SYSMAN=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# If there is a lib/ld.so, use it
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
IK_LLAMA_VERSION?=9992f6b515ee63c7d6f7beee6b8414b0a6d1dd43
|
||||
IK_LLAMA_VERSION?=cb9147fd0d9c08a9a84eee5ac405a73f4e10e3e1
|
||||
LLAMA_REPO?=https://github.com/ikawrakow/ik_llama.cpp
|
||||
|
||||
CMAKE_ARGS?=
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
LLAMA_VERSION?=1cbfd1988311775425d36c0ce066590f7d3049cf
|
||||
LLAMA_VERSION?=221f0f6356efe2260023208365705ec5d5a7c8f5
|
||||
LLAMA_REPO?=https://github.com/ggerganov/llama.cpp
|
||||
|
||||
CMAKE_ARGS?=
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
# MiniMax-M3 chat-template parser, vendored from upstream llama.cpp PR #24523.
|
||||
#
|
||||
# Upstream has since merged the *model* half of #24523 (LLM_ARCH_MINIMAX_M3,
|
||||
# src/models/minimax-m3.cpp, the gguf-py constants and conversion/minimax.py), so
|
||||
# only the chat half is carried here: M3's namespace token "]<]minimax[>[" collides
|
||||
# with the autoparser's markup delimiters, so common/chat.cpp needs a dedicated
|
||||
# template detection + PEG parser that upstream does not have yet.
|
||||
#
|
||||
# Rebased against LLAMA_VERSION 0d47ea7427463093e69128bf2c2f9cd06b3ee5b3, which also
|
||||
# renamed common_chat_params::thinking_end_tag to thinking_end_tags (a vector).
|
||||
# LLAMA_VERSION is auto-bumped nightly; if a bump rejects this patch, re-vendor from
|
||||
# #24523 — or, once the chat half merges upstream, delete this file.
|
||||
# See https://github.com/mudler/LocalAI/issues/10820 and PR #10837.
|
||||
diff --git a/common/chat.cpp b/common/chat.cpp
|
||||
index 7a6e7238c..2dd015a2e 100644
|
||||
--- a/common/chat.cpp
|
||||
+++ b/common/chat.cpp
|
||||
@@ -2121,6 +2121,191 @@ static common_chat_params common_chat_params_init_deepseek_v3_2(const common_cha
|
||||
return data;
|
||||
}
|
||||
|
||||
+static common_chat_params common_chat_params_init_minimax_m3(const common_chat_template & tmpl,
|
||||
+ const autoparser::generation_params & inputs) {
|
||||
+ common_chat_params data;
|
||||
+
|
||||
+ data.prompt = common_chat_template_direct_apply_impl(tmpl, inputs);
|
||||
+ data.generation_prompt = common_chat_template_generation_prompt_impl(tmpl, inputs);
|
||||
+ data.format = COMMON_CHAT_FORMAT_PEG_NATIVE;
|
||||
+ data.supports_thinking = true;
|
||||
+ data.thinking_start_tag = "<mm:think>";
|
||||
+ data.thinking_end_tags = {"</mm:think>"};
|
||||
+
|
||||
+ // M3 prefixes every tool tag with the namespace token "]<]minimax[>[";
|
||||
+ // params use the parameter name as the tag (<file_path>...</file_path>).
|
||||
+ const std::string NS = "]<]minimax[>[";
|
||||
+ const std::string THINK_START = "<mm:think>";
|
||||
+ const std::string THINK_END = "</mm:think>";
|
||||
+ const std::string FC_START = NS + "<tool_call>";
|
||||
+ const std::string FC_END = NS + "</tool_call>";
|
||||
+ const std::string INVOKE_END = NS + "</invoke>";
|
||||
+
|
||||
+ data.preserved_tokens = {
|
||||
+ NS,
|
||||
+ "<tool_call>",
|
||||
+ "</tool_call>",
|
||||
+ THINK_START,
|
||||
+ THINK_END,
|
||||
+ };
|
||||
+
|
||||
+ auto has_tools = inputs.tools.is_array() && !inputs.tools.empty();
|
||||
+ auto has_response_format = !inputs.json_schema.is_null() && inputs.json_schema.is_object();
|
||||
+ auto extract_reasoning = inputs.reasoning_format != COMMON_REASONING_FORMAT_NONE;
|
||||
+ auto include_grammar = has_response_format || (has_tools && inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE);
|
||||
+
|
||||
+ const std::string GEN_PROMPT = data.generation_prompt;
|
||||
+
|
||||
+ if (inputs.has_continuation()) {
|
||||
+ const auto & msg = inputs.continue_msg;
|
||||
+
|
||||
+ data.generation_prompt = GEN_PROMPT + THINK_START + msg.reasoning_content;
|
||||
+ if (inputs.continue_final_message == COMMON_CHAT_CONTINUATION_CONTENT) {
|
||||
+ data.generation_prompt += THINK_END + msg.render_content();
|
||||
+ }
|
||||
+
|
||||
+ data.prompt += data.generation_prompt;
|
||||
+ }
|
||||
+
|
||||
+ auto parser = build_chat_peg_parser([&](common_chat_peg_builder & p) {
|
||||
+ auto generation_prompt = p.literal(GEN_PROMPT);
|
||||
+ auto end = p.end();
|
||||
+
|
||||
+ auto reasoning = p.eps();
|
||||
+ // M3 can emit a bare </mm:think> (no opener) after tool results; keep the opener optional.
|
||||
+ if (extract_reasoning && inputs.enable_thinking) {
|
||||
+ reasoning = p.optional(p.optional(p.literal(THINK_START)) + p.reasoning(p.until(THINK_END)) + THINK_END);
|
||||
+ } else if (extract_reasoning) {
|
||||
+ reasoning = p.optional(p.optional(p.literal(THINK_START)) + p.until(THINK_END) + p.literal(THINK_END));
|
||||
+ }
|
||||
+
|
||||
+ if (has_response_format) {
|
||||
+ auto response_format = p.rule("response-format",
|
||||
+ p.literal("```json") + p.space() +
|
||||
+ p.content(p.schema(p.json(), "response-format-schema", inputs.json_schema)) +
|
||||
+ p.space() + p.literal("```"));
|
||||
+ return generation_prompt + reasoning + response_format + end;
|
||||
+ }
|
||||
+
|
||||
+ if (!has_tools || inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_NONE) {
|
||||
+ return generation_prompt + reasoning + p.content(p.rest()) + end;
|
||||
+ }
|
||||
+
|
||||
+ auto tool_choice = p.choice();
|
||||
+ foreach_function(inputs.tools, [&](const json & tool) {
|
||||
+ const auto & function = tool.at("function");
|
||||
+ std::string name = function.at("name");
|
||||
+ auto params = function.contains("parameters") ? function.at("parameters") : json::object();
|
||||
+ const auto & props = params.contains("properties") ? params.at("properties") : json::object();
|
||||
+
|
||||
+ std::set<std::string> required;
|
||||
+ if (params.contains("required")) {
|
||||
+ params.at("required").get_to(required);
|
||||
+ }
|
||||
+
|
||||
+ auto schema_info = common_schema_info();
|
||||
+ schema_info.resolve_refs(params);
|
||||
+
|
||||
+ std::vector<common_peg_parser> required_parsers;
|
||||
+ std::vector<common_peg_parser> optional_parsers;
|
||||
+ for (const auto & [param_name, param_schema] : props.items()) {
|
||||
+ bool is_required = required.find(param_name) != required.end();
|
||||
+ bool is_string = schema_info.resolves_to_string(param_schema);
|
||||
+
|
||||
+ const std::string p_close = NS + "</" + param_name + ">";
|
||||
+
|
||||
+ auto arg = p.tool_arg(
|
||||
+ p.tool_arg_open(
|
||||
+ p.literal(NS + "<") +
|
||||
+ p.tool_arg_name(p.literal(param_name)) +
|
||||
+ p.literal(">")) +
|
||||
+ (is_string
|
||||
+ ? p.ac(p.tool_arg_string_value(p.until(p_close)) +
|
||||
+ p.tool_arg_close(p.literal(p_close)), p_close)
|
||||
+ : p.tool_arg_json_value(p.schema(p.json(),
|
||||
+ "tool-" + name + "-arg-" + param_name + "-schema",
|
||||
+ param_schema, false)) +
|
||||
+ p.tool_arg_close(p.literal(p_close))));
|
||||
+
|
||||
+ auto named_arg = p.rule("tool-" + name + "-arg-" + param_name, arg);
|
||||
+ if (is_required) {
|
||||
+ required_parsers.push_back(named_arg);
|
||||
+ } else {
|
||||
+ optional_parsers.push_back(named_arg);
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ common_peg_parser args_seq = p.eps();
|
||||
+ for (size_t i = 0; i < required_parsers.size(); i++) {
|
||||
+ if (i > 0) {
|
||||
+ args_seq = args_seq + p.space();
|
||||
+ }
|
||||
+ args_seq = args_seq + required_parsers[i];
|
||||
+ }
|
||||
+
|
||||
+ if (!optional_parsers.empty()) {
|
||||
+ common_peg_parser any_opt = p.choice();
|
||||
+ for (const auto & opt : optional_parsers) {
|
||||
+ any_opt |= opt;
|
||||
+ }
|
||||
+ args_seq = args_seq + p.repeat(p.space() + any_opt, 0, -1);
|
||||
+ }
|
||||
+
|
||||
+ common_peg_parser invoke_body = args_seq;
|
||||
+ auto func_parser = p.tool(
|
||||
+ p.tool_open(p.literal(NS + "<invoke name=\"") +
|
||||
+ p.tool_name(p.literal(name)) + p.literal("\">")) +
|
||||
+ p.space() + invoke_body + p.space() +
|
||||
+ p.tool_close(p.literal(INVOKE_END)));
|
||||
+
|
||||
+ tool_choice |= p.rule("tool-" + name, func_parser);
|
||||
+ });
|
||||
+
|
||||
+ auto require_tools = inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED;
|
||||
+
|
||||
+ common_peg_parser tool_calls = p.eps();
|
||||
+ if (inputs.parallel_tool_calls) {
|
||||
+ tool_calls = p.trigger_rule("tool-call",
|
||||
+ p.literal(FC_START) + p.space() + tool_choice +
|
||||
+ p.zero_or_more(p.space() + tool_choice) + p.space() + p.literal(FC_END));
|
||||
+ } else {
|
||||
+ tool_calls = p.trigger_rule("tool-call",
|
||||
+ p.literal(FC_START) + p.space() + tool_choice + p.space() + p.literal(FC_END));
|
||||
+ }
|
||||
+
|
||||
+ if (!require_tools) {
|
||||
+ tool_calls = p.optional(tool_calls);
|
||||
+ }
|
||||
+
|
||||
+ auto content_before_tools = p.content(p.until(FC_START));
|
||||
+ return generation_prompt + reasoning + content_before_tools + tool_calls + end;
|
||||
+ });
|
||||
+
|
||||
+ data.parser = parser.save();
|
||||
+
|
||||
+ if (include_grammar) {
|
||||
+ data.grammar_lazy = !(has_response_format || (has_tools && inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED));
|
||||
+ data.grammar = build_grammar([&](const common_grammar_builder & builder) {
|
||||
+ foreach_function(inputs.tools, [&](const json & tool) {
|
||||
+ const auto & function = tool.at("function");
|
||||
+ auto schema = function.contains("parameters") ? function.at("parameters") : json::object();
|
||||
+ builder.resolve_refs(schema);
|
||||
+ });
|
||||
+ if (has_response_format) {
|
||||
+ auto schema = inputs.json_schema;
|
||||
+ builder.resolve_refs(schema);
|
||||
+ }
|
||||
+ parser.build_grammar(builder, data.grammar_lazy);
|
||||
+ });
|
||||
+
|
||||
+ data.grammar_triggers = {
|
||||
+ { COMMON_GRAMMAR_TRIGGER_TYPE_WORD, FC_START },
|
||||
+ };
|
||||
+ }
|
||||
+
|
||||
+ return data;
|
||||
+}
|
||||
+
|
||||
// Cohere2 MoE (a.k.a. "North Code") parser.
|
||||
//
|
||||
// The assistant turn is fully marker-wrapped:
|
||||
@@ -2707,6 +2892,15 @@ std::optional<common_chat_params> common_chat_try_specialized_template(
|
||||
return common_chat_params_init_gigachat_v3(tmpl, params);
|
||||
}
|
||||
|
||||
+ // MiniMax-M3: the namespace token "]<]minimax[>[" collides with the autoparser's
|
||||
+ // markup delimiters, so detect the template and use a dedicated parser.
|
||||
+ if (src.find("]<]minimax[>[") != std::string::npos &&
|
||||
+ src.find("<tool_call>") != std::string::npos &&
|
||||
+ src.find("<invoke name=") != std::string::npos) {
|
||||
+ LOG_DBG("Using specialized template: MiniMax-M3\n");
|
||||
+ return common_chat_params_init_minimax_m3(tmpl, params);
|
||||
+ }
|
||||
+
|
||||
// DeepSeek V3.2/V4 format detection: template defines dsml_token and uses it for tool calls.
|
||||
// The template source contains the token as a variable assignment, not as a literal in markup.
|
||||
// V3.2 names the tool call block "function_calls", V4 names it "tool_calls".
|
||||
@@ -12,10 +12,11 @@ grep -e "flags" /proc/cpuinfo | head -1
|
||||
|
||||
BINARY=llama-cpp-fallback
|
||||
|
||||
# CPU images (x86, arm64, darwin) ship a single llama-cpp-cpu-all built with ggml
|
||||
# CPU images and most x86 GPU images ship a single llama-cpp-cpu-all built with ggml
|
||||
# CPU_ALL_VARIANTS: ggml's backend registry dlopens the best libggml-cpu-*.so for this
|
||||
# host, so no shell-side AVX probing. GPU images (cublas/sycl/vulkan/hipblas) ship only
|
||||
# llama-cpp-fallback (the accelerator does the compute), so fall back to it when absent.
|
||||
# host, so no shell-side AVX probing. GPU arm64 images still ship llama-cpp-fallback
|
||||
# until their builder toolchains support ggml's complete arm variant matrix, and so do
|
||||
# the SYCL images, whose icpx compiler hangs on the sapphirerapids variant.
|
||||
if [ -e "$CURDIR"/llama-cpp-cpu-all ]; then
|
||||
BINARY=llama-cpp-cpu-all
|
||||
fi
|
||||
@@ -42,6 +43,27 @@ else
|
||||
if [ -d "$CURDIR/lib/hipblaslt/library" ]; then
|
||||
export HIPBLASLT_TENSILE_LIBPATH="$CURDIR"/lib/hipblaslt/library
|
||||
fi
|
||||
# Backends built for Intel GPUs carry a copy of the Intel graphics driver,
|
||||
# and libze_loader is only there in those builds. Level Zero looks for a
|
||||
# driver on its own, so point it at the copy that came with this backend: it
|
||||
# was built against the same C library, while the machine's own driver may
|
||||
# not have been, and loading that one can crash on start.
|
||||
#
|
||||
# Anything the user set is left alone, so a machine with a graphics card
|
||||
# newer than the driver carried here can still be told to use its own.
|
||||
# Nothing is said about OpenCL: no OpenCL driver is carried, so anything we
|
||||
# set there would leave OpenCL worse off than the machine's own setup.
|
||||
if [ -e "$CURDIR/lib/libze_loader.so.1" ]; then
|
||||
if [ -e "$CURDIR/lib/libze_intel_gpu.so.1" ] && [ -z "${ZE_ENABLE_ALT_DRIVERS:-}" ]; then
|
||||
export ZE_ENABLE_ALT_DRIVERS="$CURDIR"/lib/libze_intel_gpu.so.1
|
||||
fi
|
||||
# Ask the driver how much graphics memory is free. Without this,
|
||||
# llama.cpp reads zero on an integrated graphics chip, because such a
|
||||
# chip shares the system memory instead of having its own.
|
||||
if [ -z "${ZES_ENABLE_SYSMAN:-}" ]; then
|
||||
export ZES_ENABLE_SYSMAN=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# If there is a lib/ld.so, use it
|
||||
@@ -55,4 +77,4 @@ echo "Using binary: $BINARY"
|
||||
exec "$CURDIR"/$BINARY "$@"
|
||||
|
||||
# We should never reach this point, however just in case we do, run fallback
|
||||
exec "$CURDIR"/llama-cpp-fallback "$@"
|
||||
exec "$CURDIR"/llama-cpp-fallback "$@"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
# Pinned to the HEAD of feature/turboquant-kv-cache on https://github.com/TheTom/llama-cpp-turboquant.
|
||||
# Auto-bumped nightly by .github/workflows/bump_deps.yaml.
|
||||
TURBOQUANT_VERSION?=c26cbdffcf6fc9b7430cd6b117757e9a3f70b7ea
|
||||
TURBOQUANT_VERSION?=8a891f4b566efdbd3cea92fafee3227a0a267683
|
||||
LLAMA_REPO?=https://github.com/TheTom/llama-cpp-turboquant
|
||||
|
||||
CMAKE_ARGS?=
|
||||
|
||||
@@ -12,9 +12,12 @@ grep -e "flags" /proc/cpuinfo | head -1
|
||||
|
||||
BINARY=turboquant-fallback
|
||||
|
||||
# x86/arm64 ship a single turboquant-cpu-all built with ggml CPU_ALL_VARIANTS: ggml's
|
||||
# CPU images and most x86 GPU images ship a single turboquant-cpu-all built with ggml
|
||||
# CPU_ALL_VARIANTS: ggml's
|
||||
# backend registry dlopens the best libggml-cpu-*.so for this host, so no shell-side
|
||||
# probing. ROCm ships only turboquant-fallback, so fall back to it when cpu-all is absent.
|
||||
# probing. GPU arm64 images still ship turboquant-fallback until their builder toolchains
|
||||
# support ggml's complete arm variant matrix, and so do the SYCL images, whose icpx
|
||||
# compiler hangs on the sapphirerapids variant.
|
||||
if [ -e "$CURDIR"/turboquant-cpu-all ]; then
|
||||
BINARY=turboquant-cpu-all
|
||||
fi
|
||||
@@ -40,6 +43,27 @@ else
|
||||
if [ -d "$CURDIR/lib/hipblaslt/library" ]; then
|
||||
export HIPBLASLT_TENSILE_LIBPATH="$CURDIR"/lib/hipblaslt/library
|
||||
fi
|
||||
# Backends built for Intel GPUs carry a copy of the Intel graphics driver,
|
||||
# and libze_loader is only there in those builds. Level Zero looks for a
|
||||
# driver on its own, so point it at the copy that came with this backend: it
|
||||
# was built against the same C library, while the machine's own driver may
|
||||
# not have been, and loading that one can crash on start.
|
||||
#
|
||||
# Anything the user set is left alone, so a machine with a graphics card
|
||||
# newer than the driver carried here can still be told to use its own.
|
||||
# Nothing is said about OpenCL: no OpenCL driver is carried, so anything we
|
||||
# set there would leave OpenCL worse off than the machine's own setup.
|
||||
if [ -e "$CURDIR/lib/libze_loader.so.1" ]; then
|
||||
if [ -e "$CURDIR/lib/libze_intel_gpu.so.1" ] && [ -z "${ZE_ENABLE_ALT_DRIVERS:-}" ]; then
|
||||
export ZE_ENABLE_ALT_DRIVERS="$CURDIR"/lib/libze_intel_gpu.so.1
|
||||
fi
|
||||
# Ask the driver how much graphics memory is free. Without this, the
|
||||
# backend reads zero on an integrated graphics chip, because such a chip
|
||||
# shares the system memory instead of having its own.
|
||||
if [ -z "${ZES_ENABLE_SYSMAN:-}" ]; then
|
||||
export ZES_ENABLE_SYSMAN=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# If there is a lib/ld.so, use it
|
||||
|
||||
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
|
||||
|
||||
# CrispASR version (release tag)
|
||||
CRISPASR_REPO?=https://github.com/CrispStrobe/CrispASR
|
||||
CRISPASR_VERSION?=677e95d0e60010f10636c3a0b1ba215b38a4a943
|
||||
CRISPASR_VERSION?=fcb79282a6bc52e13d858026c42b24fb6e63c97a
|
||||
SO_TARGET?=libgocrispasr.so
|
||||
|
||||
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
|
||||
|
||||
@@ -7,8 +7,18 @@ GO_TAGS?=
|
||||
JOBS?=$(shell nproc --ignore=1)
|
||||
|
||||
# qwentts.cpp version
|
||||
#
|
||||
# Held at 35ebe537 rather than tracking latest: abab6b3 hangs in synthesis.
|
||||
# TTS() never returns from the native call, so tests-qwen3-tts-cpp goes from
|
||||
# ~5 minutes to the 20 minute Go test timeout. Reproduced on master on
|
||||
# 2026-08-01 and again on re-run, and the bump PR (#11241) was merged with
|
||||
# this same check already red.
|
||||
#
|
||||
# The regression is in 35ebe537..abab6b3, three upstream commits whose only
|
||||
# functional change is 26dd8adb, "predictor: unroll the frame into one cgraph
|
||||
# and sample in standard ops". Restore the bump once that is fixed upstream.
|
||||
QWEN3TTS_REPO?=https://github.com/ServeurpersoCom/qwentts.cpp
|
||||
QWEN3TTS_CPP_VERSION?=abab6b3bf317cfa1b788efce1d25f4f9239395ad
|
||||
QWEN3TTS_CPP_VERSION?=35ebe5376b82a0a59d008586d55bbe623d449011
|
||||
SO_TARGET?=libgoqwen3ttscpp.so
|
||||
|
||||
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
|
||||
|
||||
@@ -11,7 +11,7 @@ JOBS?=$(shell nproc --ignore=1)
|
||||
# build; leaving this on `master` always picks up the latest C-API surface
|
||||
# (incl. the per-detection accessor functions used by gorfdetrcpp.go).
|
||||
RFDETR_REPO?=https://github.com/localai-org/rf-detr.cpp.git
|
||||
RFDETR_VERSION?=65c0ffcc9a9bc9dae38252f63d0417c9845a6cf7
|
||||
RFDETR_VERSION?=98d0f381b832ef08a608b65c7dd78db066ed8b9a
|
||||
|
||||
ifeq ($(NATIVE),false)
|
||||
CMAKE_ARGS+=-DGGML_NATIVE=OFF
|
||||
|
||||
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
|
||||
|
||||
# stablediffusion.cpp (ggml)
|
||||
STABLEDIFFUSION_GGML_REPO?=https://github.com/leejet/stable-diffusion.cpp
|
||||
STABLEDIFFUSION_GGML_VERSION?=e31a86ce9110b11a98bd5990c329093244c2d1e3
|
||||
STABLEDIFFUSION_GGML_VERSION?=db99efdd6d2a43c7937fd55b3359206c680a75b0
|
||||
|
||||
CMAKE_ARGS+=-DGGML_MAX_NAME=128
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
|
||||
|
||||
# whisper.cpp version
|
||||
WHISPER_REPO?=https://github.com/ggml-org/whisper.cpp
|
||||
WHISPER_CPP_VERSION?=4523d0ce373ee4b2176b3251fff29fd4864fcf38
|
||||
WHISPER_CPP_VERSION?=2ca53bb45e38748d07b310eeb36245a7157ac882
|
||||
SO_TARGET?=libgowhisper.so
|
||||
|
||||
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
|
||||
|
||||
@@ -883,6 +883,34 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
|
||||
|
||||
return backend_pb2.Result(message="Media generated", success=True)
|
||||
|
||||
def UpscaleImage(self, request, context):
|
||||
try:
|
||||
if not request.src:
|
||||
return backend_pb2.Result(success=False, message="No source image provided")
|
||||
if not request.dst:
|
||||
return backend_pb2.Result(success=False, message="No destination path provided")
|
||||
|
||||
scale = request.scale if request.scale > 0 else 2
|
||||
image = Image.open(request.src).convert("RGB")
|
||||
|
||||
# If the loaded pipeline supports upscaling (e.g. StableDiffusionUpscalePipeline),
|
||||
# use it; otherwise fall back to high-quality Lanczos resize.
|
||||
if self.pipe is not None and self.PipelineType in ("StableDiffusionUpscalePipeline", "StableDiffusionLatentUpscalePipeline"):
|
||||
print(f"UpscaleImage: using diffusers upscale pipeline ({self.PipelineType})", file=sys.stderr)
|
||||
upscaled = self.pipe(prompt="", image=image).images[0]
|
||||
else:
|
||||
# Fallback: high-quality Lanczos resize
|
||||
print(f"UpscaleImage: no upscale pipeline loaded, using Lanczos resize (scale={scale})", file=sys.stderr)
|
||||
new_w = image.width * scale
|
||||
new_h = image.height * scale
|
||||
upscaled = image.resize((new_w, new_h), Image.LANCZOS)
|
||||
|
||||
upscaled.save(request.dst)
|
||||
return backend_pb2.Result(message="Image upscaled", success=True)
|
||||
except Exception as e:
|
||||
print(f"UpscaleImage error: {e}", file=sys.stderr)
|
||||
return backend_pb2.Result(success=False, message=str(e))
|
||||
|
||||
def GenerateVideo(self, request, context):
|
||||
try:
|
||||
prompt = request.prompt
|
||||
|
||||
@@ -15,3 +15,12 @@ sglang[all]>=0.5.11
|
||||
# load-bearing for flash-attn-4, and this is the narrower change. Raise the
|
||||
# bound once 0.46.0 final ships.
|
||||
nvidia-modelopt<0.46
|
||||
|
||||
# Same failure mode as the nvidia-modelopt bound above, via a different
|
||||
# package. sglang -> flashinfer-python -> cuda-tile, unbounded, and the
|
||||
# global --prerelease=allow resolves it to 1.6.0rc3, whose build backend
|
||||
# imports wheel_stub without declaring it in build-system.requires. With
|
||||
# --no-build-isolation nothing installs it and the build dies with
|
||||
# "No module named 'wheel_stub'". 1.5.0 is the newest stable release.
|
||||
# Raise the bound once 1.6.0 final ships.
|
||||
cuda-tile<1.6
|
||||
|
||||
@@ -15,3 +15,12 @@ sglang[all]>=0.5.11
|
||||
# load-bearing for flash-attn-4, and this is the narrower change. Raise the
|
||||
# bound once 0.46.0 final ships.
|
||||
nvidia-modelopt<0.46
|
||||
|
||||
# Same failure mode as the nvidia-modelopt bound above, via a different
|
||||
# package. sglang -> flashinfer-python -> cuda-tile, unbounded, and the
|
||||
# global --prerelease=allow resolves it to 1.6.0rc3, whose build backend
|
||||
# imports wheel_stub without declaring it in build-system.requires. With
|
||||
# --no-build-isolation nothing installs it and the build dies with
|
||||
# "No module named 'wheel_stub'". 1.5.0 is the newest stable release.
|
||||
# Raise the bound once 1.6.0 final ships.
|
||||
cuda-tile<1.6
|
||||
|
||||
@@ -13,3 +13,12 @@
|
||||
# FunctionCallParser, ReasoningParser); the [all] extras are optional
|
||||
# accelerators not required at import time.
|
||||
sglang>=0.5.11
|
||||
|
||||
# Same failure mode the cublas profiles carry an nvidia-modelopt bound for,
|
||||
# reached through a different package. sglang -> flashinfer-python ->
|
||||
# cuda-tile, unbounded, and the global --prerelease=allow resolves it to
|
||||
# 1.6.0rc3, whose build backend imports wheel_stub without declaring it in
|
||||
# build-system.requires. With --no-build-isolation nothing installs it and
|
||||
# the build dies with "No module named 'wheel_stub'". 1.5.0 is the newest
|
||||
# stable release. Raise the bound once 1.6.0 final ships.
|
||||
cuda-tile<1.6
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
@@ -107,6 +108,13 @@ For documentation and support:
|
||||
// Run the thing!
|
||||
err = ctx.Run(&cli.CLI.Context)
|
||||
if err != nil {
|
||||
// A command that has already told the user what went wrong returns
|
||||
// only a status. Logging it as well would print a bare "exit status 1"
|
||||
// underneath the explanation they just read.
|
||||
var reported cli.ExitCodeError
|
||||
if errors.As(err, &reported) {
|
||||
os.Exit(reported.Code)
|
||||
}
|
||||
xlog.Fatal("Error running the application", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -553,12 +553,17 @@ func (a *Application) start() error {
|
||||
// once at startup and reused across chat sessions that opt in via metadata.
|
||||
if !a.applicationConfig.DisableLocalAIAssistant {
|
||||
holder := mcpTools.NewLocalAIAssistantHolder()
|
||||
var nodeRegistry *nodes.NodeRegistry
|
||||
if a.distributed != nil {
|
||||
nodeRegistry = a.distributed.Registry
|
||||
}
|
||||
assistantClient := localaiInproc.New(
|
||||
a.applicationConfig,
|
||||
a.applicationConfig.SystemState,
|
||||
a.backendLoader,
|
||||
a.modelLoader,
|
||||
a.galleryService,
|
||||
nodeRegistry,
|
||||
)
|
||||
// Wire usage tracking so the assistant's get_usage_stats tool
|
||||
// returns real data; nil values keep the tool returning a clear
|
||||
|
||||
@@ -444,6 +444,13 @@ func New(opts ...config.AppOption) (*Application, error) {
|
||||
// when gallery data refreshes instead of using a fixed TTL.
|
||||
vram.SetGalleryGenerationFunc(gallery.GalleryGeneration)
|
||||
|
||||
// Fill those caches ahead of the first visitor. An estimate for an entry
|
||||
// nobody has asked about yet costs a remote probe of its weight files, and
|
||||
// the model gallery asks for one per row, so without this the first page
|
||||
// spends seconds filling in its own sizes while somebody watches it.
|
||||
// Non-blocking, and bounded: see DefaultEstimateWarmConfig.
|
||||
gallery.WarmEstimateCache(options.Context, options.Galleries, options.SystemState, gallery.EstimateWarmConfigFromEnv())
|
||||
|
||||
if options.ConfigFile != "" {
|
||||
if err := application.ModelConfigLoader().LoadMultipleModelConfigsSingleFile(options.ConfigFile, configLoaderOpts...); err != nil {
|
||||
xlog.Error("error loading config file", "error", err)
|
||||
|
||||
37
core/backend/upscale.go
Normal file
37
core/backend/upscale.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/pkg/grpc/proto"
|
||||
model "github.com/mudler/LocalAI/pkg/model"
|
||||
)
|
||||
|
||||
// ImageUpscale loads the model specified in modelConfig and calls UpscaleImage
|
||||
// on the backend, writing the result to dst.
|
||||
func ImageUpscale(ctx context.Context, src, dst string, scale int, loader *model.ModelLoader, modelConfig config.ModelConfig, appConfig *config.ApplicationConfig) (func() error, error) {
|
||||
opts := ModelOptions(modelConfig, appConfig, model.WithContext(ctx))
|
||||
inferenceModel, err := loader.Load(opts...)
|
||||
if err != nil {
|
||||
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fn := func() error {
|
||||
_, err := inferenceModel.UpscaleImage(
|
||||
ctx,
|
||||
&proto.UpscaleImageRequest{
|
||||
Src: src,
|
||||
Dst: dst,
|
||||
Scale: int32(scale),
|
||||
},
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
return fn, nil
|
||||
}
|
||||
|
||||
// ImageUpscaleFunc is a test-friendly indirection.
|
||||
var ImageUpscaleFunc = ImageUpscale
|
||||
@@ -1,30 +0,0 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Model string
|
||||
BaseURL string
|
||||
APIKey string
|
||||
In io.Reader
|
||||
Out io.Writer
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, opts Options) error {
|
||||
if opts.In == nil {
|
||||
opts.In = strings.NewReader("")
|
||||
}
|
||||
if opts.Out == nil {
|
||||
opts.Out = io.Discard
|
||||
}
|
||||
|
||||
session, err := newChatSession(ctx, newLocalAIChatClient(opts.BaseURL, opts.APIKey), opts.Model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runTerminalChat(ctx, session, opts.In, opts.Out)
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Run chat", func() {
|
||||
It("streams a single chat response", func() {
|
||||
var capturedModel string
|
||||
var capturedAuth string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/models" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
writeResponse(w, `{"object":"list","data":[{"id":"test-model","object":"model"}]}`)
|
||||
return
|
||||
}
|
||||
|
||||
Expect(r.URL.Path).To(Equal("/v1/chat/completions"))
|
||||
capturedAuth = r.Header.Get("Authorization")
|
||||
|
||||
var body struct {
|
||||
Model string `json:"model"`
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed())
|
||||
capturedModel = body.Model
|
||||
Expect(body.Messages).To(HaveLen(1))
|
||||
Expect(body.Messages[0].Role).To(Equal("user"))
|
||||
Expect(body.Messages[0].Content).To(Equal("hello"))
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
writeResponse(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\n")
|
||||
writeResponse(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"}}]}\n\n")
|
||||
writeResponse(w, "data: [DONE]\n\n")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var out bytes.Buffer
|
||||
err := Run(GinkgoT().Context(), Options{
|
||||
Model: "test-model",
|
||||
BaseURL: server.URL + "/v1",
|
||||
APIKey: "secret",
|
||||
In: strings.NewReader("hello\n/exit\n"),
|
||||
Out: &out,
|
||||
})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(capturedModel).To(Equal("test-model"))
|
||||
Expect(capturedAuth).To(Equal("Bearer secret"))
|
||||
Expect(out.String()).To(ContainSubstring("assistant: hi!"))
|
||||
Expect(out.String()).To(ContainSubstring("bye"))
|
||||
})
|
||||
|
||||
It("auto-selects the only available model", func() {
|
||||
server := chatTestServer([]string{"solo"}, nil)
|
||||
defer server.Close()
|
||||
|
||||
var out bytes.Buffer
|
||||
err := Run(GinkgoT().Context(), Options{
|
||||
BaseURL: server.URL + "/v1",
|
||||
In: strings.NewReader("/exit\n"),
|
||||
Out: &out,
|
||||
})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out.String()).To(ContainSubstring("LocalAI chat (solo)"))
|
||||
})
|
||||
|
||||
It("returns an actionable error when no models are installed", func() {
|
||||
server := chatTestServer(nil, nil)
|
||||
defer server.Close()
|
||||
|
||||
err := Run(GinkgoT().Context(), Options{
|
||||
BaseURL: server.URL + "/v1",
|
||||
In: strings.NewReader(""),
|
||||
})
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("no chat models are installed"))
|
||||
Expect(err.Error()).To(ContainSubstring("local-ai models install <model>"))
|
||||
})
|
||||
|
||||
It("returns an actionable error when multiple models are available without a selection", func() {
|
||||
server := chatTestServer([]string{"alpha", "beta"}, nil)
|
||||
defer server.Close()
|
||||
|
||||
err := Run(GinkgoT().Context(), Options{
|
||||
BaseURL: server.URL + "/v1",
|
||||
In: strings.NewReader(""),
|
||||
})
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("multiple models are available"))
|
||||
Expect(err.Error()).To(ContainSubstring("--model"))
|
||||
Expect(err.Error()).To(ContainSubstring("alpha"))
|
||||
Expect(err.Error()).To(ContainSubstring("beta"))
|
||||
})
|
||||
|
||||
It("lists and switches models inside the chat", func() {
|
||||
requestedModels := []string{}
|
||||
server := chatTestServer([]string{"alpha", "beta"}, func(model string) {
|
||||
requestedModels = append(requestedModels, model)
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
var out bytes.Buffer
|
||||
err := Run(GinkgoT().Context(), Options{
|
||||
Model: "alpha",
|
||||
BaseURL: server.URL + "/v1",
|
||||
In: strings.NewReader("/models\n/model beta\nhello\n/exit\n"),
|
||||
Out: &out,
|
||||
})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out.String()).To(ContainSubstring("* alpha"))
|
||||
Expect(out.String()).To(ContainSubstring(" beta"))
|
||||
Expect(out.String()).To(ContainSubstring("switched to beta; conversation cleared"))
|
||||
Expect(requestedModels).To(Equal([]string{"beta"}))
|
||||
})
|
||||
})
|
||||
|
||||
func chatTestServer(models []string, onChat func(model string)) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/models":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
writeResponse(w, `{"object":"list","data":[`)
|
||||
for i, model := range models {
|
||||
if i > 0 {
|
||||
writeResponse(w, ",")
|
||||
}
|
||||
writeResponsef(w, `{"id":%q,"object":"model"}`, model)
|
||||
}
|
||||
writeResponse(w, `]}`)
|
||||
case "/v1/chat/completions":
|
||||
var body struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed())
|
||||
if onChat != nil {
|
||||
onChat(body.Model)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
writeResponse(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"}}]}\n\n")
|
||||
writeResponse(w, "data: [DONE]\n\n")
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func writeResponse(w io.Writer, text string) {
|
||||
_, err := fmt.Fprint(w, text)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
|
||||
func writeResponsef(w io.Writer, format string, args ...any) {
|
||||
_, err := fmt.Fprintf(w, format, args...)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
openai "github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
type chatClient interface {
|
||||
ListModels(ctx context.Context) ([]string, error)
|
||||
StreamChat(ctx context.Context, model string, messages []chatMessage, out io.Writer) (string, error)
|
||||
}
|
||||
|
||||
type localAIChatClient struct {
|
||||
client *openai.Client
|
||||
}
|
||||
|
||||
func newLocalAIChatClient(baseURL string, apiKey string) *localAIChatClient {
|
||||
cfg := openai.DefaultConfig(apiKey)
|
||||
cfg.BaseURL = baseURL
|
||||
return &localAIChatClient{client: openai.NewClientWithConfig(cfg)}
|
||||
}
|
||||
|
||||
func (c *localAIChatClient) ListModels(ctx context.Context) ([]string, error) {
|
||||
resp, err := c.client.ListModels(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
models := make([]string, 0, len(resp.Models))
|
||||
for _, model := range resp.Models {
|
||||
if model.ID != "" {
|
||||
models = append(models, model.ID)
|
||||
}
|
||||
}
|
||||
sort.Strings(models)
|
||||
return models, nil
|
||||
}
|
||||
|
||||
func (c *localAIChatClient) StreamChat(ctx context.Context, model string, messages []chatMessage, out io.Writer) (string, error) {
|
||||
stream, err := c.client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{
|
||||
Model: model,
|
||||
Messages: openAIChatMessages(messages),
|
||||
})
|
||||
if err != nil {
|
||||
return "", friendlyChatError(err, model)
|
||||
}
|
||||
defer func() {
|
||||
_ = stream.Close()
|
||||
}()
|
||||
|
||||
var answer strings.Builder
|
||||
for {
|
||||
resp, err := stream.Recv()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return answer.String(), friendlyChatError(err, model)
|
||||
}
|
||||
if len(resp.Choices) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
token := resp.Choices[0].Delta.Content
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
answer.WriteString(token)
|
||||
if _, err := fmt.Fprint(out, token); err != nil {
|
||||
return answer.String(), err
|
||||
}
|
||||
}
|
||||
|
||||
return answer.String(), nil
|
||||
}
|
||||
|
||||
func openAIChatMessages(messages []chatMessage) []openai.ChatCompletionMessage {
|
||||
converted := make([]openai.ChatCompletionMessage, len(messages))
|
||||
for i, message := range messages {
|
||||
converted[i] = openai.ChatCompletionMessage{
|
||||
Role: message.Role,
|
||||
Content: message.Content,
|
||||
}
|
||||
}
|
||||
return converted
|
||||
}
|
||||
|
||||
func friendlyChatError(err error, model string) error {
|
||||
var apiErr *openai.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
switch apiErr.HTTPStatusCode {
|
||||
case 404:
|
||||
return fmt.Errorf("model %q is not available. Run `local-ai models list`, install a model with `local-ai models install <model>`, or switch with `/model <name>`", model)
|
||||
case 403:
|
||||
return fmt.Errorf("model %q is disabled. Enable it from LocalAI settings or choose another model with `/model <name>`", model)
|
||||
}
|
||||
if apiErr.Message != "" {
|
||||
return errors.New(apiErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "model") && strings.Contains(msg, "not found") {
|
||||
return fmt.Errorf("model %q is not available. Run `local-ai models list`, install a model with `local-ai models install <model>`, or switch with `/model <name>`", model)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package chat
|
||||
|
||||
import "strings"
|
||||
|
||||
func formatChatModelList(models []string, current string) string {
|
||||
var b strings.Builder
|
||||
for _, model := range models {
|
||||
prefix := " "
|
||||
if model == current {
|
||||
prefix = "* "
|
||||
}
|
||||
b.WriteString(prefix)
|
||||
b.WriteString(model)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
153
core/cli/chat/paths.go
Normal file
153
core/cli/chat/paths.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// stateDirMode matches the mode nib uses for the same directory. The directory
|
||||
// holds an API key, so it stays owner-only.
|
||||
const stateDirMode = 0o700
|
||||
|
||||
// configFileMode keeps the config owner-only: nib stores the user's API key in
|
||||
// it alongside the keys written here.
|
||||
const configFileMode = 0o600
|
||||
|
||||
// StateDir resolves where the chat agent keeps its config, plugins, and
|
||||
// skills. This is user-scoped rather than server-scoped: chat is a client that
|
||||
// may target a remote LocalAI, so it does not belong under LOCALAI_CONFIG_DIR.
|
||||
func StateDir(override string) (string, error) {
|
||||
if override != "" {
|
||||
return override, nil
|
||||
}
|
||||
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
|
||||
return filepath.Join(xdg, "localai", "chat"), nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolving home directory for the agent state dir: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".config", "localai", "chat"), nil
|
||||
}
|
||||
|
||||
// ConfigPath is the agent's config file inside dir.
|
||||
func ConfigPath(dir string) string { return filepath.Join(dir, "config.yaml") }
|
||||
|
||||
// EnsureStateDir creates dir and, on first run only, seeds a config file
|
||||
// pointing at baseURL. It deliberately does not seed a model: a baked-in model
|
||||
// name goes stale as soon as the user installs a different one.
|
||||
//
|
||||
// The config file is machine-managed from here on: nib rewrites it whenever it
|
||||
// self-configures, so hand-written comments in it do not survive.
|
||||
func EnsureStateDir(dir, baseURL string) error {
|
||||
if err := os.MkdirAll(dir, stateDirMode); err != nil {
|
||||
return fmt.Errorf("creating agent state dir %s: %w", dir, err)
|
||||
}
|
||||
path := ConfigPath(dir)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return nil // already configured; never overwrite the user's file
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("checking agent config %s: %w", path, err)
|
||||
}
|
||||
|
||||
seed := map[string]string{"base_url": baseURL}
|
||||
data, err := yaml.Marshal(seed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encoding seed agent config: %w", err)
|
||||
}
|
||||
if err := writeConfigFile(path, data); err != nil {
|
||||
return fmt.Errorf("writing seed agent config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PersistModel records the chosen model in the agent config, preserving every
|
||||
// other key the user may have set, including the api_key nib writes there.
|
||||
//
|
||||
// The file is machine-managed: this overlays the model onto the parsed keys and
|
||||
// re-marshals, which drops comments. That is deliberate rather than an
|
||||
// oversight, because nib's own save path does the same thing and would erase
|
||||
// them on its next write regardless.
|
||||
func PersistModel(dir, model string) error {
|
||||
// PersistModel is callable before EnsureStateDir, so it cannot assume the
|
||||
// directory exists.
|
||||
if err := os.MkdirAll(dir, stateDirMode); err != nil {
|
||||
return fmt.Errorf("creating agent state dir %s: %w", dir, err)
|
||||
}
|
||||
path := ConfigPath(dir)
|
||||
|
||||
values := map[string]any{}
|
||||
// #nosec G304 -- path is the fixed config.yaml name under the user-selected
|
||||
// chat state directory; selecting that directory is the documented override.
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("reading agent config %s: %w", path, err)
|
||||
}
|
||||
if err == nil {
|
||||
if err := yaml.Unmarshal(data, &values); err != nil {
|
||||
return fmt.Errorf("parsing agent config %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
values["model"] = model
|
||||
|
||||
out, err := yaml.Marshal(values)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encoding agent config: %w", err)
|
||||
}
|
||||
if err := writeConfigFile(path, out); err != nil {
|
||||
return fmt.Errorf("writing agent config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeConfigFile replaces path with data atomically: it writes a temporary
|
||||
// file next to the target and renames it over the target. Writing the target in
|
||||
// place would truncate it first, so an interrupted or out-of-disk write would
|
||||
// leave a half-written config and destroy the api_key nib keeps in the same
|
||||
// file. The temporary file must share the directory because rename is only
|
||||
// atomic within one filesystem.
|
||||
func writeConfigFile(path string, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
|
||||
// A randomized name rather than a fixed config.yaml.tmp, so two concurrent
|
||||
// writers cannot corrupt each other's temporary file.
|
||||
tmp, err := os.CreateTemp(dir, "config.yaml.*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating temp file in %s: %w", dir, err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
renamed := false
|
||||
defer func() {
|
||||
if !renamed {
|
||||
// Leave no litter behind on any failure path.
|
||||
_ = os.Remove(tmpPath)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("writing %s: %w", tmpPath, err)
|
||||
}
|
||||
// Flush before the rename: renaming a file whose contents are still only in
|
||||
// the page cache can still lose them across a crash.
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("syncing %s: %w", tmpPath, err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("closing %s: %w", tmpPath, err)
|
||||
}
|
||||
// CreateTemp already asks for 0600, but the umask can only ever clear bits,
|
||||
// so set the mode explicitly rather than inheriting whatever survived.
|
||||
if err := os.Chmod(tmpPath, configFileMode); err != nil {
|
||||
return fmt.Errorf("setting mode on %s: %w", tmpPath, err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
return fmt.Errorf("replacing %s: %w", path, err)
|
||||
}
|
||||
renamed = true
|
||||
return nil
|
||||
}
|
||||
186
core/cli/chat/paths_test.go
Normal file
186
core/cli/chat/paths_test.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// richConfig stands in for a config nib has already taken ownership of: a
|
||||
// comment, a secret, and a nested block. A flat scalar alone would not catch a
|
||||
// writer that mangles structure or drops a key it does not know about.
|
||||
const richConfig = `# hand written note
|
||||
base_url: http://x.invalid/v1
|
||||
api_key: secret-token
|
||||
mcp_servers:
|
||||
files:
|
||||
command: mcp-files
|
||||
args:
|
||||
- --root
|
||||
- /tmp
|
||||
`
|
||||
|
||||
var _ = Describe("Agent state directory", func() {
|
||||
Describe("StateDir", func() {
|
||||
It("prefers an explicit override", func() {
|
||||
Expect(StateDir("/custom/dir")).To(Equal("/custom/dir"))
|
||||
})
|
||||
|
||||
It("uses XDG_CONFIG_HOME when set", func() {
|
||||
tmp := GinkgoT().TempDir()
|
||||
GinkgoT().Setenv("XDG_CONFIG_HOME", tmp)
|
||||
Expect(StateDir("")).To(Equal(filepath.Join(tmp, "localai", "chat")))
|
||||
})
|
||||
|
||||
It("falls back to ~/.config/localai/chat", func() {
|
||||
tmp := GinkgoT().TempDir()
|
||||
GinkgoT().Setenv("XDG_CONFIG_HOME", "")
|
||||
GinkgoT().Setenv("HOME", tmp)
|
||||
Expect(StateDir("")).To(Equal(filepath.Join(tmp, ".config", "localai", "chat")))
|
||||
})
|
||||
|
||||
It("fails when neither XDG_CONFIG_HOME nor a home directory is resolvable", func() {
|
||||
GinkgoT().Setenv("XDG_CONFIG_HOME", "")
|
||||
GinkgoT().Setenv("HOME", "")
|
||||
|
||||
dir, err := StateDir("")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("agent state dir"))
|
||||
// No silent fallback to a relative path: writing an API key into the
|
||||
// working directory would be worse than refusing.
|
||||
Expect(dir).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("EnsureStateDir", func() {
|
||||
It("creates the directory and seeds base_url on first run", func() {
|
||||
dir := filepath.Join(GinkgoT().TempDir(), "chat")
|
||||
Expect(EnsureStateDir(dir, "http://127.0.0.1:8080/v1")).To(Succeed())
|
||||
|
||||
data, err := os.ReadFile(ConfigPath(dir))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(ContainSubstring("base_url: http://127.0.0.1:8080/v1"))
|
||||
// A model must NOT be seeded: it goes stale as soon as the user
|
||||
// installs a different one.
|
||||
Expect(string(data)).ToNot(ContainSubstring("model:"))
|
||||
})
|
||||
|
||||
It("keeps the seeded config and its directory owner-only", func() {
|
||||
dir := filepath.Join(GinkgoT().TempDir(), "chat")
|
||||
Expect(EnsureStateDir(dir, "http://127.0.0.1:8080/v1")).To(Succeed())
|
||||
|
||||
// nib writes the user's api_key into this same file, so the modes are
|
||||
// load-bearing, not cosmetic.
|
||||
config, err := os.Stat(ConfigPath(dir))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(config.Mode().Perm()).To(Equal(os.FileMode(0o600)))
|
||||
|
||||
state, err := os.Stat(dir)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(state.Mode().Perm()).To(Equal(os.FileMode(0o700)))
|
||||
})
|
||||
|
||||
It("leaves an existing config byte-for-byte untouched", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
Expect(os.WriteFile(ConfigPath(dir), []byte(richConfig), 0o600)).To(Succeed())
|
||||
|
||||
Expect(EnsureStateDir(dir, "http://127.0.0.1:8080/v1")).To(Succeed())
|
||||
|
||||
data, err := os.ReadFile(ConfigPath(dir))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Byte-exact against a fixture carrying a comment and a nested block:
|
||||
// an implementation that "preserves" by re-marshaling through a map
|
||||
// fails here rather than passing on a flat scalar.
|
||||
Expect(string(data)).To(Equal(richConfig))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PersistModel", func() {
|
||||
It("adds a model to an existing config, preserving other keys", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
Expect(os.WriteFile(ConfigPath(dir), []byte("base_url: http://x.invalid/v1\n"), 0o600)).To(Succeed())
|
||||
|
||||
Expect(PersistModel(dir, "chosen-model")).To(Succeed())
|
||||
|
||||
data, err := os.ReadFile(ConfigPath(dir))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(ContainSubstring("base_url: http://x.invalid/v1"))
|
||||
Expect(string(data)).To(ContainSubstring("model: chosen-model"))
|
||||
})
|
||||
|
||||
It("replaces an existing model rather than duplicating the key", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
Expect(os.WriteFile(ConfigPath(dir), []byte("model: old\nbase_url: http://x.invalid/v1\n"), 0o600)).To(Succeed())
|
||||
|
||||
Expect(PersistModel(dir, "new")).To(Succeed())
|
||||
|
||||
data, err := os.ReadFile(ConfigPath(dir))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(ContainSubstring("model: new"))
|
||||
Expect(string(data)).ToNot(ContainSubstring("model: old"))
|
||||
})
|
||||
|
||||
It("preserves secrets and nested blocks it does not understand", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
Expect(os.WriteFile(ConfigPath(dir), []byte(richConfig), 0o600)).To(Succeed())
|
||||
|
||||
Expect(PersistModel(dir, "chosen-model")).To(Succeed())
|
||||
|
||||
data, err := os.ReadFile(ConfigPath(dir))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
var got map[string]any
|
||||
Expect(yaml.Unmarshal(data, &got)).To(Succeed())
|
||||
Expect(got).To(HaveKeyWithValue("model", "chosen-model"))
|
||||
Expect(got).To(HaveKeyWithValue("base_url", "http://x.invalid/v1"))
|
||||
// Losing this key logs the user out of their own server.
|
||||
Expect(got).To(HaveKeyWithValue("api_key", "secret-token"))
|
||||
Expect(got).To(HaveKeyWithValue("mcp_servers",
|
||||
HaveKeyWithValue("files", And(
|
||||
HaveKeyWithValue("command", "mcp-files"),
|
||||
HaveKeyWithValue("args", ConsistOf("--root", "/tmp")),
|
||||
)),
|
||||
))
|
||||
|
||||
// Documented, accepted behavior rather than an aspiration: the overlay
|
||||
// re-marshals, so comments do not survive. nib's own save path erases
|
||||
// them too, so preserving them here would buy nothing.
|
||||
Expect(string(data)).ToNot(ContainSubstring("# hand written note"))
|
||||
})
|
||||
|
||||
It("keeps the rewritten config owner-only and leaves no temp file behind", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
Expect(os.WriteFile(ConfigPath(dir), []byte(richConfig), 0o600)).To(Succeed())
|
||||
|
||||
Expect(PersistModel(dir, "chosen-model")).To(Succeed())
|
||||
|
||||
info, err := os.Stat(ConfigPath(dir))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(info.Mode().Perm()).To(Equal(os.FileMode(0o600)))
|
||||
|
||||
// The atomic write stages through a sibling temp file; it must not
|
||||
// survive a successful write.
|
||||
entries, err := os.ReadDir(dir)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
names := []string{}
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
Expect(names).To(ConsistOf("config.yaml"))
|
||||
})
|
||||
|
||||
It("creates the state directory when it does not exist yet", func() {
|
||||
// Task 4 may persist a picked model before anything else has run.
|
||||
dir := filepath.Join(GinkgoT().TempDir(), "chat")
|
||||
|
||||
Expect(PersistModel(dir, "chosen-model")).To(Succeed())
|
||||
|
||||
data, err := os.ReadFile(ConfigPath(dir))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(ContainSubstring("model: chosen-model"))
|
||||
})
|
||||
})
|
||||
})
|
||||
86
core/cli/chat/probe.go
Normal file
86
core/cli/chat/probe.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
openai "github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrUnreachable means nothing answered at the endpoint. Callers use this
|
||||
// to decide whether offering to start a server makes sense.
|
||||
ErrUnreachable = errors.New("no LocalAI server reachable")
|
||||
// ErrUnauthorized means the server answered but rejected the credentials.
|
||||
ErrUnauthorized = errors.New("LocalAI server rejected the API key")
|
||||
)
|
||||
|
||||
// Probe lists the models the endpoint advertises. It classifies the two
|
||||
// failures that need different advice: nothing listening, and bad credentials.
|
||||
//
|
||||
// The returned list is what the server advertises, verbatim and in server
|
||||
// order. LocalAI happily lists non-model entries it finds in the models
|
||||
// directory (stray archives, dotfiles), and guessing which advertised IDs are
|
||||
// real belongs to whoever presents them, not here.
|
||||
func Probe(ctx context.Context, baseURL, apiKey string) ([]string, error) {
|
||||
cfg := openai.DefaultConfig(apiKey)
|
||||
cfg.BaseURL = baseURL
|
||||
|
||||
resp, err := openai.NewClientWithConfig(cfg).ListModels(ctx)
|
||||
if err != nil {
|
||||
if status, answered := responseStatus(err); answered {
|
||||
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||||
return nil, fmt.Errorf("%w: %w", ErrUnauthorized, err)
|
||||
}
|
||||
// The server answered, so it is up; surface its error as-is.
|
||||
return nil, fmt.Errorf("listing models at %s: %w", baseURL, err)
|
||||
}
|
||||
// A caller who cancelled the probe learned nothing about the endpoint,
|
||||
// so claiming it is unreachable would send them to fix a server that
|
||||
// may be fine. A deadline is left alone: an endpoint that cannot answer
|
||||
// within the probe's budget is unreachable for our purposes.
|
||||
var urlErr *url.Error
|
||||
if errors.As(err, &urlErr) && !errors.Is(err, context.Canceled) {
|
||||
// Only a failure to complete the round trip means nothing is
|
||||
// listening. A reply we could not parse is a different problem,
|
||||
// so it falls through to the generic error below.
|
||||
return nil, fmt.Errorf("%w at %s: %w", ErrUnreachable, baseURL, err)
|
||||
}
|
||||
return nil, fmt.Errorf("listing models at %s: %w", baseURL, err)
|
||||
}
|
||||
|
||||
models := make([]string, 0, len(resp.Models))
|
||||
for _, m := range resp.Models {
|
||||
if m.ID != "" {
|
||||
models = append(models, m.ID)
|
||||
}
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// responseStatus reports the HTTP status a failed call came back with, and
|
||||
// whether there was one at all.
|
||||
//
|
||||
// go-openai splits this across two types depending on the error body, and both
|
||||
// occur against a real LocalAI: it returns *openai.APIError when the body
|
||||
// parses as an OpenAI error envelope, which is what LocalAI's normal error
|
||||
// handler sends, and *openai.RequestError when it does not, which is what
|
||||
// LocalAI sends when started with opaque errors, since that handler replies
|
||||
// with a bare status and no body.
|
||||
func responseStatus(err error) (int, bool) {
|
||||
// *RequestError is checked first because it is the outer type when
|
||||
// go-openai nests one error inside the other; the inner value in that case
|
||||
// carries no status.
|
||||
var reqErr *openai.RequestError
|
||||
if errors.As(err, &reqErr) {
|
||||
return reqErr.HTTPStatusCode, true
|
||||
}
|
||||
var apiErr *openai.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
return apiErr.HTTPStatusCode, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
169
core/cli/chat/probe_test.go
Normal file
169
core/cli/chat/probe_test.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Probe", func() {
|
||||
It("returns the advertised models", func() {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
Expect(json.NewEncoder(w).Encode(map[string]any{
|
||||
"object": "list",
|
||||
"data": []map[string]string{
|
||||
{"id": "model-a", "object": "model"},
|
||||
{"id": "model-b", "object": "model"},
|
||||
},
|
||||
})).To(Succeed())
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
models, err := Probe(context.Background(), srv.URL+"/v1", "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(models).To(Equal([]string{"model-a", "model-b"}))
|
||||
})
|
||||
|
||||
It("reports an unreachable server distinguishably", func() {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
url := srv.URL
|
||||
srv.Close() // nothing is listening now
|
||||
|
||||
_, err := Probe(context.Background(), url+"/v1", "")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(errors.Is(err, ErrUnreachable)).To(BeTrue(), "want ErrUnreachable, got %v", err)
|
||||
})
|
||||
|
||||
It("reports an auth failure distinguishably", func() {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := Probe(context.Background(), srv.URL+"/v1", "bad-key")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(errors.Is(err, ErrUnauthorized)).To(BeTrue(), "want ErrUnauthorized, got %v", err)
|
||||
})
|
||||
|
||||
// LocalAI's normal error handler replies with an OpenAI error envelope, and
|
||||
// its opaque-errors handler replies with a bare status and no body. Those
|
||||
// reach the client as two different go-openai types, so both have to be
|
||||
// classified the same way.
|
||||
It("reports an auth failure carrying an error envelope distinguishably", func() {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
Expect(json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": map[string]any{"message": "invalid api key", "code": http.StatusUnauthorized},
|
||||
})).To(Succeed())
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := Probe(context.Background(), srv.URL+"/v1", "bad-key")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(errors.Is(err, ErrUnauthorized)).To(BeTrue(), "want ErrUnauthorized, got %v", err)
|
||||
})
|
||||
|
||||
It("does not call a server that answered with an error unreachable", func() {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := Probe(context.Background(), srv.URL+"/v1", "")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(errors.Is(err, ErrUnreachable)).To(BeFalse(), "a server that replied is not unreachable, got %v", err)
|
||||
Expect(errors.Is(err, ErrUnauthorized)).To(BeFalse(), "500 is not an auth failure, got %v", err)
|
||||
})
|
||||
|
||||
// Pointing chat at some other service that happens to be listening is a
|
||||
// different problem from nothing listening, and needs different advice.
|
||||
It("does not call a reply it could not parse unreachable", func() {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, err := w.Write([]byte("<html><body>not LocalAI</body></html>"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := Probe(context.Background(), srv.URL+"/v1", "")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(errors.Is(err, ErrUnreachable)).To(BeFalse(), "something answered, got %v", err)
|
||||
})
|
||||
|
||||
It("returns every advertised id, including ones that are not models", func() {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
Expect(json.NewEncoder(w).Encode(map[string]any{
|
||||
"object": "list",
|
||||
"data": []map[string]string{
|
||||
{"id": "zeta", "object": "model"},
|
||||
{"id": ".gitignore", "object": "model"},
|
||||
{"id": "alpha", "object": "model"},
|
||||
{"id": "voice.tar.bz2", "object": "model"},
|
||||
},
|
||||
})).To(Succeed())
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Verbatim and in server order: deciding which of these are real, and
|
||||
// what order to show them in, belongs to the caller.
|
||||
models, err := Probe(context.Background(), srv.URL+"/v1", "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(models).To(Equal([]string{"zeta", ".gitignore", "alpha", "voice.tar.bz2"}))
|
||||
})
|
||||
|
||||
It("stops early when the context is already cancelled", func() {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
Expect(json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": []any{}})).To(Succeed())
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, err := Probe(ctx, srv.URL+"/v1", "")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(errors.Is(err, context.Canceled)).To(BeTrue(), "want the cancellation preserved, got %v", err)
|
||||
// A cancelled probe learned nothing about the endpoint, so it must not
|
||||
// send the caller off to start a server that may already be running.
|
||||
Expect(errors.Is(err, ErrUnreachable)).To(BeFalse(), "cancelling is not a verdict on the server, got %v", err)
|
||||
})
|
||||
|
||||
It("reports a server that never answers as unreachable", func() {
|
||||
release := make(chan struct{})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-release
|
||||
}))
|
||||
defer srv.Close()
|
||||
defer close(release)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, err := Probe(ctx, srv.URL+"/v1", "")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(errors.Is(err, ErrUnreachable)).To(BeTrue(), "want ErrUnreachable, got %v", err)
|
||||
Expect(errors.Is(err, context.DeadlineExceeded)).To(BeTrue(), "want the deadline preserved, got %v", err)
|
||||
})
|
||||
|
||||
It("returns an empty list when the server has no models", func() {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
Expect(json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": []any{}})).To(Succeed())
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
models, err := Probe(context.Background(), srv.URL+"/v1", "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(models).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
95
core/cli/chat/resolve.go
Normal file
95
core/cli/chat/resolve.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/mudler/xlog"
|
||||
)
|
||||
|
||||
// ModelChooser asks the user to pick one of models. It is nil when the session
|
||||
// is not interactive.
|
||||
type ModelChooser func(models []string) (string, error)
|
||||
|
||||
// ModelRequest is everything model resolution needs.
|
||||
type ModelRequest struct {
|
||||
Flag string // --model
|
||||
Configured string // model recorded in the agent config
|
||||
Available []string // models the server advertises
|
||||
StateDir string // where an interactive choice is persisted
|
||||
Choose ModelChooser // nil means non-interactive
|
||||
// Notify reports a problem that is worth telling the user about but not
|
||||
// worth failing over. Nil discards it. It exists because the one such
|
||||
// problem here, a choice that could not be saved, changes what the user
|
||||
// should expect next: they will be asked again. A log line does not reach
|
||||
// them, since the agent runs at log level error by default.
|
||||
Notify func(message string)
|
||||
}
|
||||
|
||||
// ResolveModel picks the model for this invocation. A flag or a configured
|
||||
// value wins outright and is not persisted; only an interactive choice is
|
||||
// written back, so the prompt appears at most once.
|
||||
//
|
||||
// Available is used exactly as the server gave it. LocalAI advertises stray
|
||||
// files it finds in the models directory alongside real models, but real model
|
||||
// IDs contain dots too (lfm2.5-8b-a1b), so any client-side "looks like a
|
||||
// filename" heuristic would eventually hide a model the user has. Deciding
|
||||
// which advertised IDs are real belongs to the endpoint, not to a guess here.
|
||||
func ResolveModel(req ModelRequest) (string, error) {
|
||||
if req.Flag != "" {
|
||||
return req.Flag, nil
|
||||
}
|
||||
if req.Configured != "" {
|
||||
return req.Configured, nil
|
||||
}
|
||||
|
||||
// The server's /v1/models ordering is not stable between calls, so sort
|
||||
// before showing or listing: the same number must mean the same model on
|
||||
// the next run. Sort a copy; the caller's slice is not ours to reorder.
|
||||
available := append([]string(nil), req.Available...)
|
||||
sort.Strings(available)
|
||||
|
||||
switch len(available) {
|
||||
case 0:
|
||||
return "", errors.New("the LocalAI server has no models installed. Install one with 'local-ai models install <name>', then run 'local-ai chat' again")
|
||||
case 1:
|
||||
return available[0], nil
|
||||
}
|
||||
|
||||
if req.Choose == nil {
|
||||
return "", fmt.Errorf(
|
||||
"several models are available; pick one with --model. Available: %s",
|
||||
strings.Join(available, ", "),
|
||||
)
|
||||
}
|
||||
|
||||
chosen, err := req.Choose(available)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Choose is an interface, so its answer is checked rather than trusted.
|
||||
// What comes back is persisted and every later run starts against it, so a
|
||||
// chooser that returns an empty string or a name of its own would record a
|
||||
// model the server never offered and there would be nothing left to catch
|
||||
// it.
|
||||
if !slices.Contains(available, chosen) {
|
||||
return "", fmt.Errorf(
|
||||
"the model chooser answered %q, which is not one of the available models: %s",
|
||||
chosen, strings.Join(available, ", "),
|
||||
)
|
||||
}
|
||||
if req.StateDir != "" {
|
||||
if err := PersistModel(req.StateDir, chosen); err != nil {
|
||||
// A failure to remember the choice must not block the session: the
|
||||
// user picked a model, so honour it and say what will happen.
|
||||
xlog.Warn("could not save the model choice", "error", err, "model", chosen)
|
||||
if req.Notify != nil {
|
||||
req.Notify(fmt.Sprintf("Your choice of %s could not be saved, so this question comes back next time: %v", chosen, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
return chosen, nil
|
||||
}
|
||||
156
core/cli/chat/resolve_test.go
Normal file
156
core/cli/chat/resolve_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ResolveModel", func() {
|
||||
It("prefers the flag over everything", func() {
|
||||
got, err := ResolveModel(ModelRequest{
|
||||
Flag: "from-flag",
|
||||
Configured: "from-config",
|
||||
Available: []string{"a", "b"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got).To(Equal("from-flag"))
|
||||
})
|
||||
|
||||
It("uses the configured model when no flag is given", func() {
|
||||
got, err := ResolveModel(ModelRequest{
|
||||
Configured: "from-config",
|
||||
Available: []string{"a", "b"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got).To(Equal("from-config"))
|
||||
})
|
||||
|
||||
It("auto-selects when the server offers exactly one model", func() {
|
||||
got, err := ResolveModel(ModelRequest{Available: []string{"only-one"}})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got).To(Equal("only-one"))
|
||||
})
|
||||
|
||||
It("errors and lists the options when several models exist and there is no chooser", func() {
|
||||
_, err := ResolveModel(ModelRequest{Available: []string{"a", "b"}})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("a"))
|
||||
Expect(err.Error()).To(ContainSubstring("b"))
|
||||
Expect(err.Error()).To(ContainSubstring("--model"))
|
||||
})
|
||||
|
||||
It("sorts before offering, so the same number means the same model next run", func() {
|
||||
var offered []string
|
||||
available := []string{"zeta", "alpha", "mid"}
|
||||
_, err := ResolveModel(ModelRequest{
|
||||
Available: available,
|
||||
StateDir: GinkgoT().TempDir(),
|
||||
Choose: func(models []string) (string, error) {
|
||||
offered = models
|
||||
return models[0], nil
|
||||
},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// The server's /v1/models ordering is unstable between calls.
|
||||
Expect(offered).To(Equal([]string{"alpha", "mid", "zeta"}))
|
||||
// Sorting must happen on a copy: the caller still owns this slice, and
|
||||
// reordering it under them would move whatever they index into it.
|
||||
Expect(available).To(Equal([]string{"zeta", "alpha", "mid"}))
|
||||
})
|
||||
|
||||
It("lists models in sorted order in the several-models error", func() {
|
||||
_, err := ResolveModel(ModelRequest{Available: []string{"zeta", "alpha"}})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("alpha, zeta"))
|
||||
})
|
||||
|
||||
It("asks the chooser when several models exist, and persists the answer", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
got, err := ResolveModel(ModelRequest{
|
||||
Available: []string{"a", "b"},
|
||||
StateDir: dir,
|
||||
Choose: func(models []string) (string, error) { return models[1], nil },
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got).To(Equal("b"))
|
||||
|
||||
data, err := os.ReadFile(ConfigPath(dir))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(ContainSubstring("model: b"))
|
||||
})
|
||||
|
||||
// The answer is persisted and every later run starts against it, and
|
||||
// ModelChooser is exported, so the invariant has to hold for choosers this
|
||||
// package did not write.
|
||||
DescribeTable("refuses an answer the chooser was not offered",
|
||||
func(answer string) {
|
||||
dir := GinkgoT().TempDir()
|
||||
got, err := ResolveModel(ModelRequest{
|
||||
Available: []string{"alpha", "zeta"},
|
||||
StateDir: dir,
|
||||
Choose: func([]string) (string, error) { return answer, nil },
|
||||
})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(got).To(BeEmpty())
|
||||
Expect(err.Error()).To(ContainSubstring("alpha, zeta"))
|
||||
|
||||
_, statErr := os.Stat(ConfigPath(dir))
|
||||
Expect(os.IsNotExist(statErr)).To(BeTrue(), "nothing may be recorded for an answer that was refused")
|
||||
},
|
||||
Entry("nothing at all", ""),
|
||||
Entry("a model the server never offered", "gamma"),
|
||||
Entry("an offered model with stray whitespace", " alpha"),
|
||||
Entry("an offered model in the wrong case", "Alpha"),
|
||||
)
|
||||
|
||||
It("notifies, and still honours the choice, when it cannot be persisted", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
// A directory where the config file belongs: the write fails for any
|
||||
// user, including root.
|
||||
Expect(os.MkdirAll(ConfigPath(dir), 0o700)).To(Succeed())
|
||||
|
||||
var notices []string
|
||||
got, err := ResolveModel(ModelRequest{
|
||||
Available: []string{"a", "b"},
|
||||
StateDir: dir,
|
||||
Choose: func(models []string) (string, error) { return models[0], nil },
|
||||
Notify: func(message string) { notices = append(notices, message) },
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got).To(Equal("a"))
|
||||
Expect(notices).To(HaveLen(1))
|
||||
Expect(notices[0]).To(ContainSubstring("a"))
|
||||
Expect(notices[0]).To(ContainSubstring("could not be saved"))
|
||||
})
|
||||
|
||||
It("says nothing when the choice was saved", func() {
|
||||
var notices []string
|
||||
_, err := ResolveModel(ModelRequest{
|
||||
Available: []string{"a", "b"},
|
||||
StateDir: GinkgoT().TempDir(),
|
||||
Choose: func(models []string) (string, error) { return models[0], nil },
|
||||
Notify: func(message string) { notices = append(notices, message) },
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(notices).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("propagates a chooser cancellation", func() {
|
||||
cancelled := errors.New("cancelled")
|
||||
_, err := ResolveModel(ModelRequest{
|
||||
Available: []string{"a", "b"},
|
||||
StateDir: GinkgoT().TempDir(),
|
||||
Choose: func([]string) (string, error) { return "", cancelled },
|
||||
})
|
||||
Expect(errors.Is(err, cancelled)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("errors with an install hint when the server has no models", func() {
|
||||
_, err := ResolveModel(ModelRequest{Available: nil})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("local-ai models install"))
|
||||
})
|
||||
})
|
||||
475
core/cli/chat/run.go
Normal file
475
core/cli/chat/run.go
Normal file
@@ -0,0 +1,475 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/nib/app"
|
||||
nibcmd "github.com/mudler/nib/cmd"
|
||||
nibconfig "github.com/mudler/nib/config"
|
||||
nibtypes "github.com/mudler/nib/types"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// Options is everything the chat command passes down from its flags.
|
||||
type Options struct {
|
||||
Args []string // forwarded to the agent verbatim
|
||||
Endpoint string // the server root, e.g. http://127.0.0.1:8080
|
||||
BaseURL string // the API base, e.g. http://127.0.0.1:8080/v1
|
||||
APIKey string
|
||||
Model string
|
||||
StateDir string
|
||||
TraceDir string
|
||||
Yolo bool
|
||||
// ProbeTimeout bounds each check of the server. Zero means
|
||||
// defaultProbeTimeout.
|
||||
ProbeTimeout time.Duration
|
||||
|
||||
In io.Reader
|
||||
Out io.Writer
|
||||
ErrOut io.Writer
|
||||
}
|
||||
|
||||
// ExitStatus reports the status the process should exit with for an agent run
|
||||
// that failed, and whether err is such a failure.
|
||||
//
|
||||
// nib writes what went wrong to the error stream itself and hands back nothing
|
||||
// but a code, so an error that satisfies this has already been explained to the
|
||||
// user and must not be reported a second time. The refusal to open a
|
||||
// full-screen session on a stdin that cannot be read arrives this way, and it
|
||||
// is the one a user is most likely to meet: 'echo q | local-ai chat' names
|
||||
// --cli, and burying that under a second message would hide the fix.
|
||||
func ExitStatus(err error) (int, bool) {
|
||||
var exit app.ExitError
|
||||
if errors.As(err, &exit) {
|
||||
return exit.Code, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// shutdownSignals end the session. SIGHUP is one of them because this is a
|
||||
// terminal program: once the terminal is gone there is nobody left to talk to,
|
||||
// and a server started for the session has to go with it.
|
||||
var shutdownSignals = []os.Signal{os.Interrupt, syscall.SIGTERM, syscall.SIGHUP}
|
||||
|
||||
// shutdownContext derives a context that is cancelled when the process is
|
||||
// asked to stop.
|
||||
//
|
||||
// Without it a signal kills this process where it stands, skipping every
|
||||
// deferred call, and a 'local-ai run' started for the session is reparented to
|
||||
// init with nothing left that knows to shut it down. An interactive Ctrl+C is
|
||||
// safe on its own, because the child shares this process' foreground process
|
||||
// group and the terminal signals all of it, but a SIGTERM from a supervisor or
|
||||
// a script reaches only this process.
|
||||
//
|
||||
// Since nib v0.5.1 cancelling this context does end the session: RunTUI passes
|
||||
// it to bubbletea, which unwinds the program and reports the context's own
|
||||
// error. The server is still stopped on cancellation rather than on the way
|
||||
// out (see runSession), because registering here removes SIGHUP's default
|
||||
// terminate disposition, and a guarantee about a server this process owns is
|
||||
// not worth resting on how promptly a third party unwinds its interface.
|
||||
//
|
||||
// A handler rather than SysProcAttr.Pdeathsig on the child: Pdeathsig is
|
||||
// Linux-only, and in Go it is delivered when the OS thread that forked exits
|
||||
// rather than when the process does, so it can fire on a perfectly healthy
|
||||
// parent. Setpgid is not an alternative either, since taking the child out of
|
||||
// the foreground process group is what would break the Ctrl+C that works
|
||||
// today. SIGKILL stays uncovered, as it must: nothing in the process can
|
||||
// observe it.
|
||||
func shutdownContext(parent context.Context) (context.Context, context.CancelFunc) {
|
||||
return signal.NotifyContext(parent, shutdownSignals...)
|
||||
}
|
||||
|
||||
// Run starts the agent: resolve where state lives, make sure a server is
|
||||
// reachable, pick a model, then hand off to nib.
|
||||
func Run(ctx context.Context, opts Options) error {
|
||||
ctx, stop := shutdownContext(ctx)
|
||||
defer stop()
|
||||
|
||||
p, err := prepare(ctx, opts, isTerminal(opts.In))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A server this process started belongs to this session, and Stop is
|
||||
// nil-safe and idempotent, so one defer covers both cases and costs nothing
|
||||
// when runSession has already stopped it.
|
||||
defer p.server.Stop()
|
||||
|
||||
return runSession(ctx, p.server, func(ctx context.Context) error {
|
||||
return runAgent(ctx, p.dir, p.model, opts)
|
||||
})
|
||||
}
|
||||
|
||||
// runSession hands the terminal to agent, and stops a server started for this
|
||||
// session as soon as the context is cancelled rather than when agent returns.
|
||||
//
|
||||
// The difference matters because the deferred Stop in Run is only reached once
|
||||
// agent returns, and how long that takes is nib's business rather than ours.
|
||||
// nib v0.5.1 does unwind the TUI on a cancelled context, so it does return; a
|
||||
// SIGHUP no longer leaves the interface on screen with the server behind it,
|
||||
// which it did before, when bubbletea's own SIGINT and SIGTERM handler was the
|
||||
// only thing that ever quit the program and registering for SIGHUP had removed
|
||||
// the default disposition that used to end the process. Watching the context
|
||||
// keeps the guarantee independent of what the agent does with it.
|
||||
func runSession(ctx context.Context, server *StartedServer, agent func(context.Context) error) error {
|
||||
returned := make(chan struct{})
|
||||
defer close(returned)
|
||||
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
server.Stop()
|
||||
case <-returned:
|
||||
}
|
||||
}()
|
||||
|
||||
return agent(ctx)
|
||||
}
|
||||
|
||||
// preparation is what the agent needs once the environment is ready: where its
|
||||
// state lives, which model to talk to, and the server this process started on
|
||||
// the user's behalf, if any.
|
||||
type preparation struct {
|
||||
dir string
|
||||
model string
|
||||
server *StartedServer
|
||||
}
|
||||
|
||||
// prepare does everything that has to happen before the agent takes over the
|
||||
// terminal. It is split out of Run because all of it is testable and none of
|
||||
// what follows is: once app.Run has the terminal there is no seam left.
|
||||
//
|
||||
// interactive says whether there is a user to prompt. It is a parameter rather
|
||||
// than a second read of opts.In so the prompts can be driven over a pipe.
|
||||
func prepare(ctx context.Context, opts Options, interactive bool) (_ *preparation, err error) {
|
||||
dir, dirErr := StateDir(opts.StateDir)
|
||||
if dirErr != nil {
|
||||
return nil, dirErr
|
||||
}
|
||||
if err := EnsureStateDir(dir, opts.BaseURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if isLocalOnlyArgs(opts.Args) {
|
||||
return &preparation{dir: dir}, nil
|
||||
}
|
||||
|
||||
// One prompter for every question this run asks; see its doc comment for
|
||||
// why the reader cannot be rebuilt per question.
|
||||
var prompts *prompter
|
||||
if interactive {
|
||||
prompts = newPrompter(opts.In, opts.ErrOut)
|
||||
}
|
||||
|
||||
var started *StartedServer
|
||||
defer func() {
|
||||
// Nothing after the spawn may leave a server behind: the caller only
|
||||
// learns about it through a successful return.
|
||||
if err != nil {
|
||||
started.Stop()
|
||||
}
|
||||
}()
|
||||
|
||||
models, err := probeModels(ctx, opts)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUnauthorized) {
|
||||
return nil, fmt.Errorf("the LocalAI server at %s rejected the API key. Pass --api-key or set LOCALAI_API_KEY", opts.Endpoint)
|
||||
}
|
||||
if !errors.Is(err, ErrUnreachable) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var confirm Confirmer
|
||||
if interactive {
|
||||
confirm = prompts.yesNo
|
||||
}
|
||||
var startErr error
|
||||
started, startErr = OfferToStart(ctx, StartOptions{
|
||||
Endpoint: opts.Endpoint,
|
||||
Confirm: confirm,
|
||||
Stderr: opts.ErrOut,
|
||||
})
|
||||
if startErr != nil {
|
||||
err = startErr
|
||||
if errors.Is(startErr, ErrDeclined) {
|
||||
err = fmt.Errorf("no LocalAI server at %s. Start one with 'local-ai run', or point elsewhere with --endpoint", opts.Endpoint)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
say(opts.ErrOut, "Started a temporary LocalAI server; it stops when you exit. Use 'local-ai run' for a persistent one.\n")
|
||||
|
||||
if models, err = probeModels(ctx, opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var chooser ModelChooser
|
||||
if interactive {
|
||||
chooser = prompts.choose
|
||||
}
|
||||
model, err := ResolveModel(ModelRequest{
|
||||
Flag: opts.Model,
|
||||
Configured: configuredModel(dir),
|
||||
Available: models,
|
||||
StateDir: dir,
|
||||
Choose: chooser,
|
||||
Notify: func(message string) { say(opts.ErrOut, "%s\n", message) },
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &preparation{dir: dir, model: model, server: started}, nil
|
||||
}
|
||||
|
||||
func runAgent(ctx context.Context, dir, model string, opts Options) error {
|
||||
return app.Run(ctx, agentOptions(dir, model, opts))
|
||||
}
|
||||
|
||||
// agentOptions builds the request handed to nib. It is split out of runAgent
|
||||
// because app.Run takes the terminal and cannot be called from a test, while
|
||||
// what is asked of it is exactly the part worth pinning.
|
||||
//
|
||||
// The stream fields are the interesting ones, and they are not symmetric.
|
||||
//
|
||||
// nib reads a non-nil stream as "the embedder wants this used", and refuses
|
||||
// every mode but --cli when such a stream is not a terminal, because the
|
||||
// full-screen interface renders on /dev/tty and would otherwise ignore it in
|
||||
// silence. Nil means "not injected": nib falls back to the process stream and
|
||||
// behaves as standalone nib does.
|
||||
//
|
||||
// Stdin is passed through as it comes. A piped or redirected stdin really is
|
||||
// ignored by the interface, so the refusal is the honest answer there, and it
|
||||
// is the one users meet: 'echo q | local-ai chat' says to re-run with --cli
|
||||
// rather than opening a full-screen session that will never read the question.
|
||||
//
|
||||
// Stdout is different, and the process stream is deliberately sent as nil. The
|
||||
// interface does write to stdout even when it is a pipe: that is the whole of
|
||||
// nib's shell-capture idiom, out=$(local-ai chat --height 50%), which is what
|
||||
// the Ctrl+Space widget emitted by --init is built on. Injecting os.Stdout
|
||||
// there would refuse the widget for a stream nib was going to use anyway.
|
||||
//
|
||||
// The test is identity with os.Stdout rather than whether it happens to be a
|
||||
// terminal, which means a shell redirect goes the same way as the widget:
|
||||
// 'local-ai chat > out.txt' no longer refuses either, and renders on /dev/tty
|
||||
// with the capture line landing in the file. That is not a second decision, it
|
||||
// is the same one. Both are the process stdout as the shell handed it over,
|
||||
// differing only in being a pipe rather than a regular file, which nib's gate
|
||||
// does not look at and should not. Refusing one would refuse the other.
|
||||
//
|
||||
// What stays injected, and so stays subject to the refusal, is a writer some
|
||||
// in-process caller chose for itself rather than inherited: a bytes.Buffer, or
|
||||
// an *os.File it opened. The specs rely on that.
|
||||
//
|
||||
// Stderr is never gated by nib, so it is passed through unchanged.
|
||||
//
|
||||
// The config values go through Overrides rather than Defaults, and that is not
|
||||
// a detail. Defaults are seeds: they sit BENEATH the config file, so the file
|
||||
// silently undoes them. Everything here is a decision this invocation already
|
||||
// made on the user's behalf, and a flag that the file can undo is not a flag.
|
||||
// It was not a rare case either, since EnsureStateDir writes base_url on the
|
||||
// first run and an interactive choice writes model, so from the second run on
|
||||
// the file carried a value for both and --endpoint and --model did nothing.
|
||||
//
|
||||
// The one asymmetry to plan around is that nib cannot tell "set to the zero
|
||||
// value" from "not set", so an override only ever raises a field. --yolo can
|
||||
// turn approval off, but nothing on the command line can turn it back on over
|
||||
// an approval_mode: auto in the file; that needs a config edit. Same shape for
|
||||
// the strings, which is what makes an unset --api-key or --trace-dir leave the
|
||||
// file's value standing, as it should.
|
||||
//
|
||||
// nib's own --trace-dir and --yolo, and their NIB_TRACE_DIR and NIB_YOLO twins,
|
||||
// are resolved after the config load and so still outrank these. That is
|
||||
// deliberate upstream: they are instructions to nib rather than ambient
|
||||
// environment.
|
||||
func agentOptions(dir, model string, opts Options) app.Options {
|
||||
// Model is the model this run resolved, which already prefers --model and
|
||||
// falls back to the file's own model, so the override restates the file's
|
||||
// value rather than fighting it whenever no flag was given.
|
||||
//
|
||||
// BaseURL is the endpoint this run probed, offered to start a server for,
|
||||
// and seeded the config with. Handing nib a different one is precisely the
|
||||
// split that made --endpoint a no-op, so the agent talks to the server
|
||||
// LocalAI checked. Pointing somewhere else for good is LOCALAI_CHAT_ENDPOINT
|
||||
// or --endpoint, not a hand-edited base_url the probe never reads.
|
||||
//
|
||||
// APIKey and TraceDir are the flags as given, empty when they were not, and
|
||||
// an empty override leaves the file alone. TraceDir is runtime-only in nib
|
||||
// (yaml:"-"), so no file value exists for it to beat today; it belongs here
|
||||
// with the other flags rather than one rung down for a reason that could
|
||||
// quietly stop being true.
|
||||
overrides := nibtypes.Config{
|
||||
Model: model,
|
||||
APIKey: opts.APIKey,
|
||||
BaseURL: opts.BaseURL,
|
||||
TraceDir: opts.TraceDir,
|
||||
}
|
||||
if opts.Yolo {
|
||||
overrides.ApprovalMode = "auto"
|
||||
}
|
||||
|
||||
return app.Options{
|
||||
Args: opts.Args,
|
||||
ProgramName: "local-ai chat",
|
||||
BaseDir: dir,
|
||||
Overrides: overrides,
|
||||
SkipSetup: true,
|
||||
SkipBareEnv: true,
|
||||
Stdin: opts.In,
|
||||
Stdout: ownStdout(opts.Out),
|
||||
Stderr: opts.ErrOut,
|
||||
}
|
||||
}
|
||||
|
||||
// ownStdout reports the writer as nib's own rather than as an injected one when
|
||||
// it is the process stdout, by answering nil for it. See agentOptions for why
|
||||
// that distinction is the difference between a working Ctrl+Space widget and a
|
||||
// refused one.
|
||||
func ownStdout(w io.Writer) io.Writer {
|
||||
if f, ok := w.(*os.File); ok && f == os.Stdout {
|
||||
return nil
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// defaultProbeTimeout bounds a check of the server. Listing models is cheap,
|
||||
// so this is long enough that a loaded server is never given up on and short
|
||||
// enough that a hung one does not leave the user staring at nothing.
|
||||
const defaultProbeTimeout = 30 * time.Second
|
||||
|
||||
// probeModels lists what the endpoint offers, under a budget.
|
||||
func probeModels(ctx context.Context, opts Options) ([]string, error) {
|
||||
timeout := opts.ProbeTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultProbeTimeout
|
||||
}
|
||||
// A real deadline rather than a cancel plus a timer. Probe reads
|
||||
// context.Canceled as "the caller gave up", which is a statement about the
|
||||
// caller and not about the endpoint, and only a deadline as "nothing
|
||||
// answered in time". Expiring the budget as a cancellation would stop
|
||||
// ErrUnreachable firing for precisely the hung servers that the offer to
|
||||
// start one exists for.
|
||||
probeCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
return Probe(probeCtx, opts.BaseURL, opts.APIKey)
|
||||
}
|
||||
|
||||
// isLocalOnlyArgs reports whether the forwarded arguments do their work
|
||||
// without ever reaching a model, in which case demanding a running server (and
|
||||
// offering to start one) would be an obstacle rather than a service.
|
||||
//
|
||||
// Two groups qualify. The management subcommands edit nib's own state: plugin,
|
||||
// skill, and the mcp verbs that add or remove configured servers, which is
|
||||
// asked of nib rather than restated, because bare 'mcp' and its transport
|
||||
// flags do serve the agent and do need a model. The other group is the flags
|
||||
// that only print something, above all --init: its shell snippet goes into an
|
||||
// rc file, typically long before any server exists.
|
||||
func isLocalOnlyArgs(args []string) bool {
|
||||
if len(args) == 0 {
|
||||
return false
|
||||
}
|
||||
// A scan rather than a look at args[0]: the mode flags this command
|
||||
// translates are prepended, so --init is not necessarily first. Positional
|
||||
// text cannot be mistaken for a flag here, since nib ignores what is left
|
||||
// after flag parsing.
|
||||
for _, a := range args {
|
||||
switch {
|
||||
case a == "--init", a == "-init", strings.HasPrefix(a, "--init="), strings.HasPrefix(a, "-init="):
|
||||
return true
|
||||
case a == "--version", a == "-version":
|
||||
return true
|
||||
}
|
||||
}
|
||||
switch args[0] {
|
||||
case "plugin", "skill":
|
||||
return true
|
||||
case "mcp":
|
||||
return len(args) >= 2 && nibcmd.IsMCPManageSubcommand(args[1])
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// configuredModel reads the model already recorded in the agent config, if any.
|
||||
func configuredModel(dir string) string {
|
||||
cfg := nibconfig.LoadWith(nibconfig.LoadOptions{BaseDir: dir, SkipBareEnv: true})
|
||||
return cfg.Model
|
||||
}
|
||||
|
||||
func isTerminal(in io.Reader) bool {
|
||||
f, ok := in.(*os.File)
|
||||
return ok && term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
|
||||
// say writes a line of interactive chatter: a question, or a notice about
|
||||
// something that did not stop the session. A write that fails is not worth
|
||||
// failing over, and when the terminal really is gone the read that follows the
|
||||
// question says so.
|
||||
func say(w io.Writer, format string, args ...any) {
|
||||
_, _ = fmt.Fprintf(w, format, args...)
|
||||
}
|
||||
|
||||
// prompter asks this run's questions on the user's terminal.
|
||||
//
|
||||
// It owns the buffered reader rather than wrapping opts.In per question,
|
||||
// because bufio reads ahead: a throwaway reader for the "start a server?"
|
||||
// question swallows the model choice that was typed behind it, and the next
|
||||
// question then sees EOF. A real run asks both, one after the other.
|
||||
type prompter struct {
|
||||
in *bufio.Reader
|
||||
out io.Writer
|
||||
}
|
||||
|
||||
func newPrompter(in io.Reader, out io.Writer) *prompter {
|
||||
return &prompter{in: bufio.NewReader(in), out: out}
|
||||
}
|
||||
|
||||
// yesNo satisfies Confirmer. Anything that is not an explicit yes is a no, so
|
||||
// a closed stream declines rather than proceeding on the user's behalf.
|
||||
func (p *prompter) yesNo(question string) (bool, error) {
|
||||
say(p.out, "%s [y/N]: ", question)
|
||||
line, err := p.in.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return false, fmt.Errorf("reading the answer: %w", err)
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(line)) {
|
||||
case "y", "yes":
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// choose satisfies ModelChooser. It answers with a list index rather than with
|
||||
// what the user typed, so the result can only ever be one of the models it was
|
||||
// offered: a model name is not something to accept unvalidated here, since
|
||||
// ResolveModel persists whatever comes back and every later run then starts
|
||||
// against it.
|
||||
func (p *prompter) choose(models []string) (string, error) {
|
||||
if len(models) == 0 {
|
||||
return "", errors.New("there is nothing to choose from")
|
||||
}
|
||||
say(p.out, "Several models are available:\n")
|
||||
for i, m := range models {
|
||||
say(p.out, " %d) %s\n", i+1, m)
|
||||
}
|
||||
say(p.out, "Pick one [1-%d]: ", len(models))
|
||||
|
||||
line, err := p.in.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return "", fmt.Errorf("reading the choice: %w", err)
|
||||
}
|
||||
answer := strings.TrimSpace(line)
|
||||
n, err := strconv.Atoi(answer)
|
||||
if err != nil || n < 1 || n > len(models) {
|
||||
return "", fmt.Errorf("not a valid choice: %q. Pick a number between 1 and %d, or pass --model", answer, len(models))
|
||||
}
|
||||
return models[n-1], nil
|
||||
}
|
||||
629
core/cli/chat/run_test.go
Normal file
629
core/cli/chat/run_test.go
Normal file
@@ -0,0 +1,629 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/nib/app"
|
||||
nibconfig "github.com/mudler/nib/config"
|
||||
nibtypes "github.com/mudler/nib/types"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// modelServer answers /v1/models with the given ids, as LocalAI does.
|
||||
func modelServer(ids ...string) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
data := make([]map[string]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
data = append(data, map[string]string{"id": id, "object": "model"})
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
Expect(json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": data})).To(Succeed())
|
||||
}))
|
||||
}
|
||||
|
||||
var _ = Describe("prepare", func() {
|
||||
var (
|
||||
dir string
|
||||
errOut *bytes.Buffer
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
dir = GinkgoT().TempDir()
|
||||
errOut = &bytes.Buffer{}
|
||||
})
|
||||
|
||||
// optionsFor points a run at srv, with no input to read: the default is a
|
||||
// session nobody can be asked anything in.
|
||||
optionsFor := func(srv *httptest.Server) Options {
|
||||
endpoint := "http://127.0.0.1:0"
|
||||
base := endpoint + "/v1"
|
||||
if srv != nil {
|
||||
endpoint, base = srv.URL, srv.URL+"/v1"
|
||||
}
|
||||
return Options{
|
||||
Endpoint: endpoint,
|
||||
BaseURL: base,
|
||||
StateDir: dir,
|
||||
In: strings.NewReader(""),
|
||||
Out: &bytes.Buffer{},
|
||||
ErrOut: errOut,
|
||||
}
|
||||
}
|
||||
|
||||
It("uses the only model the server offers", func() {
|
||||
srv := modelServer("the-only-model")
|
||||
defer srv.Close()
|
||||
|
||||
p, err := prepare(context.Background(), optionsFor(srv), false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.model).To(Equal("the-only-model"))
|
||||
Expect(p.dir).To(Equal(dir))
|
||||
Expect(p.server).To(BeNil(), "nothing was started, so nothing is owned")
|
||||
})
|
||||
|
||||
It("seeds the agent config with the endpoint on first run", func() {
|
||||
srv := modelServer("m")
|
||||
defer srv.Close()
|
||||
|
||||
_, err := prepare(context.Background(), optionsFor(srv), false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
data, err := os.ReadFile(ConfigPath(dir))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(ContainSubstring(srv.URL + "/v1"))
|
||||
})
|
||||
|
||||
It("lets --model win over what the server offers", func() {
|
||||
srv := modelServer("a", "b")
|
||||
defer srv.Close()
|
||||
|
||||
opts := optionsFor(srv)
|
||||
opts.Model = "not-listed-yet"
|
||||
p, err := prepare(context.Background(), opts, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.model).To(Equal("not-listed-yet"))
|
||||
})
|
||||
|
||||
It("advises about the API key when the server rejects it", func() {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := prepare(context.Background(), optionsFor(srv), false)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("--api-key"))
|
||||
Expect(err.Error()).To(ContainSubstring(srv.URL))
|
||||
})
|
||||
|
||||
// Not interactive means nobody can answer the offer, so the advice has to
|
||||
// stand on its own.
|
||||
It("advises how to start a server when none is reachable", func() {
|
||||
srv := modelServer()
|
||||
url := srv.URL
|
||||
srv.Close() // nothing is listening now
|
||||
|
||||
opts := optionsFor(nil)
|
||||
opts.Endpoint, opts.BaseURL = url, url+"/v1"
|
||||
_, err := prepare(context.Background(), opts, false)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("local-ai run"))
|
||||
Expect(err.Error()).To(ContainSubstring(url))
|
||||
})
|
||||
|
||||
// A server that accepts the connection and then never replies is the case
|
||||
// the offer to start one exists for, so the budget has to expire as a
|
||||
// deadline: Probe reads a cancellation as "the caller gave up" and refuses
|
||||
// to call the endpoint unreachable on the strength of it.
|
||||
It("treats a server that never answers as one that is not there", func(ctx SpecContext) {
|
||||
release := make(chan struct{})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-release:
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
defer close(release)
|
||||
|
||||
opts := optionsFor(srv)
|
||||
opts.ProbeTimeout = 100 * time.Millisecond
|
||||
_, err := prepare(context.Background(), opts, false)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("local-ai run"), "want the offer-a-server advice, got %v", err)
|
||||
}, SpecTimeout(30*time.Second))
|
||||
|
||||
It("asks which model to use and remembers the answer", func() {
|
||||
srv := modelServer("zeta", "alpha")
|
||||
defer srv.Close()
|
||||
|
||||
opts := optionsFor(srv)
|
||||
opts.In = strings.NewReader("2\n")
|
||||
p, err := prepare(context.Background(), opts, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// The list is sorted before it is shown, so 2 is zeta, not the second
|
||||
// thing the server happened to name.
|
||||
Expect(p.model).To(Equal("zeta"))
|
||||
Expect(errOut.String()).To(ContainSubstring("1) alpha"))
|
||||
Expect(errOut.String()).To(ContainSubstring("2) zeta"))
|
||||
|
||||
data, err := os.ReadFile(ConfigPath(dir))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(ContainSubstring("zeta"))
|
||||
})
|
||||
|
||||
// The choice is prompted for once and remembered. When remembering it fails
|
||||
// the user is about to be asked again on every future run, so they have to
|
||||
// be told here: a log line is invisible at the default log level.
|
||||
It("says so on the prompt when the choice cannot be remembered", func() {
|
||||
srv := modelServer("zeta", "alpha")
|
||||
defer srv.Close()
|
||||
|
||||
// A directory where the config file belongs: writable state dir,
|
||||
// unwritable config, on any platform and as any user.
|
||||
Expect(os.MkdirAll(ConfigPath(dir), 0o700)).To(Succeed())
|
||||
|
||||
opts := optionsFor(srv)
|
||||
opts.In = strings.NewReader("1\n")
|
||||
p, err := prepare(context.Background(), opts, true)
|
||||
|
||||
// Failing to remember the choice must not cost the user their session.
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.model).To(Equal("alpha"))
|
||||
Expect(errOut.String()).To(ContainSubstring("could not be saved"), "the user has to learn they will be asked again")
|
||||
})
|
||||
|
||||
It("does not ask again once a model is recorded", func() {
|
||||
srv := modelServer("zeta", "alpha")
|
||||
defer srv.Close()
|
||||
|
||||
Expect(PersistModel(dir, "alpha")).To(Succeed())
|
||||
|
||||
opts := optionsFor(srv)
|
||||
opts.In = strings.NewReader("") // an answer would have nothing to read
|
||||
p, err := prepare(context.Background(), opts, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.model).To(Equal("alpha"))
|
||||
Expect(errOut.String()).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("says what to install when the server has no models", func() {
|
||||
srv := modelServer()
|
||||
defer srv.Close()
|
||||
|
||||
_, err := prepare(context.Background(), optionsFor(srv), false)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("models install"))
|
||||
})
|
||||
|
||||
Describe("arguments that only touch local state", func() {
|
||||
unreachable := func(args ...string) Options {
|
||||
opts := optionsFor(nil) // port 0: nothing can ever answer here
|
||||
opts.Args = args
|
||||
return opts
|
||||
}
|
||||
|
||||
DescribeTable("skips the server entirely",
|
||||
func(args ...string) {
|
||||
p, err := prepare(context.Background(), unreachable(args...), false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.model).To(BeEmpty())
|
||||
Expect(p.server).To(BeNil())
|
||||
},
|
||||
Entry("plugin", "plugin", "list"),
|
||||
Entry("skill", "skill", "list"),
|
||||
Entry("mcp add", "mcp", "add", "srv"),
|
||||
Entry("mcp list", "mcp", "list"),
|
||||
// The shell snippet is what a user puts in their rc file, long
|
||||
// before any server exists.
|
||||
Entry("the shell integration script", "--init", "zsh"),
|
||||
Entry("the version", "--version"),
|
||||
)
|
||||
|
||||
// Bare 'mcp' and its transport flags serve the agent over MCP, so they
|
||||
// need a model like any other session. Only the verbs that edit the
|
||||
// configured servers are local.
|
||||
DescribeTable("still needs a server",
|
||||
func(args ...string) {
|
||||
_, err := prepare(context.Background(), unreachable(args...), false)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("local-ai run"))
|
||||
},
|
||||
Entry("mcp over stdio", "mcp", "--stdio"),
|
||||
Entry("bare mcp", "mcp"),
|
||||
)
|
||||
})
|
||||
|
||||
// A reader per question would read ahead into a buffer it then discards, so
|
||||
// the second question would see EOF whenever both answers were typed ahead.
|
||||
// That is the shape of a real run: the offer to start a server is followed
|
||||
// by the model prompt.
|
||||
It("keeps reading answers from the same stream across questions", func() {
|
||||
out := &bytes.Buffer{}
|
||||
p := newPrompter(strings.NewReader("y\n2\n"), out)
|
||||
|
||||
yes, err := p.yesNo("Start one now?")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(yes).To(BeTrue())
|
||||
|
||||
chosen, err := p.choose([]string{"alpha", "zeta"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(chosen).To(Equal("zeta"))
|
||||
})
|
||||
|
||||
// Whatever the chooser returns is persisted and used for every later run,
|
||||
// so an answer that is not one of the offered models must never come back
|
||||
// as one.
|
||||
Describe("the model prompt", func() {
|
||||
offered := []string{"alpha", "zeta"}
|
||||
|
||||
DescribeTable("refuses an answer that is not one of the numbers shown",
|
||||
func(answer string) {
|
||||
chosen, err := newPrompter(strings.NewReader(answer), &bytes.Buffer{}).choose(offered)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(chosen).To(BeEmpty())
|
||||
},
|
||||
Entry("nothing at all", ""),
|
||||
Entry("a blank line", "\n"),
|
||||
Entry("only spaces", " \n"),
|
||||
Entry("zero", "0\n"),
|
||||
Entry("past the end", "3\n"),
|
||||
Entry("negative", "-1\n"),
|
||||
Entry("a model name", "zeta\n"),
|
||||
Entry("a number with a suffix", "1x\n"),
|
||||
)
|
||||
|
||||
It("says how to answer when the answer was not a number", func() {
|
||||
_, err := newPrompter(strings.NewReader("banana\n"), &bytes.Buffer{}).choose(offered)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("between 1 and 2"))
|
||||
Expect(err.Error()).To(ContainSubstring("--model"))
|
||||
})
|
||||
|
||||
It("returns the model shown against the number", func() {
|
||||
chosen, err := newPrompter(strings.NewReader("1\n"), &bytes.Buffer{}).choose(offered)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(chosen).To(Equal("alpha"))
|
||||
})
|
||||
|
||||
It("refuses to ask when there is nothing to offer", func() {
|
||||
chosen, err := newPrompter(strings.NewReader("1\n"), &bytes.Buffer{}).choose(nil)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(chosen).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
// A server started for this session is stopped by a deferred call, which a
|
||||
// signal skips: the process dies where it stands and leaves 'local-ai run'
|
||||
// reparented to init.
|
||||
Describe("shutdown signals", func() {
|
||||
It("ends the session when the terminal goes away", func() {
|
||||
ctx, stop := shutdownContext(context.Background())
|
||||
defer stop()
|
||||
|
||||
self, err := os.FindProcess(os.Getpid())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(self.Signal(syscall.SIGHUP)).To(Succeed())
|
||||
|
||||
Eventually(ctx.Done()).WithTimeout(5 * time.Second).Should(BeClosed())
|
||||
Expect(ctx.Err()).To(MatchError(context.Canceled))
|
||||
})
|
||||
|
||||
// SIGINT and SIGTERM cannot be delivered here to prove the same thing:
|
||||
// Ginkgo registers for both to abort the suite, and a signal goes to
|
||||
// every registered listener.
|
||||
It("also listens for an interrupt and a terminate", func() {
|
||||
Expect(shutdownSignals).To(ContainElements(os.Signal(os.Interrupt), os.Signal(syscall.SIGTERM)))
|
||||
})
|
||||
})
|
||||
|
||||
// Cancelling the context does unwind nib's TUI since v0.5.1, but how long
|
||||
// that takes is nib's business, and the deferred Stop in Run is only reached
|
||||
// once the agent returns. A server this process started is ours to end, so
|
||||
// the guarantee is made here instead, where it does not depend on the agent
|
||||
// at all. Before v0.5.1 there was no guarantee to be had on the SIGHUP path:
|
||||
// bubbletea's own SIGINT and SIGTERM handler was the only thing that ever
|
||||
// quit the program, and registering for SIGHUP took away the default
|
||||
// disposition that used to end the process.
|
||||
Describe("runSession", func() {
|
||||
It("stops the session's server on cancellation, without waiting for the agent", func() {
|
||||
server, proc := stoppableServer()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
err := runSession(ctx, server, func(ctx context.Context) error {
|
||||
cancel()
|
||||
Eventually(func() int32 { return proc.interrupts.Load() }).
|
||||
WithTimeout(5 * time.Second).
|
||||
Should(BeNumerically(">", 0), "the server has to be stopped while the agent is still running")
|
||||
return nil
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(proc.lastSignal.Load()).To(Equal(os.Interrupt))
|
||||
})
|
||||
|
||||
It("leaves the server alone for as long as the session lasts", func() {
|
||||
server, proc := stoppableServer()
|
||||
|
||||
Expect(runSession(context.Background(), server, func(context.Context) error {
|
||||
return nil
|
||||
})).To(Succeed())
|
||||
Expect(proc.interrupts.Load()).To(BeZero())
|
||||
Expect(proc.kills.Load()).To(BeZero())
|
||||
})
|
||||
|
||||
It("returns what the agent returned", func() {
|
||||
failed := errors.New("the agent gave up")
|
||||
server, _ := stoppableServer()
|
||||
|
||||
Expect(runSession(context.Background(), server, func(context.Context) error {
|
||||
return failed
|
||||
})).To(MatchError(failed))
|
||||
})
|
||||
|
||||
// Most sessions run against a server the user already had, and there is
|
||||
// nothing to stop then.
|
||||
It("copes with a session that started no server", func() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
Expect(runSession(ctx, nil, func(context.Context) error {
|
||||
return nil
|
||||
})).To(Succeed())
|
||||
})
|
||||
})
|
||||
|
||||
// Which streams reach nib decides two user-visible behaviours at once, and
|
||||
// they pull in opposite directions, so both are pinned here rather than left
|
||||
// to whoever next edits the literal.
|
||||
//
|
||||
// nib refuses every mode but --cli when a stream it was handed is not a
|
||||
// terminal. That refusal is wanted for stdin, where it is what tells someone
|
||||
// piping a question to re-run with --cli. It is not wanted for the process
|
||||
// stdout, where it would refuse the Ctrl+Space widget that --init emits:
|
||||
// out=$(local-ai chat --height 50%) puts a pipe on stdout by construction,
|
||||
// and writing the chosen command into that pipe is the entire point.
|
||||
Describe("agentOptions", func() {
|
||||
// optionsWithStreams is a request that differs from the next only in
|
||||
// what it was told to read and write.
|
||||
optionsWithStreams := func(in io.Reader, out, errOut io.Writer) Options {
|
||||
return Options{
|
||||
BaseURL: "http://127.0.0.1:8080/v1",
|
||||
In: in,
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
}
|
||||
}
|
||||
|
||||
Describe("stdout", func() {
|
||||
// The regression this exists to catch: reinstating
|
||||
// 'Stdout: opts.Out' breaks Ctrl+Space and nothing else notices.
|
||||
It("hands nib nothing for the process stdout, so the capture widget is not refused", func() {
|
||||
o := agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, os.Stdout, os.Stderr))
|
||||
Expect(o.Stdout).To(BeNil(), "injecting os.Stdout is what refuses out=$(local-ai chat)")
|
||||
})
|
||||
|
||||
It("keeps a stdout the caller chose, which the refusal still guards", func() {
|
||||
out := &bytes.Buffer{}
|
||||
o := agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, out, os.Stderr))
|
||||
Expect(o.Stdout).To(BeIdenticalTo(out))
|
||||
})
|
||||
|
||||
// Being an *os.File is not what makes a stream nib's own; being the
|
||||
// process stdout is. This is a file an in-process caller opened for
|
||||
// itself, not one a shell redirect handed over as stdout, which
|
||||
// still arrives as os.Stdout and is still nil-ed. It was never going
|
||||
// to receive the interface, so it stays injected and stays refused.
|
||||
It("keeps a file that is not the process stdout", func() {
|
||||
f, err := os.CreateTemp(GinkgoT().TempDir(), "captured")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
DeferCleanup(f.Close)
|
||||
|
||||
o := agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, f, os.Stderr))
|
||||
Expect(o.Stdout).To(BeIdenticalTo(f))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("stdin", func() {
|
||||
// The opposite regression: nilling stdin the way stdout is nilled
|
||||
// would silently drop the refusal that names --cli.
|
||||
It("hands the process stdin over, so a piped session is still refused", func() {
|
||||
o := agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, os.Stdout, os.Stderr))
|
||||
Expect(o.Stdin).To(BeIdenticalTo(os.Stdin))
|
||||
})
|
||||
|
||||
It("hands over a stdin the caller chose", func() {
|
||||
in := strings.NewReader("a question")
|
||||
o := agentOptions(dir, "a-model", optionsWithStreams(in, os.Stdout, os.Stderr))
|
||||
Expect(o.Stdin).To(BeIdenticalTo(in))
|
||||
})
|
||||
})
|
||||
|
||||
// nib gates stdin and stdout and nothing else, so there is no reason to
|
||||
// hide the error stream from it.
|
||||
It("hands the error stream over whatever it is", func() {
|
||||
errOut := &bytes.Buffer{}
|
||||
o := agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, os.Stdout, errOut))
|
||||
Expect(o.Stderr).To(BeIdenticalTo(errOut))
|
||||
|
||||
o = agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, os.Stdout, os.Stderr))
|
||||
Expect(o.Stderr).To(BeIdenticalTo(os.Stderr))
|
||||
})
|
||||
|
||||
It("names the command a user would type, not the binary nib ships as", func() {
|
||||
o := agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, os.Stdout, os.Stderr))
|
||||
Expect(o.ProgramName).To(Equal("local-ai chat"),
|
||||
"the --init widget invokes this name, so a user has to be able to run it")
|
||||
})
|
||||
|
||||
It("carries the resolved session through to nib", func() {
|
||||
opts := optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)
|
||||
opts.Args = []string{"--cli"}
|
||||
opts.APIKey = "a-key"
|
||||
opts.TraceDir = "/traces"
|
||||
|
||||
o := agentOptions(dir, "the-model", opts)
|
||||
Expect(o.Args).To(Equal([]string{"--cli"}))
|
||||
Expect(o.BaseDir).To(Equal(dir))
|
||||
Expect(o.Overrides.Model).To(Equal("the-model"))
|
||||
Expect(o.Overrides.APIKey).To(Equal("a-key"))
|
||||
Expect(o.Overrides.BaseURL).To(Equal("http://127.0.0.1:8080/v1"))
|
||||
Expect(o.Overrides.TraceDir).To(Equal("/traces"))
|
||||
// The model and the server are settled before nib starts, and the
|
||||
// bare MODEL and API_KEY variables belong to some other tool.
|
||||
Expect(o.SkipSetup).To(BeTrue())
|
||||
Expect(o.SkipBareEnv).To(BeTrue())
|
||||
})
|
||||
|
||||
// Defaults sit beneath the config file. Anything routed through them is
|
||||
// accepted from the command line and then thrown away the moment the
|
||||
// file carries the same key, which is the normal state rather than an
|
||||
// edge case. Nothing this command resolves belongs there, so the channel
|
||||
// stays empty and this says so: it is what fails if the block is moved
|
||||
// back a rung.
|
||||
It("seeds nothing, because a seed is not a flag", func() {
|
||||
opts := optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)
|
||||
opts.APIKey = "a-key"
|
||||
opts.TraceDir = "/traces"
|
||||
opts.Yolo = true
|
||||
|
||||
Expect(agentOptions(dir, "the-model", opts).Defaults).To(Equal(nibtypes.Config{}),
|
||||
"Defaults lose to the config file, so a value placed there is a flag that does nothing")
|
||||
})
|
||||
|
||||
It("asks for automatic approval only when --yolo was given", func() {
|
||||
opts := optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)
|
||||
Expect(agentOptions(dir, "a-model", opts).Overrides.ApprovalMode).To(BeEmpty())
|
||||
|
||||
opts.Yolo = true
|
||||
Expect(agentOptions(dir, "a-model", opts).Overrides.ApprovalMode).To(Equal("auto"))
|
||||
})
|
||||
|
||||
// The specs above pin what is handed over. These pin what nib does with
|
||||
// it, which is the part that was wrong: every value below reached
|
||||
// app.Options intact and was then discarded by the config load, so a
|
||||
// spec that stops at the struct cannot see the bug. Resolving the config
|
||||
// the way app.Run resolves it can.
|
||||
Describe("the config nib actually resolves", func() {
|
||||
// writeConfig puts a config file where nib will read it, with values
|
||||
// that disagree with every flag under test.
|
||||
writeConfig := func(body string) {
|
||||
Expect(os.WriteFile(ConfigPath(dir), []byte(body), 0o600)).To(Succeed())
|
||||
}
|
||||
|
||||
// resolve loads the config exactly as app.Run does, so the precedence
|
||||
// under test is nib's own rather than a restatement of it here.
|
||||
resolve := func(o app.Options) nibtypes.Config {
|
||||
return nibconfig.LoadWith(nibconfig.LoadOptions{
|
||||
BaseDir: o.BaseDir,
|
||||
Defaults: o.Defaults,
|
||||
Overrides: o.Overrides,
|
||||
SkipBareEnv: o.SkipBareEnv,
|
||||
})
|
||||
}
|
||||
|
||||
It("sends the requests to the endpoint the flag named, not the one on disk", func() {
|
||||
writeConfig("base_url: http://127.0.0.1:9999/v1\n")
|
||||
|
||||
opts := optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)
|
||||
opts.BaseURL = "http://127.0.0.1:8080/v1"
|
||||
|
||||
cfg := resolve(agentOptions(dir, "a-model", opts))
|
||||
Expect(cfg.BaseURL).To(Equal("http://127.0.0.1:8080/v1"),
|
||||
"--endpoint probed 8080; every turn has to go there too")
|
||||
})
|
||||
|
||||
It("uses the model the flag named, not the one the picker recorded", func() {
|
||||
writeConfig("model: recorded-model\n")
|
||||
|
||||
cfg := resolve(agentOptions(dir, "flag-model", optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)))
|
||||
Expect(cfg.Model).To(Equal("flag-model"))
|
||||
})
|
||||
|
||||
It("uses the key the flag named, not the one nib saved", func() {
|
||||
writeConfig("api_key: saved-key\n")
|
||||
|
||||
opts := optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)
|
||||
opts.APIKey = "flag-key"
|
||||
|
||||
cfg := resolve(agentOptions(dir, "a-model", opts))
|
||||
Expect(cfg.APIKey).To(Equal("flag-key"))
|
||||
})
|
||||
|
||||
It("turns approval off for --yolo even when the file demands it", func() {
|
||||
writeConfig("approval_mode: prompt\n")
|
||||
|
||||
opts := optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)
|
||||
opts.Yolo = true
|
||||
|
||||
cfg := resolve(agentOptions(dir, "a-model", opts))
|
||||
Expect(cfg.ApprovalMode).To(Equal("auto"))
|
||||
})
|
||||
|
||||
// The other half of the same rule, and the reason an unset flag is
|
||||
// not a demand for the empty string: an override only ever raises a
|
||||
// field, so what the user configured survives a run that said
|
||||
// nothing about it.
|
||||
It("leaves what the file configured alone when no flag was given", func() {
|
||||
writeConfig("api_key: saved-key\napproval_mode: prompt\n")
|
||||
|
||||
cfg := resolve(agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)))
|
||||
Expect(cfg.APIKey).To(Equal("saved-key"))
|
||||
Expect(cfg.ApprovalMode).To(Equal("prompt"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// nib reports its own failures on the error stream and returns nothing but
|
||||
// a status, so anything that reaches here as one has already been explained
|
||||
// once. The refusal to open a full-screen session on a stdin that cannot be
|
||||
// read is the one users meet: 'echo q | local-ai chat' names --cli, and a
|
||||
// second message on top would bury the fix.
|
||||
Describe("ExitStatus", func() {
|
||||
It("recognises a status the agent already explained", func() {
|
||||
code, reported := ExitStatus(app.ExitError{Code: 2})
|
||||
Expect(reported).To(BeTrue())
|
||||
Expect(code).To(Equal(2))
|
||||
})
|
||||
|
||||
It("finds one that has been wrapped", func() {
|
||||
code, reported := ExitStatus(fmt.Errorf("running the agent: %w", app.ExitError{Code: 1}))
|
||||
Expect(reported).To(BeTrue())
|
||||
Expect(code).To(Equal(1))
|
||||
})
|
||||
|
||||
It("leaves an ordinary failure to be reported", func() {
|
||||
_, reported := ExitStatus(errors.New("no LocalAI server at http://127.0.0.1:8080"))
|
||||
Expect(reported).To(BeFalse())
|
||||
})
|
||||
|
||||
It("says nothing about a run that succeeded", func() {
|
||||
_, reported := ExitStatus(nil)
|
||||
Expect(reported).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
It("reports a state dir it cannot create", func() {
|
||||
blocked := filepath.Join(dir, "a-file")
|
||||
Expect(os.WriteFile(blocked, []byte("not a dir"), 0o600)).To(Succeed())
|
||||
|
||||
opts := optionsFor(nil)
|
||||
opts.StateDir = filepath.Join(blocked, "chat")
|
||||
_, err := prepare(context.Background(), opts, false)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("agent state dir"))
|
||||
})
|
||||
})
|
||||
276
core/cli/chat/server.go
Normal file
276
core/cli/chat/server.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAI/pkg/httpclient"
|
||||
)
|
||||
|
||||
// ErrDeclined means no server was started, either because the session is not
|
||||
// interactive or because the user said no.
|
||||
var ErrDeclined = errors.New("no server started")
|
||||
|
||||
// errServerExited means the process we spawned died before it ever reported
|
||||
// ready, so there is no point in polling out the rest of the budget.
|
||||
var errServerExited = errors.New("the LocalAI server exited before it became ready")
|
||||
|
||||
const (
|
||||
// defaultReadyTimeout bounds the wait for a freshly spawned server. A cold
|
||||
// start probes hardware and may pull a backend, so the budget is generous.
|
||||
defaultReadyTimeout = 2 * time.Minute
|
||||
// readyPollInterval is how long to wait between readiness polls.
|
||||
readyPollInterval = 500 * time.Millisecond
|
||||
// readyProbeTimeout bounds a single readiness request, so one connection
|
||||
// that hangs cannot swallow the whole budget.
|
||||
readyProbeTimeout = 5 * time.Second
|
||||
// shutdownGrace is how long a server we started gets to unload models and
|
||||
// stop its backends after SIGINT before it is killed outright.
|
||||
shutdownGrace = 10 * time.Second
|
||||
// childOutputDrainDelay bounds how long cmd.Wait keeps copying the child's
|
||||
// output after the child itself has exited.
|
||||
//
|
||||
// This is not a theoretical guard for LocalAI. 'local-ai run' spawns backend
|
||||
// subprocesses, and they inherit the write end of the pipe exec created for
|
||||
// the child's stderr. A backend that outlives its parent holds that pipe
|
||||
// open, so an unbounded cmd.Wait would block on the copy goroutine long
|
||||
// after the server itself is gone: exited would never close, Stop would burn
|
||||
// its whole grace period even on a clean shutdown, and the waiter goroutine
|
||||
// would leak.
|
||||
//
|
||||
// The value is long enough that a legitimate final burst of logs is never
|
||||
// truncated even on a loaded machine, where the copy itself takes
|
||||
// microseconds. It must stay strictly below shutdownGrace: at or above it,
|
||||
// every wedged-pipe shutdown would exhaust the grace period and then SIGKILL
|
||||
// a process that had already exited cleanly.
|
||||
childOutputDrainDelay = 5 * time.Second
|
||||
)
|
||||
|
||||
// Confirmer asks a yes/no question. Nil means the session is not interactive.
|
||||
type Confirmer func(question string) (bool, error)
|
||||
|
||||
// StartOptions configures OfferToStart.
|
||||
type StartOptions struct {
|
||||
// Endpoint is the address the user expected a server on, used in the
|
||||
// question and polled for readiness. This is the endpoint root, not the
|
||||
// /v1 API base URL: readiness is served at the root.
|
||||
Endpoint string
|
||||
// Confirm asks whether to start a server. Nil means never start.
|
||||
Confirm Confirmer
|
||||
// Stderr receives the child's output.
|
||||
Stderr io.Writer
|
||||
// Executable overrides the binary to run. Empty means os.Executable().
|
||||
Executable string
|
||||
// ReadyTimeout bounds the wait for readiness. Zero means defaultReadyTimeout.
|
||||
ReadyTimeout time.Duration
|
||||
}
|
||||
|
||||
// StartedServer is a server this process started and is responsible for.
|
||||
type StartedServer struct {
|
||||
// exited is closed once the child has been reaped. One background waiter
|
||||
// owns cmd.Wait: it may only be called once, and it is what closes the
|
||||
// pipes exec created for Stdout/Stderr and joins the goroutines copying
|
||||
// them, so calling os.Process.Wait directly instead would leak both.
|
||||
exited chan struct{}
|
||||
// waitErr is the child's exit status. It is written before exited is
|
||||
// closed and must only be read after that channel is observed closed.
|
||||
waitErr error
|
||||
|
||||
// proc is the child. It is an interface rather than *os.Process so that
|
||||
// Stop's contract, in particular that the child is asked to stop exactly
|
||||
// once however often Stop is called, can be pinned without a live process
|
||||
// to signal. Nil means nothing was ever started.
|
||||
proc processControl
|
||||
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
// processControl is the part of *os.Process that Stop needs.
|
||||
//
|
||||
// One interface rather than a pair of independent function fields: two fields
|
||||
// can be wired to each other's operation, or one left nil, and no test can tell,
|
||||
// because a fake satisfies any combination. There is nothing to swap or forget
|
||||
// here, since the sole implementation is the real process and the method names
|
||||
// carry the meaning.
|
||||
type processControl interface {
|
||||
Signal(os.Signal) error
|
||||
Kill() error
|
||||
}
|
||||
|
||||
// *os.Process satisfies processControl unmodified, so production needs no
|
||||
// adapter and no nil branch: the wiring is a single assignment.
|
||||
var _ processControl = (*os.Process)(nil)
|
||||
|
||||
// newServerCommand builds the child process. Split out from OfferToStart so the
|
||||
// process' configuration can be asserted on without spawning anything.
|
||||
func newServerCommand(bin string, stderr io.Writer) *exec.Cmd {
|
||||
cmd := exec.Command(bin, "run")
|
||||
// Stdin is left nil, so the child gets /dev/null: it is a background
|
||||
// server, and sharing the terminal would have it stealing keystrokes from
|
||||
// the agent.
|
||||
cmd.Stdout = stderr // the child's logs are diagnostics, not chat output
|
||||
cmd.Stderr = stderr
|
||||
// Bound the wait for the child's output pipes; see childOutputDrainDelay.
|
||||
cmd.WaitDelay = childOutputDrainDelay
|
||||
return cmd
|
||||
}
|
||||
|
||||
// OfferToStart asks whether to start a LocalAI server and, if allowed, spawns
|
||||
// one and waits for it to report ready.
|
||||
//
|
||||
// A child process rather than an in-process boot: RunCMD.Run installs its own
|
||||
// signal handling and blocks until shutdown, so re-entering it from a chat
|
||||
// session would entangle two lifecycles in one process.
|
||||
func OfferToStart(ctx context.Context, opts StartOptions) (*StartedServer, error) {
|
||||
if opts.Confirm == nil {
|
||||
// Not interactive. Spawning a server nobody asked for is the one thing
|
||||
// this function must never do: in CI, in a pipeline, or under a
|
||||
// supervisor there is no one to see it or shut it down.
|
||||
return nil, ErrDeclined
|
||||
}
|
||||
ok, err := opts.Confirm(fmt.Sprintf("No LocalAI server at %s. Start one now?", opts.Endpoint))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("asking whether to start a server: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
return nil, ErrDeclined
|
||||
}
|
||||
|
||||
bin := opts.Executable
|
||||
if bin == "" {
|
||||
if bin, err = os.Executable(); err != nil {
|
||||
return nil, fmt.Errorf("locating the local-ai binary: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
cmd := newServerCommand(bin, opts.Stderr)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("starting a LocalAI server with %s: %w", bin, err)
|
||||
}
|
||||
|
||||
s := &StartedServer{exited: make(chan struct{}), proc: cmd.Process}
|
||||
go func() {
|
||||
s.waitErr = cmd.Wait()
|
||||
close(s.exited)
|
||||
}()
|
||||
|
||||
timeout := opts.ReadyTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultReadyTimeout
|
||||
}
|
||||
if err := waitReady(ctx, opts.Endpoint, timeout, s.exited); err != nil {
|
||||
if errors.Is(err, errServerExited) {
|
||||
// Safe to read: errServerExited is only returned once exited has
|
||||
// been observed closed, which happens after waitErr is written.
|
||||
err = describeExit(err, s.waitErr)
|
||||
}
|
||||
s.Stop()
|
||||
return nil, fmt.Errorf("%w. Run 'local-ai run' in another terminal to see why it did not come up", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// describeExit adds what is known about how the child died to exitErr, without
|
||||
// putting os/exec's plumbing in front of the user.
|
||||
//
|
||||
// waitErr is exec.ErrWaitDelay when the child exited cleanly but something it
|
||||
// spawned still held its output pipe open past childOutputDrainDelay. The
|
||||
// sentinel's own text names the WaitDelay field, which is meaningless to a
|
||||
// user, so it is translated. Nothing is swallowed: os/exec only substitutes
|
||||
// ErrWaitDelay when the process itself exited without an error of its own (see
|
||||
// Cmd.Wait, "Report an error from the copying goroutines only if the program
|
||||
// otherwise exited normally"), so it can never stand in for an *ExitError.
|
||||
func describeExit(exitErr, waitErr error) error {
|
||||
switch {
|
||||
case waitErr == nil:
|
||||
return exitErr
|
||||
case errors.Is(waitErr, exec.ErrWaitDelay):
|
||||
return fmt.Errorf("%w, and left a subprocess of its own still running", exitErr)
|
||||
default:
|
||||
return fmt.Errorf("%w: %w", exitErr, waitErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop terminates the server this process started, giving it a chance to shut
|
||||
// down cleanly first. It is safe to call on a nil or never-started server, and
|
||||
// safe to call more than once.
|
||||
func (s *StartedServer) Stop() {
|
||||
if s == nil || s.proc == nil {
|
||||
return
|
||||
}
|
||||
s.stopOnce.Do(func() {
|
||||
// SIGINT rather than SIGKILL: local-ai run installs its own handler and
|
||||
// needs it to unload models and stop backend subprocesses. Killing it
|
||||
// outright would strand those children.
|
||||
_ = s.proc.Signal(os.Interrupt)
|
||||
|
||||
select {
|
||||
case <-s.exited:
|
||||
case <-time.After(shutdownGrace):
|
||||
// It ignored the interrupt or wedged on the way down. The user is
|
||||
// waiting on their shell prompt, so stop being polite.
|
||||
_ = s.proc.Kill()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// waitReady polls the endpoint's /readyz until the server reports ready, the
|
||||
// budget expires, the caller gives up, or exited signals that the process we
|
||||
// are waiting on is gone. A nil exited channel means there is no process to
|
||||
// watch.
|
||||
//
|
||||
// Readiness lives on the endpoint ROOT, not under the /v1 API base URL, and it
|
||||
// answers 503 for as long as startup is still in progress.
|
||||
func waitReady(ctx context.Context, endpoint string, timeout time.Duration, exited <-chan struct{}) error {
|
||||
url := strings.TrimSuffix(endpoint, "/") + "/readyz"
|
||||
|
||||
// A real deadline rather than context.WithCancel plus a timer: the latter
|
||||
// expires as context.Canceled, which every classifier here reads as "the
|
||||
// caller gave up" rather than "the endpoint never answered".
|
||||
waitCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
client := httpclient.NewWithTimeout(readyProbeTimeout)
|
||||
ticker := time.NewTicker(readyPollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-exited:
|
||||
return errServerExited
|
||||
case <-waitCtx.Done():
|
||||
// Distinguish our budget from the caller's: only ours is advice
|
||||
// about the server.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("the LocalAI server did not become ready within %s", timeout)
|
||||
case <-ticker.C:
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(waitCtx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building the readiness request for %s: %w", url, err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
continue // nothing listening yet
|
||||
}
|
||||
// Drain before closing so the next poll can reuse the connection
|
||||
// instead of opening a socket every 500ms for two minutes.
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
// Anything else means startup is still in progress; keep polling.
|
||||
}
|
||||
}
|
||||
375
core/cli/chat/server_test.go
Normal file
375
core/cli/chat/server_test.go
Normal file
@@ -0,0 +1,375 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// unusedPort is a loopback address nothing listens on, used wherever a spec
|
||||
// needs a readiness poll to keep failing. Port 1 is privileged, so no test
|
||||
// process could have bound it.
|
||||
const unusedPort = "http://127.0.0.1:1"
|
||||
|
||||
var _ = Describe("OfferToStart", func() {
|
||||
It("never spawns anything when there is no confirmer", func() {
|
||||
started, err := OfferToStart(context.Background(), StartOptions{
|
||||
Endpoint: "http://127.0.0.1:59999",
|
||||
Confirm: nil,
|
||||
Stderr: io.Discard,
|
||||
Executable: "/nonexistent/binary-that-must-not-run",
|
||||
})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(errors.Is(err, ErrDeclined)).To(BeTrue(), "want ErrDeclined, got %v", err)
|
||||
Expect(started).To(BeNil())
|
||||
})
|
||||
|
||||
It("does not spawn when the user declines", func() {
|
||||
asked := false
|
||||
started, err := OfferToStart(context.Background(), StartOptions{
|
||||
Endpoint: "http://127.0.0.1:59999",
|
||||
Confirm: func(string) (bool, error) {
|
||||
asked = true
|
||||
return false, nil
|
||||
},
|
||||
Stderr: io.Discard,
|
||||
Executable: "/nonexistent/binary-that-must-not-run",
|
||||
})
|
||||
Expect(asked).To(BeTrue(), "the user should have been asked")
|
||||
Expect(errors.Is(err, ErrDeclined)).To(BeTrue())
|
||||
Expect(started).To(BeNil())
|
||||
})
|
||||
|
||||
It("names the endpoint in the question", func() {
|
||||
var question string
|
||||
_, _ = OfferToStart(context.Background(), StartOptions{
|
||||
Endpoint: "http://example.invalid:9090",
|
||||
Confirm: func(q string) (bool, error) {
|
||||
question = q
|
||||
return false, nil
|
||||
},
|
||||
Stderr: io.Discard,
|
||||
Executable: "/nonexistent/binary-that-must-not-run",
|
||||
})
|
||||
Expect(question).To(ContainSubstring("http://example.invalid:9090"))
|
||||
})
|
||||
|
||||
It("propagates a confirmer error", func() {
|
||||
boom := errors.New("boom")
|
||||
_, err := OfferToStart(context.Background(), StartOptions{
|
||||
Endpoint: "http://127.0.0.1:59999",
|
||||
Confirm: func(string) (bool, error) { return false, boom },
|
||||
Stderr: io.Discard,
|
||||
Executable: "/nonexistent/binary-that-must-not-run",
|
||||
})
|
||||
Expect(errors.Is(err, boom)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("reports which binary it failed to launch", func() {
|
||||
started, err := OfferToStart(context.Background(), StartOptions{
|
||||
Endpoint: "http://127.0.0.1:59999",
|
||||
Confirm: func(string) (bool, error) { return true, nil },
|
||||
Stderr: io.Discard,
|
||||
Executable: "/nonexistent/binary-that-must-not-run",
|
||||
})
|
||||
Expect(started).To(BeNil())
|
||||
Expect(err).To(MatchError(ContainSubstring("starting a LocalAI server")))
|
||||
Expect(err).To(MatchError(ContainSubstring("/nonexistent/binary-that-must-not-run")))
|
||||
})
|
||||
|
||||
It("stops waiting as soon as the process it started exits", func() {
|
||||
// A harmless no-op binary rather than a real server: this exercises the
|
||||
// early-exit path without starting LocalAI, binding a port, or running
|
||||
// 'local-ai run'. Without early-exit detection the call would sit here
|
||||
// polling until ReadyTimeout.
|
||||
bin, lookErr := exec.LookPath("true")
|
||||
if lookErr != nil {
|
||||
Skip("no 'true' binary on PATH to stand in for a server that dies at once")
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
started, err := OfferToStart(context.Background(), StartOptions{
|
||||
Endpoint: unusedPort,
|
||||
Confirm: func(string) (bool, error) { return true, nil },
|
||||
Stderr: io.Discard,
|
||||
Executable: bin,
|
||||
ReadyTimeout: 30 * time.Second,
|
||||
})
|
||||
Expect(started).To(BeNil())
|
||||
Expect(err).To(MatchError(ContainSubstring("exited before it became ready")))
|
||||
Expect(time.Since(start)).To(BeNumerically("<", 10*time.Second),
|
||||
"the wait should end with the process, not with the readiness budget")
|
||||
})
|
||||
|
||||
It("gives up on a child whose grandchildren still hold its output pipe", func() {
|
||||
// The real LocalAI shape: 'local-ai run' exits but a backend
|
||||
// subprocess it spawned inherited the stderr pipe and keeps it open.
|
||||
// Without cmd.WaitDelay, cmd.Wait blocks on the copy goroutine, exited
|
||||
// never closes, and the readiness wait runs out the full budget instead
|
||||
// of reporting that the server died.
|
||||
sh, lookErr := exec.LookPath("sh")
|
||||
if lookErr != nil {
|
||||
Skip("no 'sh' binary on PATH to stand in for a server with a lingering child")
|
||||
}
|
||||
|
||||
dir := GinkgoT().TempDir()
|
||||
pidFile := filepath.Join(dir, "grandchild.pid")
|
||||
script := filepath.Join(dir, "server-with-lingering-child")
|
||||
// #nosec G306 -- this has to be executable to stand in for a binary.
|
||||
Expect(os.WriteFile(script,
|
||||
[]byte("#!"+sh+"\nsleep 30 &\necho $! > "+pidFile+"\nexit 0\n"),
|
||||
0o700)).To(Succeed())
|
||||
|
||||
// Reap the grandchild whatever happens: it outlives its own parent by
|
||||
// design, so nothing else will clean it up.
|
||||
DeferCleanup(func() {
|
||||
raw, err := os.ReadFile(pidFile)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(raw)))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = proc.Kill()
|
||||
_, _ = proc.Wait()
|
||||
})
|
||||
|
||||
start := time.Now()
|
||||
started, err := OfferToStart(context.Background(), StartOptions{
|
||||
Endpoint: unusedPort,
|
||||
Confirm: func(string) (bool, error) { return true, nil },
|
||||
Stderr: io.Discard,
|
||||
Executable: script,
|
||||
ReadyTimeout: 25 * time.Second,
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
|
||||
Expect(started).To(BeNil())
|
||||
Expect(err).To(MatchError(ContainSubstring("exited before it became ready")),
|
||||
"an unbounded cmd.Wait would report a readiness timeout instead")
|
||||
Expect(elapsed).To(BeNumerically("<", 20*time.Second),
|
||||
"the wait must be bounded by the output drain, not by the readiness budget")
|
||||
|
||||
// This is the case where cmd.Wait returns exec.ErrWaitDelay, whose own
|
||||
// text names a struct field of os/exec. Users get told what happened
|
||||
// instead.
|
||||
Expect(err).NotTo(MatchError(ContainSubstring("WaitDelay")),
|
||||
"os/exec plumbing must not reach the user")
|
||||
Expect(err).NotTo(MatchError(ContainSubstring("exec:")))
|
||||
Expect(err).To(MatchError(ContainSubstring("left a subprocess of its own still running")))
|
||||
})
|
||||
|
||||
It("reports the exit status of a server that failed outright", func() {
|
||||
// The counterpart to the case above: translating ErrWaitDelay must not
|
||||
// cost a real exit status, which is the one diagnostic worth having.
|
||||
bin, lookErr := exec.LookPath("false")
|
||||
if lookErr != nil {
|
||||
Skip("no 'false' binary on PATH to stand in for a server that fails")
|
||||
}
|
||||
|
||||
_, err := OfferToStart(context.Background(), StartOptions{
|
||||
Endpoint: unusedPort,
|
||||
Confirm: func(string) (bool, error) { return true, nil },
|
||||
Stderr: io.Discard,
|
||||
Executable: bin,
|
||||
ReadyTimeout: 30 * time.Second,
|
||||
})
|
||||
Expect(err).To(MatchError(ContainSubstring("exited before it became ready")))
|
||||
Expect(err).To(MatchError(ContainSubstring("exit status 1")))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("StartedServer.Stop", func() {
|
||||
It("is a no-op on a server that was never started", func() {
|
||||
var nilServer *StartedServer
|
||||
Expect(nilServer.Stop).NotTo(Panic())
|
||||
Expect((&StartedServer{}).Stop).NotTo(Panic())
|
||||
})
|
||||
|
||||
It("interrupts the child exactly once however often it is called", func() {
|
||||
s, proc := stoppableServer()
|
||||
|
||||
s.Stop()
|
||||
s.Stop()
|
||||
s.Stop()
|
||||
|
||||
Expect(proc.interrupts.Load()).To(Equal(int32(1)),
|
||||
"a second Stop must not signal the child again")
|
||||
Expect(proc.kills.Load()).To(BeZero(), "a child that already exited must not be killed")
|
||||
})
|
||||
|
||||
It("interrupts the child exactly once when called concurrently", func() {
|
||||
// The realistic double-Stop: a deferred Stop on the way out racing the
|
||||
// signal handler that also owns shutting the server down.
|
||||
const callers = 8
|
||||
|
||||
s, proc := stoppableServer()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(callers)
|
||||
for range callers {
|
||||
go func() {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
s.Stop()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
Expect(proc.interrupts.Load()).To(Equal(int32(1)))
|
||||
Expect(proc.kills.Load()).To(BeZero())
|
||||
})
|
||||
|
||||
It("asks the child to interrupt rather than killing it outright", func() {
|
||||
// The escalation order is the whole point of the grace period: SIGKILL
|
||||
// first would strand the backend subprocesses local-ai run owns.
|
||||
s, proc := stoppableServer()
|
||||
|
||||
s.Stop()
|
||||
|
||||
Expect(proc.lastSignal.Load()).To(Equal(os.Interrupt))
|
||||
Expect(proc.kills.Load()).To(BeZero())
|
||||
})
|
||||
})
|
||||
|
||||
// countingProcess stands in for the *os.Process that Stop drives, recording
|
||||
// what it was asked to do.
|
||||
type countingProcess struct {
|
||||
interrupts atomic.Int32
|
||||
kills atomic.Int32
|
||||
lastSignal atomic.Value
|
||||
}
|
||||
|
||||
func (p *countingProcess) Signal(sig os.Signal) error {
|
||||
p.interrupts.Add(1)
|
||||
p.lastSignal.Store(sig)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *countingProcess) Kill() error {
|
||||
p.kills.Add(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
// stoppableServer builds a StartedServer whose child has already exited, driven
|
||||
// by a countingProcess rather than a real one. Nothing is spawned.
|
||||
func stoppableServer() (*StartedServer, *countingProcess) {
|
||||
proc := &countingProcess{}
|
||||
exited := make(chan struct{})
|
||||
close(exited)
|
||||
return &StartedServer{exited: exited, proc: proc}, proc
|
||||
}
|
||||
|
||||
var _ = Describe("newServerCommand", func() {
|
||||
It("bounds how long it will wait for the child's output pipes", func() {
|
||||
cmd := newServerCommand("/nonexistent/binary-that-must-not-run", io.Discard)
|
||||
|
||||
// An unbounded wait is the failure mode: backend subprocesses inherit
|
||||
// the child's stderr pipe and can hold it open long after the server
|
||||
// itself is gone.
|
||||
Expect(cmd.WaitDelay).To(BeNumerically(">", 0), "cmd.Wait must not be unbounded")
|
||||
Expect(cmd.WaitDelay).To(BeNumerically("<", shutdownGrace),
|
||||
"a drain longer than the shutdown grace would kill a cleanly exited server")
|
||||
})
|
||||
|
||||
It("runs the server subcommand without giving it the terminal", func() {
|
||||
cmd := newServerCommand("/nonexistent/binary-that-must-not-run", io.Discard)
|
||||
|
||||
Expect(cmd.Args).To(Equal([]string{"/nonexistent/binary-that-must-not-run", "run"}))
|
||||
Expect(cmd.Stdin).To(BeNil(), "the child must not compete with the agent for stdin")
|
||||
Expect(cmd.Stdout).NotTo(BeNil())
|
||||
Expect(cmd.Stderr).NotTo(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("waitReady", func() {
|
||||
It("polls /readyz on the endpoint root and returns only once it answers 200", func() {
|
||||
// readyOnPoll is deliberately above 1. A handler that answers 200 to the
|
||||
// first poll cannot tell a correct implementation apart from one that
|
||||
// treats 503 as ready, because both return after a single request; the
|
||||
// poll count is what makes 503-as-ready observable.
|
||||
const readyOnPoll = 3
|
||||
|
||||
var polls atomic.Int32
|
||||
var paths atomic.Value
|
||||
paths.Store("")
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
paths.Store(r.URL.Path)
|
||||
if polls.Add(1) < readyOnPoll {
|
||||
// What LocalAI answers while startup is still in progress.
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
Expect(waitReady(context.Background(), srv.URL, 20*time.Second, nil)).To(Succeed())
|
||||
Expect(paths.Load()).To(Equal("/readyz"), "readiness lives on the endpoint root, not under /v1")
|
||||
Expect(polls.Load()).To(BeNumerically(">=", readyOnPoll),
|
||||
"503 means startup is still in progress and must never be accepted as ready")
|
||||
})
|
||||
|
||||
It("tolerates a trailing slash on the endpoint", func() {
|
||||
var path atomic.Value
|
||||
path.Store("")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path.Store(r.URL.Path)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
Expect(waitReady(context.Background(), srv.URL+"/", 20*time.Second, nil)).To(Succeed())
|
||||
Expect(path.Load()).To(Equal("/readyz"))
|
||||
})
|
||||
|
||||
It("reports a timeout, not a cancellation, when the budget runs out", func() {
|
||||
err := waitReady(context.Background(), unusedPort, 1200*time.Millisecond, nil)
|
||||
Expect(err).To(HaveOccurred())
|
||||
// A budget built from context.WithCancel plus a timer would surface as
|
||||
// context.Canceled, which downstream code reads as "the caller gave up"
|
||||
// and would stop classifying a hung server as unreachable.
|
||||
Expect(errors.Is(err, context.Canceled)).To(BeFalse(), "got %v", err)
|
||||
Expect(err).To(MatchError(ContainSubstring("did not become ready")))
|
||||
})
|
||||
|
||||
It("returns the caller's cancellation when the caller gives up", func() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
defer GinkgoRecover()
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
defer cancel()
|
||||
|
||||
err := waitReady(ctx, unusedPort, time.Minute, nil)
|
||||
Expect(errors.Is(err, context.Canceled)).To(BeTrue(), "got %v", err)
|
||||
})
|
||||
|
||||
It("gives up when the process it is waiting on has exited", func() {
|
||||
exited := make(chan struct{})
|
||||
close(exited)
|
||||
|
||||
err := waitReady(context.Background(), unusedPort, time.Minute, exited)
|
||||
Expect(err).To(MatchError(ContainSubstring("exited before it became ready")))
|
||||
})
|
||||
})
|
||||
@@ -1,112 +0,0 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
chatRoleUser = "user"
|
||||
chatRoleAssistant = "assistant"
|
||||
)
|
||||
|
||||
type chatMessage struct {
|
||||
Role string
|
||||
Content string
|
||||
}
|
||||
|
||||
type chatSession struct {
|
||||
client chatClient
|
||||
model string
|
||||
models []string
|
||||
messages []chatMessage
|
||||
}
|
||||
|
||||
func newChatSession(ctx context.Context, client chatClient, requestedModel string) (*chatSession, error) {
|
||||
models, err := client.ListModels(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list models: %w", err)
|
||||
}
|
||||
|
||||
model, err := resolveChatModel(requestedModel, models)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &chatSession{
|
||||
client: client,
|
||||
model: model,
|
||||
models: models,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *chatSession) CurrentModel() string {
|
||||
return s.model
|
||||
}
|
||||
|
||||
func (s *chatSession) Models() []string {
|
||||
models := make([]string, len(s.models))
|
||||
copy(models, s.models)
|
||||
return models
|
||||
}
|
||||
|
||||
func (s *chatSession) Clear() {
|
||||
s.messages = nil
|
||||
}
|
||||
|
||||
func (s *chatSession) SwitchModel(model string) error {
|
||||
if !slices.Contains(s.models, model) {
|
||||
return fmt.Errorf("model %q is not available. Use /models to see installed models", model)
|
||||
}
|
||||
s.model = model
|
||||
s.Clear()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *chatSession) Send(ctx context.Context, prompt string, out io.Writer) error {
|
||||
s.messages = append(s.messages, chatMessage{
|
||||
Role: chatRoleUser,
|
||||
Content: prompt,
|
||||
})
|
||||
|
||||
answer, err := s.client.StreamChat(ctx, s.model, s.messages, out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.messages = append(s.messages, chatMessage{
|
||||
Role: chatRoleAssistant,
|
||||
Content: answer,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveChatModel(requested string, models []string) (string, error) {
|
||||
switch {
|
||||
case requested == "" && len(models) == 0:
|
||||
return "", errors.New(`no chat models are installed.
|
||||
|
||||
Install a model first, for example:
|
||||
local-ai models list
|
||||
local-ai models install <model>
|
||||
local-ai run
|
||||
|
||||
Then start a chat session:
|
||||
local-ai chat --model <model>`)
|
||||
case requested == "" && len(models) == 1:
|
||||
return models[0], nil
|
||||
case requested == "" && len(models) > 1:
|
||||
var b strings.Builder
|
||||
b.WriteString("multiple models are available; choose one with --model:\n")
|
||||
b.WriteString(formatChatModelList(models, ""))
|
||||
return "", errors.New(b.String())
|
||||
case !slices.Contains(models, requested):
|
||||
return "", fmt.Errorf("model %q is not available. Use `local-ai models list` and `local-ai models install <model>`, or pass an installed model with --model", requested)
|
||||
default:
|
||||
return requested, nil
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Chat session", func() {
|
||||
It("keeps model switching and message history out of the terminal adapter", func() {
|
||||
client := &fakeChatClient{
|
||||
models: []string{"alpha", "beta"},
|
||||
answer: "pong",
|
||||
}
|
||||
|
||||
session, err := newChatSession(context.Background(), client, "alpha")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(session.CurrentModel()).To(Equal("alpha"))
|
||||
|
||||
Expect(session.SwitchModel("beta")).To(Succeed())
|
||||
Expect(session.CurrentModel()).To(Equal("beta"))
|
||||
Expect(session.Send(context.Background(), "ping", io.Discard)).To(Succeed())
|
||||
|
||||
Expect(client.requests).To(HaveLen(1))
|
||||
Expect(client.requests[0].model).To(Equal("beta"))
|
||||
Expect(client.requests[0].messages).To(HaveLen(1))
|
||||
Expect(client.requests[0].messages[0].Content).To(Equal("ping"))
|
||||
})
|
||||
})
|
||||
|
||||
type fakeChatClient struct {
|
||||
models []string
|
||||
answer string
|
||||
requests []fakeChatRequest
|
||||
}
|
||||
|
||||
type fakeChatRequest struct {
|
||||
model string
|
||||
messages []chatMessage
|
||||
}
|
||||
|
||||
func (c *fakeChatClient) ListModels(context.Context) ([]string, error) {
|
||||
return c.models, nil
|
||||
}
|
||||
|
||||
func (c *fakeChatClient) StreamChat(_ context.Context, model string, messages []chatMessage, out io.Writer) (string, error) {
|
||||
copied := make([]chatMessage, len(messages))
|
||||
copy(copied, messages)
|
||||
c.requests = append(c.requests, fakeChatRequest{model: model, messages: copied})
|
||||
if _, err := io.WriteString(out, c.answer); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return c.answer, nil
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func runTerminalChat(ctx context.Context, session *chatSession, in io.Reader, out io.Writer) error {
|
||||
scanner := bufio.NewScanner(in)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
|
||||
if err := writeChat(out, "LocalAI chat (%s)\n", session.CurrentModel()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeChat(out, "Type /exit to quit, /clear to reset the conversation, /models to list models.\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for {
|
||||
if err := writeChat(out, "\n> "); err != nil {
|
||||
return err
|
||||
}
|
||||
if !scanner.Scan() {
|
||||
break
|
||||
}
|
||||
|
||||
prompt := strings.TrimSpace(scanner.Text())
|
||||
switch prompt {
|
||||
case "":
|
||||
continue
|
||||
case "/bye", "/exit", "/quit":
|
||||
return writeChat(out, "bye\n")
|
||||
case "/clear":
|
||||
session.Clear()
|
||||
if err := writeChat(out, "conversation cleared\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
case "/models":
|
||||
if err := printChatModels(out, session.Models(), session.CurrentModel()); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if nextModel, ok := strings.CutPrefix(prompt, "/model "); ok {
|
||||
nextModel = strings.TrimSpace(nextModel)
|
||||
if nextModel == "" {
|
||||
if err := writeChat(out, "usage: /model <name>\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := session.SwitchModel(nextModel); err != nil {
|
||||
if writeErr := writeChat(out, "%s\n", err); writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := writeChat(out, "switched to %s; conversation cleared\n", session.CurrentModel()); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if err := writeChat(out, "assistant: "); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := session.Send(ctx, prompt, out); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeChat(out, "\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
func printChatModels(out io.Writer, models []string, current string) error {
|
||||
if len(models) == 0 {
|
||||
return writeChat(out, "no models installed\n")
|
||||
}
|
||||
return writeChat(out, "%s", formatChatModelList(models, current))
|
||||
}
|
||||
|
||||
func writeChat(out io.Writer, format string, args ...any) error {
|
||||
_, err := fmt.Fprintf(out, format, args...)
|
||||
return err
|
||||
}
|
||||
@@ -8,18 +8,72 @@ import (
|
||||
cliContext "github.com/mudler/LocalAI/core/cli/context"
|
||||
)
|
||||
|
||||
// ChatCMD runs the built-in terminal agent. Everything after the first
|
||||
// positional argument is forwarded to the agent verbatim, so its own
|
||||
// subcommands (plugin, skill, mcp) and their flags work unchanged. LocalAI's
|
||||
// own flags must therefore come first.
|
||||
type ChatCMD struct {
|
||||
Model string `short:"m" help:"Model name to use. Defaults to the only model returned by the server when exactly one is available"`
|
||||
Endpoint string `env:"LOCALAI_CHAT_ENDPOINT" default:"http://127.0.0.1:8080" help:"LocalAI server endpoint. The /v1 path is added automatically when omitted"`
|
||||
APIKey string `env:"LOCALAI_API_KEY,API_KEY" help:"API key to use when the LocalAI server requires authentication"`
|
||||
Model string `short:"m" help:"Model to use. Defaults to the only model the server offers, or asks when there are several"`
|
||||
Endpoint string `env:"LOCALAI_CHAT_ENDPOINT" default:"http://127.0.0.1:8080" help:"LocalAI server endpoint. The /v1 path is added automatically when omitted"`
|
||||
APIKey string `env:"LOCALAI_API_KEY,API_KEY" help:"API key to use when the LocalAI server requires authentication"`
|
||||
ConfigDir string `env:"LOCALAI_CHAT_CONFIG_DIR" help:"Directory holding the agent's config, plugins, and skills. Defaults to ~/.config/localai/chat" type:"path"`
|
||||
TraceDir string `env:"LOCALAI_CHAT_TRACE_DIR" help:"Write a session LLM trace (NDJSON) to this directory" type:"path"`
|
||||
|
||||
CLI bool `help:"Run in plain CLI mode instead of the full-screen interface"`
|
||||
TUI bool `help:"Force the full-screen interface"`
|
||||
Height string `help:"Run as an inline drop-down of this height, e.g. '40%'"`
|
||||
Tmux bool `help:"Run in a tmux split"`
|
||||
NoTmux bool `name:"no-tmux" help:"Never use a tmux split, even inside tmux"`
|
||||
Init string `help:"Print the shell integration script for Ctrl+Space (zsh, bash, or fish)"`
|
||||
Yolo bool `env:"LOCALAI_CHAT_YOLO" help:"Auto-approve every tool call without prompting"`
|
||||
|
||||
Args []string `arg:"" optional:"" passthrough:"" help:"Arguments forwarded to the agent, e.g. 'plugin install <url>', 'skill list', 'mcp add'"`
|
||||
}
|
||||
|
||||
func (c *ChatCMD) Run(ctx *cliContext.Context) error {
|
||||
return chatcli.Run(context.Background(), chatcli.Options{
|
||||
Model: c.Model,
|
||||
BaseURL: chatAPIBaseURL(c.Endpoint),
|
||||
APIKey: c.APIKey,
|
||||
In: os.Stdin,
|
||||
Out: os.Stdout,
|
||||
err := chatcli.Run(context.Background(), chatcli.Options{
|
||||
Args: c.agentArgs(),
|
||||
Endpoint: c.Endpoint,
|
||||
BaseURL: chatAPIBaseURL(c.Endpoint),
|
||||
APIKey: c.APIKey,
|
||||
Model: c.Model,
|
||||
StateDir: c.ConfigDir,
|
||||
TraceDir: c.TraceDir,
|
||||
Yolo: c.Yolo,
|
||||
In: os.Stdin,
|
||||
Out: os.Stdout,
|
||||
ErrOut: os.Stderr,
|
||||
})
|
||||
// The agent explains its own failures on stderr and hands back a code, so
|
||||
// carry the code out and leave the explanation to stand alone.
|
||||
if code, reported := chatcli.ExitStatus(err); reported {
|
||||
return ExitCodeError{Code: code}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// agentArgs rebuilds the argument vector the agent expects: LocalAI's mode
|
||||
// flags are declared here for discoverability and shell completion, so they
|
||||
// have to be translated back into the agent's own flag names.
|
||||
func (c *ChatCMD) agentArgs() []string {
|
||||
var args []string
|
||||
if c.CLI {
|
||||
args = append(args, "--cli")
|
||||
}
|
||||
if c.TUI {
|
||||
args = append(args, "--tui")
|
||||
}
|
||||
if c.Height != "" {
|
||||
args = append(args, "--height", c.Height)
|
||||
}
|
||||
if c.Tmux {
|
||||
args = append(args, "--tmux")
|
||||
}
|
||||
if c.NoTmux {
|
||||
args = append(args, "--no-tmux")
|
||||
}
|
||||
if c.Init != "" {
|
||||
args = append(args, "--init", c.Init)
|
||||
}
|
||||
return append(args, c.Args...)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/alecthomas/kong"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@@ -24,4 +28,70 @@ var _ = Describe("Chat command wiring", func() {
|
||||
Expect(chatAPIBaseURL("http://127.0.0.1:8080/localai")).To(Equal("http://127.0.0.1:8080/localai/v1"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("argument parsing", func() {
|
||||
parse := func(args ...string) *ChatCMD {
|
||||
var cli struct {
|
||||
Chat ChatCMD `cmd:""`
|
||||
}
|
||||
parser, err := kong.New(&cli)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = parser.Parse(append([]string{"chat"}, args...))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return &cli.Chat
|
||||
}
|
||||
|
||||
It("leaves Args empty for a bare invocation", func() {
|
||||
Expect(parse().Args).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("binds flags that precede the forwarded arguments", func() {
|
||||
c := parse("--endpoint", "http://host:9090", "--model", "m", "plugin", "list")
|
||||
Expect(c.Endpoint).To(Equal("http://host:9090"))
|
||||
Expect(c.Model).To(Equal("m"))
|
||||
Expect(c.Args).To(Equal([]string{"plugin", "list"}))
|
||||
})
|
||||
|
||||
It("forwards flags that follow the first positional to the agent", func() {
|
||||
c := parse("plugin", "install", "https://example.invalid/p", "--yes")
|
||||
Expect(c.Args).To(Equal([]string{"plugin", "install", "https://example.invalid/p", "--yes"}))
|
||||
})
|
||||
|
||||
It("parses its own mode flags", func() {
|
||||
c := parse("--cli")
|
||||
Expect(c.CLI).To(BeTrue())
|
||||
Expect(c.Args).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
// The agent prints its own diagnosis and hands back a status. main exits
|
||||
// with that status and prints nothing more, so the user reads one message
|
||||
// rather than an "exit status 1" stacked under it.
|
||||
Describe("ExitCodeError", func() {
|
||||
It("carries the status out", func() {
|
||||
Expect(ExitCodeError{Code: 2}.Code).To(Equal(2))
|
||||
})
|
||||
|
||||
It("is recognisable after wrapping", func() {
|
||||
var got ExitCodeError
|
||||
Expect(errors.As(fmt.Errorf("chat: %w", ExitCodeError{Code: 2}), &got)).To(BeTrue())
|
||||
Expect(got.Code).To(Equal(2))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("agentArgs", func() {
|
||||
It("translates mode flags into the agent's own flags", func() {
|
||||
c := &ChatCMD{CLI: true}
|
||||
Expect(c.agentArgs()).To(Equal([]string{"--cli"}))
|
||||
})
|
||||
|
||||
It("puts forwarded arguments after the translated flags", func() {
|
||||
c := &ChatCMD{Height: "40%", Args: []string{"plugin", "list"}}
|
||||
Expect(c.agentArgs()).To(Equal([]string{"--height", "40%", "plugin", "list"}))
|
||||
})
|
||||
|
||||
It("returns nothing for a bare invocation", func() {
|
||||
Expect((&ChatCMD{}).agentArgs()).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ var CLI struct {
|
||||
cliContext.Context `embed:""`
|
||||
|
||||
Run RunCMD `cmd:"" help:"Run LocalAI, this the default command if no other command is specified. Run 'local-ai run --help' for more information" default:"withargs"`
|
||||
Chat ChatCMD `cmd:"" help:"Open an interactive chat session against a running LocalAI server"`
|
||||
Chat ChatCMD `cmd:"" help:"Run the built-in terminal agent against a LocalAI server"`
|
||||
Federated FederatedCLI `cmd:"" help:"Run LocalAI in federated mode"`
|
||||
Models ModelsCMD `cmd:"" help:"Manage LocalAI models and definitions"`
|
||||
Backends BackendsCMD `cmd:"" help:"Manage LocalAI backends and definitions"`
|
||||
|
||||
15
core/cli/exit.go
Normal file
15
core/cli/exit.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package cli
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ExitCodeError is a failure a command has already reported to the user. It
|
||||
// carries nothing but the status the process should exit with, and main prints
|
||||
// nothing more for it.
|
||||
//
|
||||
// It exists for commands that hand their terminal to something that does its
|
||||
// own error reporting. Returning that subordinate's error instead would put a
|
||||
// bare "exit status 1" underneath the explanation the user has just read, and
|
||||
// returning nil would tell a script the run succeeded.
|
||||
type ExitCodeError struct{ Code int }
|
||||
|
||||
func (e ExitCodeError) Error() string { return fmt.Sprintf("exit status %d", e.Code) }
|
||||
@@ -44,6 +44,7 @@ const (
|
||||
MethodPredictStream GRPCMethod = "PredictStream"
|
||||
MethodEmbedding GRPCMethod = "Embedding"
|
||||
MethodGenerateImage GRPCMethod = "GenerateImage"
|
||||
MethodUpscaleImage GRPCMethod = "UpscaleImage"
|
||||
MethodGenerateVideo GRPCMethod = "GenerateVideo"
|
||||
MethodGenerate3D GRPCMethod = "Generate3D"
|
||||
MethodAudioTranscription GRPCMethod = "AudioTranscription"
|
||||
@@ -348,7 +349,7 @@ var BackendCapabilities = map[string]BackendCapability{
|
||||
|
||||
// --- Image/video generation backends ---
|
||||
"diffusers": {
|
||||
GRPCMethods: []GRPCMethod{MethodGenerateImage, MethodGenerateVideo},
|
||||
GRPCMethods: []GRPCMethod{MethodGenerateImage, MethodUpscaleImage, MethodGenerateVideo},
|
||||
PossibleUsecases: []string{UsecaseImage, UsecaseVideo},
|
||||
DefaultUsecases: []string{UsecaseImage},
|
||||
Description: "HuggingFace diffusers — Stable Diffusion, Flux, video generation",
|
||||
|
||||
211
core/gallery/estimate_warm.go
Normal file
211
core/gallery/estimate_warm.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/pkg/system"
|
||||
"github.com/mudler/LocalAI/pkg/vram"
|
||||
"github.com/mudler/xlog"
|
||||
)
|
||||
|
||||
// EstimateInput builds the VRAM estimator's input from a gallery entry.
|
||||
//
|
||||
// It lives here rather than beside the HTTP handler because two callers need
|
||||
// it: the handler answering one model, and the warmer below answering all of
|
||||
// them ahead of time.
|
||||
func EstimateInput(m *GalleryModel) vram.ModelEstimateInput {
|
||||
var input vram.ModelEstimateInput
|
||||
input.Size = m.Size
|
||||
if repoID := extractHFRepo(m.Overrides, m.URLs); repoID != "" {
|
||||
input.HFRepo = repoID
|
||||
}
|
||||
for _, f := range m.AdditionalFiles {
|
||||
if vram.IsWeightFile(f.URI) {
|
||||
input.Files = append(input.Files, vram.FileInput{URI: f.URI, Size: 0})
|
||||
}
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// extractHFRepo finds a HuggingFace repo ID in a model's overrides or URLs.
|
||||
func extractHFRepo(overrides map[string]any, urls []string) string {
|
||||
if overrides != nil {
|
||||
if params, ok := overrides["parameters"].(map[string]any); ok {
|
||||
if modelRef, ok := params["model"].(string); ok {
|
||||
if repoID, ok := vram.ExtractHFRepoID(modelRef); ok {
|
||||
return repoID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, u := range urls {
|
||||
if repoID, ok := vram.ExtractHFRepoID(u); ok {
|
||||
return repoID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// EstimateWarmConfig bounds the background warm-up.
|
||||
type EstimateWarmConfig struct {
|
||||
// Limit is how many gallery entries to warm, in gallery order. Zero
|
||||
// disables warming entirely. The order matters: it is the order the UI
|
||||
// lists them in, so the entries a user sees first are warmed first.
|
||||
Limit int
|
||||
// Concurrency is how many estimates run at once. Each one can be a remote
|
||||
// probe, so this is deliberately small: the point is to be finished before
|
||||
// anybody looks, not to saturate the link or the upstream.
|
||||
Concurrency int
|
||||
// Contexts are the context lengths to estimate at. These want to match what
|
||||
// the UI asks for, or the warmed entry is not the one it reads.
|
||||
Contexts []uint32
|
||||
}
|
||||
|
||||
// DefaultEstimateWarmConfig is what the server uses unless told otherwise.
|
||||
//
|
||||
// The limit is a deliberate compromise. Warming the whole gallery would be
|
||||
// thousands of remote probes on every boot, which is rude to the upstream and
|
||||
// slow to finish; warming nothing leaves the first page of the model gallery
|
||||
// paying two seconds per row. A few hundred covers what anyone browses in a
|
||||
// sitting, and everything past it still warms itself on first view.
|
||||
var DefaultEstimateWarmConfig = EstimateWarmConfig{
|
||||
Limit: 300,
|
||||
Concurrency: 4,
|
||||
Contexts: []uint32{8192, 16384, 32768, 65536, 131072, 262144},
|
||||
}
|
||||
|
||||
// WarmEstimateCache fills the gallery's derived caches in the background.
|
||||
//
|
||||
// Two things are warmed, and they are the same cost wearing different hats.
|
||||
// An estimate for an entry the server has never seen costs a network probe of
|
||||
// its weight files, and describing an entry's variants costs one probe per
|
||||
// build it offers. The UI asks for an estimate per row and a variant
|
||||
// description per model opened, so without this the first visitor pays for
|
||||
// both: ten seconds of a page filling in its own sizes, then another second
|
||||
// and a half the first time they click anything.
|
||||
//
|
||||
// Both land in the same caches underneath, which is why one pass covers them.
|
||||
//
|
||||
// It returns immediately; the work happens on its own goroutine and stops when
|
||||
// ctx is done. Failures are logged at debug and otherwise ignored: a warm-up
|
||||
// that cannot reach an upstream must never stop the server from starting, and
|
||||
// the entry it failed on simply stays cold.
|
||||
func WarmEstimateCache(ctx context.Context, galleries []config.Gallery, systemState *system.SystemState, cfg EstimateWarmConfig) {
|
||||
if cfg.Limit <= 0 || cfg.Concurrency <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
started := time.Now()
|
||||
|
||||
models, err := AvailableGalleryModelsCached(galleries, systemState)
|
||||
if err != nil {
|
||||
xlog.Debug("VRAM estimate warm-up skipped, gallery unavailable", "error", err)
|
||||
return
|
||||
}
|
||||
if len(models) > cfg.Limit {
|
||||
models = models[:cfg.Limit]
|
||||
}
|
||||
if len(models) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// The host gate the variant picker resolves against. Derived once: it
|
||||
// describes this machine, not this entry, and HostResolveEnv reads the
|
||||
// system state to build it.
|
||||
env := HostResolveEnv(ctx, systemState)
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
cursor = make(chan *GalleryModel)
|
||||
warmed int
|
||||
warmedVariants int
|
||||
mu sync.Mutex
|
||||
)
|
||||
|
||||
for i := 0; i < cfg.Concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for m := range cursor {
|
||||
// Per entry, not for the run: one unreachable weight file
|
||||
// must not hold a worker for the whole warm-up.
|
||||
entryCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
|
||||
input := EstimateInput(m)
|
||||
if len(input.Files) > 0 || input.HFRepo != "" || input.Size != "" {
|
||||
if _, err := vram.EstimateModelMultiContext(entryCtx, input, cfg.Contexts); err != nil {
|
||||
xlog.Debug("VRAM estimate warm-up failed for entry", "model", m.GetName(), "error", err)
|
||||
} else {
|
||||
mu.Lock()
|
||||
warmed++
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Describing variants probes each build the entry offers.
|
||||
// An entry that declares none costs nothing here, so this is
|
||||
// gated rather than attempted and discarded.
|
||||
if m.HasVariants() {
|
||||
if _, err := DescribeVariants(models, m, env); err != nil {
|
||||
xlog.Debug("variant warm-up failed for entry", "model", m.GetName(), "error", err)
|
||||
} else {
|
||||
mu.Lock()
|
||||
warmedVariants++
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
feed:
|
||||
for _, m := range models {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
break feed
|
||||
case cursor <- m:
|
||||
}
|
||||
}
|
||||
close(cursor)
|
||||
wg.Wait()
|
||||
|
||||
if ctx.Err() != nil {
|
||||
xlog.Debug("gallery warm-up stopped", "estimates", warmed, "variants", warmedVariants)
|
||||
return
|
||||
}
|
||||
xlog.Info("gallery caches warmed", "estimates", warmed, "variants", warmedVariants, "of", len(models), "took", time.Since(started).Round(time.Second))
|
||||
}()
|
||||
}
|
||||
|
||||
// EstimateWarmConfigFromEnv reads the warm-up bounds from the environment,
|
||||
// falling back to the defaults.
|
||||
//
|
||||
// LOCALAI_VRAM_WARM_LIMIT entries to warm; 0 disables the warm-up
|
||||
// LOCALAI_VRAM_WARM_CONCURRENCY estimates in flight at once
|
||||
//
|
||||
// Env rather than a flag because it is an operational tuning knob, not part of
|
||||
// what the server does: an air-gapped host wants it off, and a host behind a
|
||||
// slow link wants it slower, and neither is a decision the CLI should carry.
|
||||
func EstimateWarmConfigFromEnv() EstimateWarmConfig {
|
||||
cfg := DefaultEstimateWarmConfig
|
||||
if v, ok := os.LookupEnv("LOCALAI_VRAM_WARM_LIMIT"); ok {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n >= 0 {
|
||||
cfg.Limit = n
|
||||
}
|
||||
}
|
||||
if v, ok := os.LookupEnv("LOCALAI_VRAM_WARM_CONCURRENCY"); ok {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 {
|
||||
cfg.Concurrency = n
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
115
core/gallery/estimate_warm_test.go
Normal file
115
core/gallery/estimate_warm_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package gallery_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/gallery"
|
||||
"github.com/mudler/LocalAI/pkg/system"
|
||||
)
|
||||
|
||||
var _ = Describe("VRAM estimate warm-up", func() {
|
||||
var state *system.SystemState
|
||||
|
||||
BeforeEach(func() {
|
||||
dir, err := os.MkdirTemp("", "warm")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
DeferCleanup(func() { os.RemoveAll(dir) })
|
||||
state, err = system.GetSystemState(system.WithModelPath(dir))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
gallery.ResetGalleryModelCache()
|
||||
DeferCleanup(gallery.ResetGalleryModelCache)
|
||||
})
|
||||
|
||||
It("does nothing when disabled, and returns without blocking", func() {
|
||||
cfg := gallery.DefaultEstimateWarmConfig
|
||||
cfg.Limit = 0
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
gallery.WarmEstimateCache(context.Background(), []config.Gallery{}, state, cfg)
|
||||
}()
|
||||
Eventually(done, "1s").Should(BeClosed())
|
||||
})
|
||||
|
||||
It("returns immediately even when there is work to do", func() {
|
||||
// The caller is a server still starting up: warming must never be on
|
||||
// the path to listening.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
gallery.WarmEstimateCache(context.Background(), []config.Gallery{}, state, gallery.DefaultEstimateWarmConfig)
|
||||
}()
|
||||
Eventually(done, "1s").Should(BeClosed())
|
||||
})
|
||||
|
||||
It("stops when its context is cancelled", func() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
gallery.WarmEstimateCache(ctx, []config.Gallery{}, state, gallery.DefaultEstimateWarmConfig)
|
||||
cancel()
|
||||
// Nothing to assert beyond not hanging or panicking: an aborted warm-up
|
||||
// leaves entries cold, which is the state they were already in.
|
||||
Consistently(func() bool { return true }, "100ms").Should(BeTrue())
|
||||
})
|
||||
|
||||
Describe("configuration from the environment", func() {
|
||||
AfterEach(func() {
|
||||
os.Unsetenv("LOCALAI_VRAM_WARM_LIMIT")
|
||||
os.Unsetenv("LOCALAI_VRAM_WARM_CONCURRENCY")
|
||||
})
|
||||
|
||||
It("falls back to the defaults", func() {
|
||||
cfg := gallery.EstimateWarmConfigFromEnv()
|
||||
Expect(cfg.Limit).To(Equal(gallery.DefaultEstimateWarmConfig.Limit))
|
||||
Expect(cfg.Concurrency).To(Equal(gallery.DefaultEstimateWarmConfig.Concurrency))
|
||||
})
|
||||
|
||||
It("lets an operator turn it off entirely", func() {
|
||||
os.Setenv("LOCALAI_VRAM_WARM_LIMIT", "0")
|
||||
Expect(gallery.EstimateWarmConfigFromEnv().Limit).To(BeZero())
|
||||
})
|
||||
|
||||
It("lets an operator slow it down", func() {
|
||||
os.Setenv("LOCALAI_VRAM_WARM_CONCURRENCY", "1")
|
||||
Expect(gallery.EstimateWarmConfigFromEnv().Concurrency).To(Equal(1))
|
||||
})
|
||||
|
||||
It("ignores values that are not usable", func() {
|
||||
os.Setenv("LOCALAI_VRAM_WARM_LIMIT", "not-a-number")
|
||||
os.Setenv("LOCALAI_VRAM_WARM_CONCURRENCY", "0")
|
||||
cfg := gallery.EstimateWarmConfigFromEnv()
|
||||
Expect(cfg.Limit).To(Equal(gallery.DefaultEstimateWarmConfig.Limit))
|
||||
// Zero workers would be a warm-up that never runs while looking
|
||||
// enabled, so it keeps the default rather than honouring it.
|
||||
Expect(cfg.Concurrency).To(Equal(gallery.DefaultEstimateWarmConfig.Concurrency))
|
||||
})
|
||||
})
|
||||
|
||||
It("warms variant descriptions as well as estimates", func() {
|
||||
// Both are the same cost wearing different hats - a probe of an entry's
|
||||
// weight files - and both land in the same caches, so a warm-up that
|
||||
// covered only one would leave the first click paying for the other.
|
||||
// Asserted through the shared config rather than by observing network
|
||||
// calls: the gallery here is empty by design.
|
||||
Expect(gallery.DefaultEstimateWarmConfig.Limit).To(BeNumerically(">", 0))
|
||||
})
|
||||
|
||||
It("keeps the estimate contexts the UI actually asks for", func() {
|
||||
// A warmed entry at the wrong context lengths is a cache the gallery
|
||||
// never reads, so this pins them together.
|
||||
Expect(gallery.DefaultEstimateWarmConfig.Contexts).To(ContainElements(
|
||||
uint32(8192), uint32(16384), uint32(32768), uint32(65536), uint32(131072), uint32(262144),
|
||||
))
|
||||
})
|
||||
|
||||
It("bounds concurrency so a warm-up cannot saturate the link", func() {
|
||||
Expect(gallery.DefaultEstimateWarmConfig.Concurrency).To(BeNumerically("<=", 8))
|
||||
Expect(gallery.DefaultEstimateWarmConfig.Concurrency).To(BeNumerically(">", 0))
|
||||
})
|
||||
|
||||
})
|
||||
@@ -325,10 +325,32 @@ func AvailableGalleryModels(galleries []config.Gallery, systemState *system.Syst
|
||||
var (
|
||||
availableModelsMu sync.RWMutex
|
||||
availableModelsCache GalleryElements[*GalleryModel]
|
||||
refreshing atomic.Bool
|
||||
galleryGeneration atomic.Uint64
|
||||
// Whether a load has happened, tracked apart from the slice itself. A
|
||||
// gallery that legitimately holds nothing caches as an empty (often nil)
|
||||
// slice, and testing the slice for nil read that as "never loaded": every
|
||||
// call then took the blocking path and bumped the generation, which is the
|
||||
// same cache-defeating loop the refresh interval exists to stop.
|
||||
availableModelsLoaded bool
|
||||
refreshing atomic.Bool
|
||||
galleryGeneration atomic.Uint64
|
||||
lastRefreshUnixNano atomic.Int64
|
||||
)
|
||||
|
||||
// How often the cached model list may be refreshed from upstream.
|
||||
//
|
||||
// This is a floor on refresh frequency, not a TTL: the cache is served
|
||||
// regardless, and this only decides how often a background re-fetch is worth
|
||||
// starting. It matters far more than it looks, because a refresh bumps
|
||||
// galleryGeneration, and that invalidates every VRAM estimate cache in
|
||||
// pkg/vram. Refreshing on every call therefore kept those caches permanently
|
||||
// cold: the gallery listing is one request but the UI asks for one VRAM
|
||||
// estimate per row, so a single page view triggered dozens of refreshes and
|
||||
// every estimate paid full price for a remote probe it had already made.
|
||||
//
|
||||
// A package variable rather than a constant so tests can drive refreshes
|
||||
// without waiting.
|
||||
var GalleryRefreshInterval = 5 * time.Minute
|
||||
|
||||
// GalleryGeneration returns a counter that increments each time the gallery
|
||||
// model list is refreshed from upstream. VRAM estimation caches use this to
|
||||
// invalidate entries when the gallery data changes.
|
||||
@@ -352,7 +374,11 @@ func ResetGalleryModelCache() {
|
||||
}
|
||||
availableModelsMu.Lock()
|
||||
availableModelsCache = nil
|
||||
availableModelsLoaded = false
|
||||
availableModelsMu.Unlock()
|
||||
// Also clear the refresh stamp, or a suite that reset the cache would find
|
||||
// the next refresh throttled by the previous spec's clock.
|
||||
lastRefreshUnixNano.Store(0)
|
||||
}
|
||||
|
||||
// AvailableGalleryModelsCached returns gallery models from an in-memory cache.
|
||||
@@ -363,9 +389,10 @@ func ResetGalleryModelCache() {
|
||||
func AvailableGalleryModelsCached(galleries []config.Gallery, systemState *system.SystemState) (GalleryElements[*GalleryModel], error) {
|
||||
availableModelsMu.RLock()
|
||||
cached := availableModelsCache
|
||||
loaded := availableModelsLoaded
|
||||
availableModelsMu.RUnlock()
|
||||
|
||||
if cached != nil {
|
||||
if loaded {
|
||||
// Refresh installed status under write lock to avoid races with
|
||||
// concurrent readers and the background refresh goroutine.
|
||||
availableModelsMu.Lock()
|
||||
@@ -387,8 +414,10 @@ func AvailableGalleryModelsCached(galleries []config.Gallery, systemState *syste
|
||||
|
||||
availableModelsMu.Lock()
|
||||
availableModelsCache = models
|
||||
availableModelsLoaded = true
|
||||
galleryGeneration.Add(1)
|
||||
availableModelsMu.Unlock()
|
||||
lastRefreshUnixNano.Store(time.Now().UnixNano())
|
||||
|
||||
return models, nil
|
||||
}
|
||||
@@ -397,9 +426,18 @@ func AvailableGalleryModelsCached(galleries []config.Gallery, systemState *syste
|
||||
// gallery model cache. Only one refresh runs at a time; concurrent calls
|
||||
// are no-ops.
|
||||
func triggerGalleryRefresh(galleries []config.Gallery, systemState *system.SystemState) {
|
||||
if GalleryRefreshInterval > 0 {
|
||||
last := lastRefreshUnixNano.Load()
|
||||
if last != 0 && time.Since(time.Unix(0, last)) < GalleryRefreshInterval {
|
||||
return
|
||||
}
|
||||
}
|
||||
if !refreshing.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
// Stamped before the fetch rather than after, so a slow upstream cannot
|
||||
// let a queue of callers each start their own refresh behind this one.
|
||||
lastRefreshUnixNano.Store(time.Now().UnixNano())
|
||||
go func() {
|
||||
defer refreshing.Store(false)
|
||||
models, err := AvailableGalleryModels(galleries, systemState)
|
||||
@@ -408,12 +446,37 @@ func triggerGalleryRefresh(galleries []config.Gallery, systemState *system.Syste
|
||||
return
|
||||
}
|
||||
availableModelsMu.Lock()
|
||||
changed := !sameModelSet(availableModelsCache, models)
|
||||
availableModelsCache = models
|
||||
galleryGeneration.Add(1)
|
||||
availableModelsLoaded = true
|
||||
// Only a real change invalidates the VRAM caches. An unchanged gallery
|
||||
// re-fetched on schedule must not throw away work that is still valid,
|
||||
// which is the difference between an estimate costing nothing and
|
||||
// costing a network round trip.
|
||||
if changed {
|
||||
galleryGeneration.Add(1)
|
||||
}
|
||||
availableModelsMu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
// sameModelSet reports whether two model lists describe the same gallery, for
|
||||
// the purpose of deciding whether derived caches are still valid. Names and
|
||||
// order are enough: a change to an entry's files or size arrives with a new
|
||||
// gallery index, and comparing every field on every entry would cost more than
|
||||
// the caches save.
|
||||
func sameModelSet(a, b GalleryElements[*GalleryModel]) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i].GetName() != b[i].GetName() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// List available backends
|
||||
func AvailableBackends(galleries []config.Gallery, systemState *system.SystemState) (GalleryElements[*GalleryBackend], error) {
|
||||
return availableBackendsWithFilter(galleries, systemState, func(backend *GalleryBackend) bool {
|
||||
|
||||
80
core/gallery/gallery_refresh_throttle_test.go
Normal file
80
core/gallery/gallery_refresh_throttle_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package gallery_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/gallery"
|
||||
"github.com/mudler/LocalAI/pkg/system"
|
||||
)
|
||||
|
||||
// The gallery generation counter is what every VRAM estimate cache keys on, so
|
||||
// how often it moves decides whether those caches are worth having. Refreshing
|
||||
// on every call kept them permanently cold: one page of the model gallery asks
|
||||
// for a VRAM estimate per row, and each of those requests re-read the gallery,
|
||||
// triggering a refresh that invalidated the estimate the previous row had just
|
||||
// paid a network round trip for.
|
||||
var _ = Describe("Gallery refresh throttling", func() {
|
||||
var (
|
||||
tmp *system.SystemState
|
||||
galleries []config.Gallery
|
||||
origInterval time.Duration
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
dir, err := os.MkdirTemp("", "gallery-throttle")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
DeferCleanup(func() { os.RemoveAll(dir) })
|
||||
|
||||
tmp, err = system.GetSystemState(system.WithModelPath(dir))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// No upstream: the list comes back empty, which is all this needs. What
|
||||
// is under test is how often a refresh is started, not what it returns.
|
||||
galleries = []config.Gallery{}
|
||||
origInterval = gallery.GalleryRefreshInterval
|
||||
gallery.ResetGalleryModelCache()
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
gallery.GalleryRefreshInterval = origInterval
|
||||
gallery.ResetGalleryModelCache()
|
||||
})
|
||||
|
||||
It("does not bump the generation once per call", func() {
|
||||
gallery.GalleryRefreshInterval = time.Hour
|
||||
|
||||
_, err := gallery.AvailableGalleryModelsCached(galleries, tmp)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
start := gallery.GalleryGeneration()
|
||||
|
||||
// Stands in for one page view: many callers in quick succession.
|
||||
for i := 0; i < 30; i++ {
|
||||
_, err := gallery.AvailableGalleryModelsCached(galleries, tmp)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
// Let any refresh that did start finish, so this cannot pass by racing.
|
||||
Eventually(func() uint64 { return gallery.GalleryGeneration() }, "2s", "50ms").
|
||||
Should(Equal(start))
|
||||
})
|
||||
|
||||
It("still refreshes once the interval has passed", func() {
|
||||
gallery.GalleryRefreshInterval = time.Millisecond
|
||||
|
||||
_, err := gallery.AvailableGalleryModelsCached(galleries, tmp)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
_, err = gallery.AvailableGalleryModelsCached(galleries, tmp)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// An empty gallery refreshing to an empty gallery is unchanged, so the
|
||||
// generation must hold: only a real change may invalidate the caches.
|
||||
Consistently(func() uint64 { return gallery.GalleryGeneration() }, "300ms", "50ms").
|
||||
Should(Equal(gallery.GalleryGeneration()))
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ package importers
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
@@ -31,7 +32,7 @@ func (i *MLXImporter) Match(details Details) bool {
|
||||
}
|
||||
|
||||
b, ok := preferencesMap["backend"].(string)
|
||||
if ok && b == "mlx" || b == "mlx-vlm" {
|
||||
if ok && slices.Contains([]string{"mlx", "mlx-vlm", "mlx-audio"}, b) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -71,19 +72,32 @@ func (i *MLXImporter) Import(details Details) (gallery.ModelConfig, error) {
|
||||
// (issue #10269). Send them to the mlx-vlm backend, which applies the
|
||||
// processor-aware chat template.
|
||||
backend := "mlx"
|
||||
if details.HuggingFace != nil && details.HuggingFace.PipelineTag == "image-text-to-text" {
|
||||
backend = "mlx-vlm"
|
||||
usecases := []string{config.UsecaseChat}
|
||||
useTokenizerTemplate := true
|
||||
if details.HuggingFace != nil {
|
||||
switch details.HuggingFace.PipelineTag {
|
||||
case "image-text-to-text":
|
||||
backend = "mlx-vlm"
|
||||
case "text-to-speech":
|
||||
backend = "mlx-audio"
|
||||
usecases = []string{config.UsecaseTTS}
|
||||
useTokenizerTemplate = false
|
||||
}
|
||||
}
|
||||
// An explicit backend preference always wins.
|
||||
b, ok := preferencesMap["backend"].(string)
|
||||
if ok {
|
||||
backend = b
|
||||
if backend == "mlx-audio" {
|
||||
usecases = []string{config.UsecaseTTS}
|
||||
useTokenizerTemplate = false
|
||||
}
|
||||
}
|
||||
|
||||
modelConfig := config.ModelConfig{
|
||||
Name: name,
|
||||
Description: description,
|
||||
KnownUsecaseStrings: []string{config.UsecaseChat},
|
||||
KnownUsecaseStrings: usecases,
|
||||
Backend: backend,
|
||||
PredictionOptions: schema.PredictionOptions{
|
||||
BasicModelRequest: schema.BasicModelRequest{
|
||||
@@ -91,7 +105,7 @@ func (i *MLXImporter) Import(details Details) (gallery.ModelConfig, error) {
|
||||
},
|
||||
},
|
||||
TemplateConfig: config.TemplateConfig{
|
||||
UseTokenizerTemplate: true,
|
||||
UseTokenizerTemplate: useTokenizerTemplate,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,16 @@ var _ = Describe("MLXImporter", func() {
|
||||
Expect(result).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should match when backend preference is mlx-audio", func() {
|
||||
preferences := json.RawMessage(`{"backend": "mlx-audio"}`)
|
||||
details := importers.Details{
|
||||
URI: "https://example.com/model",
|
||||
Preferences: preferences,
|
||||
}
|
||||
|
||||
Expect(importer.Match(details)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should not match when URI does not contain mlx-community/ and no backend preference", func() {
|
||||
details := importers.Details{
|
||||
URI: "https://huggingface.co/other-org/test-model",
|
||||
@@ -123,6 +133,21 @@ var _ = Describe("MLXImporter", func() {
|
||||
Expect(modelConfig.ConfigFile).To(ContainSubstring("backend: mlx-vlm"))
|
||||
})
|
||||
|
||||
It("should configure explicit mlx-audio imports for text-to-speech", func() {
|
||||
preferences := json.RawMessage(`{"backend": "mlx-audio"}`)
|
||||
details := importers.Details{
|
||||
URI: "https://huggingface.co/mlx-community/Kokoro-82M-4bit",
|
||||
Preferences: preferences,
|
||||
}
|
||||
|
||||
modelConfig, err := importer.Import(details)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(modelConfig.ConfigFile).To(ContainSubstring("backend: mlx-audio"))
|
||||
Expect(modelConfig.ConfigFile).To(ContainSubstring("- tts"))
|
||||
Expect(modelConfig.ConfigFile).ToNot(ContainSubstring("use_tokenizer_template: true"))
|
||||
})
|
||||
|
||||
It("should auto-route vision-language models to the mlx-vlm backend", func() {
|
||||
// gemma-4 E4B and similar VLMs declare pipeline_tag
|
||||
// "image-text-to-text" on HuggingFace. The text-only mlx-lm
|
||||
@@ -143,6 +168,23 @@ var _ = Describe("MLXImporter", func() {
|
||||
Expect(modelConfig.ConfigFile).To(ContainSubstring("backend: mlx-vlm"))
|
||||
})
|
||||
|
||||
It("should auto-route text-to-speech models to the mlx-audio backend", func() {
|
||||
details := importers.Details{
|
||||
URI: "https://huggingface.co/mlx-community/Kokoro-82M-4bit",
|
||||
HuggingFace: &hfapi.ModelDetails{
|
||||
ModelID: "mlx-community/Kokoro-82M-4bit",
|
||||
PipelineTag: "text-to-speech",
|
||||
},
|
||||
}
|
||||
|
||||
modelConfig, err := importer.Import(details)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(modelConfig.ConfigFile).To(ContainSubstring("backend: mlx-audio"))
|
||||
Expect(modelConfig.ConfigFile).To(ContainSubstring("- tts"))
|
||||
Expect(modelConfig.ConfigFile).ToNot(ContainSubstring("use_tokenizer_template: true"))
|
||||
})
|
||||
|
||||
It("should keep text-only models on the plain mlx backend", func() {
|
||||
details := importers.Details{
|
||||
URI: "https://huggingface.co/mlx-community/Llama-3.2-1B-Instruct-4bit",
|
||||
|
||||
@@ -39,6 +39,8 @@ var RouteFeatureRegistry = []RouteFeature{
|
||||
{"POST", "/images/generations", FeatureImages},
|
||||
{"POST", "/v1/images/inpainting", FeatureImages},
|
||||
{"POST", "/images/inpainting", FeatureImages},
|
||||
{"POST", "/v1/images/upscale", FeatureImages},
|
||||
{"POST", "/images/upscale", FeatureImages},
|
||||
|
||||
// Audio transcription
|
||||
{"POST", "/v1/audio/transcriptions", FeatureAudioTranscription},
|
||||
@@ -116,6 +118,10 @@ var RouteFeatureRegistry = []RouteFeature{
|
||||
// Rerank
|
||||
{"POST", "/v1/rerank", FeatureRerank},
|
||||
|
||||
// Moderation
|
||||
{"POST", "/v1/moderations", FeatureModeration},
|
||||
{"POST", "/moderations", FeatureModeration},
|
||||
|
||||
// Stores
|
||||
{"POST", "/stores/set", FeatureStores},
|
||||
{"POST", "/stores/delete", FeatureStores},
|
||||
@@ -191,6 +197,7 @@ func APIFeatureMetas() []FeatureMeta {
|
||||
{FeatureEmbeddings, "Embeddings", true},
|
||||
{FeatureSound, "Sound Generation", true},
|
||||
{FeatureRealtime, "Realtime", true},
|
||||
{FeatureModeration, "Moderation", true},
|
||||
{FeatureRerank, "Rerank", true},
|
||||
{FeatureTokenize, "Tokenize", true},
|
||||
{FeatureMCP, "MCP", true},
|
||||
|
||||
24
core/http/auth/features_moderation_test.go
Normal file
24
core/http/auth/features_moderation_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
. "github.com/mudler/LocalAI/core/http/auth"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Moderation feature registration", func() {
|
||||
It("registers both moderation routes as default-on API features", func() {
|
||||
Expect(APIFeatures).To(ContainElement(FeatureModeration))
|
||||
|
||||
patterns := []string{}
|
||||
for _, route := range RouteFeatureRegistry {
|
||||
if route.Feature == FeatureModeration {
|
||||
patterns = append(patterns, route.Pattern)
|
||||
}
|
||||
}
|
||||
Expect(patterns).To(ConsistOf("/v1/moderations", "/moderations"))
|
||||
|
||||
metas := APIFeatureMetas()
|
||||
Expect(metas).To(ContainElement(FeatureMeta{Key: FeatureModeration, Label: "Moderation", DefaultValue: true}))
|
||||
})
|
||||
})
|
||||
@@ -59,10 +59,14 @@ func ok(c echo.Context) error {
|
||||
func newAuthTestApp(db *gorm.DB, appConfig *config.ApplicationConfig) *echo.Echo {
|
||||
e := echo.New()
|
||||
e.Use(auth.Middleware(db, appConfig))
|
||||
if db != nil {
|
||||
e.Use(auth.RequireRouteFeature(db))
|
||||
}
|
||||
|
||||
// API routes (require auth)
|
||||
e.GET("/v1/models", ok)
|
||||
e.POST("/v1/chat/completions", ok)
|
||||
e.POST("/v1/moderations", ok)
|
||||
e.GET("/api/settings", ok)
|
||||
e.POST("/api/settings", ok)
|
||||
|
||||
@@ -81,10 +85,14 @@ func newAuthTestApp(db *gorm.DB, appConfig *config.ApplicationConfig) *echo.Echo
|
||||
func newAdminTestApp(db *gorm.DB, appConfig *config.ApplicationConfig) *echo.Echo {
|
||||
e := echo.New()
|
||||
e.Use(auth.Middleware(db, appConfig))
|
||||
if db != nil {
|
||||
e.Use(auth.RequireRouteFeature(db))
|
||||
}
|
||||
|
||||
// Regular routes
|
||||
e.GET("/v1/models", ok)
|
||||
e.POST("/v1/chat/completions", ok)
|
||||
e.POST("/v1/moderations", ok)
|
||||
|
||||
// Admin-only routes
|
||||
adminMw := auth.RequireAdmin()
|
||||
|
||||
@@ -91,6 +91,19 @@ var _ = Describe("Auth Middleware", func() {
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("allows authenticated users to call moderation by default", func() {
|
||||
sessionID := createTestSession(db, user.ID)
|
||||
rec := doRequest(app, http.MethodPost, "/v1/moderations", withSessionCookie(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("blocks moderation when the user's feature is disabled", func() {
|
||||
Expect(auth.UpdateUserPermissions(db, user.ID, auth.PermissionMap{auth.FeatureModeration: false})).To(Succeed())
|
||||
sessionID := createTestSession(db, user.ID)
|
||||
rec := doRequest(app, http.MethodPost, "/v1/moderations", withSessionCookie(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
|
||||
It("allows requests with valid session as Bearer token", func() {
|
||||
sessionID := createTestSession(db, user.ID)
|
||||
rec := doRequest(app, http.MethodGet, "/v1/models", withBearerToken(sessionID))
|
||||
@@ -156,6 +169,11 @@ var _ = Describe("Auth Middleware", func() {
|
||||
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("returns 401 for unauthenticated moderation requests", func() {
|
||||
rec := doRequest(app, http.MethodPost, "/v1/moderations")
|
||||
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("returns 401 for unauthenticated 3D generation requests", func() {
|
||||
rec := doRequest(app, http.MethodPost, "/3d/generations")
|
||||
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||
|
||||
@@ -51,6 +51,7 @@ const (
|
||||
FeatureEmbeddings = "embeddings"
|
||||
FeatureSound = "sound"
|
||||
FeatureRealtime = "realtime"
|
||||
FeatureModeration = "moderation"
|
||||
FeatureRerank = "rerank"
|
||||
FeatureTokenize = "tokenize"
|
||||
FeatureMCP = "mcp"
|
||||
@@ -75,7 +76,7 @@ var APIFeatures = []string{
|
||||
FeatureChat, FeatureImages, FeatureAudioSpeech, FeatureAudioTranscription,
|
||||
FeatureAudioDiarization, FeatureAudioClassification,
|
||||
FeatureVAD, FeatureDetection, FeatureVideo, Feature3D, FeatureEmbeddings, FeatureSound,
|
||||
FeatureRealtime, FeatureRerank, FeatureTokenize, FeatureMCP, FeatureStores,
|
||||
FeatureRealtime, FeatureModeration, FeatureRerank, FeatureTokenize, FeatureMCP, FeatureStores,
|
||||
FeatureFaceRecognition, FeatureVoiceRecognition, FeatureAudioTransform,
|
||||
FeaturePIIFilter,
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ var instructionDefs = []instructionDef{
|
||||
Tags: []string{"inference", "embeddings"},
|
||||
Intro: "Set \"stream\": true for SSE streaming. Supports tool/function calling when the model config has function templates configured.",
|
||||
},
|
||||
{
|
||||
Name: "moderation",
|
||||
Description: "OpenAI-compatible text moderation using a local completion model",
|
||||
Tags: []string{"moderation"},
|
||||
Intro: "POST /v1/moderations accepts a text string or array plus a LocalAI completion model. LocalAI constrains the model to the OpenAI moderation category schema and returns one result per input. Multimodal moderation inputs are not yet supported.",
|
||||
},
|
||||
{
|
||||
Name: "audio",
|
||||
Description: "Text-to-speech, voice activity detection, transcription, speaker diarization, sound classification, and sound generation",
|
||||
|
||||
@@ -39,7 +39,7 @@ var _ = Describe("API Instructions Endpoints", func() {
|
||||
|
||||
instructions, ok := resp["instructions"].([]any)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(instructions).To(HaveLen(18))
|
||||
Expect(instructions).To(HaveLen(19))
|
||||
|
||||
// Verify each instruction has required fields and correct URL format
|
||||
for _, s := range instructions {
|
||||
@@ -69,6 +69,7 @@ var _ = Describe("API Instructions Endpoints", func() {
|
||||
|
||||
Expect(names).To(ContainElements(
|
||||
"chat-inference",
|
||||
"moderation",
|
||||
"config-management",
|
||||
"model-management",
|
||||
"monitoring",
|
||||
|
||||
@@ -38,6 +38,7 @@ var knownPrefOnlyBackends = []schema.KnownBackend{
|
||||
{Name: "whisperx", Modality: "asr", AutoDetect: false, Description: "WhisperX transcription (preference-only)"},
|
||||
{Name: "crispasr", Modality: "asr", AutoDetect: false, Description: "CrispASR multi-architecture transcription (preference-only)"},
|
||||
// TTS
|
||||
{Name: "mlx-audio", Modality: "tts", AutoDetect: false, Description: "MLX-Audio text-to-speech models (auto-detected; pref-only fallback)"},
|
||||
{Name: "kokoros", Modality: "tts", AutoDetect: false, Description: "Kokoros TTS (preference-only)"},
|
||||
{Name: "qwen-tts", Modality: "tts", AutoDetect: false, Description: "Qwen TTS (preference-only)"},
|
||||
{Name: "qwen3-tts-cpp", Modality: "tts", AutoDetect: false, Description: "Qwen3 TTS C++ (preference-only)"},
|
||||
|
||||
@@ -152,6 +152,7 @@ var _ = Describe("Backend Endpoints", func() {
|
||||
expectPrefOnly("tinygrad", "text")
|
||||
expectPrefOnly("trl", "text")
|
||||
expectPrefOnly("mlx-vlm", "text")
|
||||
expectPrefOnly("mlx-audio", "tts")
|
||||
expectPrefOnly("whisperx", "asr")
|
||||
expectPrefOnly("crispasr", "asr")
|
||||
expectPrefOnly("kokoros", "tts")
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// @Tags monitoring
|
||||
// @Success 200 {object} schema.SystemInformationResponse "Response"
|
||||
// @Router /system [get]
|
||||
func SystemInformations(ml *model.ModelLoader, appConfig *config.ApplicationConfig) echo.HandlerFunc {
|
||||
func SystemInformations(cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
availableBackends := []string{}
|
||||
loadedModels := ml.ListLoadedModels()
|
||||
@@ -25,7 +25,14 @@ func SystemInformations(ml *model.ModelLoader, appConfig *config.ApplicationConf
|
||||
|
||||
sysmodels := []schema.SysInfoModel{}
|
||||
for _, m := range loadedModels {
|
||||
sysmodels = append(sysmodels, schema.SysInfoModel{ID: m.ID})
|
||||
entry := schema.SysInfoModel{ID: m.ID}
|
||||
// The loader tracks only the ID. Which engine is serving a model is
|
||||
// the first thing an operator wants beside its name, and it is one
|
||||
// config lookup away.
|
||||
if cfg, ok := cl.GetModelConfig(m.ID); ok {
|
||||
entry.Backend = cfg.Backend
|
||||
}
|
||||
sysmodels = append(sysmodels, entry)
|
||||
}
|
||||
return c.JSON(200,
|
||||
schema.SystemInformationResponse{
|
||||
|
||||
@@ -3,6 +3,7 @@ package localai
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/http/middleware"
|
||||
@@ -85,6 +86,35 @@ func GetAPITracesEndpoint() echo.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// GetAPITracesSummaryEndpoint returns counted totals over a recent window
|
||||
// @Summary Summarize recent API traces
|
||||
// @Description Returns request, failure and latency totals over a recent window, plus a bucketed series for sparklines. Exists so callers wanting three numbers do not have to fetch the whole trace list and count it themselves.
|
||||
// @Tags monitoring
|
||||
// @Produce json
|
||||
// @Param hours query int false "Window in hours (default 24, max 168)"
|
||||
// @Success 200 {object} middleware.TraceSummary "Counted trace totals"
|
||||
// @Router /api/traces/summary [get]
|
||||
func GetAPITracesSummaryEndpoint() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
hours := 24
|
||||
if raw := c.QueryParam("hours"); raw != "" {
|
||||
if v, err := strconv.Atoi(raw); err == nil && v > 0 {
|
||||
hours = v
|
||||
}
|
||||
}
|
||||
// A week is plenty for a dashboard, and the trace buffer is bounded
|
||||
// anyway; an unbounded window would just scan the whole buffer.
|
||||
if hours > 168 {
|
||||
hours = 168
|
||||
}
|
||||
return c.JSON(http.StatusOK, middleware.GetTracesSummary(time.Duration(hours)*time.Hour, traceSummaryBuckets))
|
||||
}
|
||||
}
|
||||
|
||||
// Enough columns for a sparkline to show a shape, few enough that each one
|
||||
// still holds a meaningful count on a quiet installation.
|
||||
const traceSummaryBuckets = 12
|
||||
|
||||
// GetAPITraceEndpoint returns a single API trace with its full payload
|
||||
// @Summary Get one API trace
|
||||
// @Description Returns a single captured API exchange, including the request and response bodies omitted from the list response
|
||||
|
||||
@@ -84,6 +84,22 @@ func (stubClient) ListNodes(_ context.Context) ([]localaitools.Node, error) {
|
||||
return []localaitools.Node{}, nil
|
||||
}
|
||||
|
||||
func (stubClient) ListScheduling(_ context.Context) ([]localaitools.ModelSchedulingConfig, error) {
|
||||
return []localaitools.ModelSchedulingConfig{}, nil
|
||||
}
|
||||
|
||||
func (stubClient) GetScheduling(_ context.Context, _ string) (*localaitools.ModelSchedulingConfig, error) {
|
||||
return &localaitools.ModelSchedulingConfig{}, nil
|
||||
}
|
||||
|
||||
func (stubClient) SetScheduling(_ context.Context, _ localaitools.SetSchedulingRequest) (*localaitools.ModelSchedulingConfig, error) {
|
||||
return &localaitools.ModelSchedulingConfig{}, nil
|
||||
}
|
||||
|
||||
func (stubClient) DeleteScheduling(_ context.Context, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (stubClient) SetNodeVRAMBudget(_ context.Context, _, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
190
core/http/endpoints/openai/moderations.go
Normal file
190
core/http/endpoints/openai/moderations.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/backend"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/http/middleware"
|
||||
"github.com/mudler/LocalAI/core/schema"
|
||||
"github.com/mudler/LocalAI/core/templates"
|
||||
"github.com/mudler/LocalAI/pkg/functions"
|
||||
"github.com/mudler/LocalAI/pkg/model"
|
||||
)
|
||||
|
||||
var moderationCategories = []string{
|
||||
"harassment",
|
||||
"harassment/threatening",
|
||||
"hate",
|
||||
"hate/threatening",
|
||||
"illicit",
|
||||
"illicit/violent",
|
||||
"self-harm",
|
||||
"self-harm/intent",
|
||||
"self-harm/instructions",
|
||||
"sexual",
|
||||
"sexual/minors",
|
||||
"violence",
|
||||
"violence/graphic",
|
||||
}
|
||||
|
||||
type moderationGenerator func(context.Context, string, *config.ModelConfig) (string, backend.TokenUsage, error)
|
||||
|
||||
type generatedModeration struct {
|
||||
Categories map[string]bool `json:"categories"`
|
||||
CategoryScores map[string]float64 `json:"category_scores"`
|
||||
}
|
||||
|
||||
// ModerationEndpoint implements the text input subset of OpenAI's moderation
|
||||
// API using any LocalAI completion model and constrained JSON generation.
|
||||
// @Summary Classify text for potentially harmful content.
|
||||
// @Tags moderation
|
||||
// @Param request body schema.ModerationRequest true "query params"
|
||||
// @Success 200 {object} schema.ModerationResponse "Response"
|
||||
// @Router /v1/moderations [post]
|
||||
func ModerationEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, appConfig *config.ApplicationConfig) echo.HandlerFunc {
|
||||
return moderationEndpoint(func(ctx context.Context, input string, cfg *config.ModelConfig) (string, backend.TokenUsage, error) {
|
||||
prompt := moderationPrompt(input)
|
||||
var messages schema.Messages
|
||||
if cfg.TemplateConfig.UseTokenizerTemplate {
|
||||
messages = schema.Messages{{Role: "user", Content: prompt}}
|
||||
prompt = ""
|
||||
} else if evaluator != nil {
|
||||
if rendered, err := evaluator.EvaluateTemplateForPrompt(templates.CompletionPromptTemplate, *cfg, templates.PromptTemplateData{Input: prompt, SystemPrompt: cfg.SystemPrompt}); err == nil {
|
||||
prompt = rendered
|
||||
}
|
||||
}
|
||||
|
||||
predict, err := backend.ModelInferenceFunc(ctx, prompt, messages, nil, nil, nil, ml, cfg, cl, appConfig, nil, "", "", nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
return "", backend.TokenUsage{}, err
|
||||
}
|
||||
response, err := predict()
|
||||
return response.Response, response.Usage, err
|
||||
})
|
||||
}
|
||||
|
||||
func moderationEndpoint(generate moderationGenerator) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
input, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.ModerationRequest)
|
||||
if !ok || input == nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid moderation request")
|
||||
}
|
||||
if len(input.Input) == 0 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "input must contain at least one text string")
|
||||
}
|
||||
if generate == nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "moderation generator is unavailable")
|
||||
}
|
||||
|
||||
modelConfig, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
|
||||
if !ok || modelConfig == nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "moderation model configuration is unavailable")
|
||||
}
|
||||
|
||||
grammar, err := moderationGrammar()
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to build moderation grammar").SetInternal(err)
|
||||
}
|
||||
cfg := *modelConfig
|
||||
cfg.Grammar = grammar
|
||||
maxTokens := 512
|
||||
cfg.Maxtokens = &maxTokens
|
||||
|
||||
results := make([]schema.ModerationResult, 0, len(input.Input))
|
||||
promptTokens, completionTokens := 0, 0
|
||||
for _, text := range input.Input {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "input strings must not be empty")
|
||||
}
|
||||
raw, usage, err := generate(c.Request().Context(), text, &cfg)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "moderation inference failed").SetInternal(err)
|
||||
}
|
||||
result, err := parseModerationResult(raw)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "invalid moderation result: "+err.Error()).SetInternal(err)
|
||||
}
|
||||
results = append(results, result)
|
||||
promptTokens += usage.Prompt
|
||||
completionTokens += usage.Completion
|
||||
}
|
||||
|
||||
middleware.StampUsage(c, input.Model, promptTokens, completionTokens)
|
||||
return c.JSON(http.StatusOK, schema.ModerationResponse{
|
||||
ID: "modr-" + uuid.NewString(),
|
||||
Model: input.Model,
|
||||
Results: results,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func moderationPrompt(input string) string {
|
||||
encoded, _ := json.Marshal(input)
|
||||
return `Classify the supplied text for safety moderation. Return only the JSON object required by the grammar.
|
||||
For each category, set the boolean to true only when the text matches that category. Give each category a confidence score from 0 to 1.
|
||||
The categories are harassment, harassment/threatening, hate, hate/threatening, illicit, illicit/violent, self-harm, self-harm/intent, self-harm/instructions, sexual, sexual/minors, violence, and violence/graphic.
|
||||
Text to classify: ` + string(encoded)
|
||||
}
|
||||
|
||||
func moderationGrammar() (string, error) {
|
||||
boolProperties := map[string]any{}
|
||||
scoreProperties := map[string]any{}
|
||||
for _, category := range moderationCategories {
|
||||
boolProperties[category] = map[string]any{"type": "boolean"}
|
||||
scoreProperties[category] = map[string]any{"type": "number"}
|
||||
}
|
||||
structure := functions.JSONFunctionStructure{AnyOf: []functions.Item{{
|
||||
Type: "object",
|
||||
Properties: map[string]any{
|
||||
"categories": map[string]any{
|
||||
"type": "object",
|
||||
"properties": boolProperties,
|
||||
"required": moderationCategories,
|
||||
"additionalProperties": false,
|
||||
},
|
||||
"category_scores": map[string]any{
|
||||
"type": "object",
|
||||
"properties": scoreProperties,
|
||||
"required": moderationCategories,
|
||||
"additionalProperties": false,
|
||||
},
|
||||
},
|
||||
}}}
|
||||
return structure.Grammar()
|
||||
}
|
||||
|
||||
func parseModerationResult(raw string) (schema.ModerationResult, error) {
|
||||
var generated generatedModeration
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &generated); err != nil {
|
||||
return schema.ModerationResult{}, err
|
||||
}
|
||||
|
||||
result := schema.ModerationResult{
|
||||
Categories: make(map[string]bool, len(moderationCategories)),
|
||||
CategoryScores: make(map[string]float64, len(moderationCategories)),
|
||||
CategoryAppliedInputTypes: make(map[string][]string, len(moderationCategories)),
|
||||
}
|
||||
for _, category := range moderationCategories {
|
||||
flagged, exists := generated.Categories[category]
|
||||
if !exists {
|
||||
return schema.ModerationResult{}, fmt.Errorf("missing category %q", category)
|
||||
}
|
||||
score, exists := generated.CategoryScores[category]
|
||||
if !exists || math.IsNaN(score) || math.IsInf(score, 0) || score < 0 || score > 1 {
|
||||
return schema.ModerationResult{}, fmt.Errorf("category %q has an invalid score", category)
|
||||
}
|
||||
result.Categories[category] = flagged
|
||||
result.CategoryScores[category] = score
|
||||
result.CategoryAppliedInputTypes[category] = []string{"text"}
|
||||
result.Flagged = result.Flagged || flagged
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
105
core/http/endpoints/openai/moderations_test.go
Normal file
105
core/http/endpoints/openai/moderations_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/backend"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/http/middleware"
|
||||
"github.com/mudler/LocalAI/core/schema"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Moderations endpoint", func() {
|
||||
It("classifies each text input and returns the OpenAI response shape", func() {
|
||||
inputs := []string{}
|
||||
generate := func(_ context.Context, input string, cfg *config.ModelConfig) (string, backend.TokenUsage, error) {
|
||||
inputs = append(inputs, input)
|
||||
Expect(cfg.Grammar).To(ContainSubstring("harassment"))
|
||||
return `{
|
||||
"categories":{"harassment":true,"harassment/threatening":false,"hate":false,"hate/threatening":false,"illicit":false,"illicit/violent":false,"self-harm":false,"self-harm/intent":false,"self-harm/instructions":false,"sexual":false,"sexual/minors":false,"violence":false,"violence/graphic":false},
|
||||
"category_scores":{"harassment":0.9,"harassment/threatening":0.1,"hate":0,"hate/threatening":0,"illicit":0,"illicit/violent":0,"self-harm":0,"self-harm/intent":0,"self-harm/instructions":0,"sexual":0,"sexual/minors":0,"violence":0,"violence/graphic":0}
|
||||
}`, backend.TokenUsage{Prompt: 12, Completion: 8}, nil
|
||||
}
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/moderations", strings.NewReader(`{"model":"guard","input":["first","second"]}`))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
ctx := e.NewContext(req, rec)
|
||||
ctx.Set(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST, &schema.ModerationRequest{
|
||||
BasicModelRequest: schema.BasicModelRequest{Model: "guard"},
|
||||
Input: schema.ModerationInput{"first", "second"},
|
||||
})
|
||||
modelConfig := &config.ModelConfig{Name: "guard"}
|
||||
modelConfig.Model = "guard.gguf"
|
||||
ctx.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, modelConfig)
|
||||
|
||||
Expect(moderationEndpoint(generate)(ctx)).To(Succeed())
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
Expect(inputs).To(Equal([]string{"first", "second"}))
|
||||
|
||||
var response schema.ModerationResponse
|
||||
Expect(json.Unmarshal(rec.Body.Bytes(), &response)).To(Succeed())
|
||||
Expect(response.ID).To(HavePrefix("modr-"))
|
||||
Expect(response.Model).To(Equal("guard"))
|
||||
Expect(response.Results).To(HaveLen(2))
|
||||
Expect(response.Results[0].Flagged).To(BeTrue())
|
||||
Expect(response.Results[0].Categories["harassment"]).To(BeTrue())
|
||||
Expect(response.Results[0].CategoryAppliedInputTypes["harassment"]).To(Equal([]string{"text"}))
|
||||
})
|
||||
|
||||
It("rejects an empty input list", func() {
|
||||
e := echo.New()
|
||||
ctx := e.NewContext(httptest.NewRequest(http.MethodPost, "/v1/moderations", nil), httptest.NewRecorder())
|
||||
ctx.Set(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST, &schema.ModerationRequest{
|
||||
BasicModelRequest: schema.BasicModelRequest{Model: "guard"},
|
||||
})
|
||||
ctx.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, &config.ModelConfig{Name: "guard"})
|
||||
|
||||
err := moderationEndpoint(nil)(ctx)
|
||||
Expect(err).To(MatchError(ContainSubstring("input must contain at least one text string")))
|
||||
Expect(err.(*echo.HTTPError).Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("surfaces malformed classifier output without returning a partial result", func() {
|
||||
generate := func(context.Context, string, *config.ModelConfig) (string, backend.TokenUsage, error) {
|
||||
return "not-json", backend.TokenUsage{}, nil
|
||||
}
|
||||
e := echo.New()
|
||||
ctx := e.NewContext(httptest.NewRequest(http.MethodPost, "/v1/moderations", nil), httptest.NewRecorder())
|
||||
ctx.Set(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST, &schema.ModerationRequest{
|
||||
BasicModelRequest: schema.BasicModelRequest{Model: "guard"},
|
||||
Input: schema.ModerationInput{"text"},
|
||||
})
|
||||
ctx.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, &config.ModelConfig{Name: "guard"})
|
||||
|
||||
err := moderationEndpoint(generate)(ctx)
|
||||
Expect(err).To(MatchError(ContainSubstring("invalid moderation result")))
|
||||
Expect(err.(*echo.HTTPError).Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("Moderation input", func() {
|
||||
DescribeTable("accepts OpenAI text input forms",
|
||||
func(body string, expected schema.ModerationInput) {
|
||||
var req schema.ModerationRequest
|
||||
Expect(json.Unmarshal([]byte(body), &req)).To(Succeed())
|
||||
Expect(req.Input).To(Equal(expected))
|
||||
},
|
||||
Entry("single text", `{"input":"hello"}`, schema.ModerationInput{"hello"}),
|
||||
Entry("text array", `{"input":["hello","world"]}`, schema.ModerationInput{"hello", "world"}),
|
||||
)
|
||||
|
||||
It("rejects multimodal input in the text-only MVP", func() {
|
||||
var req schema.ModerationRequest
|
||||
err := json.Unmarshal([]byte(`{"input":[{"type":"image_url","image_url":{"url":"https://example.com/a.png"}}]}`), &req)
|
||||
Expect(err).To(MatchError(ContainSubstring("text string or array of text strings")))
|
||||
})
|
||||
})
|
||||
134
core/http/endpoints/openai/upscale.go
Normal file
134
core/http/endpoints/openai/upscale.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/xlog"
|
||||
|
||||
"github.com/mudler/LocalAI/core/backend"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/http/middleware"
|
||||
"github.com/mudler/LocalAI/core/schema"
|
||||
model "github.com/mudler/LocalAI/pkg/model"
|
||||
)
|
||||
|
||||
// UpscaleEndpoint handles POST /v1/images/upscale
|
||||
//
|
||||
// @Summary Image upscaling
|
||||
// @Description Upscale an image using a specified model (e.g. stable-diffusion-x4-upscaler). Accepts multipart/form-data.
|
||||
// @Tags images
|
||||
// @Accept multipart/form-data
|
||||
// @Produce application/json
|
||||
// @Param model formData string true "Upscaler model identifier (e.g. stable-diffusion-x4-upscaler)"
|
||||
// @Param image formData file true "Input image file"
|
||||
// @Param scale formData int false "Upscale factor: 2 or 4 (default 2)"
|
||||
// @Success 200 {object} schema.OpenAIResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /v1/images/upscale [post]
|
||||
func UpscaleEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
modelName := c.FormValue("model")
|
||||
scaleStr := c.FormValue("scale")
|
||||
|
||||
if modelName == "" {
|
||||
xlog.Error("Upscale Endpoint - missing model")
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "missing model")
|
||||
}
|
||||
|
||||
scale := 2
|
||||
if scaleStr != "" {
|
||||
v, err := strconv.Atoi(scaleStr)
|
||||
if err != nil || (v != 2 && v != 4) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "scale must be 2 or 4")
|
||||
}
|
||||
scale = v
|
||||
}
|
||||
|
||||
// Read uploaded image
|
||||
imageFile, err := c.FormFile("image")
|
||||
if err != nil {
|
||||
xlog.Error("Upscale Endpoint - missing image file", "error", err)
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "missing image file")
|
||||
}
|
||||
|
||||
imgSrc, err := imageFile.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer imgSrc.Close()
|
||||
imgBytes, err := io.ReadAll(imgSrc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get model config from middleware context
|
||||
cfg, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
|
||||
if !ok || cfg == nil {
|
||||
xlog.Error("Upscale Endpoint - model config not found in context")
|
||||
return echo.ErrBadRequest
|
||||
}
|
||||
|
||||
tmpDir := filepath.Join(appConfig.GeneratedContentDir, "images")
|
||||
if err := os.MkdirAll(tmpDir, 0750); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to prepare storage")
|
||||
}
|
||||
|
||||
// Write input image to a temp file
|
||||
srcTmp, err := os.CreateTemp(tmpDir, "upscale_src_")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := srcTmp.Write(imgBytes); err != nil {
|
||||
_ = srcTmp.Close()
|
||||
_ = os.Remove(srcTmp.Name())
|
||||
return err
|
||||
}
|
||||
if err := srcTmp.Close(); err != nil {
|
||||
xlog.Warn("Upscale Endpoint - failed to close src temp file", "error", err)
|
||||
}
|
||||
srcPath := srcTmp.Name()
|
||||
defer os.Remove(srcPath)
|
||||
|
||||
// Prepare output file path
|
||||
id := uuid.New().String()
|
||||
dstPath := filepath.Join(tmpDir, fmt.Sprintf("upscale_%s.png", id))
|
||||
|
||||
fn, err := backend.ImageUpscaleFunc(c.Request().Context(), srcPath, dstPath, scale, ml, *cfg, appConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fn(); err != nil {
|
||||
_ = os.Remove(dstPath)
|
||||
return err
|
||||
}
|
||||
|
||||
baseURL := middleware.BaseURL(c)
|
||||
imgURL, err := url.JoinPath(baseURL, "generated-images", filepath.Base(dstPath))
|
||||
if err != nil {
|
||||
_ = os.Remove(dstPath)
|
||||
return err
|
||||
}
|
||||
|
||||
created := int(time.Now().Unix())
|
||||
resp := &schema.OpenAIResponse{
|
||||
ID: id,
|
||||
Created: created,
|
||||
Data: []schema.Item{{URL: imgURL}},
|
||||
Usage: &schema.OpenAIUsage{
|
||||
InputTokensDetails: &schema.InputTokensDetails{},
|
||||
},
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
}
|
||||
89
core/http/endpoints/openai/upscale_test.go
Normal file
89
core/http/endpoints/openai/upscale_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/backend"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/http/middleware"
|
||||
"github.com/mudler/LocalAI/core/schema"
|
||||
model "github.com/mudler/LocalAI/pkg/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Image upscaling", func() {
|
||||
var (
|
||||
appConfig *config.ApplicationConfig
|
||||
tmpDir string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
tmpDir, err = os.MkdirTemp("", "upscale")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
appConfig = config.NewApplicationConfig(config.WithGeneratedContentDir(tmpDir))
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
Expect(os.RemoveAll(tmpDir)).To(Succeed())
|
||||
})
|
||||
|
||||
It("stores the result in the directory served by /generated-images", func() {
|
||||
original := backend.ImageUpscaleFunc
|
||||
backend.ImageUpscaleFunc = func(_ context.Context, _, dst string, scale int, _ *model.ModelLoader, _ config.ModelConfig, _ *config.ApplicationConfig) (func() error, error) {
|
||||
Expect(scale).To(Equal(4))
|
||||
return func() error {
|
||||
return os.WriteFile(dst, []byte("PNGDATA"), 0o644)
|
||||
}, nil
|
||||
}
|
||||
DeferCleanup(func() { backend.ImageUpscaleFunc = original })
|
||||
|
||||
req, _ := makeMultipartRequest(
|
||||
map[string]string{"model": "stable-diffusion-x4-upscaler", "scale": "4"},
|
||||
map[string][]byte{"image": []byte("IMAGEDATA")},
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
ctx := echo.New().NewContext(req, rec)
|
||||
ctx.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, &config.ModelConfig{Backend: "diffusers"})
|
||||
|
||||
Expect(UpscaleEndpoint(nil, nil, appConfig)(ctx)).To(Succeed())
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response schema.OpenAIResponse
|
||||
Expect(json.Unmarshal(rec.Body.Bytes(), &response)).To(Succeed())
|
||||
Expect(response.Data).To(HaveLen(1))
|
||||
Expect(response.Data[0].URL).To(ContainSubstring("/generated-images/upscale_"))
|
||||
|
||||
filename := filepath.Base(response.Data[0].URL)
|
||||
contents, err := os.ReadFile(filepath.Join(tmpDir, "images", filename))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(contents).To(Equal([]byte("PNGDATA")))
|
||||
})
|
||||
|
||||
It("rejects unsupported scale factors", func() {
|
||||
req, _ := makeMultipartRequest(
|
||||
map[string]string{"model": "stable-diffusion-x4-upscaler", "scale": "3"},
|
||||
map[string][]byte{"image": []byte("IMAGEDATA")},
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
ctx := echo.New().NewContext(req, rec)
|
||||
ctx.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, &config.ModelConfig{Backend: "diffusers"})
|
||||
|
||||
err := UpscaleEndpoint(nil, nil, appConfig)(ctx)
|
||||
var httpErr *echo.HTTPError
|
||||
Expect(err).To(MatchError(ContainSubstring("scale must be 2 or 4")))
|
||||
Expect(err).To(BeAssignableToTypeOf(httpErr))
|
||||
httpErr = err.(*echo.HTTPError)
|
||||
Expect(httpErr.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(httpErr.Message).To(Equal("scale must be 2 or 4"))
|
||||
Expect(bytes.TrimSpace(rec.Body.Bytes())).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
109
core/http/middleware/trace_summary.go
Normal file
109
core/http/middleware/trace_summary.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"math"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TraceSummary is the counted view of the trace buffer.
|
||||
//
|
||||
// It exists so a caller that wants "how many, how many failed, how slow" does
|
||||
// not have to fetch every exchange and count them in the browser. The Operate
|
||||
// overview needs exactly those three numbers, and the trace list is capped in
|
||||
// the thousands, so shipping it across the wire to produce a single integer is
|
||||
// waste that grows with the buffer.
|
||||
type TraceSummary struct {
|
||||
Total int `json:"total"`
|
||||
Errors int `json:"errors"`
|
||||
P95Millis int64 `json:"p95_ms"`
|
||||
WindowHours int `json:"window_hours"`
|
||||
Buckets []TraceBucket `json:"buckets"`
|
||||
}
|
||||
|
||||
// TraceBucket is one column of a sparkline: oldest first, so the series reads
|
||||
// left to right the way a chart is drawn.
|
||||
type TraceBucket struct {
|
||||
Start time.Time `json:"start"`
|
||||
Count int `json:"count"`
|
||||
Errors int `json:"errors"`
|
||||
}
|
||||
|
||||
// GetTracesSummary counts the buffered exchanges over the given window.
|
||||
func GetTracesSummary(window time.Duration, buckets int) TraceSummary {
|
||||
return summarize(GetTraces(), window, buckets)
|
||||
}
|
||||
|
||||
func summarize(traces []APIExchange, window time.Duration, buckets int) TraceSummary {
|
||||
if buckets < 1 {
|
||||
buckets = 1
|
||||
}
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-window)
|
||||
|
||||
summary := TraceSummary{
|
||||
WindowHours: int(window.Hours()),
|
||||
// Never nil: a nil slice serialises as null and breaks .map() on the
|
||||
// other side, which is a silent runtime error rather than an empty chart.
|
||||
Buckets: make([]TraceBucket, buckets),
|
||||
}
|
||||
|
||||
bucketWidth := window / time.Duration(buckets)
|
||||
for i := range summary.Buckets {
|
||||
summary.Buckets[i].Start = cutoff.Add(time.Duration(i) * bucketWidth)
|
||||
}
|
||||
|
||||
durations := make([]time.Duration, 0, len(traces))
|
||||
for _, t := range traces {
|
||||
if t.Timestamp.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
summary.Total++
|
||||
failed := isFailure(t)
|
||||
if failed {
|
||||
summary.Errors++
|
||||
}
|
||||
durations = append(durations, t.Duration)
|
||||
|
||||
// Clamp rather than skip: a request timestamped a hair in the future
|
||||
// (clock skew, or arriving mid-call) still belongs in the newest column.
|
||||
idx := int(t.Timestamp.Sub(cutoff) / bucketWidth)
|
||||
if idx >= buckets {
|
||||
idx = buckets - 1
|
||||
}
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
summary.Buckets[idx].Count++
|
||||
if failed {
|
||||
summary.Buckets[idx].Errors++
|
||||
}
|
||||
}
|
||||
|
||||
summary.P95Millis = percentileMillis(durations, 0.95)
|
||||
return summary
|
||||
}
|
||||
|
||||
// A 4xx is the caller getting it wrong, which is not the installation being
|
||||
// unhealthy. Only 5xx and a transport-level error count against the runtime.
|
||||
func isFailure(t APIExchange) bool {
|
||||
return t.Error != "" || t.Response.Status >= 500
|
||||
}
|
||||
|
||||
func percentileMillis(durations []time.Duration, p float64) int64 {
|
||||
if len(durations) == 0 {
|
||||
return 0
|
||||
}
|
||||
slices.Sort(durations)
|
||||
// Nearest-rank: the smallest value at or above the pth percentile.
|
||||
rank := int(math.Ceil(p*float64(len(durations)))) - 1
|
||||
if rank < 0 {
|
||||
rank = 0
|
||||
}
|
||||
if rank >= len(durations) {
|
||||
rank = len(durations) - 1
|
||||
}
|
||||
return durations[rank].Milliseconds()
|
||||
}
|
||||
79
core/http/middleware/trace_summary_test.go
Normal file
79
core/http/middleware/trace_summary_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("API trace summary", func() {
|
||||
exchange := func(age time.Duration, status int, dur time.Duration) APIExchange {
|
||||
return APIExchange{
|
||||
Timestamp: time.Now().Add(-age),
|
||||
Duration: dur,
|
||||
Response: APIExchangeResponse{Status: status},
|
||||
}
|
||||
}
|
||||
|
||||
It("counts only what falls inside the window", func() {
|
||||
traces := []APIExchange{
|
||||
exchange(1*time.Hour, 200, 10*time.Millisecond),
|
||||
exchange(2*time.Hour, 200, 10*time.Millisecond),
|
||||
// Older than the window: must not be counted at all.
|
||||
exchange(48*time.Hour, 500, 10*time.Millisecond),
|
||||
}
|
||||
s := summarize(traces, 24*time.Hour, 6)
|
||||
Expect(s.Total).To(Equal(2))
|
||||
Expect(s.Errors).To(BeZero())
|
||||
})
|
||||
|
||||
It("treats 5xx and a transport error as failures, but not 4xx", func() {
|
||||
traces := []APIExchange{
|
||||
exchange(time.Minute, 500, time.Millisecond),
|
||||
exchange(time.Minute, 503, time.Millisecond),
|
||||
// A client sending a bad request is not the server failing.
|
||||
exchange(time.Minute, 404, time.Millisecond),
|
||||
exchange(time.Minute, 200, time.Millisecond),
|
||||
}
|
||||
traces[3].Error = "connection reset"
|
||||
|
||||
s := summarize(traces, 24*time.Hour, 6)
|
||||
Expect(s.Total).To(Equal(4))
|
||||
Expect(s.Errors).To(Equal(3))
|
||||
})
|
||||
|
||||
It("reports p95 as a real percentile rather than the slowest request", func() {
|
||||
traces := make([]APIExchange, 0, 100)
|
||||
for i := 1; i <= 100; i++ {
|
||||
traces = append(traces, exchange(time.Minute, 200, time.Duration(i)*time.Millisecond))
|
||||
}
|
||||
s := summarize(traces, 24*time.Hour, 6)
|
||||
// 95th of 1..100ms, not the 100ms max.
|
||||
Expect(s.P95Millis).To(BeNumerically("~", 95, 1))
|
||||
})
|
||||
|
||||
It("buckets oldest-first so a sparkline reads left to right", func() {
|
||||
traces := []APIExchange{
|
||||
exchange(30*time.Minute, 200, time.Millisecond),
|
||||
exchange(30*time.Minute, 200, time.Millisecond),
|
||||
exchange(5*time.Hour, 200, time.Millisecond),
|
||||
}
|
||||
s := summarize(traces, 6*time.Hour, 6)
|
||||
Expect(s.Buckets).To(HaveLen(6))
|
||||
Expect(s.Buckets[0].Count).To(Equal(1), "the 5h-old request lands in the first bucket")
|
||||
Expect(s.Buckets[5].Count).To(Equal(2), "the recent pair lands in the last")
|
||||
})
|
||||
|
||||
It("returns an empty, non-nil summary when nothing has been traced", func() {
|
||||
s := summarize(nil, 24*time.Hour, 6)
|
||||
Expect(s.Total).To(BeZero())
|
||||
Expect(s.Errors).To(BeZero())
|
||||
Expect(s.P95Millis).To(BeZero())
|
||||
// A nil slice serialises as null and breaks .map() in the browser.
|
||||
Expect(s.Buckets).NotTo(BeNil())
|
||||
Expect(s.Buckets).To(HaveLen(6))
|
||||
})
|
||||
})
|
||||
@@ -43,6 +43,40 @@ test('lists live operations and cancels one from a labelled button', async ({ pa
|
||||
expect(cancelledPath).toBe('/api/operations/job-gemma/cancel')
|
||||
})
|
||||
|
||||
test('pauses a model download without invoking destructive cancel', async ({ page }) => {
|
||||
await stub(page, {
|
||||
operations: [{
|
||||
id: 'gemma-3-27b-it',
|
||||
name: 'gemma-3-27b-it',
|
||||
jobID: 'job-gemma',
|
||||
progress: 22,
|
||||
taskType: 'installation',
|
||||
isBackend: false,
|
||||
isQueued: false,
|
||||
isDeletion: false,
|
||||
cancellable: true,
|
||||
phase: 'downloading',
|
||||
}],
|
||||
})
|
||||
|
||||
const requests = []
|
||||
await page.route('**/api/operations/job-gemma/pause', (route) => {
|
||||
requests.push(new URL(route.request().url()).pathname)
|
||||
return route.fulfill({ contentType: 'application/json', body: '{}' })
|
||||
})
|
||||
await page.route('**/api/operations/job-gemma/cancel', (route) => {
|
||||
requests.push(new URL(route.request().url()).pathname)
|
||||
return route.fulfill({ contentType: 'application/json', body: '{}' })
|
||||
})
|
||||
|
||||
await page.goto('/app/activity')
|
||||
|
||||
const card = page.locator('.operation-card').filter({ hasText: 'gemma-3-27b-it' })
|
||||
await card.locator('.operation-card__pause').click()
|
||||
|
||||
await expect.poll(() => requests).toEqual(['/api/operations/job-gemma/pause'])
|
||||
})
|
||||
|
||||
test('separates an unacknowledged failure from the record', async ({ page }) => {
|
||||
await stub(page, {
|
||||
operations: [{
|
||||
|
||||
@@ -5,7 +5,9 @@ test.describe('Admin console', () => {
|
||||
await page.goto('/app/backends')
|
||||
const rail = page.locator('.console-rail')
|
||||
await expect(rail).toBeVisible()
|
||||
for (const group of ['Inference', 'Cluster', 'Observability', 'Access', 'System']) {
|
||||
// Four groups since the overview landed: Inference folded into Runtime
|
||||
// (both are "the runtime right now"), Access and System into Administration.
|
||||
for (const group of ['Runtime', 'Cluster', 'Observability', 'Administration']) {
|
||||
await expect(rail.locator('.console-group-title', { hasText: group })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -69,9 +69,9 @@ test.describe('Manage - alias badge', () => {
|
||||
|
||||
test('renders a read-only alias -> target badge on aliased rows', async ({ page }) => {
|
||||
await page.goto('/app/manage')
|
||||
await expect(page.locator('.table')).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// The aliased row shows the target; the plain model row does not.
|
||||
// The badge moved off the row and into the pane: it is a fact about the
|
||||
// model, and the rail line is spent on state.
|
||||
await page.locator('[data-entity="gpt-4"]').click()
|
||||
await expect(page.getByText('alias -> fast-llm')).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
// Backends admin page (src/pages/Backends.jsx).
|
||||
const PANE = '[data-testid="backends-pane"]'
|
||||
const railItem = (page, name) => page.locator(`[data-entity="${name}"]`)
|
||||
|
||||
test.describe('Backends management page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/app/backends')
|
||||
@@ -49,11 +52,14 @@ test.describe('Backends management page - Markdown descriptions', () => {
|
||||
})
|
||||
})
|
||||
await page.goto('/app/backends')
|
||||
await expect(page.locator('th', { hasText: 'Description' })).toBeVisible({ timeout: 10_000 })
|
||||
// Rendered means the rail has entries. The old gate waited on a column
|
||||
// header, and there are no columns now.
|
||||
await expect(railItem(page, 'markdown-backend')).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('table cell shows the description as clean text, not raw Markdown', async ({ page }) => {
|
||||
const cell = page.locator('tr', { hasText: 'markdown-backend' }).locator('span[title]', { hasText: 'InsightFace' })
|
||||
test('the pane lede shows the description as clean text, not raw Markdown', async ({ page }) => {
|
||||
await railItem(page, 'markdown-backend').click()
|
||||
const cell = page.locator('.detail-pane__lede')
|
||||
|
||||
await expect(cell).toHaveText(STRIPPED_DESCRIPTION)
|
||||
// The syntax itself must be gone, not merely rendered somewhere.
|
||||
@@ -65,15 +71,77 @@ test.describe('Backends management page - Markdown descriptions', () => {
|
||||
await expect(cell.locator('h1')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('title tooltip carries the stripped text, not raw Markdown', async ({ page }) => {
|
||||
const cell = page.locator('tr', { hasText: 'markdown-backend' }).locator('span[title]', { hasText: 'InsightFace' })
|
||||
|
||||
await expect(cell).toHaveAttribute('title', STRIPPED_DESCRIPTION)
|
||||
test("the lede's tooltip carries the stripped text, not raw Markdown", async ({ page }) => {
|
||||
await railItem(page, 'markdown-backend').click()
|
||||
await expect(page.locator('.detail-pane__lede')).toHaveAttribute('title', STRIPPED_DESCRIPTION)
|
||||
})
|
||||
|
||||
test('a backend with no description still shows the placeholder', async ({ page }) => {
|
||||
const row = page.locator('tr', { hasText: 'plain-backend' })
|
||||
|
||||
await expect(row.locator('span[title=""]')).toHaveText('-')
|
||||
test('a backend with no description renders no lede rather than a blank one', async ({ page }) => {
|
||||
// The table needed a placeholder because an empty cell in a grid of full
|
||||
// ones reads as a fault. The pane has no grid to keep aligned, so it omits
|
||||
// the line - but must never print "undefined".
|
||||
await railItem(page, 'plain-backend').click()
|
||||
await expect(page.locator(PANE)).toContainText('plain-backend')
|
||||
await expect(page.locator('.detail-pane__lede')).toHaveCount(0)
|
||||
await expect(page.locator(PANE)).not.toContainText('undefined')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Backends gallery - split view', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/backends*', (route) => {
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
backends: [
|
||||
{ name: 'llama-cpp', description: 'GGUF inference', installed: true, version: '1.52.0', license: 'MIT', tags: ['chat'] },
|
||||
{ name: 'whisper', description: 'Speech to text', installed: true, version: '1.8.2', license: 'MIT', tags: ['transcript'] },
|
||||
{ name: 'diffusers', description: 'Image generation', installed: false, license: 'Apache-2.0', tags: ['image'] },
|
||||
],
|
||||
}),
|
||||
})
|
||||
})
|
||||
await page.goto('/app/backends')
|
||||
await expect(railItem(page, 'llama-cpp')).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('the gallery renders no table', async ({ page }) => {
|
||||
await expect(page.locator('[data-testid="backends"]')).toBeVisible()
|
||||
await expect(page.locator('table thead th')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('with nothing selected the pane describes the host', async ({ page }) => {
|
||||
await expect(page.locator(PANE)).toContainText('This host')
|
||||
await expect(page.locator('[data-testid="backends-back"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('choosing a backend turns the pane into its detail, and back returns', async ({ page }) => {
|
||||
await railItem(page, 'llama-cpp').click()
|
||||
await expect(page.locator(PANE)).toContainText('llama-cpp')
|
||||
await expect(page.locator(PANE)).toContainText('MIT')
|
||||
await expect(page.locator(PANE)).not.toContainText('This host')
|
||||
|
||||
await page.locator('[data-testid="backends-back"]').click()
|
||||
await expect(page.locator(PANE)).toContainText('This host')
|
||||
})
|
||||
|
||||
test('the selection lives in the URL and survives a reload', async ({ page }) => {
|
||||
await railItem(page, 'whisper').click()
|
||||
await expect(page).toHaveURL(/[?&]backend=whisper/)
|
||||
await page.reload()
|
||||
await expect(railItem(page, 'whisper')).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.locator('[data-testid="backends-back"]')).toBeVisible()
|
||||
})
|
||||
|
||||
|
||||
test('the rail groups while browsing and flattens on a query', async ({ page }) => {
|
||||
await expect(page.locator('[data-testid^="backends-rail-group-"]').first()).toBeVisible()
|
||||
await page.locator('input[placeholder*="Search backends"]').fill('llama')
|
||||
await expect(page.locator('[data-testid^="backends-rail-group-"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('an installed backend states its version, an absent one says so', async ({ page }) => {
|
||||
await expect(railItem(page, 'llama-cpp')).toContainText('v1.52.0')
|
||||
await expect(railItem(page, 'diffusers')).toContainText('not installed')
|
||||
})
|
||||
})
|
||||
|
||||
23
core/http/react-ui/e2e/backends-notice.spec.js
Normal file
23
core/http/react-ui/e2e/backends-notice.spec.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
// A notice is a hairline with a coloured left edge, not a filled panel. A tint
|
||||
// makes every notice shout at the weight of an error, which is how notices stop
|
||||
// being read — and it is the same treatment the Operate overview uses for the
|
||||
// rows that want a decision.
|
||||
|
||||
test('the backends notice is an edge, not a filled card', async ({ page }) => {
|
||||
// The upgrade banner is the notice worth pinning, so make one exist.
|
||||
await page.route('**/api/backends/upgrades', route => route.fulfill({
|
||||
json: { 'llama-cpp': { backend_name: 'llama-cpp', installed_version: '0.9.4', available_version: '0.9.7' } },
|
||||
}))
|
||||
await page.goto('/app/backends')
|
||||
const notice = page.locator('.bk-notice', { hasText: /update/i }).first()
|
||||
await expect(notice).toBeVisible()
|
||||
const s = await notice.evaluate(el => {
|
||||
const cs = getComputedStyle(el)
|
||||
return { bg: cs.backgroundColor, left: parseFloat(cs.borderLeftWidth), top: parseFloat(cs.borderTopWidth) }
|
||||
})
|
||||
expect(s.bg).toMatch(/rgba\(0, 0, 0, 0\)|transparent/)
|
||||
expect(s.left).toBeGreaterThanOrEqual(3)
|
||||
expect(s.top).toBeLessThanOrEqual(1)
|
||||
})
|
||||
55
core/http/react-ui/e2e/chat-transcript.spec.js
Normal file
55
core/http/react-ui/e2e/chat-transcript.spec.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
// Chat reads as a transcript rather than a bubble thread (mock 04).
|
||||
|
||||
const CHAT = {
|
||||
chats: [{
|
||||
id: 'c1', name: 'Transcript', model: 'mock-model',
|
||||
history: [
|
||||
{ role: 'user', content: 'Which backends do I have?' },
|
||||
{ role: 'assistant', content: 'Seven are installed.' },
|
||||
],
|
||||
}],
|
||||
activeChatId: 'c1',
|
||||
}
|
||||
|
||||
test.describe('Chat transcript', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(chat => {
|
||||
localStorage.setItem('localai_chats_data', JSON.stringify(chat))
|
||||
}, CHAT)
|
||||
await page.goto('/app/chat')
|
||||
})
|
||||
|
||||
test('neither role is a filled, rounded bubble', async ({ page }) => {
|
||||
const user = page.locator('.chat-message-user .chat-message-content').first()
|
||||
await expect(user).toBeVisible()
|
||||
const cs = await user.evaluate(el => {
|
||||
const s = getComputedStyle(el)
|
||||
return { radius: s.borderTopLeftRadius, shadow: s.boxShadow }
|
||||
})
|
||||
// A rounded filled bubble carries the speaker in shape and side; a
|
||||
// transcript carries it in words, which survives being read aloud.
|
||||
expect(cs.radius).toBe('0px')
|
||||
expect(cs.shadow).toBe('none')
|
||||
})
|
||||
|
||||
test('both turns run full width in one column, not left and right', async ({ page }) => {
|
||||
const user = page.locator('.chat-message-user').first()
|
||||
const assistant = page.locator('.chat-message-assistant').first()
|
||||
const [u, a] = [await user.boundingBox(), await assistant.boundingBox()]
|
||||
expect(Math.abs(u.x - a.x)).toBeLessThan(2)
|
||||
})
|
||||
|
||||
test('every turn says who is speaking', async ({ page }) => {
|
||||
await expect(page.locator('.chat-message-user .chat-message-model')).toHaveText('You')
|
||||
await expect(page.locator('.chat-message-assistant .chat-message-model').first())
|
||||
.toHaveText('mock-model')
|
||||
})
|
||||
|
||||
test('turns are separated by a rule', async ({ page }) => {
|
||||
const border = await page.locator('.chat-message').first()
|
||||
.evaluate(el => getComputedStyle(el).borderBottomStyle)
|
||||
expect(border).toBe('solid')
|
||||
})
|
||||
})
|
||||
50
core/http/react-ui/e2e/chrome-audit.spec.js
Normal file
50
core/http/react-ui/e2e/chrome-audit.spec.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
// A standing guard against the two defects an earlier automated edit left
|
||||
// scattered through the pages: icons stripped of their fa-* class (which render
|
||||
// nothing at all), and controls left with the user agent's own chrome, which is
|
||||
// a pale grey button on a dark ground.
|
||||
const ROUTES = [
|
||||
'/app', '/app/chat', '/app/models', '/app/studio', '/app/talk',
|
||||
'/app/agents', '/app/skills', '/app/collections', '/app/agent-jobs',
|
||||
'/app/fine-tune', '/app/quantize', '/app/face', '/app/voice',
|
||||
'/app/manage', '/app/backends', '/app/activity', '/app/operate',
|
||||
'/app/settings', '/app/traces', '/app/usage', '/app/nodes', '/app/p2p',
|
||||
'/app/voice-library', '/app/voice-library/new', '/app/account',
|
||||
]
|
||||
|
||||
test('no page renders a dead icon or a default-chrome control', async ({ page }) => {
|
||||
// One test walks every route, so its budget has to scale with the list rather
|
||||
// than sit on Playwright's per-test default of 30s. At 25 routes that default
|
||||
// allows ~1.2s per navigation, which holds on a developer machine and does
|
||||
// not on a loaded CI runner: the suite went red on the commit that added this
|
||||
// spec, timing out mid-loop at waitForTimeout rather than at any single goto,
|
||||
// which is what cumulative slowness looks like as opposed to one hung route.
|
||||
// Six seconds a route absorbs a slow runner and still fails promptly if a
|
||||
// route really does hang.
|
||||
test.setTimeout(ROUTES.length * 6_000)
|
||||
|
||||
const findings = []
|
||||
for (const route of ROUTES) {
|
||||
await page.goto(route)
|
||||
await page.waitForTimeout(400)
|
||||
const found = await page.evaluate(() => {
|
||||
const out = []
|
||||
for (const el of document.querySelectorAll('button, a')) {
|
||||
if (el.getBoundingClientRect().width === 0) continue
|
||||
const cs = getComputedStyle(el)
|
||||
if (cs.borderTopStyle === 'outset' || cs.backgroundColor === 'rgb(239, 239, 239)') {
|
||||
out.push(`default-chrome: "${(el.textContent || '').trim().slice(0, 24)}" [${el.className}]`)
|
||||
}
|
||||
}
|
||||
for (const i of document.querySelectorAll('i')) {
|
||||
if (!/\bfa-/.test((i.className || '').toString())) {
|
||||
out.push(`dead-icon: [${i.className}]`)
|
||||
}
|
||||
}
|
||||
return [...new Set(out)]
|
||||
})
|
||||
for (const f of found) findings.push(`${route} — ${f}`)
|
||||
}
|
||||
expect(findings).toEqual([])
|
||||
})
|
||||
101
core/http/react-ui/e2e/console-narrow.spec.js
Normal file
101
core/http/react-ui/e2e/console-narrow.spec.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
// Small-screen behaviour of the Operate console and the dashboard stat cards.
|
||||
//
|
||||
// Both defects here are about a narrow viewport but neither is only a narrow
|
||||
// viewport problem: the stat cards were being laid out by the wrong rule at
|
||||
// every width, and the rail's height was never bounded.
|
||||
|
||||
test.describe('Operate console on a narrow screen', () => {
|
||||
test('expanding the rail leaves the page still on screen', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 800 })
|
||||
await page.goto('/app/manage')
|
||||
|
||||
const toggle = page.locator('.console-rail-toggle')
|
||||
await expect(toggle).toBeVisible()
|
||||
await toggle.click()
|
||||
await expect(page.locator('.console-rail-groups')).toBeVisible()
|
||||
|
||||
// Thirteen destinations in one column is taller than a phone. If opening
|
||||
// the menu pushes the page's own heading past the fold, the menu has
|
||||
// replaced the page instead of annotating it.
|
||||
// Manage titles itself with .view-bar__title rather than .page-title.
|
||||
const heading = page.locator('.page-title, .view-bar__title').first()
|
||||
const box = await heading.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
expect(box.y).toBeLessThan(800)
|
||||
})
|
||||
|
||||
test('the rail scrolls internally rather than growing without bound', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 800 })
|
||||
await page.goto('/app/manage')
|
||||
await page.locator('.console-rail-toggle').click()
|
||||
|
||||
const groups = page.locator('.console-rail-groups')
|
||||
await expect(groups).toBeVisible()
|
||||
const height = await groups.evaluate(el => el.getBoundingClientRect().height)
|
||||
expect(height).toBeLessThan(800)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Headline figures', () => {
|
||||
// Host used shadowed StatCards; it now shares the Operate overview's hairline
|
||||
// figure strip, so the guard is that its labels stay legible, not that it
|
||||
// keeps a card gap.
|
||||
for (const width of [768, 1024]) {
|
||||
test(`Host figure labels are not clipped at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 1000 })
|
||||
await page.goto('/app/manage')
|
||||
const labels = page.locator('.stat-strip__label')
|
||||
await expect(labels.first()).toBeVisible()
|
||||
const clipped = await labels.evaluateAll(els =>
|
||||
els.filter(el => el.scrollWidth > el.clientWidth + 1).map(el => el.textContent))
|
||||
expect(clipped).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
test('a Host figure routes into the thing it counts', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 1000 })
|
||||
await page.goto('/app/manage')
|
||||
const cell = page.locator('.stat-strip__cell').first()
|
||||
await expect(cell).toBeVisible()
|
||||
// A count is worth more when it is also the way to what it counted.
|
||||
await expect(cell).toHaveJSProperty('tagName', 'BUTTON')
|
||||
})
|
||||
|
||||
test('the figure strip keeps its height inside the flex column', async ({ page }) => {
|
||||
// .page--app is a flex column whose split view takes flex:1, so a child
|
||||
// with no intrinsic minimum gets shrunk to nothing. This strip did exactly
|
||||
// that and rendered 2px tall with four invisible cells.
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
await page.goto('/app/manage')
|
||||
const strip = page.locator('.manage-summary')
|
||||
await expect(strip).toBeVisible()
|
||||
const h = await strip.evaluate(el => el.getBoundingClientRect().height)
|
||||
expect(h).toBeGreaterThan(40)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Headline figure contrast', () => {
|
||||
test('every figure is legible against the cell it sits on', async ({ page }) => {
|
||||
// A <button> does not inherit colour, so a value with no tone rule fell
|
||||
// back to the UA's `buttontext` — pure black on the dark ground, invisible.
|
||||
await page.setViewportSize({ width: 1440, height: 950 })
|
||||
await page.goto('/app/manage')
|
||||
const bad = await page.locator('.stat-strip__value').evaluateAll(els => els
|
||||
.map(el => ({ text: el.textContent, color: getComputedStyle(el).color }))
|
||||
.filter(v => v.color === 'rgb(0, 0, 0)'))
|
||||
expect(bad).toEqual([])
|
||||
})
|
||||
|
||||
test('the strip keeps its top margin against the shared shorthand', async ({ page }) => {
|
||||
// `.stat-strip` declares `margin: 0 0 ...` later in the file, which was
|
||||
// silently resetting this element's top margin and leaving it flush
|
||||
// against the resources panel above it.
|
||||
await page.setViewportSize({ width: 1440, height: 950 })
|
||||
await page.goto('/app/manage')
|
||||
const top = await page.locator('.manage-summary')
|
||||
.evaluate(el => parseFloat(getComputedStyle(el).marginTop))
|
||||
expect(top).toBeGreaterThan(12)
|
||||
})
|
||||
})
|
||||
72
core/http/react-ui/e2e/discover-height.spec.js
Normal file
72
core/http/react-ui/e2e/discover-height.spec.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
// The split view is meant to scroll inside itself. It is easy to regress into
|
||||
// scrolling the document instead, because the shell's height rules are floors
|
||||
// (min-height: 100dvh) rather than ceilings, so any tall pane silently grows
|
||||
// the whole column and takes the rail with it.
|
||||
// A description long enough that the detail pane must overflow, which is the
|
||||
// only condition under which the bug shows.
|
||||
const LONG = Array.from({ length: 60 }, (_, i) =>
|
||||
`Paragraph ${i + 1}. This entry carries a long description so the detail pane has more content than the viewport can hold.`,
|
||||
).join('\n\n')
|
||||
|
||||
const MOCK = {
|
||||
models: [
|
||||
{ name: 'long-model', description: LONG, backend: 'llama-cpp', installed: false, tags: ['llm'] },
|
||||
{ name: 'short-model', description: 'Short.', backend: 'llama-cpp', installed: false, tags: ['llm'] },
|
||||
],
|
||||
allBackends: ['llama-cpp'], allTags: ['llm'],
|
||||
availableModels: 2, installedModels: 0, totalPages: 1, currentPage: 1,
|
||||
}
|
||||
|
||||
test.describe('Discover - the view scrolls, not the page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/models*', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify(MOCK) }))
|
||||
})
|
||||
|
||||
test('a long detail scrolls the pane and leaves the page height alone', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await page.goto('/app/models')
|
||||
await expect(page.locator('[data-testid="discover-rail-item"]').first()).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
const pageHeight = () => page.evaluate(() => document.documentElement.scrollHeight)
|
||||
const railHeight = () => page.evaluate(
|
||||
() => document.querySelector('.entity-rail')?.getBoundingClientRect().height,
|
||||
)
|
||||
|
||||
const beforePage = await pageHeight()
|
||||
const beforeRail = await railHeight()
|
||||
|
||||
await page.locator('[data-testid="discover-rail-item"]').first().click()
|
||||
await expect(page.locator('[data-testid="discover-back"]')).toBeVisible()
|
||||
|
||||
// Selecting something must not make the document taller, and must not
|
||||
// stretch the rail to match the pane.
|
||||
expect(await pageHeight()).toBe(beforePage)
|
||||
// Sub-pixel: layout can settle a fraction differently without the rail
|
||||
// having grown. A pixel of tolerance keeps this about the bug it guards.
|
||||
expect(Math.abs((await railHeight()) - beforeRail)).toBeLessThan(1)
|
||||
|
||||
// The pane is the thing that scrolls.
|
||||
const paneOverflows = await page.evaluate(() => {
|
||||
const el = document.querySelector('.split-view__pane')
|
||||
return el ? getComputedStyle(el).overflowY : null
|
||||
})
|
||||
expect(paneOverflows).toBe('auto')
|
||||
})
|
||||
|
||||
test('stacked below the breakpoint it scrolls with the document again', async ({ page }) => {
|
||||
// Pinning the height when the columns stack would trap both halves in short
|
||||
// scrollers, so the constraint is lifted there on purpose.
|
||||
await page.setViewportSize({ width: 700, height: 800 })
|
||||
await page.goto('/app/models')
|
||||
await expect(page.locator('[data-testid="discover-rail-item"]').first()).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
const overflow = await page.evaluate(() => {
|
||||
const el = document.querySelector('.split-view__pane')
|
||||
return el ? getComputedStyle(el).overflowY : null
|
||||
})
|
||||
expect(overflow).toBe('visible')
|
||||
})
|
||||
})
|
||||
52
core/http/react-ui/e2e/discover-search-focus.spec.js
Normal file
52
core/http/react-ui/e2e/discover-search-focus.spec.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
// Searching triggers a refetch. The search box lives in the rail column, so if
|
||||
// a refetch unmounts the view it takes the field you are typing into with it,
|
||||
// dropping focus and the caret. That is what this guards.
|
||||
const MOCK = {
|
||||
models: [
|
||||
{ name: 'alpha-model', description: 'a', backend: 'llama-cpp', installed: false, tags: ['llm'] },
|
||||
{ name: 'beta-model', description: 'b', backend: 'llama-cpp', installed: false, tags: ['llm'] },
|
||||
],
|
||||
allBackends: ['llama-cpp'], allTags: ['llm'],
|
||||
availableModels: 2, installedModels: 0, totalPages: 1, currentPage: 1,
|
||||
}
|
||||
|
||||
test.describe('Discover - searching keeps the view', () => {
|
||||
test('a refetch keeps the search box, its focus and its value', async ({ page }) => {
|
||||
let calls = 0
|
||||
await page.route('**/api/models*', async (route) => {
|
||||
calls += 1
|
||||
// Slow the refetch so the loading window is real and observable.
|
||||
if (calls > 1) await new Promise((r) => setTimeout(r, 600))
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(MOCK) })
|
||||
})
|
||||
|
||||
await page.goto('/app/models')
|
||||
const search = page.locator('.filter-bar-group__search input')
|
||||
await expect(search).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
await search.click()
|
||||
await search.fill('alpha')
|
||||
|
||||
// Mid-refetch: the field is still mounted, still focused, still holding
|
||||
// what was typed, and the rail is marked busy rather than replaced.
|
||||
await expect(search).toBeFocused()
|
||||
await expect(search).toHaveValue('alpha')
|
||||
await expect(page.locator('.entity-rail')).toBeVisible()
|
||||
|
||||
await page.waitForTimeout(900)
|
||||
await expect(search).toBeFocused()
|
||||
await expect(search).toHaveValue('alpha')
|
||||
})
|
||||
|
||||
test('the first load still shows a skeleton, not an empty shell', async ({ page }) => {
|
||||
// Nothing to keep on a cold start, so the skeleton is still right there.
|
||||
await page.route('**/api/models*', async (route) => {
|
||||
await new Promise((r) => setTimeout(r, 800))
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(MOCK) })
|
||||
})
|
||||
await page.goto('/app/models')
|
||||
await expect(page.getByTestId('gallery-loader')).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { test, expect } from './coverage-fixtures.js'
|
||||
// Seeds two-message chat into localStorage so we don't need a live model.
|
||||
async function seedChat(page, history) {
|
||||
await page.addInitScript((h) => {
|
||||
if (localStorage.getItem('localai_chats_data')) return
|
||||
const chat = {
|
||||
id: 'seed1', name: 'Seeded Chat', model: 'test-model',
|
||||
history: h, systemPrompt: '', mcpMode: false, mcpServers: [],
|
||||
@@ -33,6 +34,56 @@ const TWO_TURNS = [
|
||||
{ role: 'assistant', content: 'second answer' },
|
||||
]
|
||||
|
||||
test('saved message edits persist without sending a completion request', async ({ page }) => {
|
||||
await mockModels(page)
|
||||
let completionRequests = 0
|
||||
await page.route('**/v1/chat/completions', (route) => {
|
||||
completionRequests++
|
||||
route.abort()
|
||||
})
|
||||
await seedChat(page, TWO_TURNS)
|
||||
await page.goto('/app/chat')
|
||||
|
||||
const firstUser = page.locator('.chat-message-user').first()
|
||||
await firstUser.hover()
|
||||
await firstUser.getByTitle('Edit').click()
|
||||
await firstUser.getByRole('textbox').fill('edited first question')
|
||||
await firstUser.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
const firstAssistant = page.locator('.chat-message-assistant').first()
|
||||
await firstAssistant.hover()
|
||||
await firstAssistant.getByTitle('Edit').click()
|
||||
await firstAssistant.getByRole('textbox').fill('edited first answer')
|
||||
await firstAssistant.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await expect(firstUser).toContainText('edited first question')
|
||||
await expect(firstAssistant).toContainText('edited first answer')
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const data = JSON.parse(localStorage.getItem('localai_chats_data'))
|
||||
return data.chats[0].history.slice(0, 2).map(message => message.content)
|
||||
})).toEqual(['edited first question', 'edited first answer'])
|
||||
|
||||
await page.reload()
|
||||
await expect(page.locator('.chat-message-user').first()).toContainText('edited first question')
|
||||
await expect(page.locator('.chat-message-assistant').first()).toContainText('edited first answer')
|
||||
expect(completionRequests).toBe(0)
|
||||
})
|
||||
|
||||
test('cancelling a message edit leaves the original content unchanged', async ({ page }) => {
|
||||
await mockModels(page)
|
||||
await seedChat(page, TWO_TURNS)
|
||||
await page.goto('/app/chat')
|
||||
|
||||
const firstUser = page.locator('.chat-message-user').first()
|
||||
await firstUser.hover()
|
||||
await firstUser.getByTitle('Edit').click()
|
||||
await firstUser.getByRole('textbox').fill('discard this draft')
|
||||
await firstUser.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
await expect(firstUser).toContainText('first question')
|
||||
await expect(firstUser).not.toContainText('discard this draft')
|
||||
})
|
||||
|
||||
test('duplicate creates an independent copy and switches to it', async ({ page }) => {
|
||||
await mockModels(page)
|
||||
await seedChat(page, TWO_TURNS)
|
||||
@@ -112,6 +163,29 @@ const FILE_TURNS = [
|
||||
{ role: 'assistant', content: 'nope, that is it' },
|
||||
]
|
||||
|
||||
test('editing a file prompt preserves its content blocks and attachment metadata', async ({ page }) => {
|
||||
await mockModels(page)
|
||||
await seedChat(page, FILE_TURNS)
|
||||
await page.goto('/app/chat')
|
||||
|
||||
const firstUser = page.locator('.chat-message-user').first()
|
||||
await firstUser.hover()
|
||||
await firstUser.getByTitle('Edit').click()
|
||||
await firstUser.getByRole('textbox').fill('edited file question')
|
||||
await firstUser.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const data = JSON.parse(localStorage.getItem('localai_chats_data'))
|
||||
return data.chats[0].history[0]
|
||||
})).toEqual({
|
||||
...FILE_TURNS[0],
|
||||
content: [
|
||||
{ type: 'text', text: 'edited file question' },
|
||||
FILE_TURNS[0].content[1],
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('regenerating a non-last answer in a fork still sends the uploaded file content', async ({ page }) => {
|
||||
await mockModels(page)
|
||||
let sentMessages = null
|
||||
|
||||
103
core/http/react-ui/e2e/home-lanes.spec.js
Normal file
103
core/http/react-ui/e2e/home-lanes.spec.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
// Home's resident-model list and the app footer.
|
||||
|
||||
const SYS_INFO = {
|
||||
backends: ['llama-cpp'],
|
||||
loaded_models: [
|
||||
{ id: 'qwen3-8b-instruct', backend: 'llama-cpp' },
|
||||
{ id: 'parakeet-tdt-0.6b' },
|
||||
],
|
||||
}
|
||||
|
||||
async function mockLoaded(page) {
|
||||
await page.route('**/system', route => route.fulfill({ json: SYS_INFO }))
|
||||
await page.route('**/v1/models', route =>
|
||||
route.fulfill({ json: { data: [{ id: 'qwen3-8b-instruct' }, { id: 'parakeet-tdt-0.6b' }] } }))
|
||||
}
|
||||
|
||||
test.describe('Home resident models', () => {
|
||||
test('resident models read as lanes, not status chips', async ({ page }) => {
|
||||
await mockLoaded(page)
|
||||
await page.goto('/app')
|
||||
const lanes = page.locator('.home-loaded .lane')
|
||||
await expect(lanes).toHaveCount(2)
|
||||
// Model ids are identifiers, so they are set in mono like every other
|
||||
// identifier in the app.
|
||||
const family = await lanes.first().locator('.lane__name').evaluate(
|
||||
el => getComputedStyle(el).fontFamily.toLowerCase())
|
||||
expect(family).toMatch(/mono|consol|menlo/)
|
||||
})
|
||||
|
||||
test('each lane keeps its stop control', async ({ page }) => {
|
||||
await mockLoaded(page)
|
||||
await page.goto('/app')
|
||||
const lane = page.locator('.home-loaded .lane').first()
|
||||
await expect(lane.getByRole('button', { name: /stop/i })).toBeVisible()
|
||||
})
|
||||
|
||||
test('the header reports how many are resident as a figure', async ({ page }) => {
|
||||
await mockLoaded(page)
|
||||
await page.goto('/app')
|
||||
const stat = page.locator('[data-testid="home-stat-loaded"]')
|
||||
await expect(stat).toBeVisible()
|
||||
await expect(stat).toContainText('2')
|
||||
// Digits that sit in a column need to line up.
|
||||
const numeric = await stat.locator('.home-stat__value').evaluate(
|
||||
el => getComputedStyle(el).fontVariantNumeric)
|
||||
expect(numeric).toContain('tabular-nums')
|
||||
})
|
||||
|
||||
test('a resident model names the engine serving it', async ({ page }) => {
|
||||
await mockLoaded(page)
|
||||
await page.goto('/app')
|
||||
// Lanes are sorted by id, so target by content rather than position.
|
||||
const qwen = page.locator('.home-loaded .lane', { hasText: 'qwen3-8b-instruct' })
|
||||
await expect(qwen).toContainText('llama-cpp')
|
||||
})
|
||||
|
||||
test('a model without a config shows no engine rather than a guess', async ({ page }) => {
|
||||
await mockLoaded(page)
|
||||
await page.goto('/app')
|
||||
// parakeet has no backend in the payload; the column stays blank.
|
||||
const parakeet = page.locator('.home-loaded .lane', { hasText: 'parakeet-tdt-0.6b' })
|
||||
await expect(parakeet).not.toContainText('llama-cpp')
|
||||
})
|
||||
|
||||
test('jump-back-in offers the three places worth returning to', async ({ page }) => {
|
||||
await mockLoaded(page)
|
||||
await page.goto('/app')
|
||||
const lanes = page.locator('.lanes--jump .lane')
|
||||
await expect(lanes).toHaveCount(3)
|
||||
await expect(lanes.first()).toContainText('Discover')
|
||||
})
|
||||
|
||||
test('nothing resident still says so', async ({ page }) => {
|
||||
await page.route('**/system', route =>
|
||||
route.fulfill({ json: { backends: ['llama-cpp'], loaded_models: [] } }))
|
||||
await page.route('**/v1/models', route => route.fulfill({ json: { data: [{ id: 'a-model' }] } }))
|
||||
await page.goto('/app')
|
||||
await expect(page.locator('.home-loaded-empty')).toBeVisible()
|
||||
await expect(page.locator('.home-loaded .lane')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('App footer', () => {
|
||||
test('is one line, not three stacked rows', async ({ page }) => {
|
||||
await page.goto('/app')
|
||||
const footer = page.locator('.app-footer')
|
||||
await expect(footer).toBeVisible()
|
||||
// Three centred rows of chrome cost more vertical space than the content
|
||||
// they sit under is usually worth.
|
||||
const height = await footer.evaluate(el => el.getBoundingClientRect().height)
|
||||
expect(height).toBeLessThan(56)
|
||||
})
|
||||
|
||||
test('keeps every link it had', async ({ page }) => {
|
||||
await page.goto('/app')
|
||||
const footer = page.locator('.app-footer')
|
||||
for (const name of [/github/i, /documentation/i, /author/i]) {
|
||||
await expect(footer.getByRole('link', { name })).toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
69
core/http/react-ui/e2e/host-split-view.spec.js
Normal file
69
core/http/react-ui/e2e/host-split-view.spec.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
// Host is an inventory, not a catalog, so its split view differs from the two
|
||||
// galleries in exactly one place: the pane with nothing selected reports what
|
||||
// is happening rather than offering something to install.
|
||||
|
||||
const PANE = '[data-testid="host-pane"]'
|
||||
const railItems = (page) => page.locator('[data-testid="host-rail-item"]')
|
||||
const railItem = (page, id) => page.locator(`[data-entity="${id}"]`)
|
||||
|
||||
test.describe('Host - split view', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/app/manage')
|
||||
await expect(railItems(page).first()).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('the inventory renders no table', async ({ page }) => {
|
||||
await expect(page.locator('[data-testid="host"]')).toBeVisible()
|
||||
await expect(page.locator('table thead th')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('with nothing selected the pane reports the current state', async ({ page }) => {
|
||||
await expect(page.locator(PANE)).toContainText('Right now')
|
||||
await expect(page.locator(PANE)).toContainText('Loaded')
|
||||
await expect(page.locator('[data-testid="host-back"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('choosing a model turns the pane into its detail, and back returns', async ({ page }) => {
|
||||
const first = railItems(page).first()
|
||||
const name = await first.getAttribute('data-entity')
|
||||
await first.click()
|
||||
|
||||
await expect(page.locator(PANE)).toContainText(name)
|
||||
await expect(page.locator(PANE)).toContainText('State')
|
||||
await expect(page.locator(PANE)).not.toContainText('Right now')
|
||||
|
||||
await page.locator('[data-testid="host-back"]').click()
|
||||
await expect(page.locator(PANE)).toContainText('Right now')
|
||||
})
|
||||
|
||||
test('the selection lives in the URL', async ({ page }) => {
|
||||
const first = railItems(page).first()
|
||||
const name = await first.getAttribute('data-entity')
|
||||
await first.click()
|
||||
await expect(page).toHaveURL(new RegExp(`[?&]sel=${encodeURIComponent(name)}`))
|
||||
})
|
||||
|
||||
test('the rail buckets by state rather than by capability', async ({ page }) => {
|
||||
// The opposite of the galleries, and deliberately so: nobody opens Host
|
||||
// wondering which of their models does vision.
|
||||
const groups = page.locator('[data-testid^="host-rail-group-"]')
|
||||
await expect(groups.first()).toBeVisible()
|
||||
const ids = await groups.evaluateAll(els => els.map(e => e.dataset.testid))
|
||||
for (const id of ids) {
|
||||
expect(['host-rail-group-running', 'host-rail-group-idle', 'host-rail-group-disabled']).toContain(id)
|
||||
}
|
||||
})
|
||||
|
||||
test('switching tabs drops a selection that belonged to the other tab', async ({ page }) => {
|
||||
await railItems(page).first().click()
|
||||
await expect(page.locator('[data-testid="host-back"]')).toBeVisible()
|
||||
|
||||
// The other tab may legitimately be empty on a fresh host, so the contract
|
||||
// is that the stale selection is gone, not that a pane appears.
|
||||
await page.locator('.tab', { hasText: 'Backends' }).click()
|
||||
await expect(page.locator('[data-testid="host-back"]')).toHaveCount(0)
|
||||
await expect(page).not.toHaveURL(/[?&]sel=/)
|
||||
})
|
||||
})
|
||||
@@ -7,11 +7,11 @@ import { test, expect } from './coverage-fixtures.js'
|
||||
// inside a row whose hover `transform` re-anchored it. Fix portals the popover
|
||||
// to document.body, positions it before paint, and focuses without scrolling.
|
||||
test.describe('Manage Page - Action menu positioning', () => {
|
||||
test('opening a row menu keeps scroll stable and places the menu by its trigger', async ({ page }) => {
|
||||
test('opening the pane menu keeps scroll stable and places it by its trigger', async ({ page }) => {
|
||||
// Small viewport so the page is scrollable and a scroll jump is observable.
|
||||
await page.setViewportSize({ width: 1024, height: 500 })
|
||||
await page.goto('/app/manage')
|
||||
await expect(page.locator('.table')).toBeVisible({ timeout: 10_000 })
|
||||
await page.locator('[data-testid="host-rail-item"]').first().click()
|
||||
|
||||
const trigger = page.locator('button.action-menu__trigger').first()
|
||||
await expect(trigger).toBeVisible()
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
test.describe('Manage Page - Backend Logs Link', () => {
|
||||
test('row action menu exposes Backend logs entry with terminal icon', async ({ page }) => {
|
||||
test('the pane action menu exposes Backend logs with a terminal icon', async ({ page }) => {
|
||||
await page.goto('/app/manage')
|
||||
await expect(page.locator('.table')).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// Row actions live behind the kebab (ActionMenu) — open the first row's menu.
|
||||
// Actions moved out of the row and into the pane, so reaching them is now a
|
||||
// selection followed by the pane's kebab.
|
||||
await page.locator('[data-testid="host-rail-item"]').first().click()
|
||||
const trigger = page.locator('button.action-menu__trigger').first()
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click()
|
||||
@@ -17,8 +17,7 @@ test.describe('Manage Page - Backend Logs Link', () => {
|
||||
|
||||
test('Backend logs menu item navigates to backend-logs page', async ({ page }) => {
|
||||
await page.goto('/app/manage')
|
||||
await expect(page.locator('.table')).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
await page.locator('[data-testid="host-rail-item"]').first().click()
|
||||
const trigger = page.locator('button.action-menu__trigger').first()
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click()
|
||||
|
||||
@@ -46,9 +46,8 @@ test.describe('Model Editor — Back navigation', () => {
|
||||
|
||||
test('Back returns to Manage with a "Back to System" caption', async ({ page }) => {
|
||||
await page.goto('/app/manage')
|
||||
await expect(page.locator('.table')).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// Open the first row's action menu and pick "Edit configuration".
|
||||
// Actions live in the pane now, so select something first.
|
||||
await page.locator('[data-testid="host-rail-item"]').first().click()
|
||||
const trigger = page.locator('button.action-menu__trigger').first()
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user