mirror of
https://github.com/mudler/LocalAI.git
synced 2026-05-22 07:38:26 -04:00
Compare commits
37 Commits
feat/distr
...
fix/apt-mi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50580a84ae | ||
|
|
8edac61e57 | ||
|
|
0b024f0886 | ||
|
|
a6121e240e | ||
|
|
87cf736068 | ||
|
|
1ad5b5907d | ||
|
|
18e039f305 | ||
|
|
b1a99436c7 | ||
|
|
7325046650 | ||
|
|
8452068f43 | ||
|
|
0b0078047f | ||
|
|
80961d2da6 | ||
|
|
9c4c3f9d8f | ||
|
|
273416f54b | ||
|
|
c02a50f2ab | ||
|
|
76971fb2aa | ||
|
|
ebd9fcbe20 | ||
|
|
091eda8d70 | ||
|
|
fe6eb57082 | ||
|
|
13fe37df89 | ||
|
|
4916f8c880 | ||
|
|
55afda22e3 | ||
|
|
1fe3558ec6 | ||
|
|
e370318bd7 | ||
|
|
4443250756 | ||
|
|
bcef72b9c1 | ||
|
|
142919fc79 | ||
|
|
439471baec | ||
|
|
eff4be6794 | ||
|
|
f1ec30d646 | ||
|
|
f3500223d7 | ||
|
|
b69bacfcdc | ||
|
|
8e50066fa2 | ||
|
|
a0317d9926 | ||
|
|
3948b580d2 | ||
|
|
5efbe8405f | ||
|
|
ea1df8945b |
@@ -330,3 +330,16 @@ When adding a new endpoint:
|
||||
- [ ] 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`)
|
||||
- [ ] Tests cover both authenticated and unauthenticated access
|
||||
- [ ] Swagger regenerated (`make swagger`) if you changed any `@Router`/`@Tags`/`@Param` annotation
|
||||
|
||||
## Companion: MCP admin tool surface
|
||||
|
||||
**Required for admin endpoints.** Every new admin endpoint MUST be considered for the MCP admin tool surface — the REST API and the MCP tool catalog can drift silently otherwise, and both the LocalAI Assistant chat modality and the standalone `local-ai mcp-server` rely on `pkg/mcp/localaitools/` to mirror REST.
|
||||
|
||||
Two outcomes are acceptable; one is not:
|
||||
|
||||
- **Tool added.** The new endpoint is something an admin would manage conversationally (install, list, edit, toggle, upgrade). Follow the full checklist in [.agents/localai-assistant-mcp.md](localai-assistant-mcp.md): add a `LocalAIClient` interface method, implement it in both `inproc` and `httpapi`, register the tool with a `Tool*` constant, update the skill prompts, **and add the route to `toolToHTTPRoute` in `pkg/mcp/localaitools/coverage_test.go`**.
|
||||
- **Tool deliberately skipped.** The endpoint is internal/diagnostic and adding a chat path would be misleading. Document the decision in the PR description; no code action.
|
||||
- **Forgot.** This breaks the contract. The `TestToolHTTPRouteMappingComplete` test in `pkg/mcp/localaitools` is a partial guard (it checks every `Tool*` has a route mapping), but it does NOT detect new REST endpoints without a tool — that's still a process check on the PR author.
|
||||
|
||||
**Add to the bottom of the checklist below**:
|
||||
- [ ] If admin: decided whether MCP coverage is needed; if yes, tool registered + map updated; if no, skip-reason in PR description.
|
||||
|
||||
@@ -48,6 +48,8 @@ All Go tests — including backend tests — must use [Ginkgo](https://onsi.gith
|
||||
|
||||
Do not mix styles within a package. If you are extending tests in a package that already uses Ginkgo, keep using Ginkgo. If you find stdlib-style Go tests in the tree, treat them as tech debt to be migrated rather than as a pattern to follow.
|
||||
|
||||
This is enforced by `golangci-lint` via the `forbidigo` linter (see `.golangci.yml`); calls like `t.Errorf` / `t.Fatalf` / `t.Run` / `t.Skip` / `t.Logf` are flagged. Run `make lint` locally before submitting; the same check runs in CI (`.github/workflows/lint.yml`).
|
||||
|
||||
## Documentation
|
||||
|
||||
The project documentation is located in `docs/content`. When adding new features or changing existing functionality, it is crucial to update the documentation to reflect these changes. This helps users understand how to use the new capabilities and ensures the documentation stays relevant.
|
||||
|
||||
97
.agents/localai-assistant-mcp.md
Normal file
97
.agents/localai-assistant-mcp.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# LocalAI Assistant — admin MCP server
|
||||
|
||||
This document is the contract for **anyone** (human or AI agent) touching LocalAI's admin REST surface, the in-process MCP server that wraps it, or the embedded skill prompts that teach the assistant how to use it. Read this before adding/removing/renaming admin endpoints, MCP tools, or skill recipes.
|
||||
|
||||
## What this feature is
|
||||
|
||||
`pkg/mcp/localaitools/` is a public Go package that exposes LocalAI's admin/management surface as an MCP server. It is used in two ways:
|
||||
|
||||
1. **In-process**: when an admin opens a chat with `metadata.localai_assistant=true`, the chat handler injects the in-memory MCP server (paired `net.Pipe()` transport, no HTTP loopback) so the LLM can install models, manage backends and edit configs by chatting.
|
||||
2. **Standalone**: the `local-ai mcp-server --target=…` subcommand serves the same MCP server over stdio, talking HTTP to a remote LocalAI instance.
|
||||
|
||||
The two modes share **all** tool definitions and skill prompts. They differ only in their `LocalAIClient` implementation (`inproc/` calls services directly; `httpapi/` calls REST).
|
||||
|
||||
## The three things you must keep in sync
|
||||
|
||||
When you change LocalAI's admin surface, three layers must stay aligned:
|
||||
|
||||
1. **REST endpoint** in `core/http/endpoints/localai/*.go`.
|
||||
2. **MCP tool registration** in `pkg/mcp/localaitools/tools_*.go`, plus a method on `LocalAIClient` (in `client.go`) and implementations in both `inproc/client.go` **and** `httpapi/client.go`.
|
||||
3. **Skill prompt** under `pkg/mcp/localaitools/prompts/skills/*.md` — the markdown that teaches the LLM how to use the new tool. If the new tool fits an existing recipe, update that recipe; otherwise add a new file.
|
||||
|
||||
If you ship a REST endpoint without (2) and (3), conversational admins won't see the feature.
|
||||
|
||||
## Checklist for adding a new admin endpoint
|
||||
|
||||
- [ ] REST endpoint exists in `core/http/endpoints/localai/*.go` and is gated by `auth.RequireAdmin()` in `core/http/routes/localai.go`.
|
||||
- [ ] `LocalAIClient` interface in `pkg/mcp/localaitools/client.go` has a method covering the new operation.
|
||||
- [ ] DTOs added/updated in `pkg/mcp/localaitools/dto.go` (JSON-tagged; never expose raw service types).
|
||||
- [ ] `inproc/client.go` implements the new method by calling the service directly (not via HTTP loopback).
|
||||
- [ ] `httpapi/client.go` implements the new method by calling the REST endpoint.
|
||||
- [ ] Tool registration added in the appropriate `pkg/mcp/localaitools/tools_*.go`. Mutating tools must reference safety rule 1 in the description.
|
||||
- [ ] If the tool is mutating, ensure `Options{DisableMutating: true}` skips it (mirror the pattern in `tools_models.go`).
|
||||
- [ ] Skill prompt added or updated under `pkg/mcp/localaitools/prompts/skills/`. The prompt must instruct the LLM when to call the tool, what to ask the user first, and what to do on error.
|
||||
- [ ] Tests:
|
||||
- `pkg/mcp/localaitools/server_test.go` adds the tool name to `expectedFullCatalog` and `expectedReadOnlyCatalog` (if read-only).
|
||||
- Tool dispatch is added to `TestEachToolDispatchesToClient`.
|
||||
- `pkg/mcp/localaitools/httpapi/client_test.go` covers the new HTTP path.
|
||||
|
||||
## Adding a new skill recipe (no new tool)
|
||||
|
||||
Sometimes you want to teach the LLM a new pattern that uses existing tools. Drop a markdown file under `pkg/mcp/localaitools/prompts/skills/<verb>_<noun>.md`. The file is automatically embedded by `//go:embed` and assembled into the system prompt in lexicographic order. No Go changes needed.
|
||||
|
||||
Conventions:
|
||||
- Filename: `<verb>_<noun>.md` (e.g. `install_chat_model.md`, `upgrade_backend.md`).
|
||||
- First line: `# Skill: <Title Case description>`.
|
||||
- Number the steps. Reference exact tool names in backticks.
|
||||
- If the skill mutates state, remind the LLM to confirm with the user.
|
||||
|
||||
## Code conventions
|
||||
|
||||
These rules guard against the magic-literal drift that surfaced in the first audit. Do not re-introduce bare strings.
|
||||
|
||||
- **Tool names** always come from the `Tool*` constants in `pkg/mcp/localaitools/tools.go`. Tool registrations, the test catalog (`server_test.go`'s `expectedFullCatalog` / `expectedReadOnlyCatalog`), and dispatch tables reference the constants. The embedded skill prompts under `prompts/` keep bare strings — that's the one allowed exception, and `TestPromptsContainSafetyAnchors` enforces alignment.
|
||||
- **Toggle/pin actions** use the `modeladmin.Action` type (`pkg/mcp/localaitools` and `core/services/modeladmin`). Use `ActionEnable`/`ActionDisable`/`ActionPin`/`ActionUnpin`; never bare `"enable"`/`"pin"` strings.
|
||||
- **Capability tags** for `list_installed_models` use the `localaitools.Capability` type (`capability.go`). The `LocalAIClient.ListInstalledModels` interface takes a typed `Capability`, and the `inproc` switch only accepts canonical values (`"embed"`/`"embedding"` are not aliases — only `CapabilityEmbeddings`).
|
||||
- **HTTP error checks** in `httpapi.Client` use `errors.Is(err, ErrHTTPNotFound)`, not substring matches on `err.Error()`. The typed `*HTTPError` carries `StatusCode` and `Body`; add new sentinel errors as needed rather than re-introducing string matching.
|
||||
- **Channel sends** to `GalleryService.ModelGalleryChannel` / `BackendGalleryChannel` from inproc clients MUST select on `ctx.Done()` so a cancelled chat completion releases the goroutine. See `inproc.sendModelOp` / `sendBackendOp`.
|
||||
- **Disk writes** of model config YAML go through `modeladmin.writeFileAtomic` (temp file + `os.Rename`). `os.WriteFile` truncates on crash and corrupts the model.
|
||||
- **MCP server lifecycle**: every initialised holder MUST register `Close()` with `signals.RegisterGracefulTerminationHandler`. The standalone `mcp-server` CLI uses `signal.NotifyContext` to honour SIGINT/SIGTERM.
|
||||
|
||||
## File map (where to look)
|
||||
|
||||
```
|
||||
pkg/mcp/localaitools/
|
||||
client.go # LocalAIClient interface + DTO registry
|
||||
dto.go # JSON-tagged DTOs shared by both client impls
|
||||
server.go # NewServer(client, opts) — registers tools
|
||||
tools.go # Tool* name constants (single source of truth)
|
||||
capability.go # Capability type + constants
|
||||
tools_models.go # gallery_search, install_model, import_model_uri, ...
|
||||
tools_backends.go
|
||||
tools_config.go
|
||||
tools_system.go
|
||||
tools_state.go
|
||||
prompts.go # //go:embed loader + SystemPrompt(opts)
|
||||
prompts/00_role.md
|
||||
prompts/10_safety.md # SAFETY RULES — change with care
|
||||
prompts/20_tools.md # curated tool catalog with one-liners
|
||||
prompts/skills/*.md
|
||||
inproc/client.go # in-process LocalAIClient (services-direct)
|
||||
httpapi/client.go # REST LocalAIClient (for standalone CLI / remote)
|
||||
core/http/endpoints/mcp/
|
||||
localai_assistant.go # process-wide holder + LocalToolExecutor
|
||||
core/cli/mcp_server.go # local-ai mcp-server subcommand
|
||||
```
|
||||
|
||||
## Why two clients
|
||||
|
||||
The in-process MCP server runs inside the same LocalAI binary that serves chat. Going over HTTP loopback would (a) require minting a synthetic admin API key for the server to authenticate against itself, (b) double-marshal every tool dispatch, and (c) lose access to in-process channels (e.g. `GalleryService.ModelGalleryChannel` for streaming install progress). So in-process uses `inproc.Client`. The standalone stdio CLI talks to a *remote* LocalAI; HTTP is the only option, so it uses `httpapi.Client`. Both implement the same `LocalAIClient` interface, and the parity test in `pkg/mcp/localaitools/parity_test.go` (when present) keeps their output equivalent.
|
||||
|
||||
## Why prompt-enforced confirmation, not code gates
|
||||
|
||||
The user chose KISS. Every mutating tool has a safety rule (`prompts/10_safety.md` rule 1) that requires the LLM to summarise the action and wait for explicit user confirmation before calling it. There is no `plan_*`/`apply_*` two-step in code. If you add a mutating tool, do **not** add per-tool confirmation logic in Go — instead, list the new tool name in `prompts/10_safety.md` so the LLM knows it falls under the confirmation rule.
|
||||
|
||||
## Distributed mode
|
||||
|
||||
The in-memory MCP server runs only on the head node (where the chat handler runs). `inproc.Client` wraps services that are already distributed-aware (`GalleryService` coordinates with workers; `ListNodes` reads the NATS-populated registry). No NATS routing of MCP tools — the admin surface lives on the head, period.
|
||||
39
.docker/apt-mirror.sh
Executable file
39
.docker/apt-mirror.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/bin/sh
|
||||
# Reconfigure Ubuntu apt sources to point at an alternate mirror.
|
||||
#
|
||||
# Used by Dockerfiles via `RUN --mount=type=bind,source=.docker/apt-mirror.sh,...`
|
||||
# and by CI workflows on the runner to mitigate outages of the default
|
||||
# archive.ubuntu.com / security.ubuntu.com / ports.ubuntu.com pool.
|
||||
#
|
||||
# Inputs (env):
|
||||
# APT_MIRROR Replacement for archive.ubuntu.com and security.ubuntu.com
|
||||
# (e.g. "http://azure.archive.ubuntu.com" or
|
||||
# "https://mirrors.edge.kernel.org").
|
||||
# Leave empty to keep upstream. The trailing "/ubuntu/..."
|
||||
# path is preserved by the rewrite.
|
||||
# APT_PORTS_MIRROR Replacement for ports.ubuntu.com (arm64/ppc64el/...).
|
||||
# Leave empty to keep upstream.
|
||||
#
|
||||
# Both default to empty, in which case the script is a no-op.
|
||||
|
||||
set -e
|
||||
|
||||
if [ -z "${APT_MIRROR}" ] && [ -z "${APT_PORTS_MIRROR}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Ubuntu 24.04 (noble) ships DEB822 sources at /etc/apt/sources.list.d/ubuntu.sources;
|
||||
# older releases use /etc/apt/sources.list. We rewrite whichever exists.
|
||||
for f in /etc/apt/sources.list.d/ubuntu.sources /etc/apt/sources.list; do
|
||||
[ -f "$f" ] || continue
|
||||
if [ -n "${APT_MIRROR}" ]; then
|
||||
# Use a comma delimiter so the alternation pipe in the regex
|
||||
# is not interpreted as the s/// separator.
|
||||
sed -i -E "s,https?://(archive\.ubuntu\.com|security\.ubuntu\.com),${APT_MIRROR},g" "$f"
|
||||
fi
|
||||
if [ -n "${APT_PORTS_MIRROR}" ]; then
|
||||
sed -i -E "s,https?://ports\.ubuntu\.com,${APT_PORTS_MIRROR},g" "$f"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "apt-mirror: rewrote sources (APT_MIRROR='${APT_MIRROR}', APT_PORTS_MIRROR='${APT_PORTS_MIRROR}')"
|
||||
91
.github/actions/configure-apt-mirror/action.yml
vendored
Normal file
91
.github/actions/configure-apt-mirror/action.yml
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
name: 'Configure apt mirror'
|
||||
description: |
|
||||
Reconfigure the GitHub Actions runner's Ubuntu apt sources to use an
|
||||
alternate mirror, and emit the effective URLs as outputs so callers can
|
||||
forward them as Docker build-args.
|
||||
|
||||
Two mirror profiles depending on where the runner lives, because the
|
||||
best mirror differs by network:
|
||||
|
||||
* github-hosted runners run on Azure, so they default to the
|
||||
Azure-hosted Ubuntu mirror (lowest latency, same VPC).
|
||||
* self-hosted runners (arc-runner-set, bigger-runner, ...) typically
|
||||
cannot route to azure.archive.ubuntu.com, so they default to the
|
||||
kernel.org mirror, which is publicly reachable from anywhere.
|
||||
|
||||
Pass an empty string to either input to skip the rewrite for that
|
||||
profile and keep upstream archive.ubuntu.com / ports.ubuntu.com.
|
||||
|
||||
inputs:
|
||||
github-hosted-mirror:
|
||||
description: 'archive/security mirror URL for github-hosted runners (empty = upstream)'
|
||||
required: false
|
||||
default: 'http://azure.archive.ubuntu.com'
|
||||
github-hosted-ports-mirror:
|
||||
description: 'ports.ubuntu.com mirror URL for github-hosted runners (empty = upstream)'
|
||||
required: false
|
||||
default: 'http://azure.ports.ubuntu.com'
|
||||
self-hosted-mirror:
|
||||
description: 'archive/security mirror URL for self-hosted runners (empty = upstream)'
|
||||
required: false
|
||||
default: 'https://mirrors.edge.kernel.org'
|
||||
self-hosted-ports-mirror:
|
||||
description: 'ports.ubuntu.com mirror URL for self-hosted runners (empty = upstream)'
|
||||
required: false
|
||||
default: 'https://mirrors.edge.kernel.org'
|
||||
|
||||
outputs:
|
||||
effective-mirror:
|
||||
description: 'The mirror URL actually applied for this runner (or empty)'
|
||||
value: ${{ steps.pick.outputs.mirror }}
|
||||
effective-ports-mirror:
|
||||
description: 'The ports mirror URL actually applied for this runner (or empty)'
|
||||
value: ${{ steps.pick.outputs.ports-mirror }}
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Pick effective mirror for this runner
|
||||
id: pick
|
||||
shell: bash
|
||||
env:
|
||||
RUNNER_ENV: ${{ runner.environment }}
|
||||
GH_MIRROR: ${{ inputs.github-hosted-mirror }}
|
||||
GH_PORTS_MIRROR: ${{ inputs.github-hosted-ports-mirror }}
|
||||
SH_MIRROR: ${{ inputs.self-hosted-mirror }}
|
||||
SH_PORTS_MIRROR: ${{ inputs.self-hosted-ports-mirror }}
|
||||
run: |
|
||||
if [ "${RUNNER_ENV}" = "github-hosted" ]; then
|
||||
MIRROR="${GH_MIRROR}"
|
||||
PORTS_MIRROR="${GH_PORTS_MIRROR}"
|
||||
else
|
||||
MIRROR="${SH_MIRROR}"
|
||||
PORTS_MIRROR="${SH_PORTS_MIRROR}"
|
||||
fi
|
||||
echo "configure-apt-mirror: runner=${RUNNER_ENV} mirror='${MIRROR}' ports-mirror='${PORTS_MIRROR}'"
|
||||
echo "mirror=${MIRROR}" >> "$GITHUB_OUTPUT"
|
||||
echo "ports-mirror=${PORTS_MIRROR}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Rewrite apt sources
|
||||
if: steps.pick.outputs.mirror != '' || steps.pick.outputs.ports-mirror != ''
|
||||
shell: bash
|
||||
env:
|
||||
APT_MIRROR: ${{ steps.pick.outputs.mirror }}
|
||||
APT_PORTS_MIRROR: ${{ steps.pick.outputs.ports-mirror }}
|
||||
run: |
|
||||
set -e
|
||||
# Ubuntu 24.04 (noble) ships DEB822 sources at
|
||||
# /etc/apt/sources.list.d/ubuntu.sources; older releases use
|
||||
# /etc/apt/sources.list. Rewrite whichever exists.
|
||||
for f in /etc/apt/sources.list.d/ubuntu.sources /etc/apt/sources.list; do
|
||||
sudo test -f "$f" || continue
|
||||
if [ -n "${APT_MIRROR}" ]; then
|
||||
# Comma delimiter so the alternation pipe in the regex is not
|
||||
# interpreted as the s/// separator.
|
||||
sudo sed -i -E "s,https?://(archive\.ubuntu\.com|security\.ubuntu\.com),${APT_MIRROR},g" "$f"
|
||||
fi
|
||||
if [ -n "${APT_PORTS_MIRROR}" ]; then
|
||||
sudo sed -i -E "s,https?://ports\.ubuntu\.com,${APT_PORTS_MIRROR},g" "$f"
|
||||
fi
|
||||
done
|
||||
echo "Runner apt mirror configured (APT_MIRROR='${APT_MIRROR}', APT_PORTS_MIRROR='${APT_PORTS_MIRROR}')"
|
||||
45
.github/bump_vllm_wheel.sh
vendored
Executable file
45
.github/bump_vllm_wheel.sh
vendored
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
# Bump the cublas13 vLLM wheel pin in requirements-cublas13-after.txt.
|
||||
#
|
||||
# vLLM's PyPI wheel is built against CUDA 12 so the cublas13 build pulls a
|
||||
# cu130-flavoured wheel from vLLM's per-tag index at
|
||||
# https://wheels.vllm.ai/<TAG>/cu130/. That URL segment is itself version-locked
|
||||
# (no /latest/ alias upstream), so bumping vLLM means rewriting both the URL
|
||||
# segment and the version constraint atomically. bump_deps.sh handles git-sha
|
||||
# vars in Makefiles; this script handles the two-value rewrite specific to the
|
||||
# vLLM requirements file.
|
||||
set -xe
|
||||
REPO=$1 # vllm-project/vllm
|
||||
FILE=$2 # backend/python/vllm/requirements-cublas13-after.txt
|
||||
VAR=$3 # VLLM_VERSION (used for output file names so the workflow can read them)
|
||||
|
||||
if [ -z "$FILE" ] || [ -z "$REPO" ] || [ -z "$VAR" ]; then
|
||||
echo "usage: $0 <repo> <requirements-file> <var-name>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# /releases/latest returns the most recent non-prerelease tag.
|
||||
LATEST_TAG=$(curl -sS -H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/$REPO/releases/latest" \
|
||||
| python3 -c "import json,sys; print(json.load(sys.stdin)['tag_name'])")
|
||||
|
||||
# Strip leading 'v' (vLLM tags are 'v0.20.0', the URL/version use '0.20.0').
|
||||
NEW_VERSION="${LATEST_TAG#v}"
|
||||
|
||||
set +e
|
||||
CURRENT_VERSION=$(grep -oE '^vllm==[0-9]+\.[0-9]+\.[0-9]+' "$FILE" | head -1 | cut -d= -f3)
|
||||
set -e
|
||||
|
||||
# sed both lines unconditionally — peter-evans/create-pull-request opens no PR
|
||||
# when the working tree is clean, so a no-op rewrite is safe.
|
||||
sed -i "$FILE" \
|
||||
-e "s|wheels\.vllm\.ai/[^/]*/cu130|wheels.vllm.ai/$NEW_VERSION/cu130|g" \
|
||||
-e "s|^vllm==.*|vllm==$NEW_VERSION|"
|
||||
|
||||
if [ -z "$CURRENT_VERSION" ]; then
|
||||
echo "Could not find vllm==X.Y.Z in $FILE."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Changes: https://github.com/$REPO/compare/v${CURRENT_VERSION}...${LATEST_TAG}" >> "${VAR}_message.txt"
|
||||
echo "${NEW_VERSION}" >> "${VAR}_commit.txt"
|
||||
124
.github/workflows/backend.yml
vendored
124
.github/workflows/backend.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
skip-drivers: ${{ matrix.skip-drivers }}
|
||||
context: ${{ matrix.context }}
|
||||
ubuntu-version: ${{ matrix.ubuntu-version }}
|
||||
amdgpu-targets: ${{ matrix.amdgpu-targets }}
|
||||
amdgpu-targets: ${{ matrix.amdgpu-targets || 'gfx908,gfx90a,gfx942,gfx950,gfx1030,gfx1100,gfx1101,gfx1102,gfx1151,gfx1200,gfx1201' }}
|
||||
secrets:
|
||||
dockerUsername: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
dockerPassword: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
@@ -698,6 +698,19 @@ jobs:
|
||||
dockerfile: "./backend/Dockerfile.golang"
|
||||
context: "./"
|
||||
ubuntu-version: '2404'
|
||||
- build-type: 'cublas'
|
||||
cuda-major-version: "12"
|
||||
cuda-minor-version: "8"
|
||||
platforms: 'linux/amd64'
|
||||
tag-latest: 'auto'
|
||||
tag-suffix: '-gpu-nvidia-cuda-12-vibevoice-cpp'
|
||||
runs-on: 'ubuntu-latest'
|
||||
base-image: "ubuntu:24.04"
|
||||
skip-drivers: 'false'
|
||||
backend: "vibevoice-cpp"
|
||||
dockerfile: "./backend/Dockerfile.golang"
|
||||
context: "./"
|
||||
ubuntu-version: '2404'
|
||||
- build-type: 'cublas'
|
||||
cuda-major-version: "12"
|
||||
cuda-minor-version: "8"
|
||||
@@ -1440,6 +1453,19 @@ jobs:
|
||||
dockerfile: "./backend/Dockerfile.golang"
|
||||
context: "./"
|
||||
ubuntu-version: '2404'
|
||||
- build-type: 'cublas'
|
||||
cuda-major-version: "13"
|
||||
cuda-minor-version: "0"
|
||||
platforms: 'linux/amd64'
|
||||
tag-latest: 'auto'
|
||||
tag-suffix: '-gpu-nvidia-cuda-13-vibevoice-cpp'
|
||||
runs-on: 'ubuntu-latest'
|
||||
base-image: "ubuntu:24.04"
|
||||
skip-drivers: 'false'
|
||||
backend: "vibevoice-cpp"
|
||||
dockerfile: "./backend/Dockerfile.golang"
|
||||
context: "./"
|
||||
ubuntu-version: '2404'
|
||||
- build-type: 'cublas'
|
||||
cuda-major-version: "13"
|
||||
cuda-minor-version: "0"
|
||||
@@ -1466,6 +1492,19 @@ jobs:
|
||||
backend: "qwen3-tts-cpp"
|
||||
dockerfile: "./backend/Dockerfile.golang"
|
||||
context: "./"
|
||||
- build-type: 'cublas'
|
||||
cuda-major-version: "13"
|
||||
cuda-minor-version: "0"
|
||||
platforms: 'linux/arm64'
|
||||
skip-drivers: 'false'
|
||||
tag-latest: 'auto'
|
||||
tag-suffix: '-nvidia-l4t-cuda-13-arm64-vibevoice-cpp'
|
||||
base-image: "ubuntu:24.04"
|
||||
ubuntu-version: '2404'
|
||||
runs-on: 'ubuntu-24.04-arm'
|
||||
backend: "vibevoice-cpp"
|
||||
dockerfile: "./backend/Dockerfile.golang"
|
||||
context: "./"
|
||||
- build-type: 'cublas'
|
||||
cuda-major-version: "13"
|
||||
cuda-minor-version: "0"
|
||||
@@ -2633,6 +2672,85 @@ jobs:
|
||||
dockerfile: "./backend/Dockerfile.golang"
|
||||
context: "./"
|
||||
ubuntu-version: '2404'
|
||||
# vibevoice-cpp
|
||||
- build-type: ''
|
||||
cuda-major-version: ""
|
||||
cuda-minor-version: ""
|
||||
platforms: 'linux/amd64,linux/arm64'
|
||||
tag-latest: 'auto'
|
||||
tag-suffix: '-cpu-vibevoice-cpp'
|
||||
runs-on: 'ubuntu-latest'
|
||||
base-image: "ubuntu:24.04"
|
||||
skip-drivers: 'false'
|
||||
backend: "vibevoice-cpp"
|
||||
dockerfile: "./backend/Dockerfile.golang"
|
||||
context: "./"
|
||||
ubuntu-version: '2404'
|
||||
- build-type: 'sycl_f32'
|
||||
cuda-major-version: ""
|
||||
cuda-minor-version: ""
|
||||
platforms: 'linux/amd64'
|
||||
tag-latest: 'auto'
|
||||
tag-suffix: '-gpu-intel-sycl-f32-vibevoice-cpp'
|
||||
runs-on: 'ubuntu-latest'
|
||||
base-image: "intel/oneapi-basekit:2025.3.0-0-devel-ubuntu24.04"
|
||||
skip-drivers: 'false'
|
||||
backend: "vibevoice-cpp"
|
||||
dockerfile: "./backend/Dockerfile.golang"
|
||||
context: "./"
|
||||
ubuntu-version: '2404'
|
||||
- build-type: 'sycl_f16'
|
||||
cuda-major-version: ""
|
||||
cuda-minor-version: ""
|
||||
platforms: 'linux/amd64'
|
||||
tag-latest: 'auto'
|
||||
tag-suffix: '-gpu-intel-sycl-f16-vibevoice-cpp'
|
||||
runs-on: 'ubuntu-latest'
|
||||
base-image: "intel/oneapi-basekit:2025.3.0-0-devel-ubuntu24.04"
|
||||
skip-drivers: 'false'
|
||||
backend: "vibevoice-cpp"
|
||||
dockerfile: "./backend/Dockerfile.golang"
|
||||
context: "./"
|
||||
ubuntu-version: '2404'
|
||||
- build-type: 'vulkan'
|
||||
cuda-major-version: ""
|
||||
cuda-minor-version: ""
|
||||
platforms: 'linux/amd64,linux/arm64'
|
||||
tag-latest: 'auto'
|
||||
tag-suffix: '-gpu-vulkan-vibevoice-cpp'
|
||||
runs-on: 'ubuntu-latest'
|
||||
base-image: "ubuntu:24.04"
|
||||
skip-drivers: 'false'
|
||||
backend: "vibevoice-cpp"
|
||||
dockerfile: "./backend/Dockerfile.golang"
|
||||
context: "./"
|
||||
ubuntu-version: '2404'
|
||||
- build-type: 'cublas'
|
||||
cuda-major-version: "12"
|
||||
cuda-minor-version: "0"
|
||||
platforms: 'linux/arm64'
|
||||
skip-drivers: 'false'
|
||||
tag-latest: 'auto'
|
||||
tag-suffix: '-nvidia-l4t-arm64-vibevoice-cpp'
|
||||
base-image: "nvcr.io/nvidia/l4t-jetpack:r36.4.0"
|
||||
runs-on: 'ubuntu-24.04-arm'
|
||||
backend: "vibevoice-cpp"
|
||||
dockerfile: "./backend/Dockerfile.golang"
|
||||
context: "./"
|
||||
ubuntu-version: '2204'
|
||||
- build-type: 'hipblas'
|
||||
cuda-major-version: ""
|
||||
cuda-minor-version: ""
|
||||
platforms: 'linux/amd64'
|
||||
tag-latest: 'auto'
|
||||
tag-suffix: '-gpu-rocm-hipblas-vibevoice-cpp'
|
||||
base-image: "rocm/dev-ubuntu-24.04:6.4.4"
|
||||
runs-on: 'ubuntu-latest'
|
||||
skip-drivers: 'false'
|
||||
backend: "vibevoice-cpp"
|
||||
dockerfile: "./backend/Dockerfile.golang"
|
||||
context: "./"
|
||||
ubuntu-version: '2404'
|
||||
# voxtral
|
||||
- build-type: ''
|
||||
cuda-major-version: ""
|
||||
@@ -3027,6 +3145,10 @@ jobs:
|
||||
tag-suffix: "-metal-darwin-arm64-qwen3-tts-cpp"
|
||||
build-type: "metal"
|
||||
lang: "go"
|
||||
- backend: "vibevoice-cpp"
|
||||
tag-suffix: "-metal-darwin-arm64-vibevoice-cpp"
|
||||
build-type: "metal"
|
||||
lang: "go"
|
||||
- backend: "voxtral"
|
||||
tag-suffix: "-metal-darwin-arm64-voxtral"
|
||||
build-type: "metal"
|
||||
|
||||
28
.github/workflows/backend_build.yml
vendored
28
.github/workflows/backend_build.yml
vendored
@@ -61,7 +61,7 @@ on:
|
||||
amdgpu-targets:
|
||||
description: 'AMD GPU targets for ROCm/HIP builds'
|
||||
required: false
|
||||
default: 'gfx908,gfx90a,gfx942,gfx950,gfx1030,gfx1100,gfx1101,gfx1102,gfx1151,gfx1200,gfx1201'
|
||||
default: ''
|
||||
type: string
|
||||
secrets:
|
||||
dockerUsername:
|
||||
@@ -80,6 +80,14 @@ jobs:
|
||||
quay_username: ${{ secrets.quayUsername }}
|
||||
steps:
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Configure apt mirror on runner
|
||||
id: apt_mirror
|
||||
uses: ./.github/actions/configure-apt-mirror
|
||||
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
if: inputs.runs-on == 'ubuntu-latest'
|
||||
@@ -97,20 +105,6 @@ jobs:
|
||||
docker-images: true
|
||||
swap-storage: true
|
||||
|
||||
- name: Force Install GIT latest
|
||||
run: |
|
||||
sudo apt-get update \
|
||||
&& sudo apt-get install -y software-properties-common \
|
||||
&& sudo apt-get update \
|
||||
&& sudo add-apt-repository -y ppa:git-core/ppa \
|
||||
&& sudo apt-get update \
|
||||
&& sudo apt-get install -y git
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Release space from worker
|
||||
if: inputs.runs-on == 'ubuntu-latest'
|
||||
run: |
|
||||
@@ -231,6 +225,8 @@ jobs:
|
||||
BACKEND=${{ inputs.backend }}
|
||||
UBUNTU_VERSION=${{ inputs.ubuntu-version }}
|
||||
AMDGPU_TARGETS=${{ inputs.amdgpu-targets }}
|
||||
APT_MIRROR=${{ steps.apt_mirror.outputs.effective-mirror }}
|
||||
APT_PORTS_MIRROR=${{ steps.apt_mirror.outputs.effective-ports-mirror }}
|
||||
DEPS_REFRESH=${{ steps.deps_refresh.outputs.key }}
|
||||
context: ${{ inputs.context }}
|
||||
file: ${{ inputs.dockerfile }}
|
||||
@@ -255,6 +251,8 @@ jobs:
|
||||
BACKEND=${{ inputs.backend }}
|
||||
UBUNTU_VERSION=${{ inputs.ubuntu-version }}
|
||||
AMDGPU_TARGETS=${{ inputs.amdgpu-targets }}
|
||||
APT_MIRROR=${{ steps.apt_mirror.outputs.effective-mirror }}
|
||||
APT_PORTS_MIRROR=${{ steps.apt_mirror.outputs.effective-ports-mirror }}
|
||||
DEPS_REFRESH=${{ steps.deps_refresh.outputs.key }}
|
||||
context: ${{ inputs.context }}
|
||||
file: ${{ inputs.dockerfile }}
|
||||
|
||||
1
.github/workflows/backend_pr.yml
vendored
1
.github/workflows/backend_pr.yml
vendored
@@ -53,6 +53,7 @@ jobs:
|
||||
skip-drivers: ${{ matrix.skip-drivers }}
|
||||
context: ${{ matrix.context }}
|
||||
ubuntu-version: ${{ matrix.ubuntu-version }}
|
||||
amdgpu-targets: ${{ matrix.amdgpu-targets || 'gfx908,gfx90a,gfx942,gfx950,gfx1030,gfx1100,gfx1101,gfx1102,gfx1151,gfx1200,gfx1201' }}
|
||||
secrets:
|
||||
quayUsername: ${{ secrets.LOCALAI_REGISTRY_USERNAME }}
|
||||
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
|
||||
|
||||
2
.github/workflows/build-test.yaml
vendored
2
.github/workflows/build-test.yaml
vendored
@@ -50,6 +50,8 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Configure apt mirror on runner
|
||||
uses: ./.github/actions/configure-apt-mirror
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
|
||||
36
.github/workflows/bump_deps.yaml
vendored
36
.github/workflows/bump_deps.yaml
vendored
@@ -80,5 +80,37 @@ jobs:
|
||||
body: ${{ steps.bump.outputs.message }}
|
||||
signoff: true
|
||||
|
||||
|
||||
|
||||
bump-vllm-wheel:
|
||||
# vLLM's cu130 wheel comes from a per-tag index URL (no /latest/ alias),
|
||||
# so the cublas13 requirements file pins both a URL segment and a version
|
||||
# constraint. bump_deps.sh handles git-sha-in-Makefile only — this job
|
||||
# rewrites both values atomically when a new vLLM stable tag ships.
|
||||
if: github.repository == 'mudler/LocalAI'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Bump vLLM cu130 wheel pin 🔧
|
||||
id: bump
|
||||
run: |
|
||||
bash .github/bump_vllm_wheel.sh vllm-project/vllm backend/python/vllm/requirements-cublas13-after.txt VLLM_VERSION
|
||||
{
|
||||
echo 'message<<EOF'
|
||||
cat "VLLM_VERSION_message.txt"
|
||||
echo EOF
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo 'commit<<EOF'
|
||||
cat "VLLM_VERSION_commit.txt"
|
||||
echo EOF
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
rm -rfv VLLM_VERSION_message.txt VLLM_VERSION_commit.txt
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
token: ${{ secrets.UPDATE_BOT_TOKEN }}
|
||||
push-to-fork: ci-forks/LocalAI
|
||||
commit-message: ':arrow_up: Update vllm-project/vllm cu130 wheel'
|
||||
title: 'chore: :arrow_up: Update vllm-project/vllm cu130 wheel to `${{ steps.bump.outputs.commit }}`'
|
||||
branch: "update/VLLM_VERSION"
|
||||
body: ${{ steps.bump.outputs.message }}
|
||||
signoff: true
|
||||
|
||||
10
.github/workflows/checksum_checker.yaml
vendored
10
.github/workflows/checksum_checker.yaml
vendored
@@ -8,15 +8,9 @@ jobs:
|
||||
if: github.repository == 'mudler/LocalAI'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Force Install GIT latest
|
||||
run: |
|
||||
sudo apt-get update \
|
||||
&& sudo apt-get install -y software-properties-common \
|
||||
&& sudo apt-get update \
|
||||
&& sudo add-apt-repository -y ppa:git-core/ppa \
|
||||
&& sudo apt-get update \
|
||||
&& sudo apt-get install -y git
|
||||
- uses: actions/checkout@v6
|
||||
- name: Configure apt mirror on runner
|
||||
uses: ./.github/actions/configure-apt-mirror
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
|
||||
21
.github/workflows/image_build.yml
vendored
21
.github/workflows/image_build.yml
vendored
@@ -70,6 +70,13 @@ jobs:
|
||||
runs-on: ${{ inputs.runs-on }}
|
||||
steps:
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Configure apt mirror on runner
|
||||
id: apt_mirror
|
||||
uses: ./.github/actions/configure-apt-mirror
|
||||
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
if: inputs.runs-on == 'ubuntu-latest'
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
@@ -85,16 +92,6 @@ jobs:
|
||||
large-packages: true
|
||||
docker-images: true
|
||||
swap-storage: true
|
||||
- name: Force Install GIT latest
|
||||
run: |
|
||||
sudo apt-get update \
|
||||
&& sudo apt-get install -y software-properties-common \
|
||||
&& sudo apt-get update \
|
||||
&& sudo add-apt-repository -y ppa:git-core/ppa \
|
||||
&& sudo apt-get update \
|
||||
&& sudo apt-get install -y git
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Release space from worker
|
||||
if: inputs.runs-on == 'ubuntu-latest'
|
||||
@@ -205,6 +202,8 @@ jobs:
|
||||
SKIP_DRIVERS=${{ inputs.skip-drivers }}
|
||||
UBUNTU_VERSION=${{ inputs.ubuntu-version }}
|
||||
UBUNTU_CODENAME=${{ inputs.ubuntu-codename }}
|
||||
APT_MIRROR=${{ steps.apt_mirror.outputs.effective-mirror }}
|
||||
APT_PORTS_MIRROR=${{ steps.apt_mirror.outputs.effective-ports-mirror }}
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
cache-from: type=registry,ref=quay.io/go-skynet/ci-cache:cache-localai${{ inputs.tag-suffix }}
|
||||
@@ -228,6 +227,8 @@ jobs:
|
||||
SKIP_DRIVERS=${{ inputs.skip-drivers }}
|
||||
UBUNTU_VERSION=${{ inputs.ubuntu-version }}
|
||||
UBUNTU_CODENAME=${{ inputs.ubuntu-codename }}
|
||||
APT_MIRROR=${{ steps.apt_mirror.outputs.effective-mirror }}
|
||||
APT_PORTS_MIRROR=${{ steps.apt_mirror.outputs.effective-ports-mirror }}
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
cache-from: type=registry,ref=quay.io/go-skynet/ci-cache:cache-localai${{ inputs.tag-suffix }}
|
||||
|
||||
48
.github/workflows/lint.yml
vendored
Normal file
48
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: 'lint'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- 'examples/**'
|
||||
- 'README.md'
|
||||
- '**/*.md'
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
concurrency:
|
||||
group: ci-lint-${{ github.head_ref || github.ref }}-${{ github.repository }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
golangci-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history so golangci-lint's new-from-merge-base can reach
|
||||
# origin/master and compute the diff against it.
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.26.x'
|
||||
cache: false
|
||||
- name: install golangci-lint
|
||||
run: |
|
||||
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh \
|
||||
| sh -s -- -b "$(go env GOPATH)/bin" v2.11.4
|
||||
- name: generate grpc proto sources
|
||||
# pkg/grpc/proto/*.go is generated, not checked in. Several packages
|
||||
# import it, so without this step typecheck fails project-wide.
|
||||
run: make protogen-go
|
||||
- name: stub react-ui dist for go:embed
|
||||
# core/http/app.go has //go:embed react-ui/dist/*; the glob needs at
|
||||
# least one non-hidden entry to satisfy typecheck. We don't run
|
||||
# `make react-ui` here because lint doesn't need the real bundle.
|
||||
run: |
|
||||
mkdir -p core/http/react-ui/dist
|
||||
touch core/http/react-ui/dist/index.html
|
||||
- name: lint
|
||||
run: make lint
|
||||
2
.github/workflows/release.yaml
vendored
2
.github/workflows/release.yaml
vendored
@@ -49,6 +49,8 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Configure apt mirror on runner
|
||||
uses: ./.github/actions/configure-apt-mirror
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
|
||||
119
.github/workflows/test-extra.yml
vendored
119
.github/workflows/test-extra.yml
vendored
@@ -36,6 +36,7 @@ jobs:
|
||||
sglang: ${{ steps.detect.outputs.sglang }}
|
||||
acestep-cpp: ${{ steps.detect.outputs.acestep-cpp }}
|
||||
qwen3-tts-cpp: ${{ steps.detect.outputs.qwen3-tts-cpp }}
|
||||
vibevoice-cpp: ${{ steps.detect.outputs.vibevoice-cpp }}
|
||||
voxtral: ${{ steps.detect.outputs.voxtral }}
|
||||
kokoros: ${{ steps.detect.outputs.kokoros }}
|
||||
insightface: ${{ steps.detect.outputs.insightface }}
|
||||
@@ -507,6 +508,33 @@ jobs:
|
||||
- name: Build llama-cpp backend image and run audio transcription gRPC e2e tests
|
||||
run: |
|
||||
make test-extra-backend-llama-cpp-transcription
|
||||
# PR-acceptance smoke gate: always runs on every PR (no detect-changes gate, no
|
||||
# paths filter). Pulls the pre-built master CPU llama-cpp image from quay
|
||||
# instead of building from source, so the cost is a docker pull (~30s) plus the
|
||||
# short Qwen3-0.6B model download. Exercises the full gRPC surface — health,
|
||||
# load, predict, stream — plus the logprobs/logit_bias specs that moved out of
|
||||
# core/http/app_test.go. Anything heavier or per-backend is gated to the
|
||||
# detect-changes path-filter above.
|
||||
tests-llama-cpp-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25.4'
|
||||
- name: Pull pre-built llama-cpp backend image
|
||||
run: docker pull quay.io/go-skynet/local-ai-backends:master-cpu-llama-cpp
|
||||
- name: Run e2e-backends smoke
|
||||
env:
|
||||
BACKEND_IMAGE: quay.io/go-skynet/local-ai-backends:master-cpu-llama-cpp
|
||||
BACKEND_TEST_CAPS: health,load,predict,stream,logprobs,logit_bias
|
||||
run: |
|
||||
make test-extra-backend
|
||||
# Realtime e2e with sherpa-onnx driving VAD + STT + TTS against a mocked LLM.
|
||||
# Builds the sherpa-onnx Docker image, extracts the rootfs so the e2e suite
|
||||
# can discover the backend binary + shared libs, downloads the three model
|
||||
@@ -765,6 +793,97 @@ jobs:
|
||||
- name: Test qwen3-tts-cpp
|
||||
run: |
|
||||
make --jobs=5 --output-sync=target -C backend/go/qwen3-tts-cpp test
|
||||
# Per-backend smoke for vibevoice-cpp: builds the .so + Go binary and
|
||||
# runs `make -C backend/go/vibevoice-cpp test`. test.sh auto-downloads
|
||||
# the published mudler/vibevoice.cpp-models bundle (TTS Q8_0 + ASR Q4_K
|
||||
# + tokenizer + voice) and runs the closed-loop TTS → ASR Go test.
|
||||
tests-vibevoice-cpp:
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.vibevoice-cpp == 'true' || needs.detect-changes.outputs.run-all == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
- name: Dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential cmake curl libopenblas-dev ffmpeg
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
- name: Display Go version
|
||||
run: go version
|
||||
- name: Proto Dependencies
|
||||
run: |
|
||||
curl -L -s https://github.com/protocolbuffers/protobuf/releases/download/v26.1/protoc-26.1-linux-x86_64.zip -o protoc.zip && \
|
||||
unzip -j -d /usr/local/bin protoc.zip bin/protoc && \
|
||||
rm protoc.zip
|
||||
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.2
|
||||
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@1958fcbe2ca8bd93af633f11e97d44e567e945af
|
||||
PATH="$PATH:$HOME/go/bin" make protogen-go
|
||||
- name: Build vibevoice-cpp
|
||||
run: |
|
||||
make --jobs=5 --output-sync=target -C backend/go/vibevoice-cpp
|
||||
- name: Test vibevoice-cpp
|
||||
run: |
|
||||
make --jobs=5 --output-sync=target -C backend/go/vibevoice-cpp test
|
||||
# End-to-end TTS via the e2e-backends gRPC harness. Builds the
|
||||
# vibevoice-cpp Docker image and drives Backend/TTS against it with a
|
||||
# real LocalAI gRPC client.
|
||||
tests-vibevoice-cpp-grpc-tts:
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.vibevoice-cpp == 'true' || needs.detect-changes.outputs.run-all == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25.4'
|
||||
- name: Build vibevoice-cpp backend image and run TTS gRPC e2e tests
|
||||
run: |
|
||||
make test-extra-backend-vibevoice-cpp-tts
|
||||
# End-to-end transcription via the e2e-backends gRPC harness. The
|
||||
# vibevoice ASR is a 7B-param model (Q4_K weights ~10 GB on disk)
|
||||
# and the JFK 30 s decode is too heavy for a free 4-core
|
||||
# ubuntu-latest pool runner - two CI attempts got SIGTERM'd during
|
||||
# LoadModel, before the test could even progress. Use the
|
||||
# self-hosted 'bigger-runner' label (same one the GPU image builds
|
||||
# in backend.yml use) and the documented dotnet/ghc/android cache
|
||||
# purge to clear ~10-20 GB of headroom for the model + Docker
|
||||
# image + working dir.
|
||||
tests-vibevoice-cpp-grpc-transcription:
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.vibevoice-cpp == 'true' || needs.detect-changes.outputs.run-all == 'true'
|
||||
runs-on: bigger-runner
|
||||
timeout-minutes: 150
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
- name: Dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
make build-essential curl unzip ca-certificates git tar
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25.4'
|
||||
- name: Free disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /opt/hostedtoolcache/CodeQL || true
|
||||
df -h
|
||||
- name: Build vibevoice-cpp backend image and run ASR gRPC e2e tests
|
||||
run: |
|
||||
make test-extra-backend-vibevoice-cpp-transcription
|
||||
tests-voxtral:
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.voxtral == 'true' || needs.detect-changes.outputs.run-all == 'true'
|
||||
|
||||
76
.github/workflows/test.yml
vendored
76
.github/workflows/test.yml
vendored
@@ -3,6 +3,12 @@ name: 'tests'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- 'examples/**'
|
||||
- 'README.md'
|
||||
- '**/*.md'
|
||||
- 'backend/**'
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
@@ -97,73 +103,9 @@ jobs:
|
||||
node-version: '22'
|
||||
- name: Build React UI
|
||||
run: make react-ui
|
||||
- name: Build backends
|
||||
run: |
|
||||
make backends/transformers
|
||||
mkdir external && mv backends/transformers external/transformers
|
||||
make backends/llama-cpp backends/local-store backends/silero-vad backends/piper backends/whisper backends/stablediffusion-ggml
|
||||
- name: Test
|
||||
run: |
|
||||
TRANSFORMER_BACKEND=$PWD/external/transformers/run.sh PATH="$PATH:/root/go/bin" GO_TAGS="tts" make --jobs 5 --output-sync=target test
|
||||
- name: Setup tmate session if tests fail
|
||||
if: ${{ failure() }}
|
||||
uses: mxschmitt/action-tmate@v3.23
|
||||
with:
|
||||
detached: true
|
||||
connect-timeout-seconds: 180
|
||||
limit-access-to-actor: true
|
||||
|
||||
tests-e2e-container:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Release space from worker
|
||||
run: |
|
||||
echo "Listing top largest packages"
|
||||
pkgs=$(dpkg-query -Wf '${Installed-Size}\t${Package}\t${Status}\n' | awk '$NF == "installed"{print $1 "\t" $2}' | sort -nr)
|
||||
head -n 30 <<< "${pkgs}"
|
||||
echo
|
||||
df -h
|
||||
echo
|
||||
sudo apt-get remove -y '^llvm-.*|^libllvm.*' || true
|
||||
sudo apt-get remove --auto-remove android-sdk-platform-tools || true
|
||||
sudo apt-get purge --auto-remove android-sdk-platform-tools || true
|
||||
sudo rm -rf /usr/local/lib/android
|
||||
sudo apt-get remove -y '^dotnet-.*|^aspnetcore-.*' || true
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo apt-get remove -y '^mono-.*' || true
|
||||
sudo apt-get remove -y '^ghc-.*' || true
|
||||
sudo apt-get remove -y '.*jdk.*|.*jre.*' || true
|
||||
sudo apt-get remove -y 'php.*' || true
|
||||
sudo apt-get remove -y hhvm powershell firefox monodoc-manual msbuild || true
|
||||
sudo apt-get remove -y '^google-.*' || true
|
||||
sudo apt-get remove -y azure-cli || true
|
||||
sudo apt-get remove -y '^mongo.*-.*|^postgresql-.*|^mysql-.*|^mssql-.*' || true
|
||||
sudo apt-get remove -y '^gfortran-.*' || true
|
||||
sudo apt-get autoremove -y
|
||||
sudo apt-get clean
|
||||
echo
|
||||
echo "Listing top largest packages"
|
||||
pkgs=$(dpkg-query -Wf '${Installed-Size}\t${Package}\t${Status}\n' | awk '$NF == "installed"{print $1 "\t" $2}' | sort -nr)
|
||||
head -n 30 <<< "${pkgs}"
|
||||
echo
|
||||
sudo rm -rfv build || true
|
||||
df -h
|
||||
- name: Clone
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
- name: Dependencies
|
||||
run: |
|
||||
# Install protoc
|
||||
curl -L -s https://github.com/protocolbuffers/protobuf/releases/download/v26.1/protoc-26.1-linux-x86_64.zip -o protoc.zip && \
|
||||
unzip -j -d /usr/local/bin protoc.zip bin/protoc && \
|
||||
rm protoc.zip
|
||||
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.2
|
||||
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@1958fcbe2ca8bd93af633f11e97d44e567e945af
|
||||
PATH="$PATH:$HOME/go/bin" make protogen-go
|
||||
- name: Test
|
||||
run: |
|
||||
PATH="$PATH:$HOME/go/bin" make backends/local-store backends/silero-vad backends/llama-cpp backends/whisper backends/piper backends/stablediffusion-ggml docker-build-e2e e2e-aio
|
||||
PATH="$PATH:/root/go/bin" make --jobs 5 --output-sync=target test
|
||||
- name: Setup tmate session if tests fail
|
||||
if: ${{ failure() }}
|
||||
uses: mxschmitt/action-tmate@v3.23
|
||||
@@ -200,10 +142,6 @@ jobs:
|
||||
node-version: '22'
|
||||
- name: Build React UI
|
||||
run: make react-ui
|
||||
- name: Build llama-cpp-darwin
|
||||
run: |
|
||||
make protogen-go
|
||||
make backends/llama-cpp-darwin
|
||||
- name: Test
|
||||
run: |
|
||||
export C_INCLUDE_PATH=/usr/local/include
|
||||
|
||||
86
.github/workflows/tests-aio.yml
vendored
Normal file
86
.github/workflows/tests-aio.yml
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: 'tests-aio'
|
||||
|
||||
# Runs the all-in-one (AIO) Docker image with real backends + real models.
|
||||
# Heavy: builds llama-cpp/whisper/piper/silero-vad/stablediffusion-ggml/local-store
|
||||
# and exercises end-to-end inference inside the container. Moved out of test.yml
|
||||
# (which used to run on every PR) so PR CI no longer pays this cost.
|
||||
#
|
||||
# Triggers:
|
||||
# - schedule (nightly @ 04:00 UTC) — catches packaging/image regressions within 24h
|
||||
# - workflow_dispatch — manual run on-demand
|
||||
# - push to master/tags — sanity check after merge / before release
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 4 * * *'
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
concurrency:
|
||||
group: ci-tests-aio-${{ github.head_ref || github.ref }}-${{ github.repository }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
tests-aio:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Release space from worker
|
||||
run: |
|
||||
echo "Listing top largest packages"
|
||||
pkgs=$(dpkg-query -Wf '${Installed-Size}\t${Package}\t${Status}\n' | awk '$NF == "installed"{print $1 "\t" $2}' | sort -nr)
|
||||
head -n 30 <<< "${pkgs}"
|
||||
echo
|
||||
df -h
|
||||
echo
|
||||
sudo apt-get remove -y '^llvm-.*|^libllvm.*' || true
|
||||
sudo apt-get remove --auto-remove android-sdk-platform-tools || true
|
||||
sudo apt-get purge --auto-remove android-sdk-platform-tools || true
|
||||
sudo rm -rf /usr/local/lib/android
|
||||
sudo apt-get remove -y '^dotnet-.*|^aspnetcore-.*' || true
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo apt-get remove -y '^mono-.*' || true
|
||||
sudo apt-get remove -y '^ghc-.*' || true
|
||||
sudo apt-get remove -y '.*jdk.*|.*jre.*' || true
|
||||
sudo apt-get remove -y 'php.*' || true
|
||||
sudo apt-get remove -y hhvm powershell firefox monodoc-manual msbuild || true
|
||||
sudo apt-get remove -y '^google-.*' || true
|
||||
sudo apt-get remove -y azure-cli || true
|
||||
sudo apt-get remove -y '^mongo.*-.*|^postgresql-.*|^mysql-.*|^mssql-.*' || true
|
||||
sudo apt-get remove -y '^gfortran-.*' || true
|
||||
sudo apt-get autoremove -y
|
||||
sudo apt-get clean
|
||||
echo
|
||||
echo "Listing top largest packages"
|
||||
pkgs=$(dpkg-query -Wf '${Installed-Size}\t${Package}\t${Status}\n' | awk '$NF == "installed"{print $1 "\t" $2}' | sort -nr)
|
||||
head -n 30 <<< "${pkgs}"
|
||||
echo
|
||||
sudo rm -rfv build || true
|
||||
df -h
|
||||
- name: Clone
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
- name: Dependencies
|
||||
run: |
|
||||
# Install protoc
|
||||
curl -L -s https://github.com/protocolbuffers/protobuf/releases/download/v26.1/protoc-26.1-linux-x86_64.zip -o protoc.zip && \
|
||||
unzip -j -d /usr/local/bin protoc.zip bin/protoc && \
|
||||
rm protoc.zip
|
||||
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.2
|
||||
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@1958fcbe2ca8bd93af633f11e97d44e567e945af
|
||||
PATH="$PATH:$HOME/go/bin" make protogen-go
|
||||
- name: Test
|
||||
run: |
|
||||
PATH="$PATH:$HOME/go/bin" make backends/local-store backends/silero-vad backends/llama-cpp backends/whisper backends/piper backends/stablediffusion-ggml docker-build-e2e e2e-aio
|
||||
- name: Setup tmate session if tests fail
|
||||
if: ${{ failure() }}
|
||||
uses: mxschmitt/action-tmate@v3.23
|
||||
with:
|
||||
detached: true
|
||||
connect-timeout-seconds: 180
|
||||
limit-access-to-actor: true
|
||||
8
.github/workflows/tests-e2e.yml
vendored
8
.github/workflows/tests-e2e.yml
vendored
@@ -3,6 +3,12 @@ name: 'E2E Backend Tests'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- 'examples/**'
|
||||
- 'README.md'
|
||||
- '**/*.md'
|
||||
- 'backend/**'
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
@@ -24,6 +30,8 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
- name: Configure apt mirror on runner
|
||||
uses: ./.github/actions/configure-apt-mirror
|
||||
- name: Setup Go ${{ matrix.go-version }}
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
|
||||
2
.github/workflows/tests-ui-e2e.yml
vendored
2
.github/workflows/tests-ui-e2e.yml
vendored
@@ -26,6 +26,8 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
- name: Configure apt mirror on runner
|
||||
uses: ./.github/actions/configure-apt-mirror
|
||||
- name: Setup Go ${{ matrix.go-version }}
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
|
||||
2
.github/workflows/update_swagger.yaml
vendored
2
.github/workflows/update_swagger.yaml
vendored
@@ -11,6 +11,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Configure apt mirror on runner
|
||||
uses: ./.github/actions/configure-apt-mirror
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
|
||||
53
.golangci.yml
Normal file
53
.golangci.yml
Normal file
@@ -0,0 +1,53 @@
|
||||
version: "2"
|
||||
|
||||
# Only issues introduced relative to master are reported. Pre-existing issues
|
||||
# in the codebase do not fail the lint job; they're treated as a baseline that
|
||||
# can be cleaned up incrementally. New code (added lines on a branch) is held
|
||||
# to the full linter set. Locally, `make lint-all` overrides this and reports
|
||||
# every issue.
|
||||
issues:
|
||||
# origin/master because in shallow CI checkouts only the remote-tracking
|
||||
# branch exists; a bare 'master' ref isn't reachable locally.
|
||||
new-from-merge-base: origin/master
|
||||
|
||||
linters:
|
||||
default: standard
|
||||
# staticcheck is noisy on this codebase (mostly QF style suggestions like
|
||||
# "could use tagged switch" or "unnecessary fmt.Sprintf"). Re-enable
|
||||
# selectively if a high-signal subset is identified.
|
||||
disable:
|
||||
- staticcheck
|
||||
enable:
|
||||
- forbidigo
|
||||
settings:
|
||||
forbidigo:
|
||||
forbid:
|
||||
- pattern: '^t\.Errorf$'
|
||||
msg: 'LocalAI tests must use Ginkgo/Gomega; use Expect(...).To(...) instead of t.Errorf. See .agents/coding-style.md.'
|
||||
- pattern: '^t\.Error$'
|
||||
msg: 'LocalAI tests must use Ginkgo/Gomega; use Expect(...).To(...) instead of t.Error. See .agents/coding-style.md.'
|
||||
- pattern: '^t\.Fatalf$'
|
||||
msg: 'LocalAI tests must use Ginkgo/Gomega; use Expect(...).To(Succeed()) / Fail(...) instead of t.Fatalf. See .agents/coding-style.md.'
|
||||
- pattern: '^t\.Fatal$'
|
||||
msg: 'LocalAI tests must use Ginkgo/Gomega; use Expect(...).To(Succeed()) / Fail(...) instead of t.Fatal. See .agents/coding-style.md.'
|
||||
- pattern: '^t\.Run$'
|
||||
msg: 'LocalAI tests must use Ginkgo/Gomega; use Describe/Context/It instead of t.Run. See .agents/coding-style.md.'
|
||||
- pattern: '^t\.Skip$'
|
||||
msg: 'LocalAI tests must use Ginkgo/Gomega; use Skip(...) instead of t.Skip. See .agents/coding-style.md.'
|
||||
- pattern: '^t\.Skipf$'
|
||||
msg: 'LocalAI tests must use Ginkgo/Gomega; use Skip(...) instead of t.Skipf. See .agents/coding-style.md.'
|
||||
- pattern: '^t\.SkipNow$'
|
||||
msg: 'LocalAI tests must use Ginkgo/Gomega; use Skip(...) instead of t.SkipNow. See .agents/coding-style.md.'
|
||||
- pattern: '^t\.Logf$'
|
||||
msg: 'LocalAI tests must use Ginkgo/Gomega; use GinkgoWriter / fmt.Fprintf(GinkgoWriter, ...) instead of t.Logf. See .agents/coding-style.md.'
|
||||
- pattern: '^t\.Log$'
|
||||
msg: 'LocalAI tests must use Ginkgo/Gomega; use GinkgoWriter / fmt.Fprintln(GinkgoWriter, ...) instead of t.Log. See .agents/coding-style.md.'
|
||||
- pattern: '^t\.Fail$'
|
||||
msg: 'LocalAI tests must use Ginkgo/Gomega; use Fail(...) instead of t.Fail. See .agents/coding-style.md.'
|
||||
- pattern: '^t\.FailNow$'
|
||||
msg: 'LocalAI tests must use Ginkgo/Gomega; use Fail(...) instead of t.FailNow. See .agents/coding-style.md.'
|
||||
exclusions:
|
||||
paths:
|
||||
# Upstream whisper.cpp source tree fetched by the whisper backend Makefile.
|
||||
- 'backend/go/whisper/sources'
|
||||
- 'docs/'
|
||||
@@ -28,6 +28,7 @@ LocalAI follows the Linux kernel project's [guidelines for AI coding assistants]
|
||||
| [.agents/api-endpoints-and-auth.md](.agents/api-endpoints-and-auth.md) | Adding API endpoints, auth middleware, feature permissions, user access control |
|
||||
| [.agents/debugging-backends.md](.agents/debugging-backends.md) | Debugging runtime backend failures, dependency conflicts, rebuilding backends |
|
||||
| [.agents/adding-gallery-models.md](.agents/adding-gallery-models.md) | Adding GGUF models from HuggingFace to the model gallery |
|
||||
| [.agents/localai-assistant-mcp.md](.agents/localai-assistant-mcp.md) | LocalAI Assistant chat modality — adding admin tools to the in-process MCP server, editing skill prompts, keeping REST + MCP + skills in sync |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
@@ -36,5 +37,6 @@ LocalAI follows the Linux kernel project's [guidelines for AI coding assistants]
|
||||
- **Comments**: Explain *why*, not *what*
|
||||
- **Docs**: Update `docs/content/` when adding features or changing config
|
||||
- **New API endpoints**: LocalAI advertises its capability surface in several independent places — swagger `@Tags`, `/api/instructions` registry, auth `RouteFeatureRegistry`, React UI `capabilities.js`, docs. Read [.agents/api-endpoints-and-auth.md](.agents/api-endpoints-and-auth.md) and follow its checklist — missing any surface means clients, admins, and the UI won't know the endpoint exists.
|
||||
- **Admin endpoints → MCP tool**: every admin endpoint that an admin would manage conversationally (install/list/edit/toggle/upgrade) MUST also be exposed as an MCP tool in `pkg/mcp/localaitools/`. The LocalAI Assistant chat modality and the standalone `local-ai mcp-server` consume that package; drift between REST and MCP is a real risk. Read [.agents/localai-assistant-mcp.md](.agents/localai-assistant-mcp.md) — the `TestToolHTTPRouteMappingComplete` test fails until you wire the new tool and update the route map.
|
||||
- **Build**: Inspect `Makefile` and `.github/workflows/` — ask the user before running long builds
|
||||
- **UI**: The active UI is the React app in `core/http/react-ui/`. The older Alpine.js/HTML UI in `core/http/static/` is pending deprecation — all new UI work goes in the React UI
|
||||
|
||||
16
Dockerfile
16
Dockerfile
@@ -1,12 +1,20 @@
|
||||
ARG BASE_IMAGE=ubuntu:24.04
|
||||
ARG INTEL_BASE_IMAGE=${BASE_IMAGE}
|
||||
ARG UBUNTU_CODENAME=noble
|
||||
# Optional alternate Ubuntu apt mirror(s). Empty = use upstream.
|
||||
# See .docker/apt-mirror.sh for accepted values.
|
||||
ARG APT_MIRROR=""
|
||||
ARG APT_PORTS_MIRROR=""
|
||||
|
||||
FROM ${BASE_IMAGE} AS requirements
|
||||
|
||||
ARG APT_MIRROR
|
||||
ARG APT_PORTS_MIRROR
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && \
|
||||
RUN --mount=type=bind,source=.docker/apt-mirror.sh,target=/usr/local/sbin/apt-mirror \
|
||||
APT_MIRROR="${APT_MIRROR}" APT_PORTS_MIRROR="${APT_PORTS_MIRROR}" sh /usr/local/sbin/apt-mirror && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl wget espeak-ng libgomp1 \
|
||||
ffmpeg libopenblas0 libopenblas-dev libopus0 sox && \
|
||||
@@ -240,10 +248,14 @@ WORKDIR /build
|
||||
# This is a temporary workaround until Intel fixes their repository
|
||||
FROM ${INTEL_BASE_IMAGE} AS intel
|
||||
ARG UBUNTU_CODENAME=noble
|
||||
ARG APT_MIRROR
|
||||
ARG APT_PORTS_MIRROR
|
||||
RUN wget -qO - https://repositories.intel.com/gpu/intel-graphics.key | \
|
||||
gpg --yes --dearmor --output /usr/share/keyrings/intel-graphics.gpg
|
||||
RUN echo "deb [arch=amd64 signed-by=/usr/share/keyrings/intel-graphics.gpg] https://repositories.intel.com/gpu/ubuntu ${UBUNTU_CODENAME}/lts/2350 unified" > /etc/apt/sources.list.d/intel-graphics.list
|
||||
RUN apt-get update && \
|
||||
RUN --mount=type=bind,source=.docker/apt-mirror.sh,target=/usr/local/sbin/apt-mirror \
|
||||
APT_MIRROR="${APT_MIRROR}" APT_PORTS_MIRROR="${APT_PORTS_MIRROR}" sh /usr/local/sbin/apt-mirror && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
intel-oneapi-runtime-libs && \
|
||||
apt-get clean && \
|
||||
|
||||
157
Makefile
157
Makefile
@@ -1,5 +1,5 @@
|
||||
# Disable parallel execution for backend builds
|
||||
.NOTPARALLEL: backends/diffusers backends/llama-cpp backends/turboquant backends/outetts backends/piper backends/stablediffusion-ggml backends/whisper backends/faster-whisper backends/silero-vad backends/local-store backends/huggingface backends/rfdetr backends/insightface backends/speaker-recognition backends/kitten-tts backends/kokoro backends/chatterbox backends/llama-cpp-darwin backends/neutts build-darwin-python-backend build-darwin-go-backend backends/mlx backends/diffuser-darwin backends/mlx-vlm backends/mlx-audio backends/mlx-distributed backends/stablediffusion-ggml-darwin backends/vllm backends/vllm-omni backends/sglang backends/moonshine backends/pocket-tts backends/qwen-tts backends/faster-qwen3-tts backends/qwen-asr backends/nemo backends/voxcpm backends/whisperx backends/ace-step backends/acestep-cpp backends/fish-speech backends/voxtral backends/opus backends/trl backends/llama-cpp-quantization backends/kokoros backends/sam3-cpp backends/qwen3-tts-cpp backends/tinygrad backends/sherpa-onnx
|
||||
.NOTPARALLEL: backends/diffusers backends/llama-cpp backends/turboquant backends/outetts backends/piper backends/stablediffusion-ggml backends/whisper backends/faster-whisper backends/silero-vad backends/local-store backends/huggingface backends/rfdetr backends/insightface backends/speaker-recognition backends/kitten-tts backends/kokoro backends/chatterbox backends/llama-cpp-darwin backends/neutts build-darwin-python-backend build-darwin-go-backend backends/mlx backends/diffuser-darwin backends/mlx-vlm backends/mlx-audio backends/mlx-distributed backends/stablediffusion-ggml-darwin backends/vllm backends/vllm-omni backends/sglang backends/moonshine backends/pocket-tts backends/qwen-tts backends/faster-qwen3-tts backends/qwen-asr backends/nemo backends/voxcpm backends/whisperx backends/ace-step backends/acestep-cpp backends/fish-speech backends/voxtral backends/opus backends/trl backends/llama-cpp-quantization backends/kokoros backends/sam3-cpp backends/qwen3-tts-cpp backends/vibevoice-cpp backends/tinygrad backends/sherpa-onnx
|
||||
|
||||
GOCMD=go
|
||||
GOTEST=$(GOCMD) test
|
||||
@@ -10,6 +10,13 @@ LAUNCHER_BINARY_NAME=local-ai-launcher
|
||||
UBUNTU_VERSION?=2404
|
||||
UBUNTU_CODENAME?=noble
|
||||
|
||||
# Optional Ubuntu apt mirror overrides forwarded to docker builds.
|
||||
# Empty = use upstream archive.ubuntu.com / security.ubuntu.com / ports.ubuntu.com.
|
||||
# Set e.g. APT_MIRROR=http://azure.archive.ubuntu.com to route apt traffic
|
||||
# during outages of the default Ubuntu pool.
|
||||
APT_MIRROR?=
|
||||
APT_PORTS_MIRROR?=
|
||||
|
||||
GORELEASER?=
|
||||
|
||||
export BUILD_TYPE?=
|
||||
@@ -65,7 +72,7 @@ endif
|
||||
TEST_PATHS?=./api/... ./pkg/... ./core/...
|
||||
|
||||
|
||||
.PHONY: all test build vendor
|
||||
.PHONY: all test build vendor lint lint-all
|
||||
|
||||
all: help
|
||||
|
||||
@@ -85,6 +92,7 @@ clean: ## Remove build related file
|
||||
clean-tests:
|
||||
rm -rf test-models
|
||||
rm -rf test-dir
|
||||
rm -f tests/e2e/mock-backend/mock-backend
|
||||
|
||||
## Install Go tools
|
||||
install-go-tools:
|
||||
@@ -143,32 +151,56 @@ osx-signed: build
|
||||
run: ## run local-ai
|
||||
CGO_LDFLAGS="$(CGO_LDFLAGS)" $(GOCMD) run ./
|
||||
|
||||
test-models/testmodel.ggml:
|
||||
mkdir -p test-models
|
||||
mkdir -p test-dir
|
||||
wget -q https://huggingface.co/mradermacher/gpt2-alpaca-gpt4-GGUF/resolve/main/gpt2-alpaca-gpt4.Q4_K_M.gguf -O test-models/testmodel.ggml
|
||||
wget -q https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin -O test-models/whisper-en
|
||||
wget -q https://cdn.openai.com/whisper/draft-20220913a/micro-machines.wav -O test-dir/audio.wav
|
||||
cp tests/models_fixtures/* test-models
|
||||
|
||||
prepare-test: protogen-go
|
||||
cp tests/models_fixtures/* test-models
|
||||
prepare-test: protogen-go build-mock-backend
|
||||
|
||||
########################################################
|
||||
## Tests
|
||||
########################################################
|
||||
|
||||
## Test targets
|
||||
test: test-models/testmodel.ggml protogen-go
|
||||
## After the test-suite reorg (see plans/test-reorg) the default `make test`
|
||||
## no longer downloads multi-GB GGUF/whisper fixtures or builds llama-cpp /
|
||||
## transformers / piper / whisper / stablediffusion-ggml. core/http/app_test.go
|
||||
## now drives the mock-backend binary built by build-mock-backend; real-backend
|
||||
## inference moved into tests/e2e-backends/ (per-backend, path-filtered) and
|
||||
## tests/e2e-aio/ (nightly).
|
||||
test: prepare-test
|
||||
@echo 'Running tests'
|
||||
export GO_TAGS="debug"
|
||||
$(MAKE) prepare-test
|
||||
OPUS_SHIM_LIBRARY=$(abspath ./pkg/opus/shim/libopusshim.so) \
|
||||
HUGGINGFACE_GRPC=$(abspath ./)/backend/python/transformers/run.sh TEST_DIR=$(abspath ./)/test-dir/ FIXTURES=$(abspath ./)/tests/fixtures CONFIG_FILE=$(abspath ./)/test-models/config.yaml MODELS_PATH=$(abspath ./)/test-models BACKENDS_PATH=$(abspath ./)/backends \
|
||||
$(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --label-filter="!llama-gguf" --flake-attempts $(TEST_FLAKES) --fail-fast -v -r $(TEST_PATHS)
|
||||
$(MAKE) test-llama-gguf
|
||||
$(MAKE) test-tts
|
||||
$(MAKE) test-stablediffusion
|
||||
$(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --flake-attempts $(TEST_FLAKES) --fail-fast -v -r $(TEST_PATHS)
|
||||
|
||||
########################################################
|
||||
## Lint
|
||||
########################################################
|
||||
## Runs golangci-lint with config from .golangci.yml. Includes the standard
|
||||
## linter set plus forbidigo, which enforces the Ginkgo/Gomega-only test
|
||||
## convention documented in .agents/coding-style.md.
|
||||
##
|
||||
## LINT_EXCLUDE_DIRS_RE matches directories whose Go packages can't typecheck
|
||||
## without C/C++ headers we don't install in the lint runner (cgo wrappers
|
||||
## around llama.cpp, piper/spdlog, silero-vad/onnxruntime, and Fyne/OpenGL for
|
||||
## the launcher). Their compile-time correctness is enforced by their own
|
||||
## build pipelines. Keep this as a deny list — `go list ./...` discovers
|
||||
## everything else automatically, so new packages are scanned by default.
|
||||
LINT_EXCLUDE_DIRS_RE=/(backend/go/(piper|silero-vad|llm)|cmd/launcher)(/|$$)
|
||||
|
||||
lint:
|
||||
@command -v golangci-lint >/dev/null 2>&1 || { \
|
||||
echo 'golangci-lint not installed. Install: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest'; \
|
||||
exit 1; \
|
||||
}
|
||||
golangci-lint run $$(go list -e -f '{{.Dir}}' ./... | grep -vE '$(LINT_EXCLUDE_DIRS_RE)')
|
||||
|
||||
## Like `lint` but reports every issue, including the pre-existing baseline
|
||||
## that `lint` ignores via .golangci.yml's new-from-merge-base. Use this to
|
||||
## see what's available to clean up.
|
||||
lint-all:
|
||||
@command -v golangci-lint >/dev/null 2>&1 || { \
|
||||
echo 'golangci-lint not installed. Install: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest'; \
|
||||
exit 1; \
|
||||
}
|
||||
golangci-lint run --new=false --new-from-merge-base= --new-from-rev= $$(go list -e -f '{{.Dir}}' ./... | grep -vE '$(LINT_EXCLUDE_DIRS_RE)')
|
||||
|
||||
########################################################
|
||||
## E2E AIO tests (uses standard image with pre-configured models)
|
||||
@@ -184,6 +216,8 @@ docker-build-e2e:
|
||||
--build-arg CUDA_MINOR_VERSION=$(CUDA_MINOR_VERSION) \
|
||||
--build-arg UBUNTU_VERSION=$(UBUNTU_VERSION) \
|
||||
--build-arg UBUNTU_CODENAME=$(UBUNTU_CODENAME) \
|
||||
--build-arg APT_MIRROR=$(APT_MIRROR) \
|
||||
--build-arg APT_PORTS_MIRROR=$(APT_PORTS_MIRROR) \
|
||||
--build-arg GO_TAGS="$(GO_TAGS)" \
|
||||
-t local-ai:tests -f Dockerfile .
|
||||
|
||||
@@ -211,6 +245,8 @@ prepare-e2e:
|
||||
--build-arg CUDA_MINOR_VERSION=$(CUDA_MINOR_VERSION) \
|
||||
--build-arg UBUNTU_VERSION=$(UBUNTU_VERSION) \
|
||||
--build-arg UBUNTU_CODENAME=$(UBUNTU_CODENAME) \
|
||||
--build-arg APT_MIRROR=$(APT_MIRROR) \
|
||||
--build-arg APT_PORTS_MIRROR=$(APT_PORTS_MIRROR) \
|
||||
--build-arg GO_TAGS="$(GO_TAGS)" \
|
||||
--build-arg MAKEFLAGS="$(DOCKER_MAKEFLAGS)" \
|
||||
-t localai-tests .
|
||||
@@ -235,20 +271,12 @@ teardown-e2e:
|
||||
## Integration and unit tests
|
||||
########################################################
|
||||
|
||||
test-llama-gguf: prepare-test
|
||||
TEST_DIR=$(abspath ./)/test-dir/ FIXTURES=$(abspath ./)/tests/fixtures CONFIG_FILE=$(abspath ./)/test-models/config.yaml MODELS_PATH=$(abspath ./)/test-models BACKENDS_PATH=$(abspath ./)/backends \
|
||||
$(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --label-filter="llama-gguf" --flake-attempts $(TEST_FLAKES) -v -r $(TEST_PATHS)
|
||||
|
||||
test-tts: prepare-test
|
||||
TEST_DIR=$(abspath ./)/test-dir/ FIXTURES=$(abspath ./)/tests/fixtures CONFIG_FILE=$(abspath ./)/test-models/config.yaml MODELS_PATH=$(abspath ./)/test-models BACKENDS_PATH=$(abspath ./)/backends \
|
||||
$(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --label-filter="tts" --flake-attempts $(TEST_FLAKES) -v -r $(TEST_PATHS)
|
||||
|
||||
test-stablediffusion: prepare-test
|
||||
TEST_DIR=$(abspath ./)/test-dir/ FIXTURES=$(abspath ./)/tests/fixtures CONFIG_FILE=$(abspath ./)/test-models/config.yaml MODELS_PATH=$(abspath ./)/test-models BACKENDS_PATH=$(abspath ./)/backends \
|
||||
$(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --label-filter="stablediffusion" --flake-attempts $(TEST_FLAKES) -v -r $(TEST_PATHS)
|
||||
|
||||
test-stores:
|
||||
$(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --label-filter="stores" --flake-attempts $(TEST_FLAKES) -v -r tests/integration
|
||||
## Storage / vector-store integration. Requires the local-store backend to
|
||||
## be available — we build it on demand and pass its location via
|
||||
## BACKENDS_PATH (the model loader looks there for the gRPC binary).
|
||||
test-stores: backends/local-store
|
||||
BACKENDS_PATH=$(abspath ./)/backends \
|
||||
$(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --flake-attempts $(TEST_FLAKES) -v -r tests/integration
|
||||
|
||||
test-opus:
|
||||
@echo 'Running opus backend tests'
|
||||
@@ -260,6 +288,8 @@ test-opus-docker:
|
||||
docker build --target builder \
|
||||
--build-arg BUILD_TYPE=$(or $(BUILD_TYPE),) \
|
||||
--build-arg BASE_IMAGE=$(or $(BASE_IMAGE),ubuntu:24.04) \
|
||||
--build-arg APT_MIRROR=$(APT_MIRROR) \
|
||||
--build-arg APT_PORTS_MIRROR=$(APT_PORTS_MIRROR) \
|
||||
--build-arg BACKEND=opus \
|
||||
-t localai-opus-test -f backend/Dockerfile.golang .
|
||||
docker run --rm localai-opus-test \
|
||||
@@ -269,23 +299,13 @@ test-realtime: build-mock-backend
|
||||
@echo 'Running realtime e2e tests (mock backend)'
|
||||
$(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --label-filter="Realtime && !real-models" --flake-attempts $(TEST_FLAKES) -v -r ./tests/e2e
|
||||
|
||||
# Real-model realtime tests. Set REALTIME_TEST_MODEL to use your own pipeline,
|
||||
# or leave unset to auto-build one from the component env vars below.
|
||||
# Container-based real-model realtime testing. Build env vars / pipeline
|
||||
# definition kept here so test-realtime-models-docker can drive a fully wired
|
||||
# pipeline (VAD + STT + LLM + TTS) from inside a containerised runner.
|
||||
REALTIME_VAD?=silero-vad-ggml
|
||||
REALTIME_STT?=whisper-1
|
||||
REALTIME_LLM?=qwen3-0.6b
|
||||
REALTIME_TTS?=tts-1
|
||||
REALTIME_BACKENDS_PATH?=$(abspath ./)/backends
|
||||
|
||||
test-realtime-models: build-mock-backend
|
||||
@echo 'Running realtime e2e tests (real models)'
|
||||
REALTIME_TEST_MODEL=$${REALTIME_TEST_MODEL:-realtime-test-pipeline} \
|
||||
REALTIME_VAD=$(REALTIME_VAD) \
|
||||
REALTIME_STT=$(REALTIME_STT) \
|
||||
REALTIME_LLM=$(REALTIME_LLM) \
|
||||
REALTIME_TTS=$(REALTIME_TTS) \
|
||||
REALTIME_BACKENDS_PATH=$(REALTIME_BACKENDS_PATH) \
|
||||
$(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --label-filter="Realtime" --flake-attempts $(TEST_FLAKES) -v -r ./tests/e2e
|
||||
|
||||
# --- Container-based real-model testing ---
|
||||
|
||||
@@ -311,6 +331,8 @@ test-realtime-models-docker: build-mock-backend
|
||||
--build-arg BUILD_TYPE=$(or $(BUILD_TYPE),cublas) \
|
||||
--build-arg CUDA_MAJOR_VERSION=$(or $(CUDA_MAJOR_VERSION),13) \
|
||||
--build-arg CUDA_MINOR_VERSION=$(or $(CUDA_MINOR_VERSION),0) \
|
||||
--build-arg APT_MIRROR=$(APT_MIRROR) \
|
||||
--build-arg APT_PORTS_MIRROR=$(APT_PORTS_MIRROR) \
|
||||
-t localai-test-runner .
|
||||
docker run --rm \
|
||||
$(REALTIME_DOCKER_FLAGS) \
|
||||
@@ -528,7 +550,9 @@ test-extra-backend: protogen-go
|
||||
|
||||
## Convenience wrappers: build the image, then exercise it.
|
||||
test-extra-backend-llama-cpp: docker-build-llama-cpp
|
||||
BACKEND_IMAGE=local-ai-backend:llama-cpp $(MAKE) test-extra-backend
|
||||
BACKEND_IMAGE=local-ai-backend:llama-cpp \
|
||||
BACKEND_TEST_CAPS=health,load,predict,stream,logprobs,logit_bias \
|
||||
$(MAKE) test-extra-backend
|
||||
|
||||
test-extra-backend-ik-llama-cpp: docker-build-ik-llama-cpp
|
||||
BACKEND_IMAGE=local-ai-backend:ik-llama-cpp $(MAKE) test-extra-backend
|
||||
@@ -824,6 +848,32 @@ test-extra-backend-sherpa-onnx-tts: docker-build-sherpa-onnx
|
||||
BACKEND_TEST_CAPS=health,load,tts \
|
||||
$(MAKE) test-extra-backend
|
||||
|
||||
## VibeVoice TTS via the vibevoice-cpp backend. ModelFile is the
|
||||
## realtime gguf; the supplementary tokenizer + voice prompt land
|
||||
## alongside it under the harness's models dir and are wired through
|
||||
## via the standard Options[] convention (tokenizer=, voice=).
|
||||
test-extra-backend-vibevoice-cpp-tts: docker-build-vibevoice-cpp
|
||||
BACKEND_IMAGE=local-ai-backend:vibevoice-cpp \
|
||||
BACKEND_TEST_MODEL_URL='https://huggingface.co/mudler/vibevoice.cpp-models/resolve/main/vibevoice-realtime-0.5B-q8_0.gguf#vibevoice-realtime-0.5B-q8_0.gguf' \
|
||||
BACKEND_TEST_EXTRA_FILES='https://huggingface.co/mudler/vibevoice.cpp-models/resolve/main/tokenizer.gguf#tokenizer.gguf|https://huggingface.co/mudler/vibevoice.cpp-models/resolve/main/voice-en-Carter_man.gguf#voice-en-Carter_man.gguf' \
|
||||
BACKEND_TEST_OPTIONS=tokenizer:tokenizer.gguf,voice:voice-en-Carter_man.gguf \
|
||||
BACKEND_TEST_CAPS=health,load,tts \
|
||||
$(MAKE) test-extra-backend
|
||||
|
||||
## VibeVoice ASR (long-form, with diarization). type=asr tells the
|
||||
## backend's Load() to slot ModelFile into the asr_model role; the
|
||||
## tokenizer is supplied via Options[]. Uses the Q4_K quant (~10 GB)
|
||||
## rather than Q8_0 (~14 GB) so the bundle fits inside ubuntu-latest's
|
||||
## post-image disk budget.
|
||||
test-extra-backend-vibevoice-cpp-transcription: docker-build-vibevoice-cpp
|
||||
BACKEND_IMAGE=local-ai-backend:vibevoice-cpp \
|
||||
BACKEND_TEST_MODEL_URL='https://huggingface.co/mudler/vibevoice.cpp-models/resolve/main/vibevoice-asr-q4_k.gguf#vibevoice-asr-q4_k.gguf' \
|
||||
BACKEND_TEST_EXTRA_FILES='https://huggingface.co/mudler/vibevoice.cpp-models/resolve/main/tokenizer.gguf#tokenizer.gguf' \
|
||||
BACKEND_TEST_AUDIO_URL=https://github.com/ggml-org/whisper.cpp/raw/master/samples/jfk.wav \
|
||||
BACKEND_TEST_OPTIONS=type:asr,tokenizer:tokenizer.gguf \
|
||||
BACKEND_TEST_CAPS=health,load,transcription \
|
||||
$(MAKE) test-extra-backend
|
||||
|
||||
## sglang mirrors the vllm setup: HuggingFace model id, same tiny Qwen,
|
||||
## tool-call extraction via sglang's native qwen parser. CPU builds use
|
||||
## sglang's upstream pyproject_cpu.toml recipe (see backend/python/sglang/install.sh).
|
||||
@@ -866,6 +916,8 @@ docker:
|
||||
--build-arg CUDA_MINOR_VERSION=$(CUDA_MINOR_VERSION) \
|
||||
--build-arg UBUNTU_VERSION=$(UBUNTU_VERSION) \
|
||||
--build-arg UBUNTU_CODENAME=$(UBUNTU_CODENAME) \
|
||||
--build-arg APT_MIRROR=$(APT_MIRROR) \
|
||||
--build-arg APT_PORTS_MIRROR=$(APT_PORTS_MIRROR) \
|
||||
-t $(DOCKER_IMAGE) .
|
||||
|
||||
docker-cuda12:
|
||||
@@ -879,6 +931,8 @@ docker-cuda12:
|
||||
--build-arg BUILD_TYPE=$(BUILD_TYPE) \
|
||||
--build-arg UBUNTU_VERSION=$(UBUNTU_VERSION) \
|
||||
--build-arg UBUNTU_CODENAME=$(UBUNTU_CODENAME) \
|
||||
--build-arg APT_MIRROR=$(APT_MIRROR) \
|
||||
--build-arg APT_PORTS_MIRROR=$(APT_PORTS_MIRROR) \
|
||||
-t $(DOCKER_IMAGE)-cuda-12 .
|
||||
|
||||
docker-image-intel:
|
||||
@@ -892,6 +946,8 @@ docker-image-intel:
|
||||
--build-arg CUDA_MINOR_VERSION=$(CUDA_MINOR_VERSION) \
|
||||
--build-arg UBUNTU_VERSION=$(UBUNTU_VERSION) \
|
||||
--build-arg UBUNTU_CODENAME=$(UBUNTU_CODENAME) \
|
||||
--build-arg APT_MIRROR=$(APT_MIRROR) \
|
||||
--build-arg APT_PORTS_MIRROR=$(APT_PORTS_MIRROR) \
|
||||
-t $(DOCKER_IMAGE) .
|
||||
|
||||
########################################################
|
||||
@@ -960,6 +1016,7 @@ BACKEND_WHISPER = whisper|golang|.|false|true
|
||||
BACKEND_VOXTRAL = voxtral|golang|.|false|true
|
||||
BACKEND_ACESTEP_CPP = acestep-cpp|golang|.|false|true
|
||||
BACKEND_QWEN3_TTS_CPP = qwen3-tts-cpp|golang|.|false|true
|
||||
BACKEND_VIBEVOICE_CPP = vibevoice-cpp|golang|.|false|true
|
||||
BACKEND_OPUS = opus|golang|.|false|true
|
||||
BACKEND_SHERPA_ONNX = sherpa-onnx|golang|.|false|true
|
||||
|
||||
@@ -1014,7 +1071,10 @@ define docker-build-backend
|
||||
--build-arg CUDA_MINOR_VERSION=$(CUDA_MINOR_VERSION) \
|
||||
--build-arg UBUNTU_VERSION=$(UBUNTU_VERSION) \
|
||||
--build-arg UBUNTU_CODENAME=$(UBUNTU_CODENAME) \
|
||||
--build-arg APT_MIRROR=$(APT_MIRROR) \
|
||||
--build-arg APT_PORTS_MIRROR=$(APT_PORTS_MIRROR) \
|
||||
$(if $(FROM_SOURCE),--build-arg FROM_SOURCE=$(FROM_SOURCE)) \
|
||||
$(if $(AMDGPU_TARGETS),--build-arg AMDGPU_TARGETS=$(AMDGPU_TARGETS)) \
|
||||
$(if $(filter true,$(5)),--build-arg BACKEND=$(1)) \
|
||||
-t local-ai-backend:$(1) -f backend/Dockerfile.$(2) $(3)
|
||||
endef
|
||||
@@ -1066,6 +1126,7 @@ $(eval $(call generate-docker-build-target,$(BACKEND_WHISPERX)))
|
||||
$(eval $(call generate-docker-build-target,$(BACKEND_ACE_STEP)))
|
||||
$(eval $(call generate-docker-build-target,$(BACKEND_ACESTEP_CPP)))
|
||||
$(eval $(call generate-docker-build-target,$(BACKEND_QWEN3_TTS_CPP)))
|
||||
$(eval $(call generate-docker-build-target,$(BACKEND_VIBEVOICE_CPP)))
|
||||
$(eval $(call generate-docker-build-target,$(BACKEND_MLX)))
|
||||
$(eval $(call generate-docker-build-target,$(BACKEND_MLX_VLM)))
|
||||
$(eval $(call generate-docker-build-target,$(BACKEND_MLX_DISTRIBUTED)))
|
||||
@@ -1080,7 +1141,7 @@ $(eval $(call generate-docker-build-target,$(BACKEND_SHERPA_ONNX)))
|
||||
docker-save-%: backend-images
|
||||
docker save local-ai-backend:$* -o backend-images/$*.tar
|
||||
|
||||
docker-build-backends: docker-build-llama-cpp docker-build-ik-llama-cpp docker-build-turboquant docker-build-rerankers docker-build-vllm docker-build-vllm-omni docker-build-sglang docker-build-transformers docker-build-outetts docker-build-diffusers docker-build-kokoro docker-build-faster-whisper docker-build-coqui docker-build-chatterbox docker-build-vibevoice docker-build-moonshine docker-build-pocket-tts docker-build-qwen-tts docker-build-fish-speech docker-build-faster-qwen3-tts docker-build-qwen-asr docker-build-nemo docker-build-voxcpm docker-build-whisperx docker-build-ace-step docker-build-acestep-cpp docker-build-voxtral docker-build-mlx-distributed docker-build-trl docker-build-llama-cpp-quantization docker-build-tinygrad docker-build-kokoros docker-build-sam3-cpp docker-build-qwen3-tts-cpp docker-build-insightface docker-build-speaker-recognition docker-build-sherpa-onnx
|
||||
docker-build-backends: docker-build-llama-cpp docker-build-ik-llama-cpp docker-build-turboquant docker-build-rerankers docker-build-vllm docker-build-vllm-omni docker-build-sglang docker-build-transformers docker-build-outetts docker-build-diffusers docker-build-kokoro docker-build-faster-whisper docker-build-coqui docker-build-chatterbox docker-build-vibevoice docker-build-moonshine docker-build-pocket-tts docker-build-qwen-tts docker-build-fish-speech docker-build-faster-qwen3-tts docker-build-qwen-asr docker-build-nemo docker-build-voxcpm docker-build-whisperx docker-build-ace-step docker-build-acestep-cpp docker-build-voxtral docker-build-mlx-distributed docker-build-trl docker-build-llama-cpp-quantization docker-build-tinygrad docker-build-kokoros docker-build-sam3-cpp docker-build-qwen3-tts-cpp docker-build-vibevoice-cpp docker-build-insightface docker-build-speaker-recognition docker-build-sherpa-onnx
|
||||
|
||||
########################################################
|
||||
### Mock Backend for E2E Tests
|
||||
|
||||
15
README.md
15
README.md
@@ -38,7 +38,7 @@
|
||||
- **Built-in AI agents** — autonomous agents with tool use, RAG, MCP, and skills
|
||||
- **Privacy-first** — your data never leaves your infrastructure
|
||||
|
||||
Created and maintained by [Ettore Di Giacinto](https://github.com/mudler).
|
||||
Created by [Ettore Di Giacinto](https://github.com/mudler) and maintained by the [LocalAI team](#team).
|
||||
|
||||
> [:book: Documentation](https://localai.io/) | [:speech_balloon: Discord](https://discord.gg/uJAeKSAGDy) | [💻 Quickstart](https://localai.io/basics/getting_started/) | [🖼️ Models](https://models.localai.io/) | [❓FAQ](https://localai.io/faq/)
|
||||
|
||||
@@ -201,13 +201,14 @@ See the full [Backend & Model Compatibility Table](https://localai.io/model-comp
|
||||
- [Media & blog posts](https://localai.io/basics/news/#media-blogs-social)
|
||||
- [Examples](https://github.com/mudler/LocalAI-examples)
|
||||
|
||||
## Autonomous Development Team
|
||||
## Team
|
||||
|
||||
LocalAI is helped being maintained by a team of autonomous AI agents led by an AI Scrum Master.
|
||||
LocalAI is maintained by a small team of humans, together with the wider community of contributors.
|
||||
|
||||
- **Live Reports**: [reports.localai.io](http://reports.localai.io)
|
||||
- **Project Board**: [Agent task tracking](https://github.com/users/mudler/projects/6)
|
||||
- **Blog Post**: [Learn about the experiment](https://mudler.pm/posts/2026/02/28/a-call-to-open-source-maintainers-stop-babysitting-ai-how-i-built-a-100-local-autonomous-dev-team-to-maintain-localai-and-why-you-should-too/)
|
||||
- **[Ettore Di Giacinto](https://github.com/mudler)** — original author and project lead
|
||||
- **[Richard Palethorpe](https://github.com/richiejp)** — maintainer
|
||||
|
||||
A huge thank you to everyone who contributes code, reviews PRs, files issues, and helps users in [Discord](https://discord.gg/uJAeKSAGDy) — LocalAI is a community-driven project and wouldn't exist without you. See the full [contributors list](https://github.com/mudler/LocalAI/graphs/contributors).
|
||||
|
||||
## Citation
|
||||
|
||||
@@ -250,7 +251,7 @@ A special thanks to individual sponsors, a full list is on [GitHub](https://gith
|
||||
|
||||
## License
|
||||
|
||||
LocalAI is a community-driven project created by [Ettore Di Giacinto](https://github.com/mudler/).
|
||||
LocalAI is a community-driven project created by [Ettore Di Giacinto](https://github.com/mudler/) and maintained by the [LocalAI team](#team).
|
||||
|
||||
MIT - Author Ettore Di Giacinto <mudler@localai.io>
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
ARG BASE_IMAGE=ubuntu:24.04
|
||||
ARG APT_MIRROR=""
|
||||
ARG APT_PORTS_MIRROR=""
|
||||
|
||||
FROM ${BASE_IMAGE} AS builder
|
||||
ARG BACKEND=rerankers
|
||||
@@ -14,8 +16,14 @@ ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG GO_VERSION=1.25.4
|
||||
ARG UBUNTU_VERSION=2404
|
||||
ARG AMDGPU_TARGETS
|
||||
ENV AMDGPU_TARGETS=${AMDGPU_TARGETS}
|
||||
ARG APT_MIRROR
|
||||
ARG APT_PORTS_MIRROR
|
||||
|
||||
RUN apt-get update && \
|
||||
RUN --mount=type=bind,source=.docker/apt-mirror.sh,target=/usr/local/sbin/apt-mirror \
|
||||
APT_MIRROR="${APT_MIRROR}" APT_PORTS_MIRROR="${APT_PORTS_MIRROR}" sh /usr/local/sbin/apt-mirror && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
git ccache \
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
ARG BASE_IMAGE=ubuntu:24.04
|
||||
ARG GRPC_BASE_IMAGE=${BASE_IMAGE}
|
||||
ARG APT_MIRROR=""
|
||||
ARG APT_PORTS_MIRROR=""
|
||||
|
||||
|
||||
# The grpc target does one thing, it builds and installs GRPC. This is in it's own layer so that it can be effectively cached by CI.
|
||||
@@ -12,12 +14,16 @@ ARG GRPC_VERSION=v1.65.0
|
||||
ARG CMAKE_FROM_SOURCE=false
|
||||
# CUDA Toolkit 13.x compatibility: CMake 3.31.9+ fixes toolchain detection/arch table issues
|
||||
ARG CMAKE_VERSION=3.31.10
|
||||
ARG APT_MIRROR
|
||||
ARG APT_PORTS_MIRROR
|
||||
|
||||
ENV MAKEFLAGS=${GRPC_MAKEFLAGS}
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN apt-get update && \
|
||||
RUN --mount=type=bind,source=.docker/apt-mirror.sh,target=/usr/local/sbin/apt-mirror \
|
||||
APT_MIRROR="${APT_MIRROR}" APT_PORTS_MIRROR="${APT_PORTS_MIRROR}" sh /usr/local/sbin/apt-mirror && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
build-essential curl libssl-dev \
|
||||
@@ -71,8 +77,12 @@ ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG GO_VERSION=1.25.4
|
||||
ARG UBUNTU_VERSION=2404
|
||||
ARG APT_MIRROR
|
||||
ARG APT_PORTS_MIRROR
|
||||
|
||||
RUN apt-get update && \
|
||||
RUN --mount=type=bind,source=.docker/apt-mirror.sh,target=/usr/local/sbin/apt-mirror \
|
||||
APT_MIRROR="${APT_MIRROR}" APT_PORTS_MIRROR="${APT_PORTS_MIRROR}" sh /usr/local/sbin/apt-mirror && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ccache git \
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
ARG BASE_IMAGE=ubuntu:24.04
|
||||
ARG GRPC_BASE_IMAGE=${BASE_IMAGE}
|
||||
ARG APT_MIRROR=""
|
||||
ARG APT_PORTS_MIRROR=""
|
||||
|
||||
|
||||
# The grpc target does one thing, it builds and installs GRPC. This is in it's own layer so that it can be effectively cached by CI.
|
||||
@@ -12,12 +14,16 @@ ARG GRPC_VERSION=v1.65.0
|
||||
ARG CMAKE_FROM_SOURCE=false
|
||||
# CUDA Toolkit 13.x compatibility: CMake 3.31.9+ fixes toolchain detection/arch table issues
|
||||
ARG CMAKE_VERSION=3.31.10
|
||||
ARG APT_MIRROR
|
||||
ARG APT_PORTS_MIRROR
|
||||
|
||||
ENV MAKEFLAGS=${GRPC_MAKEFLAGS}
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN apt-get update && \
|
||||
RUN --mount=type=bind,source=.docker/apt-mirror.sh,target=/usr/local/sbin/apt-mirror \
|
||||
APT_MIRROR="${APT_MIRROR}" APT_PORTS_MIRROR="${APT_PORTS_MIRROR}" sh /usr/local/sbin/apt-mirror && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
build-essential curl libssl-dev \
|
||||
@@ -73,8 +79,12 @@ ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG GO_VERSION=1.25.4
|
||||
ARG UBUNTU_VERSION=2404
|
||||
ARG APT_MIRROR
|
||||
ARG APT_PORTS_MIRROR
|
||||
|
||||
RUN apt-get update && \
|
||||
RUN --mount=type=bind,source=.docker/apt-mirror.sh,target=/usr/local/sbin/apt-mirror \
|
||||
APT_MIRROR="${APT_MIRROR}" APT_PORTS_MIRROR="${APT_PORTS_MIRROR}" sh /usr/local/sbin/apt-mirror && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ccache git \
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
ARG BASE_IMAGE=ubuntu:24.04
|
||||
ARG APT_MIRROR=""
|
||||
ARG APT_PORTS_MIRROR=""
|
||||
|
||||
FROM ${BASE_IMAGE} AS builder
|
||||
ARG BACKEND=rerankers
|
||||
@@ -13,8 +15,12 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG UBUNTU_VERSION=2404
|
||||
ARG APT_MIRROR
|
||||
ARG APT_PORTS_MIRROR
|
||||
|
||||
RUN apt-get update && \
|
||||
RUN --mount=type=bind,source=.docker/apt-mirror.sh,target=/usr/local/sbin/apt-mirror \
|
||||
APT_MIRROR="${APT_MIRROR}" APT_PORTS_MIRROR="${APT_PORTS_MIRROR}" sh /usr/local/sbin/apt-mirror && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ccache \
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
ARG BASE_IMAGE=ubuntu:24.04
|
||||
ARG APT_MIRROR=""
|
||||
ARG APT_PORTS_MIRROR=""
|
||||
|
||||
FROM ${BASE_IMAGE} AS builder
|
||||
ARG BACKEND=kokoros
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG APT_MIRROR
|
||||
ARG APT_PORTS_MIRROR
|
||||
|
||||
RUN apt-get update && \
|
||||
RUN --mount=type=bind,source=.docker/apt-mirror.sh,target=/usr/local/sbin/apt-mirror \
|
||||
APT_MIRROR="${APT_MIRROR}" APT_PORTS_MIRROR="${APT_PORTS_MIRROR}" sh /usr/local/sbin/apt-mirror && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
git ccache \
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
ARG BASE_IMAGE=ubuntu:24.04
|
||||
ARG GRPC_BASE_IMAGE=${BASE_IMAGE}
|
||||
ARG APT_MIRROR=""
|
||||
ARG APT_PORTS_MIRROR=""
|
||||
|
||||
|
||||
# The grpc target does one thing, it builds and installs GRPC. This is in it's own layer so that it can be effectively cached by CI.
|
||||
@@ -12,12 +14,16 @@ ARG GRPC_VERSION=v1.65.0
|
||||
ARG CMAKE_FROM_SOURCE=false
|
||||
# CUDA Toolkit 13.x compatibility: CMake 3.31.9+ fixes toolchain detection/arch table issues
|
||||
ARG CMAKE_VERSION=3.31.10
|
||||
ARG APT_MIRROR
|
||||
ARG APT_PORTS_MIRROR
|
||||
|
||||
ENV MAKEFLAGS=${GRPC_MAKEFLAGS}
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN apt-get update && \
|
||||
RUN --mount=type=bind,source=.docker/apt-mirror.sh,target=/usr/local/sbin/apt-mirror \
|
||||
APT_MIRROR="${APT_MIRROR}" APT_PORTS_MIRROR="${APT_PORTS_MIRROR}" sh /usr/local/sbin/apt-mirror && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
build-essential curl libssl-dev \
|
||||
@@ -71,8 +77,12 @@ ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG GO_VERSION=1.25.4
|
||||
ARG UBUNTU_VERSION=2404
|
||||
ARG APT_MIRROR
|
||||
ARG APT_PORTS_MIRROR
|
||||
|
||||
RUN apt-get update && \
|
||||
RUN --mount=type=bind,source=.docker/apt-mirror.sh,target=/usr/local/sbin/apt-mirror \
|
||||
APT_MIRROR="${APT_MIRROR}" APT_PORTS_MIRROR="${APT_PORTS_MIRROR}" sh /usr/local/sbin/apt-mirror && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ccache git \
|
||||
|
||||
@@ -310,6 +310,11 @@ message ModelOptions {
|
||||
bool Reranking = 71;
|
||||
|
||||
repeated string Overrides = 72;
|
||||
|
||||
// EngineArgs carries a JSON-encoded map of backend-native engine arguments
|
||||
// applied verbatim to the backend's engine constructor (e.g. vLLM AsyncEngineArgs).
|
||||
// Unknown keys produce an error at LoadModel time.
|
||||
string EngineArgs = 73;
|
||||
}
|
||||
|
||||
message Result {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
IK_LLAMA_VERSION?=3a945af45d45936341a45bbf7deda56776a4af26
|
||||
IK_LLAMA_VERSION?=a8aecbf15933295af96504f9a693998322185b5c
|
||||
LLAMA_REPO?=https://github.com/ikawrakow/ik_llama.cpp
|
||||
|
||||
CMAKE_ARGS?=
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
LLAMA_VERSION?=f53577432541bb9edc1588c4ef45c66bf07e4468
|
||||
LLAMA_VERSION?=beb42fffa45eded44804a1fd4916146222371581
|
||||
LLAMA_REPO?=https://github.com/ggerganov/llama.cpp
|
||||
|
||||
CMAKE_ARGS?=
|
||||
@@ -34,6 +34,9 @@ else ifeq ($(BUILD_TYPE),hipblas)
|
||||
export CXX=$(ROCM_HOME)/llvm/bin/clang++
|
||||
export CC=$(ROCM_HOME)/llvm/bin/clang
|
||||
AMDGPU_TARGETS?=gfx908,gfx90a,gfx942,gfx950,gfx1030,gfx1100,gfx1101,gfx1102,gfx1151,gfx1200,gfx1201
|
||||
ifeq ($(strip $(AMDGPU_TARGETS)),)
|
||||
$(error AMDGPU_TARGETS is empty — set it to a comma-separated list of gfx targets e.g. gfx1100,gfx1101)
|
||||
endif
|
||||
CMAKE_ARGS+=-DGGML_HIP=ON -DAMDGPU_TARGETS=$(AMDGPU_TARGETS)
|
||||
else ifeq ($(BUILD_TYPE),vulkan)
|
||||
CMAKE_ARGS+=-DGGML_VULKAN=1
|
||||
|
||||
@@ -442,7 +442,7 @@ static void params_parse(server_context& /*ctx_server*/, const backend::ModelOpt
|
||||
|
||||
// Draft model for speculative decoding
|
||||
if (!request->draftmodel().empty()) {
|
||||
params.speculative.mparams_dft.path = request->draftmodel();
|
||||
params.speculative.draft.mparams.path = request->draftmodel();
|
||||
// Default to draft type if a draft model is set but no explicit type
|
||||
if (params.speculative.type == COMMON_SPECULATIVE_TYPE_NONE) {
|
||||
params.speculative.type = COMMON_SPECULATIVE_TYPE_DRAFT;
|
||||
@@ -679,39 +679,39 @@ static void params_parse(server_context& /*ctx_server*/, const backend::ModelOpt
|
||||
}
|
||||
} else if (!strcmp(optname, "spec_n_max") || !strcmp(optname, "draft_max")) {
|
||||
if (optval != NULL) {
|
||||
try { params.speculative.n_max = std::stoi(optval_str); } catch (...) {}
|
||||
try { params.speculative.draft.n_max = std::stoi(optval_str); } catch (...) {}
|
||||
}
|
||||
} else if (!strcmp(optname, "spec_n_min") || !strcmp(optname, "draft_min")) {
|
||||
if (optval != NULL) {
|
||||
try { params.speculative.n_min = std::stoi(optval_str); } catch (...) {}
|
||||
try { params.speculative.draft.n_min = std::stoi(optval_str); } catch (...) {}
|
||||
}
|
||||
} else if (!strcmp(optname, "spec_p_min") || !strcmp(optname, "draft_p_min")) {
|
||||
if (optval != NULL) {
|
||||
try { params.speculative.p_min = std::stof(optval_str); } catch (...) {}
|
||||
try { params.speculative.draft.p_min = std::stof(optval_str); } catch (...) {}
|
||||
}
|
||||
} else if (!strcmp(optname, "spec_p_split")) {
|
||||
if (optval != NULL) {
|
||||
try { params.speculative.p_split = std::stof(optval_str); } catch (...) {}
|
||||
try { params.speculative.draft.p_split = std::stof(optval_str); } catch (...) {}
|
||||
}
|
||||
} else if (!strcmp(optname, "spec_ngram_size_n") || !strcmp(optname, "ngram_size_n")) {
|
||||
if (optval != NULL) {
|
||||
try { params.speculative.ngram_size_n = (uint16_t)std::stoi(optval_str); } catch (...) {}
|
||||
try { params.speculative.ngram_simple.size_n = (uint16_t)std::stoi(optval_str); } catch (...) {}
|
||||
}
|
||||
} else if (!strcmp(optname, "spec_ngram_size_m") || !strcmp(optname, "ngram_size_m")) {
|
||||
if (optval != NULL) {
|
||||
try { params.speculative.ngram_size_m = (uint16_t)std::stoi(optval_str); } catch (...) {}
|
||||
try { params.speculative.ngram_simple.size_m = (uint16_t)std::stoi(optval_str); } catch (...) {}
|
||||
}
|
||||
} else if (!strcmp(optname, "spec_ngram_min_hits") || !strcmp(optname, "ngram_min_hits")) {
|
||||
if (optval != NULL) {
|
||||
try { params.speculative.ngram_min_hits = (uint16_t)std::stoi(optval_str); } catch (...) {}
|
||||
try { params.speculative.ngram_simple.min_hits = (uint16_t)std::stoi(optval_str); } catch (...) {}
|
||||
}
|
||||
} else if (!strcmp(optname, "draft_gpu_layers")) {
|
||||
if (optval != NULL) {
|
||||
try { params.speculative.n_gpu_layers = std::stoi(optval_str); } catch (...) {}
|
||||
try { params.speculative.draft.n_gpu_layers = std::stoi(optval_str); } catch (...) {}
|
||||
}
|
||||
} else if (!strcmp(optname, "draft_ctx_size")) {
|
||||
if (optval != NULL) {
|
||||
try { params.speculative.n_ctx = std::stoi(optval_str); } catch (...) {}
|
||||
try { params.speculative.draft.n_ctx = std::stoi(optval_str); } catch (...) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -933,8 +933,8 @@ public:
|
||||
if (!params.mmproj.path.empty()) {
|
||||
error_msg += " (with mmproj: " + params.mmproj.path + ")";
|
||||
}
|
||||
if (params.speculative.has_dft() && !params.speculative.mparams_dft.path.empty()) {
|
||||
error_msg += " (with draft model: " + params.speculative.mparams_dft.path + ")";
|
||||
if (params.speculative.has_dft() && !params.speculative.draft.mparams.path.empty()) {
|
||||
error_msg += " (with draft model: " + params.speculative.draft.mparams.path + ")";
|
||||
}
|
||||
|
||||
// Add captured error details if available
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Patch the shared backend/cpp/llama-cpp/grpc-server.cpp *copy* used by the
|
||||
# turboquant build to account for two gaps between upstream and the fork:
|
||||
# turboquant build to account for the gaps between upstream and the fork:
|
||||
#
|
||||
# 1. Augment the kv_cache_types[] allow-list so `LoadModel` accepts the
|
||||
# fork-specific `turbo2` / `turbo3` / `turbo4` cache types.
|
||||
@@ -11,6 +11,14 @@
|
||||
# "<__media__>", and Go-side tooling falls back to that sentinel when the
|
||||
# backend does not expose media_marker, so substituting the literal keeps
|
||||
# behavior identical on the turboquant path.
|
||||
# 3. Revert the `common_params_speculative` field references to the
|
||||
# pre-refactor flat layout. Upstream ggml-org/llama.cpp#22397 split the
|
||||
# struct into nested `draft` / `ngram_simple` / `ngram_mod` / etc. members;
|
||||
# the turboquant fork branched before that PR and still exposes the flat
|
||||
# `n_max`, `mparams_dft`, `ngram_size_n`, ... fields. The substitutions
|
||||
# below map the new nested paths back to the legacy flat names so the
|
||||
# shared grpc-server.cpp keeps compiling against the fork's common.h.
|
||||
# Drop this block once the fork rebases past #22397.
|
||||
#
|
||||
# We patch the *copy* sitting in turboquant-<flavor>-build/, never the original
|
||||
# under backend/cpp/llama-cpp/, so the stock llama-cpp build keeps compiling
|
||||
@@ -77,4 +85,27 @@ else
|
||||
echo "==> $SRC has no get_media_marker() call, skipping media-marker patch"
|
||||
fi
|
||||
|
||||
if grep -q 'params\.speculative\.draft\.\|params\.speculative\.ngram_simple\.' "$SRC"; then
|
||||
echo "==> patching $SRC to revert common_params_speculative refs to pre-#22397 flat layout"
|
||||
# Each substitution is the exact post-refactor path → legacy flat field.
|
||||
# Order doesn't matter because the source paths are disjoint, but we keep
|
||||
# the most-specific (mparams.path) first for readability.
|
||||
sed -E \
|
||||
-e 's/params\.speculative\.draft\.mparams\.path/params.speculative.mparams_dft.path/g' \
|
||||
-e 's/params\.speculative\.draft\.n_max/params.speculative.n_max/g' \
|
||||
-e 's/params\.speculative\.draft\.n_min/params.speculative.n_min/g' \
|
||||
-e 's/params\.speculative\.draft\.p_min/params.speculative.p_min/g' \
|
||||
-e 's/params\.speculative\.draft\.p_split/params.speculative.p_split/g' \
|
||||
-e 's/params\.speculative\.draft\.n_gpu_layers/params.speculative.n_gpu_layers/g' \
|
||||
-e 's/params\.speculative\.draft\.n_ctx/params.speculative.n_ctx/g' \
|
||||
-e 's/params\.speculative\.ngram_simple\.size_n/params.speculative.ngram_size_n/g' \
|
||||
-e 's/params\.speculative\.ngram_simple\.size_m/params.speculative.ngram_size_m/g' \
|
||||
-e 's/params\.speculative\.ngram_simple\.min_hits/params.speculative.ngram_min_hits/g' \
|
||||
"$SRC" > "$SRC.tmp"
|
||||
mv "$SRC.tmp" "$SRC"
|
||||
echo "==> speculative field rename OK"
|
||||
else
|
||||
echo "==> $SRC has no post-#22397 speculative field refs, skipping spec rename patch"
|
||||
fi
|
||||
|
||||
echo "==> all patches applied"
|
||||
|
||||
@@ -10,7 +10,7 @@ set(SAM3_BUILD_TESTS OFF CACHE BOOL "Disable sam3.cpp tests" FORCE)
|
||||
|
||||
add_subdirectory(./sources/sam3.cpp)
|
||||
|
||||
add_library(gosam3 MODULE gosam3.cpp)
|
||||
add_library(gosam3 MODULE cpp/gosam3.cpp)
|
||||
target_link_libraries(gosam3 PRIVATE sam3 ggml)
|
||||
|
||||
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU" AND CMAKE_CXX_COMPILER_VERSION VERSION_LESS 9.0)
|
||||
|
||||
@@ -111,7 +111,7 @@ libgosam3-fallback.so: sources/sam3.cpp
|
||||
SO_TARGET=libgosam3-fallback.so CMAKE_ARGS="$(CMAKE_ARGS) -DGGML_AVX=off -DGGML_AVX2=off -DGGML_AVX512=off -DGGML_FMA=off -DGGML_F16C=off -DGGML_BMI2=off" $(MAKE) libgosam3-custom
|
||||
rm -rfv build*
|
||||
|
||||
libgosam3-custom: CMakeLists.txt gosam3.cpp gosam3.h
|
||||
libgosam3-custom: CMakeLists.txt cpp/gosam3.cpp cpp/gosam3.h
|
||||
mkdir -p build-$(SO_TARGET) && \
|
||||
cd build-$(SO_TARGET) && \
|
||||
cmake .. $(CMAKE_ARGS) && \
|
||||
|
||||
@@ -4,7 +4,7 @@ set(CMAKE_POSITION_INDEPENDENT_CODE ON)
|
||||
|
||||
add_subdirectory(./sources/stablediffusion-ggml.cpp)
|
||||
|
||||
add_library(gosd MODULE gosd.cpp)
|
||||
add_library(gosd MODULE cpp/gosd.cpp)
|
||||
target_link_libraries(gosd PRIVATE stable-diffusion ggml)
|
||||
|
||||
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU" AND CMAKE_CXX_COMPILER_VERSION VERSION_LESS 9.0)
|
||||
|
||||
@@ -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?=b8bdffc19962be7e5a84bfefeb2e31bd885b571a
|
||||
STABLEDIFFUSION_GGML_VERSION?=3d6064b37ef4607917f8acf2ca8c8906d5087413
|
||||
|
||||
CMAKE_ARGS+=-DGGML_MAX_NAME=128
|
||||
|
||||
@@ -119,7 +119,7 @@ libgosd-fallback.so: sources/stablediffusion-ggml.cpp
|
||||
SO_TARGET=libgosd-fallback.so CMAKE_ARGS="$(CMAKE_ARGS) -DGGML_AVX=off -DGGML_AVX2=off -DGGML_AVX512=off -DGGML_FMA=off -DGGML_F16C=off -DGGML_BMI2=off" $(MAKE) libgosd-custom
|
||||
rm -rfv build*
|
||||
|
||||
libgosd-custom: CMakeLists.txt gosd.cpp gosd.h
|
||||
libgosd-custom: CMakeLists.txt cpp/gosd.cpp cpp/gosd.h
|
||||
mkdir -p build-$(SO_TARGET) && \
|
||||
cd build-$(SO_TARGET) && \
|
||||
cmake .. $(CMAKE_ARGS) && \
|
||||
|
||||
71
backend/go/vibevoice-cpp/CMakeLists.txt
Normal file
71
backend/go/vibevoice-cpp/CMakeLists.txt
Normal file
@@ -0,0 +1,71 @@
|
||||
cmake_minimum_required(VERSION 3.18)
|
||||
project(govibevoicecpp LANGUAGES C CXX)
|
||||
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
|
||||
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||
|
||||
set(VIBEVOICE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/sources/vibevoice.cpp)
|
||||
|
||||
# Override upstream's CMAKE_CUDA_ARCHITECTURES before add_subdirectory.
|
||||
if(NOT DEFINED CMAKE_CUDA_ARCHITECTURES)
|
||||
set(CMAKE_CUDA_ARCHITECTURES "75-virtual;80-virtual;86-real;89-real")
|
||||
endif()
|
||||
|
||||
# Force-disable upstream tests/examples — we only need libvibevoice.
|
||||
set(VIBEVOICE_BUILD_TESTS OFF CACHE BOOL "" FORCE)
|
||||
set(VIBEVOICE_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
|
||||
set(VIBEVOICE_BUILD_SERVER OFF CACHE BOOL "" FORCE)
|
||||
|
||||
# vibevoice.cpp's top-level CMakeLists already adds third_party/ggml as a
|
||||
# subdirectory — no need to add it explicitly here, just include the
|
||||
# whole project.
|
||||
add_subdirectory(${VIBEVOICE_DIR} vibevoice EXCLUDE_FROM_ALL)
|
||||
|
||||
add_library(govibevoicecpp MODULE cpp/govibevoicecpp.cpp)
|
||||
|
||||
# libvibevoice is STATIC; without --whole-archive the linker GCs the
|
||||
# vv_capi_* symbols (purego dlopens them by name, nothing in our
|
||||
# translation unit references them). Force the static archive's
|
||||
# entire contents into the MODULE so dlsym finds vv_capi_load etc.
|
||||
if(APPLE)
|
||||
target_link_libraries(govibevoicecpp PRIVATE -Wl,-force_load $<TARGET_FILE:vibevoice>)
|
||||
elseif(MSVC)
|
||||
target_link_libraries(govibevoicecpp PRIVATE vibevoice)
|
||||
set_property(TARGET govibevoicecpp APPEND PROPERTY LINK_FLAGS "/WHOLEARCHIVE:vibevoice")
|
||||
else()
|
||||
target_link_libraries(govibevoicecpp PRIVATE
|
||||
-Wl,--whole-archive vibevoice -Wl,--no-whole-archive)
|
||||
endif()
|
||||
|
||||
target_include_directories(govibevoicecpp PRIVATE ${VIBEVOICE_DIR}/include)
|
||||
target_include_directories(govibevoicecpp SYSTEM PRIVATE ${VIBEVOICE_DIR}/third_party/ggml/include)
|
||||
|
||||
# Link GPU backends if available — vibevoice's own CMake already links
|
||||
# these to the libvibevoice STATIC library, but we re-link them on the
|
||||
# MODULE so resolved symbols include all backend kernels.
|
||||
foreach(backend blas cuda metal vulkan)
|
||||
if(TARGET ggml-${backend})
|
||||
target_link_libraries(govibevoicecpp PRIVATE ggml-${backend})
|
||||
string(TOUPPER ${backend} BACKEND_UPPER)
|
||||
target_compile_definitions(govibevoicecpp PRIVATE VIBEVOICE_HAVE_${BACKEND_UPPER})
|
||||
if(backend STREQUAL "cuda")
|
||||
find_package(CUDAToolkit QUIET)
|
||||
if(CUDAToolkit_FOUND)
|
||||
target_link_libraries(govibevoicecpp PRIVATE CUDA::cudart)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
if(MSVC)
|
||||
target_compile_options(govibevoicecpp PRIVATE /W4 /wd4100 /wd4505)
|
||||
else()
|
||||
target_compile_options(govibevoicecpp PRIVATE -Wall -Wextra -Wshadow
|
||||
-Wno-unused-parameter -Wno-unused-function -Wno-sign-conversion)
|
||||
endif()
|
||||
|
||||
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU" AND CMAKE_CXX_COMPILER_VERSION VERSION_LESS 9.0)
|
||||
target_link_libraries(govibevoicecpp PRIVATE stdc++fs)
|
||||
endif()
|
||||
|
||||
set_property(TARGET govibevoicecpp PROPERTY CXX_STANDARD 17)
|
||||
set_target_properties(govibevoicecpp PROPERTIES LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR})
|
||||
128
backend/go/vibevoice-cpp/Makefile
Normal file
128
backend/go/vibevoice-cpp/Makefile
Normal file
@@ -0,0 +1,128 @@
|
||||
CMAKE_ARGS?=
|
||||
BUILD_TYPE?=
|
||||
NATIVE?=false
|
||||
|
||||
GOCMD?=go
|
||||
GO_TAGS?=
|
||||
JOBS?=$(shell nproc --ignore=1)
|
||||
|
||||
# vibevoice.cpp version
|
||||
VIBEVOICE_REPO?=https://github.com/mudler/vibevoice.cpp
|
||||
VIBEVOICE_CPP_VERSION?=master
|
||||
SO_TARGET?=libgovibevoicecpp.so
|
||||
|
||||
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
|
||||
CMAKE_ARGS+=-DVIBEVOICE_BUILD_TESTS=OFF
|
||||
CMAKE_ARGS+=-DVIBEVOICE_BUILD_EXAMPLES=OFF
|
||||
|
||||
ifeq ($(NATIVE),false)
|
||||
CMAKE_ARGS+=-DGGML_NATIVE=OFF
|
||||
endif
|
||||
|
||||
ifeq ($(BUILD_TYPE),cublas)
|
||||
CMAKE_ARGS+=-DGGML_CUDA=ON -DVIBEVOICE_GGML_CUDA=ON
|
||||
else ifeq ($(BUILD_TYPE),openblas)
|
||||
CMAKE_ARGS+=-DGGML_BLAS=ON -DGGML_BLAS_VENDOR=OpenBLAS
|
||||
else ifeq ($(BUILD_TYPE),clblas)
|
||||
CMAKE_ARGS+=-DGGML_CLBLAST=ON -DCLBlast_DIR=/some/path
|
||||
else ifeq ($(BUILD_TYPE),hipblas)
|
||||
CMAKE_ARGS+=-DGGML_HIPBLAS=ON -DVIBEVOICE_GGML_HIPBLAS=ON
|
||||
else ifeq ($(BUILD_TYPE),vulkan)
|
||||
CMAKE_ARGS+=-DGGML_VULKAN=ON -DVIBEVOICE_GGML_VULKAN=ON
|
||||
else ifeq ($(OS),Darwin)
|
||||
ifneq ($(BUILD_TYPE),metal)
|
||||
CMAKE_ARGS+=-DGGML_METAL=OFF
|
||||
else
|
||||
CMAKE_ARGS+=-DGGML_METAL=ON -DVIBEVOICE_GGML_METAL=ON
|
||||
CMAKE_ARGS+=-DGGML_METAL_EMBED_LIBRARY=ON
|
||||
endif
|
||||
endif
|
||||
|
||||
ifeq ($(BUILD_TYPE),sycl_f16)
|
||||
CMAKE_ARGS+=-DGGML_SYCL=ON \
|
||||
-DCMAKE_C_COMPILER=icx \
|
||||
-DCMAKE_CXX_COMPILER=icpx \
|
||||
-DGGML_SYCL_F16=ON
|
||||
endif
|
||||
|
||||
ifeq ($(BUILD_TYPE),sycl_f32)
|
||||
CMAKE_ARGS+=-DGGML_SYCL=ON \
|
||||
-DCMAKE_C_COMPILER=icx \
|
||||
-DCMAKE_CXX_COMPILER=icpx
|
||||
endif
|
||||
|
||||
sources/vibevoice.cpp:
|
||||
mkdir -p sources/vibevoice.cpp
|
||||
cd sources/vibevoice.cpp && \
|
||||
git init && \
|
||||
git remote add origin $(VIBEVOICE_REPO) && \
|
||||
git fetch origin && \
|
||||
git checkout $(VIBEVOICE_CPP_VERSION) && \
|
||||
git submodule update --init --recursive --depth 1 --single-branch
|
||||
|
||||
# Detect OS
|
||||
UNAME_S := $(shell uname -s)
|
||||
|
||||
# Only build CPU variants on Linux
|
||||
ifeq ($(UNAME_S),Linux)
|
||||
VARIANT_TARGETS = libgovibevoicecpp-avx.so libgovibevoicecpp-avx2.so libgovibevoicecpp-avx512.so libgovibevoicecpp-fallback.so
|
||||
else
|
||||
# On non-Linux (e.g., Darwin), build only fallback variant
|
||||
VARIANT_TARGETS = libgovibevoicecpp-fallback.so
|
||||
endif
|
||||
|
||||
vibevoice-cpp: main.go govibevoicecpp.go $(VARIANT_TARGETS)
|
||||
CGO_ENABLED=0 $(GOCMD) build -tags "$(GO_TAGS)" -o vibevoice-cpp ./
|
||||
|
||||
package: vibevoice-cpp
|
||||
bash package.sh
|
||||
|
||||
build: package
|
||||
|
||||
clean: purge
|
||||
rm -rf libgovibevoicecpp*.so package sources/vibevoice.cpp vibevoice-cpp
|
||||
|
||||
purge:
|
||||
rm -rf build*
|
||||
|
||||
# Variants must build sequentially
|
||||
.NOTPARALLEL:
|
||||
|
||||
# Build all variants (Linux only)
|
||||
ifeq ($(UNAME_S),Linux)
|
||||
libgovibevoicecpp-avx.so: sources/vibevoice.cpp
|
||||
$(info ${GREEN}I vibevoice-cpp build info:avx${RESET})
|
||||
SO_TARGET=libgovibevoicecpp-avx.so CMAKE_ARGS="$(CMAKE_ARGS) -DGGML_AVX=on -DGGML_AVX2=off -DGGML_AVX512=off -DGGML_FMA=off -DGGML_F16C=off -DGGML_BMI2=off" $(MAKE) libgovibevoicecpp-custom
|
||||
rm -rf build-libgovibevoicecpp-avx.so
|
||||
|
||||
libgovibevoicecpp-avx2.so: sources/vibevoice.cpp
|
||||
$(info ${GREEN}I vibevoice-cpp build info:avx2${RESET})
|
||||
SO_TARGET=libgovibevoicecpp-avx2.so CMAKE_ARGS="$(CMAKE_ARGS) -DGGML_AVX=on -DGGML_AVX2=on -DGGML_AVX512=off -DGGML_FMA=on -DGGML_F16C=on -DGGML_BMI2=on" $(MAKE) libgovibevoicecpp-custom
|
||||
rm -rf build-libgovibevoicecpp-avx2.so
|
||||
|
||||
libgovibevoicecpp-avx512.so: sources/vibevoice.cpp
|
||||
$(info ${GREEN}I vibevoice-cpp build info:avx512${RESET})
|
||||
SO_TARGET=libgovibevoicecpp-avx512.so CMAKE_ARGS="$(CMAKE_ARGS) -DGGML_AVX=on -DGGML_AVX2=on -DGGML_AVX512=on -DGGML_FMA=on -DGGML_F16C=on -DGGML_BMI2=on" $(MAKE) libgovibevoicecpp-custom
|
||||
rm -rf build-libgovibevoicecpp-avx512.so
|
||||
endif
|
||||
|
||||
# Build fallback variant (all platforms)
|
||||
libgovibevoicecpp-fallback.so: sources/vibevoice.cpp
|
||||
$(info ${GREEN}I vibevoice-cpp build info:fallback${RESET})
|
||||
SO_TARGET=libgovibevoicecpp-fallback.so CMAKE_ARGS="$(CMAKE_ARGS) -DGGML_AVX=off -DGGML_AVX2=off -DGGML_AVX512=off -DGGML_FMA=off -DGGML_F16C=off -DGGML_BMI2=off" $(MAKE) libgovibevoicecpp-custom
|
||||
rm -rf build-libgovibevoicecpp-fallback.so
|
||||
|
||||
libgovibevoicecpp-custom: CMakeLists.txt cpp/govibevoicecpp.cpp cpp/govibevoicecpp.h
|
||||
mkdir -p build-$(SO_TARGET) && \
|
||||
cd build-$(SO_TARGET) && \
|
||||
cmake .. $(CMAKE_ARGS) && \
|
||||
cmake --build . --config Release -j$(JOBS) --target govibevoicecpp && \
|
||||
cd .. && \
|
||||
mv build-$(SO_TARGET)/libgovibevoicecpp.so ./$(SO_TARGET)
|
||||
|
||||
test: vibevoice-cpp
|
||||
@echo "Running vibevoice-cpp tests..."
|
||||
bash test.sh
|
||||
@echo "vibevoice-cpp tests completed."
|
||||
|
||||
all: vibevoice-cpp package
|
||||
41
backend/go/vibevoice-cpp/cpp/govibevoicecpp.cpp
Normal file
41
backend/go/vibevoice-cpp/cpp/govibevoicecpp.cpp
Normal file
@@ -0,0 +1,41 @@
|
||||
// vibevoice.cpp ships its purego-friendly ABI in vibevoice_capi.h.
|
||||
// This translation unit is intentionally tiny: pulling in the header
|
||||
// (and linking libvibevoice PRIVATE in CMake) is enough to make the
|
||||
// vv_capi_* symbols visible from the produced MODULE library.
|
||||
//
|
||||
// We do install a ggml log redirect so backend logs land on the gRPC
|
||||
// server's stderr — same pattern as backend/go/qwen3-tts-cpp/cpp/.
|
||||
|
||||
#include "govibevoicecpp.h"
|
||||
|
||||
#include "ggml.h"
|
||||
#include "ggml-backend.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
namespace {
|
||||
|
||||
void govibevoice_log_cb(enum ggml_log_level level, const char* msg, void* /*ud*/) {
|
||||
if (!msg) return;
|
||||
const char* tag = "?????";
|
||||
switch (level) {
|
||||
case GGML_LOG_LEVEL_DEBUG: tag = "DEBUG"; break;
|
||||
case GGML_LOG_LEVEL_INFO: tag = "INFO"; break;
|
||||
case GGML_LOG_LEVEL_WARN: tag = "WARN"; break;
|
||||
case GGML_LOG_LEVEL_ERROR: tag = "ERROR"; break;
|
||||
default: break;
|
||||
}
|
||||
std::fprintf(stderr, "[%-5s] %s", tag, msg);
|
||||
std::fflush(stderr);
|
||||
}
|
||||
|
||||
struct LogInstaller {
|
||||
LogInstaller() {
|
||||
ggml_log_set(govibevoice_log_cb, nullptr);
|
||||
ggml_backend_load_all();
|
||||
}
|
||||
};
|
||||
|
||||
LogInstaller g_install;
|
||||
|
||||
} // namespace
|
||||
7
backend/go/vibevoice-cpp/cpp/govibevoicecpp.h
Normal file
7
backend/go/vibevoice-cpp/cpp/govibevoicecpp.h
Normal file
@@ -0,0 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
// Re-exports the vibevoice.cpp flat C ABI so this MODULE library
|
||||
// resolves the same symbols that purego.RegisterLibFunc looks up by
|
||||
// name. The actual definitions live in libvibevoice (linked PRIVATE).
|
||||
|
||||
#include "vibevoice_capi.h"
|
||||
387
backend/go/vibevoice-cpp/govibevoicecpp.go
Normal file
387
backend/go/vibevoice-cpp/govibevoicecpp.go
Normal file
@@ -0,0 +1,387 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
laudio "github.com/mudler/LocalAI/pkg/audio"
|
||||
"github.com/mudler/LocalAI/pkg/grpc/base"
|
||||
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
||||
)
|
||||
|
||||
// vibevoice.cpp synthesizes 24 kHz mono 16-bit PCM. Hardcoded - the
|
||||
// model itself is fixed-rate; if the upstream ever changes this we'll
|
||||
// pick it up via vv_capi_version().
|
||||
const vibevoiceSampleRate = uint32(24000)
|
||||
|
||||
// purego-bound entry points from libgovibevoicecpp.
|
||||
var (
|
||||
CppLoad func(ttsModel, asrModel, tokenizer, voice string, threads int32) int32
|
||||
CppTTS func(text, voicePath, dstWav string,
|
||||
nSteps int32, cfgScale float32, maxSpeechFrames int32, seed uint32) int32
|
||||
CppASR func(srcWav string, outJSON []byte, capacity uint64,
|
||||
maxNewTokens int32) int32
|
||||
CppUnload func()
|
||||
CppVersion func() string
|
||||
)
|
||||
|
||||
// VibevoiceCpp speaks gRPC against vibevoice.cpp's flat C ABI. The
|
||||
// engine is a single global, so we serialize calls through SingleThread.
|
||||
type VibevoiceCpp struct {
|
||||
base.SingleThread
|
||||
threads int
|
||||
|
||||
// modelRoot is the directory we use to resolve relative paths
|
||||
// from Options[] and per-call overrides (TTSRequest.Voice).
|
||||
// Source of truth: opts.ModelPath; falls back to the dir of
|
||||
// the primary ModelFile when ModelPath is empty.
|
||||
modelRoot string
|
||||
|
||||
ttsModel string
|
||||
asrModel string
|
||||
tokenizer string
|
||||
voice string
|
||||
}
|
||||
|
||||
// resolvePath joins a relative path onto `relTo`. The gallery
|
||||
// convention is that Options[] carry paths relative to the LocalAI
|
||||
// models dir (opts.ModelPath), so anything not absolute is treated
|
||||
// as a sibling of the primary ModelFile - never CWD. Empty / already-
|
||||
// absolute / no-relTo inputs pass through unchanged.
|
||||
func resolvePath(p, relTo string) string {
|
||||
if p == "" || filepath.IsAbs(p) || relTo == "" {
|
||||
return p
|
||||
}
|
||||
return filepath.Join(relTo, p)
|
||||
}
|
||||
|
||||
// parseOptions reads opts.Options[] and pulls out the per-role
|
||||
// overrides documented in the gallery entries. Accepts both "key=value"
|
||||
// (gallery YAML style) and "key:value" (Make-target / env-var style).
|
||||
func (v *VibevoiceCpp) parseOptions(opts []string, relTo string) string {
|
||||
role := ""
|
||||
for _, raw := range opts {
|
||||
k, val, ok := strings.Cut(raw, "=")
|
||||
if !ok {
|
||||
k, val, ok = strings.Cut(raw, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
key := strings.TrimSpace(k)
|
||||
val = strings.TrimSpace(val)
|
||||
switch key {
|
||||
case "type":
|
||||
role = strings.ToLower(val)
|
||||
case "tokenizer":
|
||||
v.tokenizer = resolvePath(val, relTo)
|
||||
case "voice":
|
||||
v.voice = resolvePath(val, relTo)
|
||||
case "tts_model":
|
||||
v.ttsModel = resolvePath(val, relTo)
|
||||
case "asr_model":
|
||||
v.asrModel = resolvePath(val, relTo)
|
||||
}
|
||||
}
|
||||
return role
|
||||
}
|
||||
|
||||
func (v *VibevoiceCpp) Load(opts *pb.ModelOptions) error {
|
||||
if opts.ModelFile == "" {
|
||||
return fmt.Errorf("vibevoice-cpp: ModelFile is required")
|
||||
}
|
||||
modelFile := opts.ModelFile
|
||||
if !filepath.IsAbs(modelFile) && opts.ModelPath != "" {
|
||||
modelFile = filepath.Join(opts.ModelPath, modelFile)
|
||||
}
|
||||
|
||||
// ModelPath is the LocalAI core's models root, propagated over
|
||||
// gRPC. Use it as the resolution base for Options[] (and later
|
||||
// for TTSRequest.Voice) so gallery entries can reference paths
|
||||
// like "tokenizer=tokenizer.gguf" and have them resolved
|
||||
// against the same root the core used to drop the files.
|
||||
v.modelRoot = opts.ModelPath
|
||||
if v.modelRoot == "" {
|
||||
v.modelRoot = filepath.Dir(modelFile)
|
||||
}
|
||||
role := v.parseOptions(opts.Options, v.modelRoot)
|
||||
|
||||
// ModelFile fills the "primary" role-slot determined by `type=`
|
||||
// in Options (defaults to tts). The other slot stays exactly as
|
||||
// Options set it - so a closed-loop config with ModelFile=tts.gguf
|
||||
// + Options[asr_model=asr.gguf] resolves correctly to both slots,
|
||||
// and an explicit `tts_model=` / `asr_model=` always wins over
|
||||
// ModelFile for its own slot.
|
||||
primaryIsASR := false
|
||||
switch role {
|
||||
case "asr", "transcript", "stt", "speech-to-text":
|
||||
primaryIsASR = true
|
||||
}
|
||||
if primaryIsASR {
|
||||
if v.asrModel == "" {
|
||||
v.asrModel = modelFile
|
||||
}
|
||||
} else if v.ttsModel == "" {
|
||||
v.ttsModel = modelFile
|
||||
}
|
||||
|
||||
if v.ttsModel == "" && v.asrModel == "" {
|
||||
return fmt.Errorf("vibevoice-cpp: no TTS or ASR model resolved from ModelFile=%q + options", opts.ModelFile)
|
||||
}
|
||||
if v.tokenizer == "" {
|
||||
return fmt.Errorf("vibevoice-cpp: tokenizer is required - pass options: [tokenizer=<path>]")
|
||||
}
|
||||
|
||||
threads := int(opts.Threads)
|
||||
if threads <= 0 {
|
||||
threads = 4
|
||||
}
|
||||
v.threads = threads
|
||||
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"[vibevoice-cpp] Loading: tts=%q asr=%q tokenizer=%q voice=%q threads=%d\n",
|
||||
v.ttsModel, v.asrModel, v.tokenizer, v.voice, threads)
|
||||
|
||||
if rc := CppLoad(v.ttsModel, v.asrModel, v.tokenizer, v.voice, int32(threads)); rc != 0 {
|
||||
return fmt.Errorf("vibevoice-cpp: vv_capi_load failed (rc=%d)", rc)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *VibevoiceCpp) TTS(req *pb.TTSRequest) error {
|
||||
if v.ttsModel == "" {
|
||||
return fmt.Errorf("vibevoice-cpp: TTS requested but no realtime model was loaded")
|
||||
}
|
||||
text := req.Text
|
||||
dst := req.Dst
|
||||
if text == "" || dst == "" {
|
||||
return fmt.Errorf("vibevoice-cpp: TTS requires both text and dst")
|
||||
}
|
||||
|
||||
// req.Voice may be a bare filename (e.g. "voice-en-Emma.gguf") or an
|
||||
// absolute path. Resolve via the same modelRoot Load() used for
|
||||
// Options[] so a swap-voice request mirrors the gallery's layout.
|
||||
voice := resolvePath(req.Voice, v.modelRoot)
|
||||
|
||||
if req.Language != nil && *req.Language != "" {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"[vibevoice-cpp] note: TTSRequest.language=%q ignored - vibevoice picks language from the voice prompt\n",
|
||||
*req.Language)
|
||||
}
|
||||
|
||||
const (
|
||||
defaultSteps = 20
|
||||
defaultMaxFrames = 200
|
||||
)
|
||||
defaultCfg := float32(1.3)
|
||||
if rc := CppTTS(text, voice, dst,
|
||||
int32(defaultSteps), defaultCfg, int32(defaultMaxFrames), 0); rc != 0 {
|
||||
return fmt.Errorf("vibevoice-cpp: vv_capi_tts failed (rc=%d)", rc)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// asrSegment matches vibevoice's JSON output:
|
||||
//
|
||||
// [{"Start":0.0,"End":2.8,"Speaker":0,"Content":"…"}, ...]
|
||||
type asrSegment struct {
|
||||
Start float64 `json:"Start"`
|
||||
End float64 `json:"End"`
|
||||
Speaker int `json:"Speaker"`
|
||||
Content string `json:"Content"`
|
||||
}
|
||||
|
||||
// callASR invokes vv_capi_asr with a buffer that grows on demand.
|
||||
// vv_capi_asr returns: >0 bytes written, 0 no transcript, <0 error or
|
||||
// -required_size. We honor the resize protocol once before giving up.
|
||||
func (v *VibevoiceCpp) callASR(srcWav string, maxNewTokens int32) (string, error) {
|
||||
const startCap = 256 * 1024
|
||||
buf := make([]byte, startCap)
|
||||
rc := CppASR(srcWav, buf, uint64(len(buf)), maxNewTokens)
|
||||
if rc < 0 {
|
||||
need := -int(rc)
|
||||
if need > 0 && need < (16<<20) && need > len(buf) {
|
||||
buf = make([]byte, need+64)
|
||||
rc = CppASR(srcWav, buf, uint64(len(buf)), maxNewTokens)
|
||||
}
|
||||
}
|
||||
if rc < 0 {
|
||||
return "", fmt.Errorf("vibevoice-cpp: vv_capi_asr failed (rc=%d)", rc)
|
||||
}
|
||||
if rc == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return string(buf[:rc]), nil
|
||||
}
|
||||
|
||||
// TTSStream is the streaming counterpart to TTS. vibevoice's C ABI is
|
||||
// file-only (vv_capi_tts writes a complete WAV), so we synthesize to
|
||||
// a tempfile, then emit a streaming-WAV header followed by the PCM
|
||||
// body in chunks. The main reason this exists at all is the gRPC
|
||||
// server wrapper (pkg/grpc/server.go:TTSStream) blocks on a channel
|
||||
// that only this method can close - if we leave the default Base
|
||||
// stub in place, every TTSStream call hangs until the client
|
||||
// deadline.
|
||||
func (v *VibevoiceCpp) TTSStream(req *pb.TTSRequest, results chan []byte) error {
|
||||
defer close(results)
|
||||
if v.ttsModel == "" {
|
||||
return fmt.Errorf("vibevoice-cpp: TTSStream requested but no realtime model was loaded")
|
||||
}
|
||||
if req.Text == "" {
|
||||
return fmt.Errorf("vibevoice-cpp: TTSStream requires text")
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp("", "vibevoice-cpp-stream-*.wav")
|
||||
if err != nil {
|
||||
return fmt.Errorf("vibevoice-cpp: tempfile: %w", err)
|
||||
}
|
||||
dst := tmp.Name()
|
||||
_ = tmp.Close()
|
||||
defer func() { _ = os.Remove(dst) }()
|
||||
|
||||
if err := v.TTS(&pb.TTSRequest{
|
||||
Text: req.Text,
|
||||
Voice: req.Voice,
|
||||
Dst: dst,
|
||||
Language: req.Language,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
wav, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
return fmt.Errorf("vibevoice-cpp: read tempfile: %w", err)
|
||||
}
|
||||
|
||||
// Streaming WAV header: declare 0xFFFFFFFF for chunk sizes so HTTP
|
||||
// clients can start playback before they see the full PCM.
|
||||
const streamingSize = 0xFFFFFFFF
|
||||
hdr := laudio.NewWAVHeaderWithRate(streamingSize, vibevoiceSampleRate)
|
||||
hdr.ChunkSize = streamingSize
|
||||
hdrBuf := make([]byte, 0, laudio.WAVHeaderSize)
|
||||
w := newByteWriter(&hdrBuf)
|
||||
if err := hdr.Write(w); err != nil {
|
||||
return fmt.Errorf("vibevoice-cpp: write WAV header: %w", err)
|
||||
}
|
||||
results <- hdrBuf
|
||||
|
||||
// PCM body: send in ~64 KB slices so the client gets multiple
|
||||
// reply chunks (e2e harness asserts >=2 frames).
|
||||
pcm := laudio.StripWAVHeader(wav)
|
||||
const chunkBytes = 64 * 1024
|
||||
for off := 0; off < len(pcm); off += chunkBytes {
|
||||
end := off + chunkBytes
|
||||
if end > len(pcm) {
|
||||
end = len(pcm)
|
||||
}
|
||||
chunk := make([]byte, end-off)
|
||||
copy(chunk, pcm[off:end])
|
||||
results <- chunk
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// byteWriter adapts a *[]byte to io.Writer so we can hand it to
|
||||
// laudio.WAVHeader.Write without allocating a bytes.Buffer.
|
||||
type byteWriter struct{ buf *[]byte }
|
||||
|
||||
func newByteWriter(b *[]byte) *byteWriter { return &byteWriter{buf: b} }
|
||||
func (w *byteWriter) Write(p []byte) (int, error) {
|
||||
*w.buf = append(*w.buf, p...)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (v *VibevoiceCpp) AudioTranscription(req *pb.TranscriptRequest) (pb.TranscriptResult, error) {
|
||||
if v.asrModel == "" {
|
||||
return pb.TranscriptResult{}, fmt.Errorf("vibevoice-cpp: AudioTranscription requested but no ASR model was loaded")
|
||||
}
|
||||
if req.Dst == "" {
|
||||
return pb.TranscriptResult{}, fmt.Errorf("vibevoice-cpp: TranscriptRequest.dst (audio path) is required")
|
||||
}
|
||||
|
||||
out, err := v.callASR(req.Dst, 0)
|
||||
if err != nil {
|
||||
return pb.TranscriptResult{}, err
|
||||
}
|
||||
if out == "" {
|
||||
return pb.TranscriptResult{}, nil
|
||||
}
|
||||
|
||||
var segs []asrSegment
|
||||
if err := json.Unmarshal([]byte(out), &segs); err != nil {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"[vibevoice-cpp] WARNING: vv_capi_asr returned non-JSON, falling back to single segment: %v\n", err)
|
||||
return pb.TranscriptResult{
|
||||
Segments: []*pb.TranscriptSegment{{Id: 0, Text: strings.TrimSpace(out)}},
|
||||
Text: strings.TrimSpace(out),
|
||||
}, nil
|
||||
}
|
||||
|
||||
segments := make([]*pb.TranscriptSegment, 0, len(segs))
|
||||
parts := make([]string, 0, len(segs))
|
||||
var duration float32
|
||||
for i, s := range segs {
|
||||
// LocalAI's whisper backend uses int64 100ns ticks for
|
||||
// Start/End (seconds * 1e7); follow the same convention so
|
||||
// consumers can mix vibevoice and whisper transcripts.
|
||||
segments = append(segments, &pb.TranscriptSegment{
|
||||
Id: int32(i),
|
||||
Text: s.Content,
|
||||
Start: int64(s.Start * 1e7),
|
||||
End: int64(s.End * 1e7),
|
||||
Speaker: fmt.Sprintf("%d", s.Speaker),
|
||||
})
|
||||
parts = append(parts, strings.TrimSpace(s.Content))
|
||||
if float32(s.End) > duration {
|
||||
duration = float32(s.End)
|
||||
}
|
||||
}
|
||||
return pb.TranscriptResult{
|
||||
Segments: segments,
|
||||
Text: strings.TrimSpace(strings.Join(parts, " ")),
|
||||
Duration: duration,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AudioTranscriptionStream wraps AudioTranscription so the streaming
|
||||
// gRPC endpoint (server.go:AudioTranscriptionStream) sees its channel
|
||||
// close and the client doesn't sit waiting until deadline. vibevoice's
|
||||
// ASR doesn't expose token-level streaming - vv_capi_asr decodes the
|
||||
// whole audio and returns a JSON segment list - so we run the offline
|
||||
// transcription, emit each segment's content as a delta, then close
|
||||
// with a final_result whose Text equals the concatenated deltas (the
|
||||
// e2e harness asserts those match).
|
||||
func (v *VibevoiceCpp) AudioTranscriptionStream(req *pb.TranscriptRequest, results chan *pb.TranscriptStreamResponse) error {
|
||||
defer close(results)
|
||||
res, err := v.AudioTranscription(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var assembled strings.Builder
|
||||
for _, seg := range res.Segments {
|
||||
if seg == nil {
|
||||
continue
|
||||
}
|
||||
txt := strings.TrimSpace(seg.Text)
|
||||
if txt == "" {
|
||||
continue
|
||||
}
|
||||
delta := txt
|
||||
if assembled.Len() > 0 {
|
||||
delta = " " + txt
|
||||
}
|
||||
results <- &pb.TranscriptStreamResponse{Delta: delta}
|
||||
assembled.WriteString(delta)
|
||||
}
|
||||
final := pb.TranscriptResult{
|
||||
Segments: res.Segments,
|
||||
Duration: res.Duration,
|
||||
Language: res.Language,
|
||||
Text: assembled.String(),
|
||||
}
|
||||
results <- &pb.TranscriptStreamResponse{FinalResult: &final}
|
||||
return nil
|
||||
}
|
||||
49
backend/go/vibevoice-cpp/main.go
Normal file
49
backend/go/vibevoice-cpp/main.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
// Started internally by LocalAI - one gRPC server per loaded model.
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
|
||||
"github.com/ebitengine/purego"
|
||||
grpc "github.com/mudler/LocalAI/pkg/grpc"
|
||||
)
|
||||
|
||||
var (
|
||||
addr = flag.String("addr", "localhost:50051", "the address to connect to")
|
||||
)
|
||||
|
||||
type LibFuncs struct {
|
||||
FuncPtr any
|
||||
Name string
|
||||
}
|
||||
|
||||
func main() {
|
||||
libName := os.Getenv("VIBEVOICECPP_LIBRARY")
|
||||
if libName == "" {
|
||||
libName = "./libgovibevoicecpp-fallback.so"
|
||||
}
|
||||
|
||||
lib, err := purego.Dlopen(libName, purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
libFuncs := []LibFuncs{
|
||||
{&CppLoad, "vv_capi_load"},
|
||||
{&CppTTS, "vv_capi_tts"},
|
||||
{&CppASR, "vv_capi_asr"},
|
||||
{&CppUnload, "vv_capi_unload"},
|
||||
{&CppVersion, "vv_capi_version"},
|
||||
}
|
||||
|
||||
for _, lf := range libFuncs {
|
||||
purego.RegisterLibFunc(lf.FuncPtr, lib, lf.Name)
|
||||
}
|
||||
|
||||
flag.Parse()
|
||||
|
||||
if err := grpc.StartServer(*addr, &VibevoiceCpp{}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
58
backend/go/vibevoice-cpp/package.sh
Executable file
58
backend/go/vibevoice-cpp/package.sh
Executable file
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Bundle the vibevoice-cpp binary, the per-variant .so files, and the
|
||||
# runtime libs the binary depends on so the package is self-contained.
|
||||
# Mirrors backend/go/qwen3-tts-cpp/package.sh.
|
||||
|
||||
set -e
|
||||
|
||||
CURDIR=$(dirname "$(realpath $0)")
|
||||
REPO_ROOT="${CURDIR}/../../.."
|
||||
|
||||
mkdir -p $CURDIR/package/lib
|
||||
|
||||
cp -avf $CURDIR/vibevoice-cpp $CURDIR/package/
|
||||
cp -fv $CURDIR/libgovibevoicecpp-*.so $CURDIR/package/
|
||||
cp -fv $CURDIR/run.sh $CURDIR/package/
|
||||
|
||||
# Detect architecture and copy appropriate libraries
|
||||
if [ -f "/lib64/ld-linux-x86-64.so.2" ]; then
|
||||
echo "Detected x86_64 architecture, copying x86_64 libraries..."
|
||||
cp -arfLv /lib64/ld-linux-x86-64.so.2 $CURDIR/package/lib/ld.so
|
||||
cp -arfLv /lib/x86_64-linux-gnu/libc.so.6 $CURDIR/package/lib/libc.so.6
|
||||
cp -arfLv /lib/x86_64-linux-gnu/libgcc_s.so.1 $CURDIR/package/lib/libgcc_s.so.1
|
||||
cp -arfLv /lib/x86_64-linux-gnu/libstdc++.so.6 $CURDIR/package/lib/libstdc++.so.6
|
||||
cp -arfLv /lib/x86_64-linux-gnu/libm.so.6 $CURDIR/package/lib/libm.so.6
|
||||
cp -arfLv /lib/x86_64-linux-gnu/libgomp.so.1 $CURDIR/package/lib/libgomp.so.1
|
||||
cp -arfLv /lib/x86_64-linux-gnu/libdl.so.2 $CURDIR/package/lib/libdl.so.2
|
||||
cp -arfLv /lib/x86_64-linux-gnu/librt.so.1 $CURDIR/package/lib/librt.so.1
|
||||
cp -arfLv /lib/x86_64-linux-gnu/libpthread.so.0 $CURDIR/package/lib/libpthread.so.0
|
||||
elif [ -f "/lib/ld-linux-aarch64.so.1" ]; then
|
||||
echo "Detected ARM64 architecture, copying ARM64 libraries..."
|
||||
cp -arfLv /lib/ld-linux-aarch64.so.1 $CURDIR/package/lib/ld.so
|
||||
cp -arfLv /lib/aarch64-linux-gnu/libc.so.6 $CURDIR/package/lib/libc.so.6
|
||||
cp -arfLv /lib/aarch64-linux-gnu/libgcc_s.so.1 $CURDIR/package/lib/libgcc_s.so.1
|
||||
cp -arfLv /lib/aarch64-linux-gnu/libstdc++.so.6 $CURDIR/package/lib/libstdc++.so.6
|
||||
cp -arfLv /lib/aarch64-linux-gnu/libm.so.6 $CURDIR/package/lib/libm.so.6
|
||||
cp -arfLv /lib/aarch64-linux-gnu/libgomp.so.1 $CURDIR/package/lib/libgomp.so.1
|
||||
cp -arfLv /lib/aarch64-linux-gnu/libdl.so.2 $CURDIR/package/lib/libdl.so.2
|
||||
cp -arfLv /lib/aarch64-linux-gnu/librt.so.1 $CURDIR/package/lib/librt.so.1
|
||||
cp -arfLv /lib/aarch64-linux-gnu/libpthread.so.0 $CURDIR/package/lib/libpthread.so.0
|
||||
elif [ $(uname -s) = "Darwin" ]; then
|
||||
echo "Detected Darwin"
|
||||
else
|
||||
echo "Error: Could not detect architecture"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Package GPU libraries based on BUILD_TYPE
|
||||
GPU_LIB_SCRIPT="${REPO_ROOT}/scripts/build/package-gpu-libs.sh"
|
||||
if [ -f "$GPU_LIB_SCRIPT" ]; then
|
||||
echo "Packaging GPU libraries for BUILD_TYPE=${BUILD_TYPE:-cpu}..."
|
||||
source "$GPU_LIB_SCRIPT" "$CURDIR/package/lib"
|
||||
package_gpu_libs
|
||||
fi
|
||||
|
||||
echo "Packaging completed successfully"
|
||||
ls -liah $CURDIR/package/
|
||||
ls -liah $CURDIR/package/lib/
|
||||
49
backend/go/vibevoice-cpp/run.sh
Executable file
49
backend/go/vibevoice-cpp/run.sh
Executable file
@@ -0,0 +1,49 @@
|
||||
#!/bin/bash
|
||||
set -ex
|
||||
|
||||
CURDIR=$(dirname "$(realpath $0)")
|
||||
|
||||
cd /
|
||||
|
||||
echo "CPU info:"
|
||||
if [ "$(uname)" != "Darwin" ]; then
|
||||
grep -e "model\sname" /proc/cpuinfo | head -1
|
||||
grep -e "flags" /proc/cpuinfo | head -1
|
||||
fi
|
||||
|
||||
LIBRARY="$CURDIR/libgovibevoicecpp-fallback.so"
|
||||
|
||||
if [ "$(uname)" != "Darwin" ]; then
|
||||
if grep -q -e "\savx\s" /proc/cpuinfo ; then
|
||||
echo "CPU: AVX found OK"
|
||||
if [ -e $CURDIR/libgovibevoicecpp-avx.so ]; then
|
||||
LIBRARY="$CURDIR/libgovibevoicecpp-avx.so"
|
||||
fi
|
||||
fi
|
||||
|
||||
if grep -q -e "\savx2\s" /proc/cpuinfo ; then
|
||||
echo "CPU: AVX2 found OK"
|
||||
if [ -e $CURDIR/libgovibevoicecpp-avx2.so ]; then
|
||||
LIBRARY="$CURDIR/libgovibevoicecpp-avx2.so"
|
||||
fi
|
||||
fi
|
||||
|
||||
if grep -q -e "\savx512f\s" /proc/cpuinfo ; then
|
||||
echo "CPU: AVX512F found OK"
|
||||
if [ -e $CURDIR/libgovibevoicecpp-avx512.so ]; then
|
||||
LIBRARY="$CURDIR/libgovibevoicecpp-avx512.so"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
export LD_LIBRARY_PATH=$CURDIR/lib:$LD_LIBRARY_PATH
|
||||
export VIBEVOICECPP_LIBRARY=$LIBRARY
|
||||
|
||||
if [ -f $CURDIR/lib/ld.so ]; then
|
||||
echo "Using lib/ld.so"
|
||||
echo "Using library: $LIBRARY"
|
||||
exec $CURDIR/lib/ld.so $CURDIR/vibevoice-cpp "$@"
|
||||
fi
|
||||
|
||||
echo "Using library: $LIBRARY"
|
||||
exec $CURDIR/vibevoice-cpp "$@"
|
||||
74
backend/go/vibevoice-cpp/test.sh
Executable file
74
backend/go/vibevoice-cpp/test.sh
Executable file
@@ -0,0 +1,74 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
CURDIR=$(dirname "$(realpath $0)")
|
||||
|
||||
echo "Running vibevoice-cpp backend tests..."
|
||||
|
||||
# Required env-vars (set automatically when missing):
|
||||
# VIBEVOICE_MODEL_DIR : directory containing the gguf bundle.
|
||||
# VIBEVOICE_BINARY : path to the built backend (default ./vibevoice-cpp)
|
||||
#
|
||||
# Tests skip when the model bundle is absent and the auto-download
|
||||
# fails (e.g. no network on the runner) so local devs without HF access
|
||||
# still get green compile output.
|
||||
|
||||
cd "$CURDIR"
|
||||
|
||||
if [ -z "$VIBEVOICE_MODEL_DIR" ]; then
|
||||
export VIBEVOICE_MODEL_DIR="./vibevoice-models"
|
||||
|
||||
if [ ! -d "$VIBEVOICE_MODEL_DIR" ]; then
|
||||
echo "Creating vibevoice-models directory for tests..."
|
||||
mkdir -p "$VIBEVOICE_MODEL_DIR"
|
||||
|
||||
REPO_ID="mudler/vibevoice.cpp-models"
|
||||
echo "Repository: ${REPO_ID}"
|
||||
|
||||
# Q4_K instead of Q8_0 for the ASR model: smaller download
|
||||
# (10 GB vs 14 GB), fits on ubuntu-latest's free disk after the
|
||||
# runner image is loaded. The unit/closed-loop test only needs
|
||||
# decode quality, not Q8_0 precision.
|
||||
FILES=(
|
||||
"vibevoice-realtime-0.5B-q8_0.gguf"
|
||||
"vibevoice-asr-q4_k.gguf"
|
||||
"tokenizer.gguf"
|
||||
"voice-en-Carter_man.gguf"
|
||||
)
|
||||
|
||||
BASE_URL="https://huggingface.co/${REPO_ID}/resolve/main"
|
||||
|
||||
download_ok=1
|
||||
for file in "${FILES[@]}"; do
|
||||
dest="${VIBEVOICE_MODEL_DIR}/${file}"
|
||||
if [ -f "${dest}" ]; then
|
||||
echo " [skip] ${file} (already exists)"
|
||||
else
|
||||
echo " [download] ${file}..."
|
||||
if ! curl -fL -o "${dest}" "${BASE_URL}/${file}" --progress-bar; then
|
||||
echo " [warn] failed to download ${file} - network or HF unavailable"
|
||||
rm -f "${dest}"
|
||||
download_ok=0
|
||||
break
|
||||
fi
|
||||
echo " [done] ${file}"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$download_ok" != "1" ]; then
|
||||
echo "vibevoice-cpp: model bundle unavailable - tests will skip model-dependent cases."
|
||||
unset VIBEVOICE_MODEL_DIR
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Ensure the per-variant .so the binary will dlopen actually exists -
|
||||
# without one, every test will hit a Dlopen panic during server start.
|
||||
if [ ! -f "${CURDIR}/libgovibevoicecpp-fallback.so" ]; then
|
||||
echo "vibevoice-cpp: libgovibevoicecpp-fallback.so missing - run \`make\` first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
go test -v -timeout 900s .
|
||||
|
||||
echo "All vibevoice-cpp tests passed."
|
||||
382
backend/go/vibevoice-cpp/vibevoicecpp_test.go
Normal file
382
backend/go/vibevoice-cpp/vibevoicecpp_test.go
Normal file
@@ -0,0 +1,382 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
const (
|
||||
testAddr = "localhost:50098"
|
||||
startupWait = 5 * time.Second
|
||||
)
|
||||
|
||||
func TestVibevoiceCpp(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "VibeVoice-cpp Backend Suite")
|
||||
}
|
||||
|
||||
// modelDirOrSkip returns the staged model bundle dir, or Skip()s the
|
||||
// current spec when VIBEVOICE_MODEL_DIR is unset / lacks the gguf
|
||||
// files we need. Tests that don't depend on a model (Locking, error
|
||||
// paths) don't call this.
|
||||
func modelDirOrSkip() string {
|
||||
dir := os.Getenv("VIBEVOICE_MODEL_DIR")
|
||||
if dir == "" {
|
||||
Skip("VIBEVOICE_MODEL_DIR not set, skipping model-dependent specs")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "tokenizer.gguf")); os.IsNotExist(err) {
|
||||
Skip("tokenizer.gguf missing in " + dir)
|
||||
}
|
||||
tts, _ := filepath.Glob(filepath.Join(dir, "vibevoice-realtime-*.gguf"))
|
||||
asr, _ := filepath.Glob(filepath.Join(dir, "vibevoice-asr-*.gguf"))
|
||||
if len(tts) == 0 && len(asr) == 0 {
|
||||
Skip("neither realtime TTS nor ASR gguf found in " + dir)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// startServer launches the prebuilt backend binary and returns a
|
||||
// running *exec.Cmd. test.sh ensures `./vibevoice-cpp` is built; if
|
||||
// it isn't, every gRPC spec is skipped with a clear reason.
|
||||
func startServer() *exec.Cmd {
|
||||
binary := os.Getenv("VIBEVOICE_BINARY")
|
||||
if binary == "" {
|
||||
binary = "./vibevoice-cpp"
|
||||
}
|
||||
if _, err := os.Stat(binary); os.IsNotExist(err) {
|
||||
Skip("backend binary not found at " + binary)
|
||||
}
|
||||
cmd := exec.Command(binary, "--addr", testAddr)
|
||||
cmd.Stdout = os.Stderr
|
||||
cmd.Stderr = os.Stderr
|
||||
Expect(cmd.Start()).To(Succeed())
|
||||
time.Sleep(startupWait)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func stopServer(cmd *exec.Cmd) {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
_ = cmd.Process.Kill()
|
||||
_, _ = cmd.Process.Wait()
|
||||
}
|
||||
|
||||
func dialGRPC() *grpc.ClientConn {
|
||||
conn, err := grpc.Dial(testAddr,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithDefaultCallOptions(
|
||||
grpc.MaxCallRecvMsgSize(50*1024*1024),
|
||||
grpc.MaxCallSendMsgSize(50*1024*1024),
|
||||
),
|
||||
)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return conn
|
||||
}
|
||||
|
||||
var _ = Describe("VibeVoice-cpp", func() {
|
||||
Context("backend semantics (no purego load needed)", func() {
|
||||
It("is locking - the engine has process-global state", func() {
|
||||
Expect((&VibevoiceCpp{}).Locking()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("rejects Load with empty ModelFile", func() {
|
||||
err := (&VibevoiceCpp{}).Load(&pb.ModelOptions{})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("ModelFile"))
|
||||
})
|
||||
|
||||
It("rejects TTS without a loaded TTS model", func() {
|
||||
err := (&VibevoiceCpp{}).TTS(&pb.TTSRequest{
|
||||
Text: "no model loaded",
|
||||
Dst: "/tmp/should-not-be-written.wav",
|
||||
})
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("rejects AudioTranscription without a loaded ASR model", func() {
|
||||
_, err := (&VibevoiceCpp{}).AudioTranscription(&pb.TranscriptRequest{
|
||||
Dst: "/tmp/some.wav",
|
||||
})
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("closes the channel and errors on TTSStream without a loaded model", func() {
|
||||
ch := make(chan []byte, 4)
|
||||
err := (&VibevoiceCpp{}).TTSStream(&pb.TTSRequest{
|
||||
Text: "no model loaded",
|
||||
Dst: "/tmp/should-not-be-written.wav",
|
||||
}, ch)
|
||||
Expect(err).To(HaveOccurred())
|
||||
// Server hangs forever if the channel stays open; this guard
|
||||
// is what regresses the e2e DeadlineExceeded we're fixing.
|
||||
_, ok := <-ch
|
||||
Expect(ok).To(BeFalse(), "TTSStream must close results channel even on error")
|
||||
})
|
||||
|
||||
// parseOptions + slot fill is the source of the closed-loop CI
|
||||
// regression where ModelFile=tts.gguf + Options[asr_model=...]
|
||||
// resulted in a load with empty tts slot. These specs assert
|
||||
// the slot resolution before we ever call into purego.
|
||||
Describe("ModelFile slot resolution", func() {
|
||||
It("fills tts slot from ModelFile when only asr_model is in Options", func() {
|
||||
v := &VibevoiceCpp{}
|
||||
v.modelRoot = "/abs/root"
|
||||
role := v.parseOptions([]string{"asr_model=/abs/root/asr.gguf", "tokenizer=/abs/root/tokenizer.gguf"}, v.modelRoot)
|
||||
Expect(v.asrModel).To(Equal("/abs/root/asr.gguf"))
|
||||
Expect(v.ttsModel).To(BeEmpty())
|
||||
Expect(role).To(BeEmpty())
|
||||
// Mirror the Load() default-fill block:
|
||||
if v.ttsModel == "" {
|
||||
v.ttsModel = "/abs/root/tts.gguf"
|
||||
}
|
||||
Expect(v.ttsModel).To(Equal("/abs/root/tts.gguf"))
|
||||
Expect(v.asrModel).To(Equal("/abs/root/asr.gguf"))
|
||||
})
|
||||
|
||||
It("fills asr slot from ModelFile when type=asr is set", func() {
|
||||
v := &VibevoiceCpp{}
|
||||
v.modelRoot = "/abs/root"
|
||||
role := v.parseOptions([]string{"type=asr", "tokenizer=/abs/root/tokenizer.gguf"}, v.modelRoot)
|
||||
Expect(role).To(Equal("asr"))
|
||||
Expect(v.asrModel).To(BeEmpty())
|
||||
Expect(v.ttsModel).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("respects explicit tts_model override over ModelFile", func() {
|
||||
v := &VibevoiceCpp{}
|
||||
v.modelRoot = "/abs/root"
|
||||
_ = v.parseOptions([]string{"tts_model=/abs/root/alt.gguf"}, v.modelRoot)
|
||||
Expect(v.ttsModel).To(Equal("/abs/root/alt.gguf"))
|
||||
})
|
||||
|
||||
It("accepts colon-separated options too", func() {
|
||||
v := &VibevoiceCpp{}
|
||||
v.modelRoot = "/abs/root"
|
||||
role := v.parseOptions([]string{"type:asr", "tokenizer:/abs/root/tokenizer.gguf"}, v.modelRoot)
|
||||
Expect(role).To(Equal("asr"))
|
||||
Expect(v.tokenizer).To(Equal("/abs/root/tokenizer.gguf"))
|
||||
})
|
||||
})
|
||||
|
||||
// The gallery flow puts everything under <models_dir>/<entry>/,
|
||||
// and parameters/options carry paths *relative* to <models_dir>.
|
||||
// LocalAI core fills opts.ModelPath = <models_dir>; the backend
|
||||
// must resolve every relative path against that root, never CWD.
|
||||
Describe("resolvePath (relative-to-modelRoot)", func() {
|
||||
It("joins relative path onto relTo", func() {
|
||||
Expect(resolvePath("vibevoice-cpp/tokenizer.gguf", "/data/models")).
|
||||
To(Equal("/data/models/vibevoice-cpp/tokenizer.gguf"))
|
||||
})
|
||||
|
||||
It("passes absolute paths through unchanged", func() {
|
||||
Expect(resolvePath("/abs/somewhere/tokenizer.gguf", "/data/models")).
|
||||
To(Equal("/abs/somewhere/tokenizer.gguf"))
|
||||
})
|
||||
|
||||
It("returns input unchanged when relTo is empty", func() {
|
||||
Expect(resolvePath("vibevoice-cpp/tokenizer.gguf", "")).
|
||||
To(Equal("vibevoice-cpp/tokenizer.gguf"))
|
||||
})
|
||||
|
||||
It("returns empty input unchanged", func() {
|
||||
Expect(resolvePath("", "/data/models")).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("does not consult CWD - bare filenames stay relative to modelRoot", func() {
|
||||
// Even if the test runs in a directory containing a
|
||||
// file with this name, the lookup must not fall back
|
||||
// to CWD. This is the trap the production gallery flow
|
||||
// would otherwise hit when LocalAI is launched from a
|
||||
// directory that happens to contain a same-named file.
|
||||
prev, _ := os.Getwd()
|
||||
DeferCleanup(func() { _ = os.Chdir(prev) })
|
||||
tmpCWD, err := os.MkdirTemp("", "vv-cwd-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
DeferCleanup(func() { _ = os.RemoveAll(tmpCWD) })
|
||||
Expect(os.WriteFile(filepath.Join(tmpCWD, "tokenizer.gguf"),
|
||||
[]byte("not the real one"), 0o644)).To(Succeed())
|
||||
Expect(os.Chdir(tmpCWD)).To(Succeed())
|
||||
|
||||
got := resolvePath("tokenizer.gguf", "/data/models")
|
||||
Expect(got).To(Equal("/data/models/tokenizer.gguf"))
|
||||
})
|
||||
})
|
||||
|
||||
// Round-trip the gallery layout: relative paths in Options +
|
||||
// an absolute ModelFile (as LocalAI core delivers them) end
|
||||
// up resolved correctly inside the backend struct.
|
||||
It("Load resolves relative Options paths against opts.ModelPath", func() {
|
||||
tmpDir, err := os.MkdirTemp("", "vv-relpath-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
DeferCleanup(func() { _ = os.RemoveAll(tmpDir) })
|
||||
|
||||
// Lay out the bundle exactly as the gallery would after install:
|
||||
// <modelpath>/vibevoice-cpp/{tts,tokenizer,voice}.gguf
|
||||
subDir := filepath.Join(tmpDir, "vibevoice-cpp")
|
||||
Expect(os.MkdirAll(subDir, 0o755)).To(Succeed())
|
||||
tts := filepath.Join(subDir, "vibevoice-realtime-stub.gguf")
|
||||
tok := filepath.Join(subDir, "tokenizer.gguf")
|
||||
voice := filepath.Join(subDir, "voice.gguf")
|
||||
for _, p := range []string{tts, tok, voice} {
|
||||
Expect(os.WriteFile(p, []byte("stub"), 0o644)).To(Succeed())
|
||||
}
|
||||
|
||||
// Mirror Load()'s pre-purego prefix: parse + slot fill.
|
||||
v := &VibevoiceCpp{}
|
||||
modelFile := tts // core delivers this as an abspath already
|
||||
v.modelRoot = tmpDir
|
||||
role := v.parseOptions([]string{
|
||||
"tokenizer=vibevoice-cpp/tokenizer.gguf",
|
||||
"voice=vibevoice-cpp/voice.gguf",
|
||||
}, v.modelRoot)
|
||||
Expect(role).To(BeEmpty())
|
||||
if v.ttsModel == "" {
|
||||
v.ttsModel = modelFile
|
||||
}
|
||||
|
||||
Expect(v.ttsModel).To(Equal(tts))
|
||||
Expect(v.tokenizer).To(Equal(tok))
|
||||
Expect(v.voice).To(Equal(voice))
|
||||
Expect(v.asrModel).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("closes the channel and errors on AudioTranscriptionStream without a loaded model", func() {
|
||||
ch := make(chan *pb.TranscriptStreamResponse, 4)
|
||||
err := (&VibevoiceCpp{}).AudioTranscriptionStream(&pb.TranscriptRequest{
|
||||
Dst: "/tmp/some.wav",
|
||||
}, ch)
|
||||
Expect(err).To(HaveOccurred())
|
||||
_, ok := <-ch
|
||||
Expect(ok).To(BeFalse(), "AudioTranscriptionStream must close results channel even on error")
|
||||
})
|
||||
})
|
||||
|
||||
Context("gRPC server lifecycle", func() {
|
||||
var cmd *exec.Cmd
|
||||
|
||||
AfterEach(func() {
|
||||
stopServer(cmd)
|
||||
cmd = nil
|
||||
})
|
||||
|
||||
It("answers Health checks", func() {
|
||||
cmd = startServer()
|
||||
conn := dialGRPC()
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
resp, err := pb.NewBackendClient(conn).Health(context.Background(), &pb.HealthMessage{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(resp.Message)).To(Equal("OK"))
|
||||
})
|
||||
|
||||
It("loads the realtime TTS model", func() {
|
||||
dir := modelDirOrSkip()
|
||||
tts, _ := filepath.Glob(filepath.Join(dir, "vibevoice-realtime-*.gguf"))
|
||||
if len(tts) == 0 {
|
||||
Skip("realtime TTS gguf missing")
|
||||
}
|
||||
|
||||
cmd = startServer()
|
||||
conn := dialGRPC()
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
// Mirror the gallery contract: ModelFile is whatever LocalAI
|
||||
// core hands us; ModelPath is the models root; Options[]
|
||||
// carry paths relative to ModelPath.
|
||||
resp, err := pb.NewBackendClient(conn).LoadModel(context.Background(), &pb.ModelOptions{
|
||||
ModelFile: filepath.Base(tts[0]),
|
||||
ModelPath: dir,
|
||||
Threads: 4,
|
||||
Options: []string{"tokenizer=tokenizer.gguf"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Success).To(BeTrue(), "LoadModel msg=%q", resp.Message)
|
||||
})
|
||||
|
||||
It("runs a closed-loop TTS -> ASR with >=80% word recall", func() {
|
||||
dir := modelDirOrSkip()
|
||||
tts, _ := filepath.Glob(filepath.Join(dir, "vibevoice-realtime-*.gguf"))
|
||||
asr, _ := filepath.Glob(filepath.Join(dir, "vibevoice-asr-*.gguf"))
|
||||
if len(tts) == 0 || len(asr) == 0 {
|
||||
Skip("closed-loop needs both realtime TTS and ASR ggufs")
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "vibevoice-cpp-closedloop-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
DeferCleanup(func() { _ = os.RemoveAll(tmpDir) })
|
||||
wav := filepath.Join(tmpDir, "say.wav")
|
||||
|
||||
cmd = startServer()
|
||||
conn := dialGRPC()
|
||||
defer func() { _ = conn.Close() }()
|
||||
client := pb.NewBackendClient(conn)
|
||||
|
||||
// Gallery convention: ModelPath is the models root, every
|
||||
// path inside Options[] is relative to it.
|
||||
voiceMatches, _ := filepath.Glob(filepath.Join(dir, "voice-*.gguf"))
|
||||
loadOpts := &pb.ModelOptions{
|
||||
ModelFile: filepath.Base(tts[0]),
|
||||
ModelPath: dir,
|
||||
Threads: 4,
|
||||
Options: []string{
|
||||
"asr_model=" + filepath.Base(asr[0]),
|
||||
"tokenizer=tokenizer.gguf",
|
||||
},
|
||||
}
|
||||
if len(voiceMatches) > 0 {
|
||||
loadOpts.Options = append(loadOpts.Options, "voice="+filepath.Base(voiceMatches[0]))
|
||||
}
|
||||
loadResp, err := client.LoadModel(context.Background(), loadOpts)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(loadResp.Success).To(BeTrue(), "LoadModel msg=%q", loadResp.Message)
|
||||
|
||||
srcText := "Hello world this is a test of the synthesis system."
|
||||
_, err = client.TTS(context.Background(), &pb.TTSRequest{
|
||||
Text: srcText,
|
||||
Dst: wav,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
info, err := os.Stat(wav)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(info.Size()).To(BeNumerically(">=", 1000),
|
||||
"TTS produced suspiciously small wav (%d bytes)", info.Size())
|
||||
|
||||
resp, err := client.AudioTranscription(context.Background(), &pb.TranscriptRequest{
|
||||
Dst: wav,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
got := strings.ToLower(resp.Text)
|
||||
GinkgoWriter.Printf("source : %s\n", srcText)
|
||||
GinkgoWriter.Printf("transcribed: %s\n", got)
|
||||
|
||||
wordRE := regexp.MustCompile(`[a-z]+`)
|
||||
srcWords := wordRE.FindAllString(strings.ToLower(srcText), -1)
|
||||
Expect(srcWords).ToNot(BeEmpty())
|
||||
hits := 0
|
||||
for _, w := range srcWords {
|
||||
if strings.Contains(got, w) {
|
||||
hits++
|
||||
}
|
||||
}
|
||||
recall := float64(hits) / float64(len(srcWords))
|
||||
GinkgoWriter.Printf("recall: %d/%d = %.2f%%\n", hits, len(srcWords), recall*100)
|
||||
Expect(recall).To(BeNumerically(">=", 0.80),
|
||||
"closed-loop recall too low: %d/%d = %.2f%%",
|
||||
hits, len(srcWords), recall*100)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@ set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||
|
||||
add_subdirectory(./sources/whisper.cpp)
|
||||
|
||||
add_library(gowhisper MODULE gowhisper.cpp)
|
||||
add_library(gowhisper MODULE cpp/gowhisper.cpp)
|
||||
target_link_libraries(gowhisper PRIVATE whisper ggml)
|
||||
|
||||
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU" AND CMAKE_CXX_COMPILER_VERSION VERSION_LESS 9.0)
|
||||
|
||||
@@ -111,7 +111,7 @@ libgowhisper-fallback.so: sources/whisper.cpp
|
||||
SO_TARGET=libgowhisper-fallback.so CMAKE_ARGS="$(CMAKE_ARGS) -DGGML_AVX=off -DGGML_AVX2=off -DGGML_AVX512=off -DGGML_FMA=off -DGGML_F16C=off -DGGML_BMI2=off" $(MAKE) libgowhisper-custom
|
||||
rm -rfv build*
|
||||
|
||||
libgowhisper-custom: CMakeLists.txt gowhisper.cpp gowhisper.h
|
||||
libgowhisper-custom: CMakeLists.txt cpp/gowhisper.cpp cpp/gowhisper.h
|
||||
mkdir -p build-$(SO_TARGET) && \
|
||||
cd build-$(SO_TARGET) && \
|
||||
cmake .. $(CMAKE_ARGS) && \
|
||||
|
||||
@@ -572,6 +572,34 @@
|
||||
nvidia-l4t: "nvidia-l4t-arm64-qwen3-tts-cpp"
|
||||
nvidia-l4t-cuda-12: "nvidia-l4t-arm64-qwen3-tts-cpp"
|
||||
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-qwen3-tts-cpp"
|
||||
- &vibevoicecpp
|
||||
name: "vibevoice-cpp"
|
||||
description: |
|
||||
vibevoice.cpp C++ backend using GGML. Native C++ port of Microsoft VibeVoice for both
|
||||
text-to-speech (with voice cloning via voice prompt GGUFs) and long-form ASR with
|
||||
speaker diarization. Outputs 24kHz mono WAV; ASR returns per-speaker JSON segments.
|
||||
urls:
|
||||
- https://github.com/mudler/vibevoice.cpp
|
||||
tags:
|
||||
- text-to-speech
|
||||
- tts
|
||||
- speech-to-text
|
||||
- asr
|
||||
- voice-cloning
|
||||
- diarization
|
||||
alias: "vibevoice-cpp"
|
||||
capabilities:
|
||||
default: "cpu-vibevoice-cpp"
|
||||
nvidia: "cuda12-vibevoice-cpp"
|
||||
nvidia-cuda-13: "cuda13-vibevoice-cpp"
|
||||
nvidia-cuda-12: "cuda12-vibevoice-cpp"
|
||||
intel: "intel-sycl-f16-vibevoice-cpp"
|
||||
metal: "metal-vibevoice-cpp"
|
||||
amd: "rocm-vibevoice-cpp"
|
||||
vulkan: "vulkan-vibevoice-cpp"
|
||||
nvidia-l4t: "nvidia-l4t-arm64-vibevoice-cpp"
|
||||
nvidia-l4t-cuda-12: "nvidia-l4t-arm64-vibevoice-cpp"
|
||||
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-vibevoice-cpp"
|
||||
- &faster-whisper
|
||||
icon: https://avatars.githubusercontent.com/u/1520500?s=200&v=4
|
||||
description: |
|
||||
@@ -2656,6 +2684,107 @@
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-nvidia-cuda-13-qwen3-tts-cpp"
|
||||
mirrors:
|
||||
- localai/localai-backends:master-gpu-nvidia-cuda-13-qwen3-tts-cpp
|
||||
## vibevoice-cpp
|
||||
- !!merge <<: *vibevoicecpp
|
||||
name: "nvidia-l4t-arm64-vibevoice-cpp"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:latest-nvidia-l4t-arm64-vibevoice-cpp"
|
||||
mirrors:
|
||||
- localai/localai-backends:latest-nvidia-l4t-arm64-vibevoice-cpp
|
||||
- !!merge <<: *vibevoicecpp
|
||||
name: "nvidia-l4t-arm64-vibevoice-cpp-development"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-nvidia-l4t-arm64-vibevoice-cpp"
|
||||
mirrors:
|
||||
- localai/localai-backends:master-nvidia-l4t-arm64-vibevoice-cpp
|
||||
- !!merge <<: *vibevoicecpp
|
||||
name: "cuda13-nvidia-l4t-arm64-vibevoice-cpp"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:latest-nvidia-l4t-cuda-13-arm64-vibevoice-cpp"
|
||||
mirrors:
|
||||
- localai/localai-backends:latest-nvidia-l4t-cuda-13-arm64-vibevoice-cpp
|
||||
- !!merge <<: *vibevoicecpp
|
||||
name: "cuda13-nvidia-l4t-arm64-vibevoice-cpp-development"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-nvidia-l4t-cuda-13-arm64-vibevoice-cpp"
|
||||
mirrors:
|
||||
- localai/localai-backends:master-nvidia-l4t-cuda-13-arm64-vibevoice-cpp
|
||||
- !!merge <<: *vibevoicecpp
|
||||
name: "cpu-vibevoice-cpp"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:latest-cpu-vibevoice-cpp"
|
||||
mirrors:
|
||||
- localai/localai-backends:latest-cpu-vibevoice-cpp
|
||||
- !!merge <<: *vibevoicecpp
|
||||
name: "metal-vibevoice-cpp"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:latest-metal-darwin-arm64-vibevoice-cpp"
|
||||
mirrors:
|
||||
- localai/localai-backends:latest-metal-darwin-arm64-vibevoice-cpp
|
||||
- !!merge <<: *vibevoicecpp
|
||||
name: "metal-vibevoice-cpp-development"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-metal-darwin-arm64-vibevoice-cpp"
|
||||
mirrors:
|
||||
- localai/localai-backends:master-metal-darwin-arm64-vibevoice-cpp
|
||||
- !!merge <<: *vibevoicecpp
|
||||
name: "cpu-vibevoice-cpp-development"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-cpu-vibevoice-cpp"
|
||||
mirrors:
|
||||
- localai/localai-backends:master-cpu-vibevoice-cpp
|
||||
- !!merge <<: *vibevoicecpp
|
||||
name: "cuda12-vibevoice-cpp"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-nvidia-cuda-12-vibevoice-cpp"
|
||||
mirrors:
|
||||
- localai/localai-backends:latest-gpu-nvidia-cuda-12-vibevoice-cpp
|
||||
- !!merge <<: *vibevoicecpp
|
||||
name: "rocm-vibevoice-cpp"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-rocm-hipblas-vibevoice-cpp"
|
||||
mirrors:
|
||||
- localai/localai-backends:latest-gpu-rocm-hipblas-vibevoice-cpp
|
||||
- !!merge <<: *vibevoicecpp
|
||||
name: "intel-sycl-f32-vibevoice-cpp"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-intel-sycl-f32-vibevoice-cpp"
|
||||
mirrors:
|
||||
- localai/localai-backends:latest-gpu-intel-sycl-f32-vibevoice-cpp
|
||||
- !!merge <<: *vibevoicecpp
|
||||
name: "intel-sycl-f16-vibevoice-cpp"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-intel-sycl-f16-vibevoice-cpp"
|
||||
mirrors:
|
||||
- localai/localai-backends:latest-gpu-intel-sycl-f16-vibevoice-cpp
|
||||
- !!merge <<: *vibevoicecpp
|
||||
name: "vulkan-vibevoice-cpp"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-vulkan-vibevoice-cpp"
|
||||
mirrors:
|
||||
- localai/localai-backends:latest-gpu-vulkan-vibevoice-cpp
|
||||
- !!merge <<: *vibevoicecpp
|
||||
name: "vulkan-vibevoice-cpp-development"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-vulkan-vibevoice-cpp"
|
||||
mirrors:
|
||||
- localai/localai-backends:master-gpu-vulkan-vibevoice-cpp
|
||||
- !!merge <<: *vibevoicecpp
|
||||
name: "cuda12-vibevoice-cpp-development"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-nvidia-cuda-12-vibevoice-cpp"
|
||||
mirrors:
|
||||
- localai/localai-backends:master-gpu-nvidia-cuda-12-vibevoice-cpp
|
||||
- !!merge <<: *vibevoicecpp
|
||||
name: "rocm-vibevoice-cpp-development"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-rocm-hipblas-vibevoice-cpp"
|
||||
mirrors:
|
||||
- localai/localai-backends:master-gpu-rocm-hipblas-vibevoice-cpp
|
||||
- !!merge <<: *vibevoicecpp
|
||||
name: "intel-sycl-f32-vibevoice-cpp-development"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-intel-sycl-f32-vibevoice-cpp"
|
||||
mirrors:
|
||||
- localai/localai-backends:master-gpu-intel-sycl-f32-vibevoice-cpp
|
||||
- !!merge <<: *vibevoicecpp
|
||||
name: "intel-sycl-f16-vibevoice-cpp-development"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-intel-sycl-f16-vibevoice-cpp"
|
||||
mirrors:
|
||||
- localai/localai-backends:master-gpu-intel-sycl-f16-vibevoice-cpp
|
||||
- !!merge <<: *vibevoicecpp
|
||||
name: "cuda13-vibevoice-cpp"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-nvidia-cuda-13-vibevoice-cpp"
|
||||
mirrors:
|
||||
- localai/localai-backends:latest-gpu-nvidia-cuda-13-vibevoice-cpp
|
||||
- !!merge <<: *vibevoicecpp
|
||||
name: "cuda13-vibevoice-cpp-development"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-nvidia-cuda-13-vibevoice-cpp"
|
||||
mirrors:
|
||||
- localai/localai-backends:master-gpu-nvidia-cuda-13-vibevoice-cpp
|
||||
## kokoro
|
||||
- !!merge <<: *kokoro
|
||||
name: "kokoro-development"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
grpcio==1.80.0
|
||||
protobuf
|
||||
certifi
|
||||
packaging==24.1
|
||||
packaging==26.2
|
||||
@@ -40,7 +40,19 @@ from diffusers import DiffusionPipeline, ControlNetModel
|
||||
from diffusers import FluxPipeline, FluxTransformer2DModel, AutoencoderKLWan
|
||||
from diffusers.pipelines.stable_diffusion import safety_checker
|
||||
from diffusers.utils import load_image, export_to_video
|
||||
from compel import Compel, ReturnedEmbeddingsType
|
||||
# TODO: re-enable compel as a hard dependency once it supports transformers >= 5.
|
||||
# Tracking upstream: https://github.com/damian0815/compel/pull/129
|
||||
# and https://github.com/damian0815/compel/issues/128
|
||||
# Until then compel pins transformers ~= 4.25, which forces the pip resolver into
|
||||
# multi-hour backtracking storms in CI when DEPS_REFRESH rotates the cache.
|
||||
# Keep the import optional and gate usage on the COMPEL env var (set COMPEL=1 to opt in).
|
||||
try:
|
||||
from compel import Compel, ReturnedEmbeddingsType
|
||||
COMPEL_AVAILABLE = True
|
||||
except ImportError:
|
||||
Compel = None
|
||||
ReturnedEmbeddingsType = None
|
||||
COMPEL_AVAILABLE = False
|
||||
from optimum.quanto import freeze, qfloat8, quantize
|
||||
from transformers import T5EncoderModel
|
||||
from safetensors.torch import load_file
|
||||
@@ -66,6 +78,9 @@ from diffusers import LTX2VideoTransformer3DModel, GGUFQuantizationConfig
|
||||
|
||||
_ONE_DAY_IN_SECONDS = 60 * 60 * 24
|
||||
COMPEL = os.environ.get("COMPEL", "0") == "1"
|
||||
if COMPEL and not COMPEL_AVAILABLE:
|
||||
print("WARNING: COMPEL is enabled but the compel module is not installed. Install it manually (`pip install compel`) or unset COMPEL. Falling back to standard prompt processing.", file=sys.stderr)
|
||||
COMPEL = False
|
||||
SD_EMBED = os.environ.get("SD_EMBED", "0") == "1"
|
||||
# Warn if SD_EMBED is enabled but the module is not available
|
||||
if SD_EMBED and not SD_EMBED_AVAILABLE:
|
||||
|
||||
@@ -4,10 +4,15 @@ opencv-python
|
||||
transformers
|
||||
torchvision==0.22.1
|
||||
accelerate
|
||||
compel
|
||||
git+https://github.com/xhinker/sd_embed
|
||||
peft
|
||||
sentencepiece
|
||||
torch==2.7.1
|
||||
optimum-quanto
|
||||
ftfy
|
||||
ftfy
|
||||
# TODO: re-add compel once it supports transformers >= 5.
|
||||
# Tracking: https://github.com/damian0815/compel/pull/129
|
||||
# https://github.com/damian0815/compel/issues/128
|
||||
# compel currently pins transformers~=4.25, which forced pip into multi-hour
|
||||
# resolver backtracking storms in CI. backend.py imports it lazily and gates
|
||||
# the COMPEL=1 env var on the import succeeding, so dropping it here is safe.
|
||||
@@ -4,10 +4,15 @@ opencv-python
|
||||
transformers
|
||||
torchvision
|
||||
accelerate
|
||||
compel
|
||||
git+https://github.com/xhinker/sd_embed
|
||||
peft
|
||||
sentencepiece
|
||||
torch
|
||||
ftfy
|
||||
optimum-quanto
|
||||
# TODO: re-add compel once it supports transformers >= 5.
|
||||
# Tracking: https://github.com/damian0815/compel/pull/129
|
||||
# https://github.com/damian0815/compel/issues/128
|
||||
# compel currently pins transformers~=4.25, which forced pip into multi-hour
|
||||
# resolver backtracking storms in CI. backend.py imports it lazily and gates
|
||||
# the COMPEL=1 env var on the import succeeding, so dropping it here is safe.
|
||||
|
||||
@@ -4,10 +4,15 @@ opencv-python
|
||||
transformers
|
||||
torchvision
|
||||
accelerate
|
||||
compel
|
||||
git+https://github.com/xhinker/sd_embed
|
||||
peft
|
||||
sentencepiece
|
||||
torch
|
||||
ftfy
|
||||
optimum-quanto
|
||||
# TODO: re-add compel once it supports transformers >= 5.
|
||||
# Tracking: https://github.com/damian0815/compel/pull/129
|
||||
# https://github.com/damian0815/compel/issues/128
|
||||
# compel currently pins transformers~=4.25, which forced pip into multi-hour
|
||||
# resolver backtracking storms in CI. backend.py imports it lazily and gates
|
||||
# the COMPEL=1 env var on the import succeeding, so dropping it here is safe.
|
||||
|
||||
@@ -5,8 +5,13 @@ git+https://github.com/huggingface/diffusers
|
||||
opencv-python
|
||||
transformers
|
||||
accelerate
|
||||
compel
|
||||
peft
|
||||
sentencepiece
|
||||
optimum-quanto
|
||||
ftfy
|
||||
ftfy
|
||||
# TODO: re-add compel once it supports transformers >= 5.
|
||||
# Tracking: https://github.com/damian0815/compel/pull/129
|
||||
# https://github.com/damian0815/compel/issues/128
|
||||
# compel currently pins transformers~=4.25, which forced pip into multi-hour
|
||||
# resolver backtracking storms in CI. backend.py imports it lazily and gates
|
||||
# the COMPEL=1 env var on the import succeeding, so dropping it here is safe.
|
||||
@@ -7,9 +7,14 @@ git+https://github.com/huggingface/diffusers
|
||||
opencv-python
|
||||
transformers
|
||||
accelerate
|
||||
compel
|
||||
git+https://github.com/xhinker/sd_embed
|
||||
peft
|
||||
sentencepiece
|
||||
optimum-quanto
|
||||
ftfy
|
||||
ftfy
|
||||
# TODO: re-add compel once it supports transformers >= 5.
|
||||
# Tracking: https://github.com/damian0815/compel/pull/129
|
||||
# https://github.com/damian0815/compel/issues/128
|
||||
# compel currently pins transformers~=4.25, which forced pip into multi-hour
|
||||
# resolver backtracking storms in CI. backend.py imports it lazily and gates
|
||||
# the COMPEL=1 env var on the import succeeding, so dropping it here is safe.
|
||||
@@ -3,10 +3,15 @@ torch
|
||||
git+https://github.com/huggingface/diffusers
|
||||
transformers
|
||||
accelerate
|
||||
compel
|
||||
peft
|
||||
optimum-quanto
|
||||
numpy<2
|
||||
sentencepiece
|
||||
torchvision
|
||||
ftfy
|
||||
# TODO: re-add compel once it supports transformers >= 5.
|
||||
# Tracking: https://github.com/damian0815/compel/pull/129
|
||||
# https://github.com/damian0815/compel/issues/128
|
||||
# compel currently pins transformers~=4.25, which forced pip into multi-hour
|
||||
# resolver backtracking storms in CI. backend.py imports it lazily and gates
|
||||
# the COMPEL=1 env var on the import succeeding, so dropping it here is safe.
|
||||
|
||||
@@ -3,7 +3,6 @@ torch
|
||||
git+https://github.com/huggingface/diffusers
|
||||
transformers
|
||||
accelerate
|
||||
compel
|
||||
peft
|
||||
optimum-quanto
|
||||
numpy<2
|
||||
@@ -11,3 +10,9 @@ sentencepiece
|
||||
torchvision
|
||||
ftfy
|
||||
chardet
|
||||
# TODO: re-add compel once it supports transformers >= 5.
|
||||
# Tracking: https://github.com/damian0815/compel/pull/129
|
||||
# https://github.com/damian0815/compel/issues/128
|
||||
# compel currently pins transformers~=4.25, which forced pip into multi-hour
|
||||
# resolver backtracking storms in CI. backend.py imports it lazily and gates
|
||||
# the COMPEL=1 env var on the import succeeding, so dropping it here is safe.
|
||||
|
||||
@@ -4,8 +4,13 @@ git+https://github.com/huggingface/diffusers
|
||||
opencv-python
|
||||
transformers
|
||||
accelerate
|
||||
compel
|
||||
peft
|
||||
sentencepiece
|
||||
optimum-quanto
|
||||
ftfy
|
||||
ftfy
|
||||
# TODO: re-add compel once it supports transformers >= 5.
|
||||
# Tracking: https://github.com/damian0815/compel/pull/129
|
||||
# https://github.com/damian0815/compel/issues/128
|
||||
# compel currently pins transformers~=4.25, which forced pip into multi-hour
|
||||
# resolver backtracking storms in CI. backend.py imports it lazily and gates
|
||||
# the COMPEL=1 env var on the import succeeding, so dropping it here is safe.
|
||||
@@ -8,6 +8,7 @@ import argparse
|
||||
import signal
|
||||
import sys
|
||||
import os
|
||||
import tempfile
|
||||
import traceback
|
||||
import scipy.io.wavfile
|
||||
import backend_pb2
|
||||
@@ -204,7 +205,7 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
|
||||
# Save audio to file
|
||||
output_path = request.dst
|
||||
if not output_path:
|
||||
output_path = "/tmp/pocket-tts-output.wav"
|
||||
output_path = os.path.join(tempfile.gettempdir(), "pocket-tts-output.wav")
|
||||
|
||||
# Ensure output directory exists
|
||||
output_dir = os.path.dirname(output_path)
|
||||
|
||||
@@ -33,6 +33,7 @@ import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from concurrent import futures
|
||||
from pathlib import Path
|
||||
@@ -668,7 +669,7 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
|
||||
)
|
||||
arr = img_tensor.numpy()
|
||||
image = Image.fromarray(arr)
|
||||
dst = request.dst or "/tmp/tinygrad_image.png"
|
||||
dst = request.dst or os.path.join(tempfile.gettempdir(), "tinygrad_image.png")
|
||||
image.save(dst)
|
||||
return backend_pb2.Result(success=True, message=dst)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -19,6 +19,7 @@ import base64
|
||||
import io
|
||||
import json
|
||||
import gc
|
||||
import tempfile
|
||||
|
||||
from PIL import Image
|
||||
import torch
|
||||
@@ -117,7 +118,7 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
|
||||
# Try base64 decode
|
||||
try:
|
||||
timestamp = str(int(time.time() * 1000))
|
||||
p = f"/tmp/vl-{timestamp}.data"
|
||||
p = os.path.join(tempfile.gettempdir(), f"vl-{timestamp}.data")
|
||||
with open(p, "wb") as f:
|
||||
f.write(base64.b64decode(video_path))
|
||||
video = VideoAsset(name=p).np_ndarrays
|
||||
@@ -137,7 +138,7 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
|
||||
audio_data = base64.b64decode(audio_path)
|
||||
# Save to temp file and load
|
||||
timestamp = str(int(time.time() * 1000))
|
||||
p = f"/tmp/audio-{timestamp}.wav"
|
||||
p = os.path.join(tempfile.gettempdir(), f"audio-{timestamp}.wav")
|
||||
with open(p, "wb") as f:
|
||||
f.write(audio_data)
|
||||
audio_signal, sr = librosa.load(p, sr=16000)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import difflib
|
||||
from concurrent import futures
|
||||
import argparse
|
||||
import signal
|
||||
@@ -8,6 +10,7 @@ import os
|
||||
import json
|
||||
import time
|
||||
import gc
|
||||
import tempfile
|
||||
from typing import List
|
||||
from PIL import Image
|
||||
|
||||
@@ -101,6 +104,36 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
|
||||
opts[key.strip()] = value.strip()
|
||||
return opts
|
||||
|
||||
def _apply_engine_args(self, engine_args, engine_args_json):
|
||||
"""Apply user-supplied engine_args (JSON object) onto an AsyncEngineArgs.
|
||||
|
||||
Returns a new AsyncEngineArgs with the typed fields preserved and the
|
||||
user's overrides layered on top. Uses ``dataclasses.replace`` so vLLM's
|
||||
``__post_init__`` re-runs and auto-converts dict-valued fields like
|
||||
``compilation_config`` / ``attention_config`` into their dataclass form.
|
||||
``speculative_config`` and ``kv_transfer_config`` are accepted as dicts
|
||||
directly (vLLM converts them at engine init).
|
||||
|
||||
Unknown keys raise ValueError with the closest valid field as a hint.
|
||||
"""
|
||||
if not engine_args_json:
|
||||
return engine_args
|
||||
try:
|
||||
extra = json.loads(engine_args_json)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"engine_args is not valid JSON: {e}") from e
|
||||
if not isinstance(extra, dict):
|
||||
raise ValueError(
|
||||
f"engine_args must be a JSON object, got {type(extra).__name__}"
|
||||
)
|
||||
valid = {f.name for f in dataclasses.fields(type(engine_args))}
|
||||
for key in extra:
|
||||
if key not in valid:
|
||||
suggestion = difflib.get_close_matches(key, valid, n=1)
|
||||
hint = f" did you mean {suggestion[0]!r}?" if suggestion else ""
|
||||
raise ValueError(f"unknown engine_args key {key!r}.{hint}")
|
||||
return dataclasses.replace(engine_args, **extra)
|
||||
|
||||
def _messages_to_dicts(self, messages):
|
||||
"""Convert proto Messages to list of dicts suitable for apply_chat_template()."""
|
||||
result = []
|
||||
@@ -176,6 +209,15 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
|
||||
"audio": max(request.LimitAudioPerPrompt, 1)
|
||||
}
|
||||
|
||||
# engine_args from YAML overrides typed fields above so operators can
|
||||
# tune anything the AsyncEngineArgs dataclass exposes without waiting
|
||||
# on protobuf changes.
|
||||
try:
|
||||
engine_args = self._apply_engine_args(engine_args, request.EngineArgs)
|
||||
except ValueError as err:
|
||||
print(f"engine_args error: {err}", file=sys.stderr)
|
||||
return backend_pb2.Result(success=False, message=str(err))
|
||||
|
||||
try:
|
||||
self.llm = AsyncLLMEngine.from_engine_args(engine_args)
|
||||
except Exception as err:
|
||||
@@ -561,7 +603,7 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
|
||||
"""
|
||||
try:
|
||||
timestamp = str(int(time.time() * 1000)) # Generate timestamp
|
||||
p = f"/tmp/vl-{timestamp}.data" # Use timestamp in filename
|
||||
p = os.path.join(tempfile.gettempdir(), f"vl-{timestamp}.data")
|
||||
with open(p, "wb") as f:
|
||||
f.write(base64.b64decode(video_path))
|
||||
video = VideoAsset(name=p).np_ndarrays
|
||||
|
||||
@@ -32,6 +32,14 @@ if [ "x${BUILD_PROFILE}" == "xcpu" ]; then
|
||||
EXTRA_PIP_INSTALL_FLAGS+=" --index-strategy=unsafe-best-match"
|
||||
fi
|
||||
|
||||
# cublas13 pulls the vLLM wheel from a per-tag cu130 index (PyPI's vllm wheel
|
||||
# is built against CUDA 12 and won't load on cu130). uv's default per-package
|
||||
# first-match strategy would still pick the PyPI wheel, so allow it to consult
|
||||
# every configured index when resolving.
|
||||
if [ "x${BUILD_PROFILE}" == "xcublas13" ]; then
|
||||
EXTRA_PIP_INSTALL_FLAGS+=" --index-strategy=unsafe-best-match"
|
||||
fi
|
||||
|
||||
# JetPack 7 / L4T arm64 wheels (torch, vllm, flash-attn) live on
|
||||
# pypi.jetson-ai-lab.io and are built for cp312, so bump the venv Python
|
||||
# accordingly. JetPack 6 keeps cp310 + USE_PIP=true. unsafe-best-match
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
--extra-index-url https://download.pytorch.org/whl/cu130
|
||||
vllm
|
||||
# vLLM's PyPI wheel is built against CUDA 12 (libcudart.so.12) and won't load
|
||||
# on a cu130 host. Pull the cu130-flavoured wheel from vLLM's per-tag index
|
||||
# instead — the cublas13 case in install.sh adds --index-strategy=unsafe-best-match
|
||||
# so uv consults this index alongside PyPI.
|
||||
--extra-index-url https://wheels.vllm.ai/0.20.0/cu130
|
||||
vllm==0.20.0
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# mabma does not specify it's build dependencies per PEP517, so we need to disable build isolation
|
||||
# this also means that we need to install the basic build dependencies into the venv ourselves
|
||||
# https://github.com/Dao-AILab/causal-conv1d/issues/24
|
||||
#
|
||||
# pybind11 is needed to build fastsafetensors==0.3 (transitive dep of vllm) — its
|
||||
# setup.py imports pybind11 unconditionally but doesn't declare it in
|
||||
# build-system.requires, so under --no-build-isolation we have to seed it ourselves.
|
||||
packaging
|
||||
setuptools
|
||||
wheel
|
||||
wheel
|
||||
pybind11
|
||||
@@ -168,6 +168,58 @@ class TestBackendServicer(unittest.TestCase):
|
||||
self.assertEqual(opts["key_with_colons"], "a:b:c")
|
||||
self.assertNotIn("invalid_no_colon", opts)
|
||||
|
||||
def test_apply_engine_args_known_keys(self):
|
||||
"""
|
||||
Tests _apply_engine_args overlays user-supplied JSON onto AsyncEngineArgs.
|
||||
"""
|
||||
import sys, os, json as _json
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from backend import BackendServicer
|
||||
from vllm.engine.arg_utils import AsyncEngineArgs
|
||||
|
||||
servicer = BackendServicer()
|
||||
base = AsyncEngineArgs(model="facebook/opt-125m")
|
||||
extras = _json.dumps({
|
||||
"trust_remote_code": True,
|
||||
"max_num_seqs": 32,
|
||||
})
|
||||
out = servicer._apply_engine_args(base, extras)
|
||||
self.assertTrue(out.trust_remote_code)
|
||||
self.assertEqual(out.max_num_seqs, 32)
|
||||
# untouched fields preserved
|
||||
self.assertEqual(out.model, "facebook/opt-125m")
|
||||
|
||||
def test_apply_engine_args_unknown_key_raises(self):
|
||||
"""
|
||||
Tests _apply_engine_args rejects unknown keys with a helpful suggestion.
|
||||
"""
|
||||
import sys, os, json as _json
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from backend import BackendServicer
|
||||
from vllm.engine.arg_utils import AsyncEngineArgs
|
||||
|
||||
servicer = BackendServicer()
|
||||
base = AsyncEngineArgs(model="facebook/opt-125m")
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
servicer._apply_engine_args(base, _json.dumps({"trustremotecode": True}))
|
||||
self.assertIn("trustremotecode", str(ctx.exception))
|
||||
# close-match hint for the typo
|
||||
self.assertIn("trust_remote_code", str(ctx.exception))
|
||||
|
||||
def test_apply_engine_args_empty_passthrough(self):
|
||||
"""
|
||||
Tests that empty engine_args returns the base unchanged.
|
||||
"""
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from backend import BackendServicer
|
||||
from vllm.engine.arg_utils import AsyncEngineArgs
|
||||
|
||||
servicer = BackendServicer()
|
||||
base = AsyncEngineArgs(model="facebook/opt-125m")
|
||||
self.assertIs(servicer._apply_engine_args(base, ""), base)
|
||||
self.assertIs(servicer._apply_engine_args(base, None), base)
|
||||
|
||||
def test_tokenize_string(self):
|
||||
"""
|
||||
Tests the TokenizeString RPC returns valid tokens.
|
||||
|
||||
@@ -17,7 +17,10 @@ import (
|
||||
"github.com/mudler/LocalAI/core/services/voicerecognition"
|
||||
"github.com/mudler/LocalAI/core/templates"
|
||||
pkggrpc "github.com/mudler/LocalAI/pkg/grpc"
|
||||
localaitools "github.com/mudler/LocalAI/pkg/mcp/localaitools"
|
||||
localaiInproc "github.com/mudler/LocalAI/pkg/mcp/localaitools/inproc"
|
||||
"github.com/mudler/LocalAI/pkg/model"
|
||||
"github.com/mudler/LocalAI/pkg/signals"
|
||||
"github.com/mudler/xlog"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -60,6 +63,10 @@ type Application struct {
|
||||
|
||||
// Upgrade checker (background service for detecting backend upgrades)
|
||||
upgradeChecker *UpgradeChecker
|
||||
|
||||
// LocalAI Assistant in-process MCP server. nil when DisableLocalAIAssistant
|
||||
// is set; otherwise initialised in start() after galleryService.
|
||||
localAIAssistant *mcpTools.LocalAIAssistantHolder
|
||||
}
|
||||
|
||||
func newApplication(appConfig *config.ApplicationConfig) *Application {
|
||||
@@ -137,6 +144,13 @@ func (a *Application) UpgradeChecker() *UpgradeChecker {
|
||||
return a.upgradeChecker
|
||||
}
|
||||
|
||||
// LocalAIAssistant returns the in-process MCP holder used by the chat handler
|
||||
// when an admin opts into the assistant modality. Returns nil when the feature
|
||||
// is disabled at startup.
|
||||
func (a *Application) LocalAIAssistant() *mcpTools.LocalAIAssistantHolder {
|
||||
return a.localAIAssistant
|
||||
}
|
||||
|
||||
// distributedDB returns the PostgreSQL database for distributed coordination,
|
||||
// or nil in standalone mode.
|
||||
func (a *Application) distributedDB() *gorm.DB {
|
||||
@@ -230,6 +244,32 @@ func (a *Application) start() error {
|
||||
|
||||
a.galleryService = galleryService
|
||||
|
||||
// LocalAI Assistant: in-process MCP server exposing admin tools. Initialised
|
||||
// once at startup and reused across chat sessions that opt in via metadata.
|
||||
if !a.applicationConfig.DisableLocalAIAssistant {
|
||||
holder := mcpTools.NewLocalAIAssistantHolder()
|
||||
assistantClient := localaiInproc.New(
|
||||
a.applicationConfig,
|
||||
a.applicationConfig.SystemState,
|
||||
a.backendLoader,
|
||||
a.modelLoader,
|
||||
a.galleryService,
|
||||
)
|
||||
if err := holder.Initialize(a.applicationConfig.Context, assistantClient, localaitools.Options{}); err != nil {
|
||||
// Why log+continue instead of fail: the assistant is an optional
|
||||
// feature; a failure here must not take down the whole server.
|
||||
xlog.Warn("LocalAI Assistant initialisation failed; feature unavailable", "error", err)
|
||||
} else {
|
||||
a.localAIAssistant = holder
|
||||
// Tear the in-memory transport pair down on SIGINT/SIGTERM so the
|
||||
// goroutine ends cleanly. Mirrors how core/http/endpoints/mcp/tools.go
|
||||
// closes its per-model MCP sessions on graceful termination.
|
||||
signals.RegisterGracefulTerminationHandler(func() {
|
||||
_ = holder.Close()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize agent job service (Start() is deferred to after distributed wiring)
|
||||
agentJobService := agentpool.NewAgentJobService(
|
||||
a.ApplicationConfig(),
|
||||
|
||||
13
core/application/application_suite_test.go
Normal file
13
core/application/application_suite_test.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestApplication(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Application test suite")
|
||||
}
|
||||
171
core/application/runtime_settings_branding_test.go
Normal file
171
core/application/runtime_settings_branding_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
)
|
||||
|
||||
// seedSettings writes the given JSON fragment to runtime_settings.json
|
||||
// under a fresh temp DynamicConfigsDir and returns the directory path.
|
||||
func seedSettings(json string) string {
|
||||
dir := GinkgoT().TempDir()
|
||||
Expect(os.WriteFile(filepath.Join(dir, "runtime_settings.json"), []byte(json), 0o600)).To(Succeed())
|
||||
return dir
|
||||
}
|
||||
|
||||
var _ = Describe("loadRuntimeSettingsFromFile", func() {
|
||||
// Reproduces the "settings revert after restart" report: an admin
|
||||
// sets a branding instance name + uploads a logo, the values are
|
||||
// persisted to runtime_settings.json, but on the next startup
|
||||
// loadRuntimeSettingsFromFile() did not read those fields back so
|
||||
// appConfig.Branding stayed zero and the public /api/branding
|
||||
// endpoint fell back to LocalAI defaults.
|
||||
Describe("branding fields", func() {
|
||||
It("loads instance name, tagline, and asset basenames", func() {
|
||||
dir := seedSettings(`{
|
||||
"instance_name": "Acme AI",
|
||||
"instance_tagline": "Private inference",
|
||||
"logo_file": "logo.png",
|
||||
"logo_horizontal_file": "logo_horizontal.svg",
|
||||
"favicon_file": "favicon.ico"
|
||||
}`)
|
||||
|
||||
cfg := &config.ApplicationConfig{DynamicConfigsDir: dir}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
|
||||
Expect(cfg.Branding).To(Equal(config.BrandingConfig{
|
||||
InstanceName: "Acme AI",
|
||||
InstanceTagline: "Private inference",
|
||||
LogoFile: "logo.png",
|
||||
LogoHorizontalFile: "logo_horizontal.svg",
|
||||
FaviconFile: "favicon.ico",
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
// Adjacent fields exercise the other classes of settings that
|
||||
// previously silently reverted on restart. Each spec pairs a
|
||||
// runtime_settings.json fragment with the expected ApplicationConfig
|
||||
// state after the loader runs. A regression in any one means a
|
||||
// UI-saved setting will not survive a process restart — same shape as
|
||||
// the branding bug, different field.
|
||||
//
|
||||
// Where a field has a non-zero default (set by NewApplicationConfig),
|
||||
// the spec seeds the post-AppOptions state the loader would observe
|
||||
// at boot. Without that setup the "if at default" gate would either
|
||||
// always pass or always fail and the spec wouldn't reflect the real
|
||||
// call site.
|
||||
Describe("adjacent restart-loss fields", func() {
|
||||
It("loads auto_upgrade_backends", func() {
|
||||
cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"auto_upgrade_backends": true}`)}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
Expect(cfg.AutoUpgradeBackends).To(BeTrue())
|
||||
})
|
||||
|
||||
It("loads prefer_development_backends", func() {
|
||||
cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"prefer_development_backends": true}`)}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
Expect(cfg.PreferDevelopmentBackends).To(BeTrue())
|
||||
})
|
||||
|
||||
It("disables the LocalAI Assistant when localai_assistant_enabled=false", func() {
|
||||
cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"localai_assistant_enabled": false}`)}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
Expect(cfg.DisableLocalAIAssistant).To(BeTrue())
|
||||
})
|
||||
|
||||
It("loads open_responses_store_ttl as a duration", func() {
|
||||
cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"open_responses_store_ttl": "1h"}`)}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
Expect(cfg.OpenResponsesStoreTTL).To(Equal(time.Hour))
|
||||
})
|
||||
})
|
||||
|
||||
// The Agent Pool block has a mix of zero and non-zero defaults
|
||||
// (Enabled=true, EmbeddingModel="granite-...", MaxChunkingSize=400,
|
||||
// VectorEngine="chromem", AgentHubURL="https://agenthub.localai.io").
|
||||
// Each spec seeds the appropriate startup state so the loader's
|
||||
// "at default" check observes what New() would.
|
||||
Describe("agent pool fields", func() {
|
||||
It("loads agent_pool_enabled=false against the default-true", func() {
|
||||
cfg := &config.ApplicationConfig{
|
||||
DynamicConfigsDir: seedSettings(`{"agent_pool_enabled": false}`),
|
||||
AgentPool: config.AgentPoolConfig{Enabled: true},
|
||||
}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
Expect(cfg.AgentPool.Enabled).To(BeFalse())
|
||||
})
|
||||
|
||||
It("loads agent_pool_default_model", func() {
|
||||
cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"agent_pool_default_model": "qwen2.5-7b"}`)}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
Expect(cfg.AgentPool.DefaultModel).To(Equal("qwen2.5-7b"))
|
||||
})
|
||||
|
||||
It("overrides the granite embedding default", func() {
|
||||
cfg := &config.ApplicationConfig{
|
||||
DynamicConfigsDir: seedSettings(`{"agent_pool_embedding_model": "all-minilm"}`),
|
||||
AgentPool: config.AgentPoolConfig{EmbeddingModel: "granite-embedding-107m-multilingual"},
|
||||
}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
Expect(cfg.AgentPool.EmbeddingModel).To(Equal("all-minilm"))
|
||||
})
|
||||
|
||||
It("overrides the 400 max chunking size default", func() {
|
||||
cfg := &config.ApplicationConfig{
|
||||
DynamicConfigsDir: seedSettings(`{"agent_pool_max_chunking_size": 800}`),
|
||||
AgentPool: config.AgentPoolConfig{MaxChunkingSize: 400},
|
||||
}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
Expect(cfg.AgentPool.MaxChunkingSize).To(Equal(800))
|
||||
})
|
||||
|
||||
It("loads agent_pool_chunk_overlap", func() {
|
||||
cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"agent_pool_chunk_overlap": 50}`)}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
Expect(cfg.AgentPool.ChunkOverlap).To(Equal(50))
|
||||
})
|
||||
|
||||
It("loads agent_pool_enable_logs", func() {
|
||||
cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"agent_pool_enable_logs": true}`)}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
Expect(cfg.AgentPool.EnableLogs).To(BeTrue())
|
||||
})
|
||||
|
||||
It("loads agent_pool_collection_db_path", func() {
|
||||
cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"agent_pool_collection_db_path": "/var/lib/localai/collections.db"}`)}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
Expect(cfg.AgentPool.CollectionDBPath).To(Equal("/var/lib/localai/collections.db"))
|
||||
})
|
||||
|
||||
It("overrides the chromem vector_engine default", func() {
|
||||
cfg := &config.ApplicationConfig{
|
||||
DynamicConfigsDir: seedSettings(`{"agent_pool_vector_engine": "postgres"}`),
|
||||
AgentPool: config.AgentPoolConfig{VectorEngine: "chromem"},
|
||||
}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
Expect(cfg.AgentPool.VectorEngine).To(Equal("postgres"))
|
||||
})
|
||||
|
||||
It("loads agent_pool_database_url", func() {
|
||||
cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"agent_pool_database_url": "postgres://user:pass@db:5432/localai"}`)}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
Expect(cfg.AgentPool.DatabaseURL).To(Equal("postgres://user:pass@db:5432/localai"))
|
||||
})
|
||||
|
||||
It("overrides the agenthub.localai.io agent_hub_url default", func() {
|
||||
cfg := &config.ApplicationConfig{
|
||||
DynamicConfigsDir: seedSettings(`{"agent_pool_agent_hub_url": "https://hub.acme.io"}`),
|
||||
AgentPool: config.AgentPoolConfig{AgentHubURL: "https://agenthub.localai.io"},
|
||||
}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
Expect(cfg.AgentPool.AgentHubURL).To(Equal("https://hub.acme.io"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -548,6 +548,109 @@ func loadRuntimeSettingsFromFile(options *config.ApplicationConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
// Branding / whitelabeling. There are no env vars for these — the file is
|
||||
// the only source — so apply unconditionally. Without this block a server
|
||||
// restart silently drops the configured instance name, tagline, and asset
|
||||
// filenames.
|
||||
if settings.InstanceName != nil {
|
||||
options.Branding.InstanceName = *settings.InstanceName
|
||||
}
|
||||
if settings.InstanceTagline != nil {
|
||||
options.Branding.InstanceTagline = *settings.InstanceTagline
|
||||
}
|
||||
if settings.LogoFile != nil {
|
||||
options.Branding.LogoFile = *settings.LogoFile
|
||||
}
|
||||
if settings.LogoHorizontalFile != nil {
|
||||
options.Branding.LogoHorizontalFile = *settings.LogoHorizontalFile
|
||||
}
|
||||
if settings.FaviconFile != nil {
|
||||
options.Branding.FaviconFile = *settings.FaviconFile
|
||||
}
|
||||
|
||||
// Backend upgrade flags
|
||||
if settings.AutoUpgradeBackends != nil {
|
||||
if !options.AutoUpgradeBackends {
|
||||
options.AutoUpgradeBackends = *settings.AutoUpgradeBackends
|
||||
}
|
||||
}
|
||||
if settings.PreferDevelopmentBackends != nil {
|
||||
if !options.PreferDevelopmentBackends {
|
||||
options.PreferDevelopmentBackends = *settings.PreferDevelopmentBackends
|
||||
}
|
||||
}
|
||||
|
||||
// LocalAI Assistant — file-stored as the negation (LocalAIAssistantEnabled).
|
||||
// Default is enabled (DisableLocalAIAssistant=false). Apply the file value
|
||||
// unless env explicitly disabled the assistant (DisableLocalAIAssistant=true).
|
||||
if settings.LocalAIAssistantEnabled != nil {
|
||||
if !options.DisableLocalAIAssistant {
|
||||
options.DisableLocalAIAssistant = !*settings.LocalAIAssistantEnabled
|
||||
}
|
||||
}
|
||||
|
||||
// Open Responses TTL. Default is 0 (no expiration). Treat the on-disk
|
||||
// "0"/empty as "no expiration" — a no-op since options is already 0 —
|
||||
// and parse anything else as a duration.
|
||||
if settings.OpenResponsesStoreTTL != nil && options.OpenResponsesStoreTTL == 0 {
|
||||
v := *settings.OpenResponsesStoreTTL
|
||||
if v != "0" && v != "" {
|
||||
if dur, err := time.ParseDuration(v); err == nil {
|
||||
options.OpenResponsesStoreTTL = dur
|
||||
} else {
|
||||
xlog.Warn("invalid open_responses_store_ttl in runtime_settings.json", "error", err, "ttl", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Agent Pool. NewApplicationConfig seeds non-zero defaults for some of
|
||||
// these fields (Enabled=true, EmbeddingModel="granite-embedding-107m-
|
||||
// multilingual", MaxChunkingSize=400). The "if at default, apply file"
|
||||
// gate uses each field's actual default literal so file values can
|
||||
// override the bootstrap default while still letting an env-set value
|
||||
// (e.g. WithAgentPoolEmbeddingModel from a flag) win.
|
||||
if settings.AgentPoolEnabled != nil && options.AgentPool.Enabled {
|
||||
options.AgentPool.Enabled = *settings.AgentPoolEnabled
|
||||
}
|
||||
if settings.AgentPoolDefaultModel != nil && options.AgentPool.DefaultModel == "" {
|
||||
options.AgentPool.DefaultModel = *settings.AgentPoolDefaultModel
|
||||
}
|
||||
if settings.AgentPoolEmbeddingModel != nil {
|
||||
if options.AgentPool.EmbeddingModel == "" || options.AgentPool.EmbeddingModel == "granite-embedding-107m-multilingual" {
|
||||
options.AgentPool.EmbeddingModel = *settings.AgentPoolEmbeddingModel
|
||||
}
|
||||
}
|
||||
if settings.AgentPoolMaxChunkingSize != nil {
|
||||
if options.AgentPool.MaxChunkingSize == 0 || options.AgentPool.MaxChunkingSize == 400 {
|
||||
options.AgentPool.MaxChunkingSize = *settings.AgentPoolMaxChunkingSize
|
||||
}
|
||||
}
|
||||
if settings.AgentPoolChunkOverlap != nil && options.AgentPool.ChunkOverlap == 0 {
|
||||
options.AgentPool.ChunkOverlap = *settings.AgentPoolChunkOverlap
|
||||
}
|
||||
if settings.AgentPoolEnableLogs != nil && !options.AgentPool.EnableLogs {
|
||||
options.AgentPool.EnableLogs = *settings.AgentPoolEnableLogs
|
||||
}
|
||||
if settings.AgentPoolCollectionDBPath != nil && options.AgentPool.CollectionDBPath == "" {
|
||||
options.AgentPool.CollectionDBPath = *settings.AgentPoolCollectionDBPath
|
||||
}
|
||||
if settings.AgentPoolVectorEngine != nil {
|
||||
// Default is "chromem"; treat both that and empty as "not env-set".
|
||||
if options.AgentPool.VectorEngine == "" || options.AgentPool.VectorEngine == "chromem" {
|
||||
options.AgentPool.VectorEngine = *settings.AgentPoolVectorEngine
|
||||
}
|
||||
}
|
||||
if settings.AgentPoolDatabaseURL != nil && options.AgentPool.DatabaseURL == "" {
|
||||
options.AgentPool.DatabaseURL = *settings.AgentPoolDatabaseURL
|
||||
}
|
||||
if settings.AgentPoolAgentHubURL != nil {
|
||||
// Default is "https://agenthub.localai.io"; treat both that and empty
|
||||
// as "not env-set".
|
||||
if options.AgentPool.AgentHubURL == "" || options.AgentPool.AgentHubURL == "https://agenthub.localai.io" {
|
||||
options.AgentPool.AgentHubURL = *settings.AgentPoolAgentHubURL
|
||||
}
|
||||
}
|
||||
|
||||
xlog.Debug("Runtime settings loaded from runtime_settings.json")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -159,6 +161,19 @@ func grpcModelOpts(c config.ModelConfig, modelPath string) *pb.ModelOptions {
|
||||
})
|
||||
}
|
||||
|
||||
engineArgsJSON := ""
|
||||
if len(c.EngineArgs) > 0 {
|
||||
buf, err := json.Marshal(c.EngineArgs)
|
||||
if err != nil {
|
||||
// ModelConfig.Validate() rejects unmarshalable engine_args at
|
||||
// config load, so reaching here means the validator was bypassed.
|
||||
// Silently dropping user-set options would change runtime behaviour
|
||||
// without warning — fail loud instead.
|
||||
panic(fmt.Sprintf("engine_args marshal failed for model %q: %v (Validate() should have caught this)", c.Model, err))
|
||||
}
|
||||
engineArgsJSON = string(buf)
|
||||
}
|
||||
|
||||
opts := &pb.ModelOptions{
|
||||
CUDA: c.CUDA || c.Diffusers.CUDA,
|
||||
SchedulerType: c.Diffusers.SchedulerType,
|
||||
@@ -176,6 +191,7 @@ func grpcModelOpts(c config.ModelConfig, modelPath string) *pb.ModelOptions {
|
||||
CLIPSubfolder: c.Diffusers.ClipSubFolder,
|
||||
Options: c.Options,
|
||||
Overrides: c.Overrides,
|
||||
EngineArgs: engineArgsJSON,
|
||||
CLIPSkip: int32(c.Diffusers.ClipSkip),
|
||||
ControlNet: c.Diffusers.ControlNet,
|
||||
ContextSize: int32(ctxSize),
|
||||
|
||||
44
core/backend/options_internal_test.go
Normal file
44
core/backend/options_internal_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("grpcModelOpts EngineArgs", func() {
|
||||
It("serialises engine_args as JSON preserving nested values", func() {
|
||||
threads := 1
|
||||
cfg := config.ModelConfig{
|
||||
Threads: &threads,
|
||||
LLMConfig: config.LLMConfig{
|
||||
EngineArgs: map[string]any{
|
||||
"data_parallel_size": 8,
|
||||
"enable_expert_parallel": true,
|
||||
"speculative_config": map[string]any{
|
||||
"method": "ngram",
|
||||
"num_speculative_tokens": 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
opts := grpcModelOpts(cfg, "/tmp/models")
|
||||
Expect(opts.EngineArgs).NotTo(BeEmpty())
|
||||
|
||||
var round map[string]any
|
||||
Expect(json.Unmarshal([]byte(opts.EngineArgs), &round)).To(Succeed())
|
||||
Expect(round["data_parallel_size"]).To(BeEquivalentTo(8))
|
||||
Expect(round["enable_expert_parallel"]).To(BeTrue())
|
||||
Expect(round["speculative_config"]).To(HaveKeyWithValue("method", "ngram"))
|
||||
})
|
||||
|
||||
It("leaves EngineArgs empty when unset", func() {
|
||||
threads := 1
|
||||
opts := grpcModelOpts(config.ModelConfig{Threads: &threads}, "/tmp/models")
|
||||
Expect(opts.EngineArgs).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,7 @@ var CLI struct {
|
||||
AgentWorker AgentWorkerCMD `cmd:"" name:"agent-worker" help:"Start an agent worker for distributed mode (executes agent chats via NATS)"`
|
||||
Util UtilCMD `cmd:"" help:"Utility commands"`
|
||||
Agent AgentCMD `cmd:"" help:"Run agents standalone without the full LocalAI server"`
|
||||
MCPServer MCPServerCMD `cmd:"" name:"mcp-server" help:"Run the LocalAI admin tool surface as a stdio MCP server (controls a remote LocalAI instance over HTTP)"`
|
||||
Explorer ExplorerCMD `cmd:"" help:"Run p2p explorer"`
|
||||
Completion CompletionCMD `cmd:"" help:"Generate shell completion scripts for bash, zsh, or fish"`
|
||||
}
|
||||
|
||||
47
core/cli/mcp_server.go
Normal file
47
core/cli/mcp_server.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
cliContext "github.com/mudler/LocalAI/core/cli/context"
|
||||
localaitools "github.com/mudler/LocalAI/pkg/mcp/localaitools"
|
||||
"github.com/mudler/LocalAI/pkg/mcp/localaitools/httpapi"
|
||||
)
|
||||
|
||||
// MCPServerCMD runs the LocalAI admin tool surface as a stdio MCP server,
|
||||
// targeting a remote LocalAI instance over its HTTP API. The same Go package
|
||||
// that powers the in-process LocalAI Assistant chat modality is used here —
|
||||
// only the LocalAIClient implementation differs (httpapi instead of inproc).
|
||||
type MCPServerCMD struct {
|
||||
Target string `env:"LOCALAI_MCP_TARGET" default:"http://localhost:8080" help:"LocalAI base URL"`
|
||||
APIKey string `env:"LOCALAI_API_KEY" help:"Bearer API key for the target LocalAI"`
|
||||
ReadOnly bool `help:"Skip registration of mutating tools (install/delete/edit/upgrade/etc.) so the assistant can browse without changing remote state"`
|
||||
}
|
||||
|
||||
func (m *MCPServerCMD) Run(_ *cliContext.Context) error {
|
||||
if m.Target == "" {
|
||||
return fmt.Errorf("--target / LOCALAI_MCP_TARGET is required")
|
||||
}
|
||||
|
||||
client := httpapi.New(m.Target, m.APIKey)
|
||||
srv := localaitools.NewServer(client, localaitools.Options{
|
||||
DisableMutating: m.ReadOnly,
|
||||
})
|
||||
|
||||
// Stdio: the host (e.g. Claude Desktop, Cursor, mcphost) talks JSON-RPC
|
||||
// over our stdin/stdout. There's nothing else this process should print —
|
||||
// every other goroutine logging to stderr is fine, but stdout is sacred.
|
||||
//
|
||||
// Honour SIGINT/SIGTERM so a Ctrl-C from the host or `kill -TERM` from
|
||||
// process supervision gives srv.Run a chance to drain in-flight calls
|
||||
// before exiting.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
return srv.Run(ctx, &mcp.StdioTransport{})
|
||||
}
|
||||
@@ -101,6 +101,9 @@ type RunCMD struct {
|
||||
AgentJobRetentionDays int `env:"LOCALAI_AGENT_JOB_RETENTION_DAYS,AGENT_JOB_RETENTION_DAYS" default:"30" help:"Number of days to keep agent job history (default: 30)" group:"api"`
|
||||
OpenResponsesStoreTTL string `env:"LOCALAI_OPEN_RESPONSES_STORE_TTL,OPEN_RESPONSES_STORE_TTL" default:"0" help:"TTL for Open Responses store (e.g., 1h, 30m, 0 = no expiration)" group:"api"`
|
||||
|
||||
// LocalAI Assistant chat modality (in-process admin MCP server)
|
||||
DisableLocalAIAssistant bool `env:"LOCALAI_DISABLE_ASSISTANT" default:"false" help:"Disable the LocalAI Assistant chat modality (in-process admin MCP server)" group:"assistant"`
|
||||
|
||||
// Agent Pool (LocalAGI)
|
||||
DisableAgents bool `env:"LOCALAI_DISABLE_AGENTS" default:"false" help:"Disable the agent pool feature" group:"agents"`
|
||||
AgentPoolAPIURL string `env:"LOCALAI_AGENT_POOL_API_URL" help:"Default API URL for agents (defaults to self-referencing LocalAI)" group:"agents"`
|
||||
@@ -323,6 +326,9 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
|
||||
if r.AgentPoolDefaultModel != "" {
|
||||
opts = append(opts, config.WithAgentPoolDefaultModel(r.AgentPoolDefaultModel))
|
||||
}
|
||||
if r.DisableLocalAIAssistant {
|
||||
opts = append(opts, config.WithDisableLocalAIAssistant(true))
|
||||
}
|
||||
if r.AgentPoolMultimodalModel != "" {
|
||||
opts = append(opts, config.WithAgentPoolMultimodalModel(r.AgentPoolMultimodalModel))
|
||||
}
|
||||
|
||||
@@ -502,16 +502,58 @@ func (s *backendSupervisor) startBackend(backend, backendPath string) (string, e
|
||||
return clientAddr, nil
|
||||
}
|
||||
|
||||
// stopBackend stops a specific backend's gRPC process.
|
||||
func (s *backendSupervisor) stopBackend(backend string) {
|
||||
// resolveProcessKeys turns a caller-supplied identifier into the set of
|
||||
// process map keys it refers to. PR #9583 changed s.processes to be keyed by
|
||||
// `modelID#replicaIndex`, but external NATS handlers still pass the bare
|
||||
// model ID — without this resolver, those lookups silently no-op'd, so
|
||||
// admin "Unload model" / "Delete backend" left the worker process alive.
|
||||
//
|
||||
// - Exact match wins. Callers that already know the full processKey
|
||||
// (stopAllBackends iterating its own map) get exactly that entry.
|
||||
// - Else, an identifier without `#` is treated as a model prefix and
|
||||
// every `id#N` replica is returned.
|
||||
// - An identifier that contains `#` but doesn't match anything returns
|
||||
// nothing — no spurious prefix fallback when the caller was explicit.
|
||||
func (s *backendSupervisor) resolveProcessKeys(id string) []string {
|
||||
s.mu.Lock()
|
||||
bp, ok := s.processes[backend]
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.processes[id]; ok {
|
||||
return []string{id}
|
||||
}
|
||||
if strings.Contains(id, "#") {
|
||||
return nil
|
||||
}
|
||||
prefix := id + "#"
|
||||
var keys []string
|
||||
for k := range s.processes {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// stopBackend stops the backend process(es) matching the given identifier.
|
||||
// Accepts a bare modelID (stops every replica) or a full processKey
|
||||
// (stops just that replica).
|
||||
func (s *backendSupervisor) stopBackend(id string) {
|
||||
for _, key := range s.resolveProcessKeys(id) {
|
||||
s.stopBackendExact(key)
|
||||
}
|
||||
}
|
||||
|
||||
// stopBackendExact stops the process under exactly this key. Locking and
|
||||
// network I/O are split: the map mutation runs under the lock, the gRPC
|
||||
// Free() and proc.Stop() calls run after release so they don't block
|
||||
// other supervisor operations.
|
||||
func (s *backendSupervisor) stopBackendExact(key string) {
|
||||
s.mu.Lock()
|
||||
bp, ok := s.processes[key]
|
||||
if !ok || bp.proc == nil {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
// Clean up map and recycle port while holding lock
|
||||
delete(s.processes, backend)
|
||||
delete(s.processes, key)
|
||||
if _, portStr, err := net.SplitHostPort(bp.addr); err == nil {
|
||||
if p, err := strconv.Atoi(portStr); err == nil {
|
||||
s.freePorts = append(s.freePorts, p)
|
||||
@@ -519,16 +561,15 @@ func (s *backendSupervisor) stopBackend(backend string) {
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
// Network I/O outside the lock
|
||||
client := grpc.NewClientWithToken(bp.addr, false, nil, false, s.cmd.RegistrationToken)
|
||||
xlog.Debug("Calling Free() before stopping backend", "backend", backend)
|
||||
xlog.Debug("Calling Free() before stopping backend", "backend", key)
|
||||
if err := client.Free(context.Background()); err != nil {
|
||||
xlog.Warn("Free() failed (best-effort)", "backend", backend, "error", err)
|
||||
xlog.Warn("Free() failed (best-effort)", "backend", key, "error", err)
|
||||
}
|
||||
|
||||
xlog.Info("Stopping backend process", "backend", backend, "addr", bp.addr)
|
||||
xlog.Info("Stopping backend process", "backend", key, "addr", bp.addr)
|
||||
if err := bp.proc.Stop(); err != nil {
|
||||
xlog.Error("Error stopping backend process", "backend", backend, "error", err)
|
||||
xlog.Error("Error stopping backend process", "backend", key, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,12 +598,24 @@ func readLastLinesFromFile(path string, n int) string {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// isRunning returns whether a specific backend process is currently running.
|
||||
func (s *backendSupervisor) isRunning(backend string) bool {
|
||||
// isRunning returns whether at least one backend process matching the given
|
||||
// identifier is currently running. Accepts a bare modelID (matches any
|
||||
// replica) or a full processKey (exact match). Callers like the
|
||||
// backend.delete pre-check rely on the bare-name path.
|
||||
func (s *backendSupervisor) isRunning(id string) bool {
|
||||
keys := s.resolveProcessKeys(id)
|
||||
if len(keys) == 0 {
|
||||
// Same lock-free zero-process check the caller would have done.
|
||||
return false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
bp, ok := s.processes[backend]
|
||||
return ok && bp.proc != nil && bp.proc.IsAlive()
|
||||
for _, key := range keys {
|
||||
if bp, ok := s.processes[key]; ok && bp.proc != nil && bp.proc.IsAlive() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getAddr returns the gRPC address for a running backend, or empty string.
|
||||
|
||||
@@ -67,4 +67,55 @@ var _ = Describe("Worker per-replica process keying", func() {
|
||||
Expect(labels).To(HaveKeyWithValue("node.replica-slots", "2"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Process map lookup by bare model name", func() {
|
||||
// Regression: PR #9583 changed the supervisor's map key from
|
||||
// `modelID` to `modelID#replicaIndex`. The NATS backend.stop
|
||||
// handler kept passing the bare modelID, so the lookup silently
|
||||
// no-op'd — the worker process stayed alive after an admin
|
||||
// "Unload model" click, and subsequent chats kept being served
|
||||
// by the leftover process. The registry rows were gone, so the
|
||||
// UI reported "no models loaded" while the model kept
|
||||
// responding. resolveProcessKeys must turn a bare modelID into
|
||||
// the actual replica process keys so stop/isRunning find the
|
||||
// running processes.
|
||||
It("resolves a bare modelID to its replica process keys", func() {
|
||||
s := &backendSupervisor{
|
||||
processes: map[string]*backendProcess{
|
||||
"qwen3.6-35B#0": {addr: "127.0.0.1:50051"},
|
||||
"qwen3.6-35B#1": {addr: "127.0.0.1:50052"},
|
||||
"other-model#0": {addr: "127.0.0.1:50053"},
|
||||
},
|
||||
}
|
||||
keys := s.resolveProcessKeys("qwen3.6-35B")
|
||||
Expect(keys).To(ConsistOf("qwen3.6-35B#0", "qwen3.6-35B#1"),
|
||||
"bare modelID must match all replica process keys")
|
||||
|
||||
// Bare modelID for a model with no live processes returns nothing.
|
||||
Expect(s.resolveProcessKeys("not-loaded")).To(BeEmpty())
|
||||
|
||||
// Full processKey resolves to itself (per-replica callers stay precise).
|
||||
Expect(s.resolveProcessKeys("qwen3.6-35B#0")).To(ConsistOf("qwen3.6-35B#0"))
|
||||
|
||||
// A processKey that doesn't exist returns nothing — no spurious
|
||||
// prefix fallback when the caller was explicit.
|
||||
Expect(s.resolveProcessKeys("qwen3.6-35B#9")).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("isRunning returns false when no replica matches", func() {
|
||||
// We can only test the not-found path without a real *process.Process
|
||||
// (IsAlive() requires PID introspection). That's enough to pin the
|
||||
// regression — pre-fix, isRunning("qwen3.6-35B") would always
|
||||
// return false because the map was keyed by "qwen3.6-35B#0".
|
||||
// Post-fix, isRunning calls resolveProcessKeys first, so the
|
||||
// per-replica lookup is exercised before the IsAlive probe.
|
||||
s := &backendSupervisor{processes: map[string]*backendProcess{}}
|
||||
Expect(s.isRunning("qwen3.6-35B")).To(BeFalse())
|
||||
// resolveProcessKeys finds the replica entries (the lookup contract
|
||||
// is what the backend.delete handler relies on); the IsAlive probe
|
||||
// itself is exercised by the integration path in distributed mode.
|
||||
s.processes["qwen3.6-35B#0"] = &backendProcess{addr: "127.0.0.1:50051"}
|
||||
Expect(s.resolveProcessKeys("qwen3.6-35B")).To(ConsistOf("qwen3.6-35B#0"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -103,6 +103,28 @@ type ApplicationConfig struct {
|
||||
|
||||
// Distributed / Horizontal Scaling
|
||||
Distributed DistributedConfig
|
||||
|
||||
// LocalAI Assistant chat modality. Hard-disable the in-process admin MCP
|
||||
// server with this flag; runtime-toggleable via /api/settings.
|
||||
DisableLocalAIAssistant bool
|
||||
|
||||
// Branding / whitelabeling — runtime-mutable via /api/settings (text) and
|
||||
// /api/branding/asset/:kind (binary uploads). All values optional; empty
|
||||
// strings fall back to bundled LocalAI defaults.
|
||||
Branding BrandingConfig
|
||||
}
|
||||
|
||||
// BrandingConfig holds the whitelabel/branding configuration of the instance.
|
||||
// Text fields are exposed via the public GET /api/branding endpoint so the
|
||||
// login page can read them before authentication. Binary asset filenames
|
||||
// (logo, horizontal logo, favicon) are stored as basenames; the actual files
|
||||
// live under {DynamicConfigsDir}/branding/.
|
||||
type BrandingConfig struct {
|
||||
InstanceName string
|
||||
InstanceTagline string
|
||||
LogoFile string
|
||||
LogoHorizontalFile string
|
||||
FaviconFile string
|
||||
}
|
||||
|
||||
// AuthConfig holds configuration for user authentication and authorization.
|
||||
@@ -176,6 +198,24 @@ func NewApplicationConfig(o ...AppOption) *ApplicationConfig {
|
||||
"/healthz",
|
||||
"/api/auth/",
|
||||
"/assets/",
|
||||
// Branding read endpoint + public asset server. The login
|
||||
// screen renders before authentication completes, so it has
|
||||
// to be able to GET /api/branding and the configured logo.
|
||||
//
|
||||
// IMPORTANT: PathWithoutAuth uses a prefix match (see
|
||||
// auth.isExemptPath). The "/api/branding" entry therefore
|
||||
// also exempts POST/DELETE /api/branding/asset/:kind from
|
||||
// the *global* auth middleware. Those routes are still
|
||||
// admin-gated because they are registered with the
|
||||
// route-level adminMiddleware (auth.RequireAdmin) in
|
||||
// core/http/routes/ui_api.go — that's what keeps anonymous
|
||||
// uploads/deletes returning 401. Any new admin-only sub-route
|
||||
// added under /api/branding/* MUST also carry adminMiddleware
|
||||
// at the route registration site, otherwise it ships
|
||||
// unauthenticated. The TestBrandingRoutes_AdminGatingHolds
|
||||
// integration test in core/http/auth pins this contract.
|
||||
"/api/branding",
|
||||
"/branding/",
|
||||
},
|
||||
}
|
||||
for _, oo := range o {
|
||||
@@ -825,6 +865,15 @@ func WithAuthDefaultAPIKeyExpiry(expiry string) AppOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithDisableLocalAIAssistant hard-disables the in-process admin MCP server.
|
||||
// When set, the chat-handler branch for metadata.localai_assistant=true
|
||||
// returns a "feature unavailable" error.
|
||||
func WithDisableLocalAIAssistant(disabled bool) AppOption {
|
||||
return func(o *ApplicationConfig) {
|
||||
o.DisableLocalAIAssistant = disabled
|
||||
}
|
||||
}
|
||||
|
||||
// ToConfigLoaderOptions returns a slice of ConfigLoader Option.
|
||||
// Some options defined at the application level are going to be passed as defaults for
|
||||
// all the configuration for the models.
|
||||
@@ -915,6 +964,19 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
|
||||
agentPoolChunkOverlap := o.AgentPool.ChunkOverlap
|
||||
agentPoolEnableLogs := o.AgentPool.EnableLogs
|
||||
agentPoolCollectionDBPath := o.AgentPool.CollectionDBPath
|
||||
agentPoolVectorEngine := o.AgentPool.VectorEngine
|
||||
agentPoolDatabaseURL := o.AgentPool.DatabaseURL
|
||||
agentPoolAgentHubURL := o.AgentPool.AgentHubURL
|
||||
|
||||
// LocalAI Assistant settings
|
||||
localAIAssistantEnabled := !o.DisableLocalAIAssistant
|
||||
|
||||
// Branding settings
|
||||
instanceName := o.Branding.InstanceName
|
||||
instanceTagline := o.Branding.InstanceTagline
|
||||
logoFile := o.Branding.LogoFile
|
||||
logoHorizontalFile := o.Branding.LogoHorizontalFile
|
||||
faviconFile := o.Branding.FaviconFile
|
||||
|
||||
return RuntimeSettings{
|
||||
WatchdogEnabled: &watchdogEnabled,
|
||||
@@ -959,6 +1021,15 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
|
||||
AgentPoolChunkOverlap: &agentPoolChunkOverlap,
|
||||
AgentPoolEnableLogs: &agentPoolEnableLogs,
|
||||
AgentPoolCollectionDBPath: &agentPoolCollectionDBPath,
|
||||
AgentPoolVectorEngine: &agentPoolVectorEngine,
|
||||
AgentPoolDatabaseURL: &agentPoolDatabaseURL,
|
||||
AgentPoolAgentHubURL: &agentPoolAgentHubURL,
|
||||
LocalAIAssistantEnabled: &localAIAssistantEnabled,
|
||||
InstanceName: &instanceName,
|
||||
InstanceTagline: &instanceTagline,
|
||||
LogoFile: &logoFile,
|
||||
LogoHorizontalFile: &logoHorizontalFile,
|
||||
FaviconFile: &faviconFile,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1143,6 +1214,43 @@ func (o *ApplicationConfig) ApplyRuntimeSettings(settings *RuntimeSettings) (req
|
||||
o.AgentPool.CollectionDBPath = *settings.AgentPoolCollectionDBPath
|
||||
requireRestart = true
|
||||
}
|
||||
if settings.AgentPoolVectorEngine != nil {
|
||||
o.AgentPool.VectorEngine = *settings.AgentPoolVectorEngine
|
||||
requireRestart = true
|
||||
}
|
||||
if settings.AgentPoolDatabaseURL != nil {
|
||||
o.AgentPool.DatabaseURL = *settings.AgentPoolDatabaseURL
|
||||
requireRestart = true
|
||||
}
|
||||
if settings.AgentPoolAgentHubURL != nil {
|
||||
o.AgentPool.AgentHubURL = *settings.AgentPoolAgentHubURL
|
||||
requireRestart = true
|
||||
}
|
||||
|
||||
// LocalAI Assistant: read live at request entry by the chat handler, so
|
||||
// flipping the disable flag takes effect on the next request without a
|
||||
// restart.
|
||||
if settings.LocalAIAssistantEnabled != nil {
|
||||
o.DisableLocalAIAssistant = !*settings.LocalAIAssistantEnabled
|
||||
}
|
||||
|
||||
// Branding: read live by the public /api/branding endpoint and asset
|
||||
// server, so changes apply on the next request without a restart.
|
||||
if settings.InstanceName != nil {
|
||||
o.Branding.InstanceName = *settings.InstanceName
|
||||
}
|
||||
if settings.InstanceTagline != nil {
|
||||
o.Branding.InstanceTagline = *settings.InstanceTagline
|
||||
}
|
||||
if settings.LogoFile != nil {
|
||||
o.Branding.LogoFile = *settings.LogoFile
|
||||
}
|
||||
if settings.LogoHorizontalFile != nil {
|
||||
o.Branding.LogoHorizontalFile = *settings.LogoHorizontalFile
|
||||
}
|
||||
if settings.FaviconFile != nil {
|
||||
o.Branding.FaviconFile = *settings.FaviconFile
|
||||
}
|
||||
|
||||
// Note: ApiKeys requires special handling (merging with startup keys) - handled in caller
|
||||
|
||||
|
||||
@@ -110,5 +110,30 @@ var _ = Describe("Backend hooks and parser defaults", func() {
|
||||
}
|
||||
Expect(count).To(Equal(1))
|
||||
})
|
||||
|
||||
It("seeds production engine_args defaults", func() {
|
||||
cfg := &ModelConfig{Backend: "vllm"}
|
||||
cfg.SetDefaults()
|
||||
|
||||
Expect(cfg.EngineArgs).NotTo(BeNil())
|
||||
Expect(cfg.EngineArgs["enable_prefix_caching"]).To(Equal(true))
|
||||
Expect(cfg.EngineArgs["enable_chunked_prefill"]).To(Equal(true))
|
||||
})
|
||||
|
||||
It("does not override user-set engine_args", func() {
|
||||
cfg := &ModelConfig{
|
||||
Backend: "vllm",
|
||||
LLMConfig: LLMConfig{
|
||||
EngineArgs: map[string]any{
|
||||
"enable_prefix_caching": false,
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg.SetDefaults()
|
||||
|
||||
Expect(cfg.EngineArgs["enable_prefix_caching"]).To(Equal(false))
|
||||
// chunked_prefill is still seeded since user didn't set it
|
||||
Expect(cfg.EngineArgs["enable_chunked_prefill"]).To(Equal(true))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -45,8 +45,34 @@ func MatchParserDefaults(modelID string) map[string]string {
|
||||
return nil
|
||||
}
|
||||
|
||||
// productionEngineArgsDefaults are vLLM ≥ 0.6 features that production deployments
|
||||
// almost always want. Applied at load time when the user hasn't set the key in
|
||||
// engine_args. Anything user-supplied wins; we never silently override.
|
||||
var productionEngineArgsDefaults = map[string]any{
|
||||
"enable_prefix_caching": true,
|
||||
"enable_chunked_prefill": true,
|
||||
}
|
||||
|
||||
func vllmDefaults(cfg *ModelConfig, modelPath string) {
|
||||
// Check if user already set tool_parser or reasoning_parser in Options
|
||||
applyEngineArgDefaults(cfg)
|
||||
applyParserDefaults(cfg)
|
||||
}
|
||||
|
||||
// applyEngineArgDefaults seeds production-friendly engine_args without overwriting
|
||||
// anything the user already set.
|
||||
func applyEngineArgDefaults(cfg *ModelConfig) {
|
||||
if cfg.EngineArgs == nil {
|
||||
cfg.EngineArgs = map[string]any{}
|
||||
}
|
||||
for k, v := range productionEngineArgsDefaults {
|
||||
if _, set := cfg.EngineArgs[k]; set {
|
||||
continue
|
||||
}
|
||||
cfg.EngineArgs[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
func applyParserDefaults(cfg *ModelConfig) {
|
||||
hasToolParser := false
|
||||
hasReasoningParser := false
|
||||
for _, opt := range cfg.Options {
|
||||
@@ -61,7 +87,6 @@ func vllmDefaults(cfg *ModelConfig, modelPath string) {
|
||||
return
|
||||
}
|
||||
|
||||
// Try matching against Model field, then Name
|
||||
parsers := MatchParserDefaults(cfg.Model)
|
||||
if parsers == nil {
|
||||
parsers = MatchParserDefaults(cfg.Name)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
@@ -241,7 +242,13 @@ type LLMConfig struct {
|
||||
DisableLogStatus bool `yaml:"disable_log_stats,omitempty" json:"disable_log_stats,omitempty"` // vLLM
|
||||
DType string `yaml:"dtype,omitempty" json:"dtype,omitempty"` // vLLM
|
||||
LimitMMPerPrompt LimitMMPerPrompt `yaml:"limit_mm_per_prompt,omitempty" json:"limit_mm_per_prompt,omitempty"` // vLLM
|
||||
MMProj string `yaml:"mmproj,omitempty" json:"mmproj,omitempty"`
|
||||
// EngineArgs is a backend-native passthrough applied to the engine constructor
|
||||
// (e.g. vLLM AsyncEngineArgs). Values may be primitives or nested maps; nested
|
||||
// maps materialise into the backend's nested config dataclasses (e.g.
|
||||
// SpeculativeConfig, KVTransferConfig, CompilationConfig). Unknown keys cause
|
||||
// the backend to fail LoadModel with a list of valid names.
|
||||
EngineArgs map[string]any `yaml:"engine_args,omitempty" json:"engine_args,omitempty"`
|
||||
MMProj string `yaml:"mmproj,omitempty" json:"mmproj,omitempty"`
|
||||
|
||||
FlashAttention *string `yaml:"flash_attention,omitempty" json:"flash_attention,omitempty"`
|
||||
NoKVOffloading bool `yaml:"no_kv_offloading,omitempty" json:"no_kv_offloading,omitempty"`
|
||||
@@ -545,6 +552,15 @@ func (c *ModelConfig) Validate() (bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// engine_args crosses the gRPC boundary as a JSON-encoded string. Reject
|
||||
// unmarshalable values here so a config that would silently lose user-set
|
||||
// options at load time is rejected at parse time instead.
|
||||
if len(c.EngineArgs) > 0 {
|
||||
if _, err := json.Marshal(c.EngineArgs); err != nil {
|
||||
return false, fmt.Errorf("engine_args is not JSON-serialisable: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -230,4 +230,38 @@ mcp:
|
||||
Expect(err).To(BeNil())
|
||||
Expect(valid).To(BeTrue())
|
||||
})
|
||||
It("Test Validate rejects unmarshalable engine_args", func() {
|
||||
// chan values cannot be JSON-marshalled. A valid YAML config could
|
||||
// not produce one, but a Go caller stuffing a bad value would, and
|
||||
// silently dropping it would change runtime behaviour.
|
||||
cfg := &ModelConfig{
|
||||
Backend: "vllm",
|
||||
LLMConfig: LLMConfig{
|
||||
EngineArgs: map[string]any{
|
||||
"speculative_config": make(chan int),
|
||||
},
|
||||
},
|
||||
}
|
||||
valid, err := cfg.Validate()
|
||||
Expect(valid).To(BeFalse())
|
||||
Expect(err).ToNot(BeNil())
|
||||
Expect(err.Error()).To(ContainSubstring("engine_args is not JSON-serialisable"))
|
||||
})
|
||||
It("Test Validate accepts well-formed engine_args", func() {
|
||||
cfg := &ModelConfig{
|
||||
Backend: "vllm",
|
||||
LLMConfig: LLMConfig{
|
||||
EngineArgs: map[string]any{
|
||||
"data_parallel_size": 8,
|
||||
"speculative_config": map[string]any{
|
||||
"method": "ngram",
|
||||
"num_speculative_tokens": 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
valid, err := cfg.Validate()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(valid).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,14 +8,108 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// fixtures inlines what used to live under tests/models_fixtures/. Owning the
|
||||
// YAMLs here keeps the test self-contained: no env-var plumbing, no shared
|
||||
// directory the rest of the repo can stomp on.
|
||||
var fixtures = map[string]string{
|
||||
"config.yaml": `- name: list1
|
||||
parameters:
|
||||
model: testmodel.ggml
|
||||
top_p: 80
|
||||
top_k: 0.9
|
||||
temperature: 0.1
|
||||
context_size: 200
|
||||
stopwords:
|
||||
- "HUMAN:"
|
||||
- "### Response:"
|
||||
roles:
|
||||
user: "HUMAN:"
|
||||
system: "GPT:"
|
||||
template:
|
||||
completion: completion
|
||||
chat: ggml-gpt4all-j
|
||||
- name: list2
|
||||
parameters:
|
||||
top_p: 80
|
||||
top_k: 0.9
|
||||
temperature: 0.1
|
||||
model: testmodel.ggml
|
||||
context_size: 200
|
||||
stopwords:
|
||||
- "HUMAN:"
|
||||
- "### Response:"
|
||||
roles:
|
||||
user: "HUMAN:"
|
||||
system: "GPT:"
|
||||
template:
|
||||
completion: completion
|
||||
chat: ggml-gpt4all-j
|
||||
`,
|
||||
"embeddings.yaml": `name: text-embedding-ada-002
|
||||
embeddings: true
|
||||
parameters:
|
||||
model: huggingface://hugging-quants/Llama-3.2-1B-Instruct-Q4_K_M-GGUF/llama-3.2-1b-instruct-q4_k_m.gguf
|
||||
`,
|
||||
"grpc.yaml": `name: code-search-ada-code-001
|
||||
backend: sentencetransformers
|
||||
embeddings: true
|
||||
parameters:
|
||||
model: all-MiniLM-L6-v2
|
||||
`,
|
||||
"rwkv.yaml": `name: rwkv_test
|
||||
parameters:
|
||||
model: huggingface://bartowski/rwkv-6-world-7b-GGUF/rwkv-6-world-7b-Q4_K_M.gguf
|
||||
top_k: 80
|
||||
temperature: 0.9
|
||||
max_tokens: 4098
|
||||
top_p: 0.8
|
||||
context_size: 4098
|
||||
|
||||
roles:
|
||||
user: "User: "
|
||||
system: "System: "
|
||||
assistant: "Assistant: "
|
||||
|
||||
stopwords:
|
||||
- 'Assistant:'
|
||||
- '<s>'
|
||||
|
||||
template:
|
||||
chat: |
|
||||
{{.Input}}
|
||||
Assistant:
|
||||
completion: |
|
||||
{{.Input}}
|
||||
`,
|
||||
"whisper.yaml": `name: whisper-1
|
||||
backend: whisper
|
||||
parameters:
|
||||
model: whisper-en
|
||||
`,
|
||||
}
|
||||
|
||||
var _ = Describe("Test cases for config related functions", func() {
|
||||
|
||||
var (
|
||||
modelsPath string
|
||||
configFile string
|
||||
)
|
||||
|
||||
Context("Test Read configuration functions", func() {
|
||||
configFile = os.Getenv("CONFIG_FILE")
|
||||
BeforeEach(func() {
|
||||
tmp, err := os.MkdirTemp("", "model-config-fixtures-")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
modelsPath = tmp
|
||||
for name, body := range fixtures {
|
||||
Expect(os.WriteFile(filepath.Join(modelsPath, name), []byte(body), 0644)).To(Succeed())
|
||||
}
|
||||
configFile = filepath.Join(modelsPath, "config.yaml")
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
Expect(os.RemoveAll(modelsPath)).To(Succeed())
|
||||
})
|
||||
|
||||
It("Test readConfigFile", func() {
|
||||
config, err := readModelConfigsFromFile(configFile)
|
||||
Expect(err).To(BeNil())
|
||||
@@ -27,8 +121,8 @@ var _ = Describe("Test cases for config related functions", func() {
|
||||
|
||||
It("Test LoadConfigs", func() {
|
||||
|
||||
bcl := NewModelConfigLoader(os.Getenv("MODELS_PATH"))
|
||||
err := bcl.LoadModelConfigsFromPath(os.Getenv("MODELS_PATH"))
|
||||
bcl := NewModelConfigLoader(modelsPath)
|
||||
err := bcl.LoadModelConfigsFromPath(modelsPath)
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
configs := bcl.GetAllModelsConfigs()
|
||||
@@ -52,8 +146,8 @@ var _ = Describe("Test cases for config related functions", func() {
|
||||
|
||||
It("Test new loadconfig", func() {
|
||||
|
||||
bcl := NewModelConfigLoader(os.Getenv("MODELS_PATH"))
|
||||
err := bcl.LoadModelConfigsFromPath(os.Getenv("MODELS_PATH"))
|
||||
bcl := NewModelConfigLoader(modelsPath)
|
||||
err := bcl.LoadModelConfigsFromPath(modelsPath)
|
||||
Expect(err).To(BeNil())
|
||||
configs := bcl.GetAllModelsConfigs()
|
||||
loadedModelNames := []string{}
|
||||
|
||||
@@ -73,4 +73,20 @@ type RuntimeSettings struct {
|
||||
AgentPoolChunkOverlap *int `json:"agent_pool_chunk_overlap,omitempty"`
|
||||
AgentPoolEnableLogs *bool `json:"agent_pool_enable_logs,omitempty"`
|
||||
AgentPoolCollectionDBPath *string `json:"agent_pool_collection_db_path,omitempty"`
|
||||
AgentPoolVectorEngine *string `json:"agent_pool_vector_engine,omitempty"` // chromem | postgres
|
||||
AgentPoolDatabaseURL *string `json:"agent_pool_database_url,omitempty"` // PostgreSQL DSN when vector engine is postgres
|
||||
AgentPoolAgentHubURL *string `json:"agent_pool_agent_hub_url,omitempty"` // override the agenthub.localai.io endpoint
|
||||
|
||||
// LocalAI Assistant settings — read live by the chat handler at request
|
||||
// entry, so flipping the toggle takes effect on the next request.
|
||||
LocalAIAssistantEnabled *bool `json:"localai_assistant_enabled,omitempty"` // negation of DisableLocalAIAssistant for UI clarity
|
||||
|
||||
// Branding / whitelabeling. Text fields are user-facing; *File fields hold
|
||||
// just the basename of an uploaded asset under {DynamicConfigsDir}/branding/.
|
||||
// All optional — empty values fall back to bundled LocalAI defaults.
|
||||
InstanceName *string `json:"instance_name,omitempty"`
|
||||
InstanceTagline *string `json:"instance_tagline,omitempty"`
|
||||
LogoFile *string `json:"logo_file,omitempty"`
|
||||
LogoHorizontalFile *string `json:"logo_horizontal_file,omitempty"`
|
||||
FaviconFile *string `json:"favicon_file,omitempty"`
|
||||
}
|
||||
|
||||
49
core/config/runtime_settings_persist.go
Normal file
49
core/config/runtime_settings_persist.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// runtimeSettingsFile is the on-disk filename inside DynamicConfigsDir.
|
||||
const runtimeSettingsFile = "runtime_settings.json"
|
||||
|
||||
// ReadPersistedSettings loads runtime_settings.json from DynamicConfigsDir.
|
||||
// A missing file is not an error — the zero RuntimeSettings is returned.
|
||||
// This lets callers update only the field they own (e.g. one branding
|
||||
// asset filename) without clobbering unrelated settings already on disk.
|
||||
func (o *ApplicationConfig) ReadPersistedSettings() (RuntimeSettings, error) {
|
||||
var settings RuntimeSettings
|
||||
if o.DynamicConfigsDir == "" {
|
||||
return settings, errors.New("DynamicConfigsDir is not set")
|
||||
}
|
||||
path := filepath.Join(o.DynamicConfigsDir, runtimeSettingsFile)
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return settings, nil
|
||||
}
|
||||
if err != nil {
|
||||
return settings, err
|
||||
}
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return settings, err
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// WritePersistedSettings serialises the given RuntimeSettings to
|
||||
// runtime_settings.json with restricted permissions (it may carry API
|
||||
// keys and P2P tokens).
|
||||
func (o *ApplicationConfig) WritePersistedSettings(settings RuntimeSettings) error {
|
||||
if o.DynamicConfigsDir == "" {
|
||||
return errors.New("DynamicConfigsDir is not set")
|
||||
}
|
||||
path := filepath.Join(o.DynamicConfigsDir, runtimeSettingsFile)
|
||||
data, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
105
core/config/runtime_settings_persist_test.go
Normal file
105
core/config/runtime_settings_persist_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
)
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
|
||||
var _ = Describe("RuntimeSettings persistence helpers", func() {
|
||||
var (
|
||||
dir string
|
||||
cfg *config.ApplicationConfig
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
dir = GinkgoT().TempDir()
|
||||
cfg = &config.ApplicationConfig{DynamicConfigsDir: dir}
|
||||
})
|
||||
|
||||
// ReadPersistedSettings + WritePersistedSettings is the round-trip the
|
||||
// /api/branding/asset/:kind upload handler relies on: the upload writes
|
||||
// the basename to runtime_settings.json via these helpers, and the next
|
||||
// reader (loadRuntimeSettingsFromFile, the file watcher, or the next
|
||||
// upload) must observe that basename. A regression here would break
|
||||
// asset persistence.
|
||||
Describe("BrandingFiles round trip", func() {
|
||||
It("preserves instance_name, tagline, and basenames across read/write", func() {
|
||||
tagline := "Private inference"
|
||||
logo := "logo.png"
|
||||
settings := config.RuntimeSettings{
|
||||
InstanceName: strPtr("Acme AI"),
|
||||
InstanceTagline: &tagline,
|
||||
LogoFile: &logo,
|
||||
}
|
||||
Expect(cfg.WritePersistedSettings(settings)).To(Succeed())
|
||||
|
||||
got, err := cfg.ReadPersistedSettings()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(got.InstanceName).ToNot(BeNil())
|
||||
Expect(*got.InstanceName).To(Equal("Acme AI"))
|
||||
Expect(got.LogoFile).ToNot(BeNil())
|
||||
Expect(*got.LogoFile).To(Equal("logo.png"))
|
||||
})
|
||||
})
|
||||
|
||||
// PreserveOnSaveDoesNotClobberAssets reproduces the user-reported
|
||||
// regression: an admin uploads a logo, then clicks Save on the
|
||||
// Settings page. The Save body still has the stale pre-upload
|
||||
// logo_file (empty string) because the React state was loaded
|
||||
// before the upload. UpdateSettingsEndpoint must protect the
|
||||
// on-disk basename — branding asset filenames are owned by the
|
||||
// /api/branding/asset/:kind endpoints, not by /api/settings.
|
||||
//
|
||||
// This spec exercises what UpdateSettingsEndpoint does: read the
|
||||
// existing persisted settings, override the asset filename fields
|
||||
// from disk, then write the merged settings. The fix lives in
|
||||
// core/http/endpoints/localai/settings.go; this spec pins the
|
||||
// contract that ReadPersistedSettings exposes the basenames so the
|
||||
// handler can preserve them.
|
||||
Describe("Save preservation prevents asset clobber", func() {
|
||||
It("keeps the on-disk logo basename when /api/settings posts an empty string", func() {
|
||||
existing := "logo.png"
|
||||
Expect(cfg.WritePersistedSettings(config.RuntimeSettings{LogoFile: &existing})).To(Succeed())
|
||||
|
||||
// Simulate the body the React Settings page POSTs on Save:
|
||||
// stale empty-string logo_file, plus an unrelated user change
|
||||
// (instance_name).
|
||||
emptyLogo := ""
|
||||
newName := "Acme AI"
|
||||
body := config.RuntimeSettings{
|
||||
InstanceName: &newName,
|
||||
LogoFile: &emptyLogo,
|
||||
}
|
||||
|
||||
// Apply the same preservation step UpdateSettingsEndpoint performs.
|
||||
persisted, err := cfg.ReadPersistedSettings()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
body.LogoFile = persisted.LogoFile
|
||||
body.LogoHorizontalFile = persisted.LogoHorizontalFile
|
||||
body.FaviconFile = persisted.FaviconFile
|
||||
|
||||
Expect(cfg.WritePersistedSettings(body)).To(Succeed())
|
||||
|
||||
// On-disk runtime_settings.json must still have the uploaded
|
||||
// basename, AND the unrelated change must have landed.
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "runtime_settings.json"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
var ondisk config.RuntimeSettings
|
||||
Expect(json.Unmarshal(raw, &ondisk)).To(Succeed())
|
||||
|
||||
Expect(ondisk.LogoFile).ToNot(BeNil(), "logo_file pointer was dropped")
|
||||
Expect(*ondisk.LogoFile).To(Equal("logo.png"), "logo_file was clobbered by Save")
|
||||
Expect(ondisk.InstanceName).ToNot(BeNil())
|
||||
Expect(*ondisk.InstanceName).To(Equal("Acme AI"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/mudler/LocalAI/core/gallery"
|
||||
"github.com/mudler/LocalAI/core/schema"
|
||||
"github.com/mudler/LocalAI/pkg/downloader"
|
||||
hfapi "github.com/mudler/LocalAI/pkg/huggingface-api"
|
||||
"go.yaml.in/yaml/v2"
|
||||
)
|
||||
|
||||
@@ -42,8 +43,7 @@ func (i *WhisperImporter) Match(details Details) bool {
|
||||
}
|
||||
|
||||
// Direct URL or path ending in ggml-*.bin
|
||||
base := filepath.Base(details.URI)
|
||||
if strings.HasPrefix(base, "ggml-") && strings.HasSuffix(strings.ToLower(base), ".bin") {
|
||||
if isGGMLFilename(filepath.Base(details.URI)) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -76,6 +76,12 @@ func (i *WhisperImporter) Import(details Details) (gallery.ModelConfig, error) {
|
||||
description = "Imported from " + details.URI
|
||||
}
|
||||
|
||||
preferredQuants, _ := preferencesMap["quantizations"].(string)
|
||||
quants := []string{"q5_0"}
|
||||
if preferredQuants != "" {
|
||||
quants = strings.Split(preferredQuants, ",")
|
||||
}
|
||||
|
||||
cfg := gallery.ModelConfig{
|
||||
Name: name,
|
||||
Description: description,
|
||||
@@ -89,37 +95,43 @@ func (i *WhisperImporter) Import(details Details) (gallery.ModelConfig, error) {
|
||||
}
|
||||
|
||||
uri := downloader.URI(details.URI)
|
||||
directGGML := isGGMLFilename(filepath.Base(details.URI))
|
||||
switch {
|
||||
case uri.LooksLikeURL():
|
||||
case uri.LooksLikeURL() && directGGML:
|
||||
// Direct file URL (e.g. .../resolve/main/ggml-base.en.bin). We
|
||||
// already know the exact file the user wants — no quant pick.
|
||||
fileName, err := uri.FilenameFromUrl()
|
||||
if err != nil {
|
||||
return gallery.ModelConfig{}, err
|
||||
}
|
||||
target := filepath.Join("whisper", "models", name, fileName)
|
||||
cfg.Files = append(cfg.Files, gallery.File{
|
||||
URI: details.URI,
|
||||
Filename: fileName,
|
||||
Filename: target,
|
||||
})
|
||||
modelConfig.PredictionOptions = schema.PredictionOptions{
|
||||
BasicModelRequest: schema.BasicModelRequest{Model: fileName},
|
||||
BasicModelRequest: schema.BasicModelRequest{Model: target},
|
||||
}
|
||||
case details.HuggingFace != nil:
|
||||
// HF repo: collect every ggml-*.bin, pick the preferred quant
|
||||
// (default q5_0), nest under whisper/models/<name>/ so the same
|
||||
// repo can ship multiple quants without colliding on disk.
|
||||
var ggmlFiles []hfapi.ModelFile
|
||||
for _, f := range details.HuggingFace.Files {
|
||||
base := filepath.Base(f.Path)
|
||||
if !strings.HasPrefix(base, "ggml-") {
|
||||
continue
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(base), ".bin") {
|
||||
continue
|
||||
if isGGMLFilename(filepath.Base(f.Path)) {
|
||||
ggmlFiles = append(ggmlFiles, f)
|
||||
}
|
||||
}
|
||||
if chosen, ok := pickPreferredGGMLFile(ggmlFiles, quants); ok {
|
||||
target := filepath.Join("whisper", "models", name, filepath.Base(chosen.Path))
|
||||
cfg.Files = append(cfg.Files, gallery.File{
|
||||
URI: f.URL,
|
||||
Filename: base,
|
||||
SHA256: f.SHA256,
|
||||
URI: chosen.URL,
|
||||
Filename: target,
|
||||
SHA256: chosen.SHA256,
|
||||
})
|
||||
modelConfig.PredictionOptions = schema.PredictionOptions{
|
||||
BasicModelRequest: schema.BasicModelRequest{Model: base},
|
||||
BasicModelRequest: schema.BasicModelRequest{Model: target},
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
// Bare URI with no HF metadata (pref-only path). Point the config at
|
||||
@@ -137,3 +149,30 @@ func (i *WhisperImporter) Import(details Details) (gallery.ModelConfig, error) {
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// isGGMLFilename returns true when name follows whisper.cpp's "ggml-*.bin"
|
||||
// packaging convention. The .bin check is case-insensitive; the ggml- prefix
|
||||
// is exact.
|
||||
func isGGMLFilename(name string) bool {
|
||||
return strings.HasPrefix(name, "ggml-") && strings.HasSuffix(strings.ToLower(name), ".bin")
|
||||
}
|
||||
|
||||
// pickPreferredGGMLFile walks prefs in order and returns the first ggml file
|
||||
// whose basename contains any preference token (case-insensitive match on the
|
||||
// quant suffix, e.g. "q5_0"). When no preference matches, falls back to the
|
||||
// last file — mirroring llama-cpp's pickPreferredGroup behaviour so a missing
|
||||
// quant still yields *something* the user can run.
|
||||
func pickPreferredGGMLFile(files []hfapi.ModelFile, prefs []string) (hfapi.ModelFile, bool) {
|
||||
if len(files) == 0 {
|
||||
return hfapi.ModelFile{}, false
|
||||
}
|
||||
for _, pref := range prefs {
|
||||
lower := strings.ToLower(pref)
|
||||
for _, f := range files {
|
||||
if strings.Contains(strings.ToLower(filepath.Base(f.Path)), lower) {
|
||||
return f, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return files[len(files)-1], true
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mudler/LocalAI/core/gallery/importers"
|
||||
hfapi "github.com/mudler/LocalAI/pkg/huggingface-api"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@@ -36,6 +37,106 @@ var _ = Describe("WhisperImporter", func() {
|
||||
})
|
||||
})
|
||||
|
||||
// Real-world repo that ships *multiple* ggml-*.bin quantizations
|
||||
// (ggml-model-q4_0.bin, ggml-model-q5_0.bin, ggml-model-q8_0.bin).
|
||||
// We assert the importer (a) follows the HF metadata branch — not the
|
||||
// URL branch — when given the repo URL, (b) lays files out under
|
||||
// whisper/models/<name>/ like llama-cpp does, and (c) honours the
|
||||
// quantizations preference, defaulting to q5_0.
|
||||
Context("real-world multi-quant repo: LocalAI-io/whisper-large-v3-it-yodas-only-ggml", func() {
|
||||
const (
|
||||
uri = "https://huggingface.co/LocalAI-io/whisper-large-v3-it-yodas-only-ggml"
|
||||
name = "whisper-large-v3-it-yodas-only-ggml"
|
||||
)
|
||||
|
||||
It("defaults to q5_0 and nests the file under whisper/models/<name>/", func() {
|
||||
modelConfig, err := importers.DiscoverModelConfig(uri, json.RawMessage(`{}`))
|
||||
|
||||
Expect(err).ToNot(HaveOccurred(), fmt.Sprintf("Error: %v", err))
|
||||
Expect(modelConfig.ConfigFile).To(ContainSubstring("backend: whisper"))
|
||||
Expect(modelConfig.ConfigFile).To(ContainSubstring("transcript"))
|
||||
|
||||
Expect(modelConfig.Files).To(HaveLen(1), fmt.Sprintf("Model config: %+v", modelConfig))
|
||||
|
||||
expectedPath := "whisper/models/" + name + "/ggml-model-q5_0.bin"
|
||||
Expect(modelConfig.Files[0].Filename).To(Equal(expectedPath))
|
||||
Expect(modelConfig.Files[0].URI).To(Equal(uri + "/resolve/main/ggml-model-q5_0.bin"))
|
||||
Expect(modelConfig.Files[0].SHA256).ToNot(BeEmpty(), "HF metadata should provide a sha256")
|
||||
Expect(modelConfig.ConfigFile).To(ContainSubstring("model: " + expectedPath))
|
||||
})
|
||||
|
||||
It("honours preferences.quantizations=q4_0 to pick ggml-model-q4_0.bin", func() {
|
||||
modelConfig, err := importers.DiscoverModelConfig(uri, json.RawMessage(`{"quantizations":"q4_0"}`))
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(modelConfig.Files).To(HaveLen(1))
|
||||
|
||||
expectedPath := "whisper/models/" + name + "/ggml-model-q4_0.bin"
|
||||
Expect(modelConfig.Files[0].Filename).To(Equal(expectedPath))
|
||||
Expect(modelConfig.ConfigFile).To(ContainSubstring("model: " + expectedPath))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Import from HuggingFace file listing (offline)", func() {
|
||||
// Mirror of llama-cpp_test.go's offline HF context: build a fake
|
||||
// *hfapi.ModelDetails and assert the emitted gallery entry without
|
||||
// touching the network.
|
||||
const repoBase = "https://huggingface.co/acme/example-ggml/resolve/main/"
|
||||
|
||||
hfFile := func(path, sha string) hfapi.ModelFile {
|
||||
return hfapi.ModelFile{
|
||||
Path: path,
|
||||
SHA256: sha,
|
||||
URL: repoBase + path,
|
||||
}
|
||||
}
|
||||
|
||||
withHF := func(preferences string, files ...hfapi.ModelFile) importers.Details {
|
||||
d := importers.Details{
|
||||
URI: "https://huggingface.co/acme/example-ggml",
|
||||
HuggingFace: &hfapi.ModelDetails{
|
||||
ModelID: "acme/example-ggml",
|
||||
Files: files,
|
||||
},
|
||||
}
|
||||
if preferences != "" {
|
||||
d.Preferences = json.RawMessage(preferences)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
It("falls back to the last ggml file when no preference matches", func() {
|
||||
imp := &importers.WhisperImporter{}
|
||||
details := withHF(`{"name":"example"}`,
|
||||
hfFile("ggml-model-q4_0.bin", "aaa"),
|
||||
hfFile("ggml-model-q8_0.bin", "ccc"),
|
||||
hfFile("README.md", ""),
|
||||
)
|
||||
|
||||
modelConfig, err := imp.Import(details)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(modelConfig.Files).To(HaveLen(1))
|
||||
// Default pref is q5_0; repo has only q4_0 and q8_0 — fallback
|
||||
// is the last ggml entry, mirroring llama-cpp's behaviour.
|
||||
Expect(modelConfig.Files[0].Filename).To(Equal("whisper/models/example/ggml-model-q8_0.bin"))
|
||||
Expect(modelConfig.Files[0].SHA256).To(Equal("ccc"))
|
||||
})
|
||||
|
||||
It("ignores non-ggml files in the repo listing", func() {
|
||||
imp := &importers.WhisperImporter{}
|
||||
details := withHF(`{"name":"noise","quantizations":"q5_0"}`,
|
||||
hfFile("README.md", ""),
|
||||
hfFile("config.json", ""),
|
||||
hfFile("ggml-model-q5_0.bin", "bbb"),
|
||||
)
|
||||
|
||||
modelConfig, err := imp.Import(details)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(modelConfig.Files).To(HaveLen(1))
|
||||
Expect(modelConfig.Files[0].Filename).To(Equal("whisper/models/noise/ggml-model-q5_0.bin"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Importer interface metadata", func() {
|
||||
It("exposes name/modality/autodetect", func() {
|
||||
imp := &importers.WhisperImporter{}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user