mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-09 12:45:15 -04:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fb6419213 | ||
|
|
d0d9f1efd9 | ||
|
|
02f5194c33 |
No files matched your search
@@ -49,40 +49,6 @@ AI agents MUST NOT add `Co-Authored-By` trailers for themselves either.
|
||||
A human reviewer owns the contribution; the AI's involvement is recorded
|
||||
via `Assisted-by` (see below).
|
||||
|
||||
### Exception: automation operated by a maintainer
|
||||
|
||||
The rule above addresses the common case, an AI assistant helping a human
|
||||
contributor who then signs off. It does not fit automation that a
|
||||
maintainer runs themselves, which opens pull requests with no human
|
||||
submitter to sign. Applied literally there, nothing ever signs and the
|
||||
DCO check blocks the pull request permanently.
|
||||
|
||||
A maintainer-operated bot MUST therefore add a `Signed-off-by` trailer
|
||||
naming **the maintainer who operates it**, not the bot and not the model:
|
||||
|
||||
```
|
||||
Assisted-by: Codex:gpt-5
|
||||
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
|
||||
```
|
||||
|
||||
This is not the AI certifying the DCO. The maintainer is, exactly as they
|
||||
do for a commit they typed by hand: they configured the automation, they
|
||||
own its output, and they take responsibility for it when they merge it.
|
||||
The `Assisted-by` trailer still records that a model produced the code, so
|
||||
the provenance trail is unchanged.
|
||||
|
||||
The exception is narrow and does not widen the rule for anyone else:
|
||||
|
||||
- It applies only to automation a LocalAI maintainer operates and whose
|
||||
output that maintainer reviews before merge.
|
||||
- The sign-off names a real person who accepts DCO responsibility.
|
||||
- An AI assistant helping an outside contributor still MUST NOT sign off.
|
||||
That contributor adds their own trailer.
|
||||
- A bot MUST NOT sign off on behalf of anyone other than its operator, and
|
||||
MUST NOT add a trailer for a contributor whose branch it pushes to. If
|
||||
automation contributes to someone else's branch, it leaves the sign-off
|
||||
to that contributor.
|
||||
|
||||
## Attribution
|
||||
|
||||
When AI tools contribute to LocalAI development, proper attribution helps
|
||||
|
||||
@@ -236,58 +236,6 @@ Use these HTTP status codes:
|
||||
|
||||
If your endpoint should be tracked for usage (token counts, request counts), add the `usageMiddleware` to its middleware chain. See `core/http/middleware/usage.go` and how it's applied in `routes/openai.go`.
|
||||
|
||||
## Control-plane database health metrics
|
||||
|
||||
In distributed mode the frontend registers three OpenTelemetry gauges over the
|
||||
PostgreSQL control-plane database (`core/services/monitoring/control_plane_db.go`,
|
||||
wired in `core/application/distributed.go`). They reach `/metrics` through the
|
||||
same Prometheus exporter as the rest of the API metrics.
|
||||
|
||||
| Metric | Meaning | Page when |
|
||||
|--------|---------|-----------|
|
||||
| `localai_control_plane_oldest_xmin_age` | Transactions elapsed since the oldest snapshot any backend still holds | above a few million, and rising |
|
||||
| `localai_control_plane_longest_transaction_seconds` | Age of the longest open transaction | above 3600 |
|
||||
| `localai_control_plane_dead_tuple_ratio` | Dead tuples per live tuple, labelled by `table`, on `backend_nodes`, `node_models` and `gallery_operations` | sustained above ~10 on a small table |
|
||||
|
||||
A sustained high `localai_control_plane_oldest_xmin_age` is the one to page on.
|
||||
While it grows, autovacuum can reclaim nothing anywhere in the database no
|
||||
matter how often it runs, so the dead tuple ratio keeps climbing and a six-row
|
||||
registry table can reach hundreds of megabytes. Tuning autovacuum does not help.
|
||||
The fix is to find the transaction holding the horizon open and clear it:
|
||||
|
||||
```sql
|
||||
SELECT pid, state, age(backend_xmin) AS xmin_age, now() - xact_start AS xact_age, query
|
||||
FROM pg_stat_activity
|
||||
WHERE backend_xmin IS NOT NULL
|
||||
ORDER BY age(backend_xmin) DESC;
|
||||
```
|
||||
|
||||
Then `pg_terminate_backend(pid)` on the offenders, and `VACUUM (VERBOSE)` the
|
||||
bloated tables once the horizon has moved.
|
||||
|
||||
**A healthy-looking xmin age does not on its own prove the horizon is free.**
|
||||
The gauge reads `pg_stat_activity`, which only sees live backends. Two other
|
||||
things pin the very same horizon and are invisible there, so either one can hold
|
||||
vacuum back while the gauge reads 0:
|
||||
|
||||
```sql
|
||||
SELECT gid, prepared, database, transaction FROM pg_prepared_xacts;
|
||||
SELECT slot_name, active, xmin, catalog_xmin FROM pg_replication_slots;
|
||||
```
|
||||
|
||||
An orphaned prepared transaction is cleared with `ROLLBACK PREPARED '<gid>'`,
|
||||
and a stale slot with `pg_drop_replication_slot('<slot_name>')`. Check both
|
||||
before concluding that a bloated table has some other cause.
|
||||
|
||||
Sampling is scrape-driven behind a 30 second cache, so scrape frequency does not
|
||||
translate into database load. Failed and timed-out samples cost the same interval
|
||||
as successful ones, so a database that is already struggling is not retried on
|
||||
every scrape. A failed sample reports the last good values rather than failing the
|
||||
scrape, because these gauges matter most when the database is struggling. Before
|
||||
the first successful sample the gauges are absent rather than zero, since a zero
|
||||
xmin age would read as a healthy horizon: alert on `absent()` too if you need to
|
||||
distinguish "healthy" from "never sampled".
|
||||
|
||||
## Advertising surfaces — where to register a new capability
|
||||
|
||||
Beyond routing and auth, LocalAI publishes its capability surface in **four independent places**. When you add an endpoint — especially one introducing a net-new capability like a new media type or a new auth-gated feature — you must update every relevant surface. These aren't optional: missing them means the endpoint works but is invisible to clients, admins, and the UI.
|
||||
|
||||
@@ -45,26 +45,3 @@ Rules (both gates):
|
||||
- **Don't weaken the gate:** never hand-lower a baseline or widen a tolerance to turn a red gate green. The ratchet only moves up.
|
||||
- If a change drops coverage, **add tests** (sort `coverage-summary.json` by line% ascending to find untested code) rather than editing the baseline. When coverage legitimately rises, commit the regenerated baseline (`make test-coverage-baseline` / `test-ui-coverage-baseline`).
|
||||
- The Go gate is **strict — no tolerance**; `covermode=atomic` keeps it deterministic. The UI gate keeps a small tolerance only because its e2e coverage isn't.
|
||||
|
||||
## Distributed-mode test suites
|
||||
|
||||
Two suites cover distributed mode (frontend replicas, worker nodes, PostgreSQL), split by a Ginkgo label. Neither starts a message broker: a distributed deployment needs PostgreSQL and the frontends' own HTTP listener, and the `nats-io` modules are not in the build.
|
||||
|
||||
- `make test-e2e-distributed` runs `Distributed && !VLLMMultinode && !Cluster` over `./tests/e2e/distributed` recursively. Services are wired directly into the test binary. 231 specs in about 2m20s (measured 133.3s for the main suite plus the 8 argument-validation specs in the subpackage).
|
||||
- `make test-e2e-cluster` runs `Cluster` and spawns real `local-ai` child processes through the `tests/e2e/distributed/cluster` helper package. It **builds `./local-ai` itself** (the `e2e-binary` target), so you do not run `make build` first and cannot forget to. 26 specs, 933.8s of specs in a 15m37s target run (four measurements; the suite grew from 6 specs and roughly 509s, then 21 specs and 802.5s, then 24 specs and 897 to 907s, so budget on the newest number).
|
||||
|
||||
Both jobs live in `.github/workflows/tests-e2e-distributed.yml`, with `timeout-minutes: 45` each. They trigger on pull requests *and* on every push to `master`; the `paths-ignore` filter (see [.agents/ci-caching.md](ci-caching.md)) sits on the pull-request trigger only, so a master push always runs both. They are advisory only because `master` carries no branch protection, which is a repository setting and not a YAML key: `continue-on-error: true` would flip the run's *conclusion* to success and hide the failure, so it is not used.
|
||||
|
||||
- **The container is suite-scoped, not spec-scoped.** `SetupInfra` used to start a PostgreSQL (~10s) and a broker (~3.5s) per spec. Across the 213 specs behind it that was roughly **48 minutes of pure container startup per run**, which is why this suite was never in CI. (213 rather than the ~240 above: the larger number is everything the label filter selects, the smaller one is just the specs that call `SetupInfra`.) The broker container is gone entirely, and the PostgreSQL one now starts once in `BeforeSuite` with each spec getting its own database via `CREATE DATABASE` (~67ms), which is what the `dbName` argument was always describing. Adding a spec needs no change: call `SetupInfra("some-name")` as before, the name is a prefix and a counter keeps it unique.
|
||||
- **Consequence for new specs:** a fan-out carrier is *shared* within a Ginkgo process, so a wildcard subscriber can observe another spec's traffic. Filter assertions on an identifier your spec owns (a node ID, a job ID) instead of counting everything on `jobs.*.progress`, and verify the spec with `--randomize-all`.
|
||||
- **`BeforeSuite`, not `SynchronizedBeforeSuite`.** Under `ginkgo -p` each process then gets its own container, keeping each process's carrier isolated. A single shared database across parallel processes would let specs on different processes see each other's messages on the same subject.
|
||||
- **The label split.** The 8 argument-validation specs under `tests/e2e/distributed/cluster/` carry `Label("Distributed")` only, on purpose: they need no binary and no PostgreSQL, so they belong in the fast job. That is why `test-e2e-distributed` keeps `-r` (it must reach the subpackage) and `test-e2e-cluster` deliberately does **not** (the subpackage is out of its scope).
|
||||
- **The binary is built, and a stale one is refused.** `test-e2e-cluster` used to take `./local-ai` as given and only check that a file by that name existed. An edit that was never rebuilt therefore ran the entire suite against an older build while reporting on the working tree, which surfaced once as a spec failing on caller line numbers that matched no line in any source file. Two things close it. The target depends on `e2e-binary`, which is `go build -o local-ai ./cmd/local-ai` and not `make build` (that one also builds the React UI, which this suite never touches and CI stubs). And `localAIBinary` in the harness **fails**, on a developer box as well as under CI, when the binary is older than the newest non-test Go source in the tree, which covers a run started with `LOCALAI_E2E_BINARY` or by invoking ginkgo directly. `_test.go` files are excluded from that scan on purpose: they compile into the ginkgo suite and never into `local-ai`, so counting them would fire on every edit to the spec being run. Set `LOCALAI_E2E_BINARY` to opt out of the build; there is no opt-out of the staleness check, because a rebuild is the remedy.
|
||||
- **`--fail-on-empty` is load-bearing on both targets.** Ginkgo exits 0 when a label filter selects nothing, so without it a refactor that renames or drops `Label("Cluster")` leaves the target reporting "Test Suite Passed" having started no cluster at all. `LOCALAI_E2E_REQUIRE_BINARIES` does not cover this case: it only fires inside a spec that is actually running.
|
||||
- **The binary gate.** `localAIBinary()` and `mockBackendBinary()` **fail** rather than skip when `CI` is set, or when `LOCALAI_E2E_REQUIRE_BINARIES` is truthy; `LOCALAI_E2E_REQUIRE_BINARIES=0` (also `off`, `no`, `n`, `disabled`, and anything `strconv.ParseBool` reads as false) forces skipping even under CI. **Any value that parses as neither reads as ON**, not off: setting the variable to something meaningless means someone meant to turn the gate on, and reading it as false would quietly restore the silent skip the flag exists to remove. The whole polarity is deliberate, because in CI a skipped cluster spec is indistinguishable from a passing one: Ginkgo exits 0 on skips. Locally a missing binary still just skips, since `CI` is unset in an ordinary shell.
|
||||
- **Flake budget: no retries at all.** `--flake-attempts` is *total attempts*, not retries (ginkgo v2.29.0 `internal/group.go` sets `maxAttempts = FlakeAttempts` and loops `attempt < maxAttempts`; the flag's own usage string reads "0 - failed tests are not retried"). `DISTRIBUTED_TEST_FLAKES` defaults to **1**, so each spec runs once and a failure is a failure, and `test-e2e-cluster` pins `--flake-attempts 1` outright rather than reading the variable. The repo-wide `TEST_FLAKES=5` means up to five attempts, so up to four retries. These suites exist to surface nondeterminism, and a retry converts exactly that signal into a green run. Raise it locally when bisecting something unrelated, not in the Makefile.
|
||||
- **Coverage:** `tests/e2e/distributed` is excluded from the coverage roots (`COVERAGE_E2E_ROOTS = ./tests/e2e`, run non-recursively), and so is the `cluster` helper package beneath it. Neither suite moves the baseline, so production code that these suites are the only cover for reads as **uncovered**. Unit tests for such code belong under `./core/...` with `testutil.SetupTestDB()`.
|
||||
- **The cluster job builds against a stubbed React UI.** `core/http/react-ui/dist` is gitignored and built by Node, so the workflow writes a one-line `index.html` there to satisfy the `//go:embed react-ui/dist/*` in `core/http/app.go` and skips a full Node and Vite install. That holds only while the suite drives the HTTP API and never the UI, which has its own e2e suite. A spec that ever asserts on a UI asset would pass locally, where a real `dist/` exists, and be served the stub in CI: if you write one, the stub step has to go and the real build come back.
|
||||
- **The topology that matters is two frontends and two workers.** `cluster_busless_test.go` runs the whole programme on it, and it is the only shape in which the OWNER path and the RELAY path are live at the same time: with the two workers' tunnels landing on different replicas, every request either goes to the replica holding that worker's tunnel or is relayed through the one that does. A one-worker cluster cannot tell those apart, and cannot state that killing a replica re-homes only ITS worker. Which worker served a request is read back from `node_models` rather than assumed, and the two models are pinned to one worker each through the admin API (`PUT /api/nodes/:id/labels` plus `POST /api/nodes/scheduling`), so a selector that failed open reddens the spec instead of hiding in it.
|
||||
- **The fan-out carrier is proven under real binaries by `cluster_fanout_test.go`.** Both specs run two frontends and no workers against one PostgreSQL, publish at frontend 0 and read at frontend 1, and cost 7.0s together. They drive a gallery operation on purpose: `GET /models/jobs/<id>` answers from `galleryop`'s in-memory statuses map, which on a peer replica is filled by the `gallery.*.progress` subscriber and by nothing else (`Hydrate` runs once at startup, long before these operations exist), so a peer that answers was told over the carrier. Every other broadcast family has a durable table behind it that a peer would converge through anyway, and a spec on one of those cannot separate "the broadcast arrived" from "the row was read". The specs make that checkable rather than argued: the `gallery_operations` row is written when the gallery worker DEQUEUES an operation, so an operation still queued has none, and both specs assert zero rows while the peer is already answering with the operation's own bytes. Holding the queue is what `cluster.Options.Galleries` is for: it points every frontend at one gated index and turns the startup estimate warmer off, so the operation the spec submits is parked inside a fetch only the spec can complete. The second spec adds the oversized path: an element name of 9000 bytes puts the encoded notification past PostgreSQL's 8000-byte cap (exclusive), so the broadcast becomes a `bus_messages` row plus a notification carrying its id, and the spec asserts the row exists, decodes it, and compares its element name byte for byte with what frontend 1 answers. A one-byte control operation in the same run is asserted to leave NO row, which is what keeps "a row exists" a statement about size rather than one that is true of every operation.
|
||||
- **Do not shorten the cluster suite's waits.** Three of its six specs sit at ~167s each because they wait out a 60s staleness threshold plus a 15s health-check tick. That wait is what stops the assertions from passing before the system could have reacted, which was a real false green earlier on. If the job has to get faster, the levers are CI concurrency or making the thresholds configurable, not shorter waits.
|
||||
@@ -153,7 +153,7 @@ This is worth more than it looks. Measured over the week to 2026-07-30, **97% of
|
||||
|
||||
The volume is real: 13 gallery-only PRs merged that week with 10 open at once, and 78 of the 137 PRs opened were bot-generated.
|
||||
|
||||
`paths-ignore` on the PR trigger of `image-pr.yml` (7 jobs), `build-test.yaml` (3), `lint.yml` (2) and `tests-e2e.yml` (1) drops 13 of those 20, measured before `tests-e2e-distributed.yml` (2 jobs) landed. That workflow carries the same exclusion set for the same reason: its dependency graph is 99 packages, so an allowlist of paths would silently stop guarding the moment code moved, while a diff confined to the paths below provably cannot reach it. The excluded set:
|
||||
`paths-ignore` on the PR trigger of `image-pr.yml` (7 jobs), `build-test.yaml` (3), `lint.yml` (2) and `tests-e2e.yml` (1) drops 13 of those 20. The excluded set:
|
||||
|
||||
| Path | Why no image or Go build can see it |
|
||||
|---|---|
|
||||
@@ -192,7 +192,7 @@ What still runs, and why it has to:
|
||||
Two properties this relies on:
|
||||
|
||||
- `paths-ignore` skips a run only when **every** changed file matches, so a PR touching the gallery *and* Go code still runs everything. That is what makes the exclusion safe rather than a hole.
|
||||
- `master` carries no branch protection and no rulesets, so a skipped workflow reports no status and nothing waits on it. If required status checks are ever introduced, these five entries must be excluded from the required set or PRs will hang on "Expected — Waiting for status to be reported".
|
||||
- `master` carries no branch protection and no rulesets, so a skipped workflow reports no status and nothing waits on it. If required status checks are ever introduced, these four entries must be excluded from the required set or PRs will hang on "Expected — Waiting for status to be reported".
|
||||
|
||||
### `image.yml` on master push is gated too, by a job rather than a path filter
|
||||
|
||||
|
||||
@@ -77,56 +77,6 @@ spectrum. **Metal (Darwin) only** - it is a no-op on CUDA/CPU. Enable with
|
||||
budget). Gallery entries built on this: `deepseek-v4-flash-q4-ssd` (153 GB Flash
|
||||
on a 128 GB Mac) and `deepseek-v4-pro-q2-ssd` (433 GB Pro, experimental).
|
||||
|
||||
## CUDA architecture (do not build without one)
|
||||
|
||||
`backend/cpp/ds4/Makefile` drives upstream's **object targets** directly
|
||||
(`$(MAKE) -C ds4 ds4.o ds4_cuda.o ...`), which bypasses upstream's own guard:
|
||||
its `cuda` target refuses to build unless `CUDA_ARCH` is set, and offers
|
||||
`cuda-spark` (sm_121, DGX Spark / GB10) and `cuda-generic` (native) instead.
|
||||
Built with no `-arch`, nvcc targets its default architecture and the kernels run
|
||||
as JIT'd PTX. On GB10 that silently corrupted every prefill batch of >=128
|
||||
tokens - the model emitted text unrelated to the prompt and never closed its
|
||||
thinking block, so `content` came back empty - and cost close to two orders of
|
||||
magnitude of prefill throughput (4.21 t/s vs 325.70 t/s, same box, same model).
|
||||
Short prompts stayed correct, which is why it went unnoticed.
|
||||
|
||||
The Makefile therefore picks a gencode list from `CUDA_MAJOR_VERSION` (a build
|
||||
arg the backend matrix already declares, forwarded by `Dockerfile.ds4`) and
|
||||
`uname -m`, and passes it as `NVCC_ARCH_FLAGS` to the sub-make. Upstream's
|
||||
`CUDA_ARCH` accepts a single value, so it cannot express the fat binary the
|
||||
shipped images need; a command-line assignment beats its `:=`. An empty
|
||||
`CUDA_MAJOR_VERSION` falls back to upstream's `native` for local developer
|
||||
builds, and an unrecognised one is a hard error - no CI runner has a GPU, so a
|
||||
silent `native` there is exactly the failure mode this guards against.
|
||||
|
||||
`DS4_CUDA_HAVE_MXF4` is deliberately unset: upstream defines it only for
|
||||
single-arch sm_120/sm_121 builds and guards it with a plain `#ifdef` rather than
|
||||
`__CUDA_ARCH__`, so it cannot be combined with older archs. It gates an optional
|
||||
MXFP4 indexer fast path whose `#ifndef` branch returns 0, so omitting it costs
|
||||
speed, not correctness.
|
||||
|
||||
### Verifying a build
|
||||
|
||||
Check which flags a configuration resolves to, without compiling anything:
|
||||
|
||||
```
|
||||
make -C backend/cpp/ds4 BUILD_TYPE=cublas CUDA_MAJOR_VERSION=13 NATIVE=false \
|
||||
--eval='show: ; @echo [$(DS4_ARCH_MAKEVARS)]' show
|
||||
```
|
||||
|
||||
Do not use `make -n` for this: the recipe is `+$(MAKE) ...`, and the `+` prefix
|
||||
makes it run even under `-n`.
|
||||
|
||||
Then exercise the failure mode itself against a built backend. It only appears
|
||||
above one prefill batch, so the ordinary `predict` spec cannot catch it:
|
||||
|
||||
```
|
||||
BACKEND_BINARY=$(pwd)/backend/cpp/ds4/package/run.sh \
|
||||
BACKEND_TEST_MODEL_FILE=/path/to/ds4flash.gguf \
|
||||
BACKEND_TEST_CAPS=health,load,predict,long_prefill \
|
||||
go test -count=1 -timeout=30m -v ./tests/e2e-backends/...
|
||||
```
|
||||
|
||||
## Build matrix
|
||||
|
||||
| Build | Where | Notes |
|
||||
|
||||
@@ -94,4 +94,4 @@ The user chose KISS. Every mutating tool has a safety rule (`prompts/10_safety.m
|
||||
|
||||
## 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 PostgreSQL-backed node registry). MCP tools are not routed anywhere: the admin surface lives on the head, period.
|
||||
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.
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared compile logic for backend/Dockerfile.cachyllama.
|
||||
# Sourced (via bind mount) from both builder-fromsource and builder-prebuilt stages.
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
export CCACHE_DIR=/root/.ccache
|
||||
ccache --max-size=5G || true
|
||||
ccache -z || true
|
||||
|
||||
export CMAKE_ARGS="${CMAKE_ARGS:-} -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DCMAKE_CUDA_COMPILER_LAUNCHER=ccache"
|
||||
|
||||
if [[ -n "${CUDA_DOCKER_ARCH:-}" ]]; then
|
||||
CUDA_ARCH_ESC="${CUDA_DOCKER_ARCH//;/\\;}"
|
||||
export CMAKE_ARGS="${CMAKE_ARGS} -DCMAKE_CUDA_ARCHITECTURES=${CUDA_ARCH_ESC}"
|
||||
echo "CMAKE_ARGS(env) = ${CMAKE_ARGS}"
|
||||
rm -rf /LocalAI/backend/cpp/cachyllama-*-build
|
||||
fi
|
||||
|
||||
cd /LocalAI/backend/cpp/cachyllama
|
||||
|
||||
if [ -z "${BUILD_TYPE:-}" ]; then
|
||||
# Keep arm64 on the portable, fully linked build. CachyLLaMA's ARM
|
||||
# CPU_ALL_VARIANTS build includes SME variants that do not build reliably
|
||||
# across the Linux and Darwin toolchains used by backend CI.
|
||||
if [ "${TARGETARCH}" = "arm64" ]; then
|
||||
make cachyllama-fallback
|
||||
else
|
||||
# One ggml CPU_ALL_VARIANTS build replaces the per-microarch x86 binaries.
|
||||
make cachyllama-cpu-all
|
||||
fi
|
||||
else
|
||||
# GPU build (cublas/hipblas/sycl/vulkan/...): single fallback CPU build, the accelerator
|
||||
# does the compute. Keeps the GPU compile from also building the CPU variant matrix and
|
||||
# avoids the gcc-14 apt step on GPU base images such as nvidia l4t.
|
||||
make cachyllama-fallback
|
||||
fi
|
||||
make cachyllama-grpc
|
||||
make cachyllama-rpc-server
|
||||
|
||||
ccache -s || true
|
||||
@@ -59,9 +59,7 @@ backend/rust/*/target
|
||||
backend-images
|
||||
local-backends
|
||||
local-ai
|
||||
.claude
|
||||
.crush
|
||||
.tools
|
||||
protoc
|
||||
tests
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ This PR fixes #
|
||||
**Notes for Reviewers**
|
||||
|
||||
|
||||
**[Signed commits](../CONTRIBUTING.md#commit-messages)**
|
||||
**[Signed commits](../CONTRIBUTING.md#signing-off-on-commits-developer-certificate-of-origin)**
|
||||
- [ ] Yes, I signed my commits.
|
||||
- [ ] Documentation updated (docs/content/) for user-facing changes, or not applicable
|
||||
|
||||
|
||||
+66
-14
@@ -2584,7 +2584,7 @@ include:
|
||||
tag-latest: 'auto'
|
||||
tag-suffix: '-gpu-intel-vllm'
|
||||
runs-on: 'ubuntu-latest'
|
||||
base-image: "intel/oneapi-basekit:2025.3.2-0-devel-ubuntu24.04"
|
||||
base-image: "intel/oneapi-basekit:2025.3.0-0-devel-ubuntu24.04"
|
||||
skip-drivers: 'false'
|
||||
backend: "vllm"
|
||||
dockerfile: "./backend/Dockerfile.python"
|
||||
@@ -3754,19 +3754,6 @@ include:
|
||||
dockerfile: "./backend/Dockerfile.golang"
|
||||
context: "./"
|
||||
ubuntu-version: '2404'
|
||||
- build-type: 'hipblas'
|
||||
cuda-major-version: ""
|
||||
cuda-minor-version: ""
|
||||
platforms: 'linux/amd64'
|
||||
tag-latest: 'auto'
|
||||
tag-suffix: '-gpu-rocm-hipblas-stablediffusion-ggml'
|
||||
runs-on: 'ubuntu-latest'
|
||||
base-image: "rocm/dev-ubuntu-24.04:7.2.1"
|
||||
skip-drivers: 'false'
|
||||
backend: "stablediffusion-ggml"
|
||||
dockerfile: "./backend/Dockerfile.golang"
|
||||
context: "./"
|
||||
ubuntu-version: '2404'
|
||||
- build-type: 'sycl_f16'
|
||||
cuda-major-version: ""
|
||||
cuda-minor-version: ""
|
||||
@@ -6313,6 +6300,68 @@ include:
|
||||
context: "./"
|
||||
ubuntu-version: '2404'
|
||||
|
||||
# CachyLLaMA targets the lower-spec/APU use case with CPU and Vulkan builds.
|
||||
- build-type: ''
|
||||
cuda-major-version: ""
|
||||
cuda-minor-version: ""
|
||||
platforms: 'linux/amd64'
|
||||
platform-tag: 'amd64'
|
||||
tag-latest: 'auto'
|
||||
tag-suffix: '-cpu-cachyllama'
|
||||
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-amd64'
|
||||
runs-on: 'ubuntu-latest'
|
||||
base-image: "ubuntu:24.04"
|
||||
skip-drivers: 'false'
|
||||
backend: "cachyllama"
|
||||
dockerfile: "./backend/Dockerfile.cachyllama"
|
||||
context: "./"
|
||||
ubuntu-version: '2404'
|
||||
- build-type: ''
|
||||
cuda-major-version: ""
|
||||
cuda-minor-version: ""
|
||||
platforms: 'linux/arm64'
|
||||
platform-tag: 'arm64'
|
||||
tag-latest: 'auto'
|
||||
tag-suffix: '-cpu-cachyllama'
|
||||
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-arm64'
|
||||
runs-on: 'ubuntu-24.04-arm'
|
||||
base-image: "ubuntu:24.04"
|
||||
skip-drivers: 'false'
|
||||
backend: "cachyllama"
|
||||
dockerfile: "./backend/Dockerfile.cachyllama"
|
||||
context: "./"
|
||||
ubuntu-version: '2404'
|
||||
- build-type: 'vulkan'
|
||||
cuda-major-version: ""
|
||||
cuda-minor-version: ""
|
||||
platforms: 'linux/amd64'
|
||||
platform-tag: 'amd64'
|
||||
tag-latest: 'auto'
|
||||
tag-suffix: '-gpu-vulkan-cachyllama'
|
||||
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-vulkan-amd64'
|
||||
runs-on: 'ubuntu-latest'
|
||||
base-image: "ubuntu:24.04"
|
||||
skip-drivers: 'false'
|
||||
backend: "cachyllama"
|
||||
dockerfile: "./backend/Dockerfile.cachyllama"
|
||||
context: "./"
|
||||
ubuntu-version: '2404'
|
||||
- build-type: 'vulkan'
|
||||
cuda-major-version: ""
|
||||
cuda-minor-version: ""
|
||||
platforms: 'linux/arm64'
|
||||
platform-tag: 'arm64'
|
||||
tag-latest: 'auto'
|
||||
tag-suffix: '-gpu-vulkan-cachyllama'
|
||||
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-vulkan-arm64'
|
||||
runs-on: 'ubuntu-24.04-arm'
|
||||
base-image: "ubuntu:24.04"
|
||||
skip-drivers: 'false'
|
||||
backend: "cachyllama"
|
||||
dockerfile: "./backend/Dockerfile.cachyllama"
|
||||
context: "./"
|
||||
ubuntu-version: '2404'
|
||||
|
||||
# Darwin matrix (consumed by backend-jobs-darwin).
|
||||
includeDarwin:
|
||||
- backend: "diffusers"
|
||||
@@ -6339,6 +6388,9 @@ includeDarwin:
|
||||
- backend: "llama-cpp"
|
||||
tag-suffix: "-metal-darwin-arm64-llama-cpp"
|
||||
lang: "go"
|
||||
- backend: "cachyllama"
|
||||
tag-suffix: "-metal-darwin-arm64-cachyllama"
|
||||
lang: "go"
|
||||
- backend: "stablediffusion-ggml"
|
||||
tag-suffix: "-metal-darwin-arm64-stablediffusion-ggml"
|
||||
build-type: "metal"
|
||||
|
||||
+10
-15
@@ -3,9 +3,9 @@
|
||||
# darwin (Apple Silicon) install path. The macOS/Metal build
|
||||
# (backend/python/vllm/install.sh, Darwin branch) installs vllm-metal, which is
|
||||
# version-locked to a specific vLLM source release. install.sh derives that vLLM
|
||||
# version, and the wheel asset name, at build time from the pinned tag, so there
|
||||
# is only ONE value to bump here -- mirroring bump_vllm_wheel.sh, which bumps the
|
||||
# Linux cu130 wheel pin.
|
||||
# version at build time from vllm-metal's own installer (`vllm_v=`) at the pinned
|
||||
# tag, so there is only ONE value to bump here -- mirroring bump_vllm_wheel.sh,
|
||||
# which bumps the Linux cu130 wheel pin.
|
||||
#
|
||||
# This deliberately tracks vllm-project/vllm-metal, NOT vllm-project/vllm: the
|
||||
# darwin build can only use the exact vLLM version vllm-metal supports, so it may
|
||||
@@ -23,24 +23,19 @@ if [ -z "$FILE" ] || [ -z "$REPO" ] || [ -z "$VAR" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# vllm-metal ships frequent .dev releases, flagged as prereleases, alongside the
|
||||
# stable ones. /releases/latest skips the prereleases and returns the newest
|
||||
# stable tag, which is what darwin should pin: upstream deletes and re-cuts .dev
|
||||
# tags, and a pin to a deleted tag 404s the whole build.
|
||||
# vllm-metal ships frequent dev releases, all flagged as non-prerelease, so
|
||||
# /releases/latest returns the newest one (with its cp312 wheel asset).
|
||||
LATEST_TAG=$(gh_curl -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'])")
|
||||
|
||||
# The coupled vLLM release lives in .github/vllm-release-tag.commit at that tag
|
||||
# (since vllm-metal 0.28); releases predating that file pinned it inline in their
|
||||
# own install.sh. The extractor reads both forms.
|
||||
NEW_VLLM_VERSION=$( { gh_curl \
|
||||
"https://raw.githubusercontent.com/$REPO/$LATEST_TAG/.github/vllm-release-tag.commit" \
|
||||
|| gh_curl "https://raw.githubusercontent.com/$REPO/$LATEST_TAG/install.sh"; } \
|
||||
| "$(dirname "${BASH_SOURCE[0]}")/../scripts/lib/extract-vllm-metal-version.sh")
|
||||
# The coupled vLLM source version lives in vllm-metal's installer at that tag.
|
||||
NEW_VLLM_VERSION=$(gh_curl \
|
||||
"https://raw.githubusercontent.com/$REPO/$LATEST_TAG/install.sh" \
|
||||
| grep -oE 'vllm_v="[0-9]+\.[0-9]+\.[0-9]+"' | head -1 | cut -d'"' -f2)
|
||||
|
||||
if [ -z "$LATEST_TAG" ] || [ -z "$NEW_VLLM_VERSION" ]; then
|
||||
echo "Could not resolve vllm-metal tag ($LATEST_TAG) or its vLLM version ($NEW_VLLM_VERSION)." >&2
|
||||
echo "Could not resolve vllm-metal tag ($LATEST_TAG) or its vllm_v ($NEW_VLLM_VERSION)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
|
||||
home = Path("website/layouts/index.html").read_text()
|
||||
css = Path("website/static/css/site.css").read_text()
|
||||
install = Path("docs/content/getting-started/install.md").read_text()
|
||||
containers = Path("docs/content/getting-started/containers.md").read_text()
|
||||
|
||||
def require(condition, message):
|
||||
if not condition:
|
||||
raise SystemExit(f"FAIL: {message}")
|
||||
|
||||
require("Drop-in replacement for most upstream APIs." in home,
|
||||
"homepage must use the requested drop-in API heading")
|
||||
require("Everything else plugs into LocalAI." not in home,
|
||||
"old runtime heading must be removed")
|
||||
require("When the engine we need" not in home,
|
||||
"hero must describe user outcomes instead of team implementation")
|
||||
require('href="mailto:contact@localai.io"' in home and "business" in home.lower(),
|
||||
"homepage must provide a direct business contact action")
|
||||
require(home.index('id="localai"') < home.index('id="proof-quotes"') < home.index('id="mission"'),
|
||||
"headline testimonials must directly follow the runtime section")
|
||||
require(home.count('id="proof-quotes"') == 1,
|
||||
"headline testimonials must appear exactly once")
|
||||
require('id="engines"' not in home and "Engines we build" not in home,
|
||||
"homepage engine showcase must be removed")
|
||||
require('href="/docs/installation/index.html"' in home,
|
||||
"installation guide action must use the direct installation URL")
|
||||
require('<iframe' in install and "youtube.com/embed/cMVNnlqwfw4" in install,
|
||||
"installation page must embed the walkthrough video")
|
||||
require("## Quick Start" not in install,
|
||||
"installation landing page must not duplicate Quick Start")
|
||||
for text in ("CUDA 12", "CUDA 13", "ROCm", "Intel", "Jetson", "Vulkan", "fallback"):
|
||||
require(text.lower() in containers.lower(), f"GPU chooser must explain {text}")
|
||||
require('class="sn__e"><a href="https://github.com/mudler/parakeet.cpp">parakeet.cpp</a>' in home,
|
||||
"capability engine names must link to their repositories")
|
||||
require(".pane{min-height:" in css.replace(" ", ""),
|
||||
"all installation panes must have a fixed minimum height")
|
||||
|
||||
print("website review 143 source checks passed")
|
||||
PY
|
||||
+65
-1
@@ -29,6 +29,10 @@ updates:
|
||||
schedule:
|
||||
# Check for updates to GitHub Actions every weekday
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/backend/python/bark"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/backend/python/common/template"
|
||||
schedule:
|
||||
@@ -51,10 +55,30 @@ updates:
|
||||
ignore:
|
||||
- dependency-name: "torch"
|
||||
- dependency-name: "transformers"
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/backend/python/exllama"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/backend/python/exllama2"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/backend/python/mamba"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/backend/python/openvoice"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/backend/python/rerankers"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/backend/python/sentencetransformers"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/backend/python/transformers"
|
||||
schedule:
|
||||
@@ -62,4 +86,44 @@ updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/backend/python/vllm"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/examples/chainlit"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/examples/functions"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/examples/langchain/langchainpy-localai-example"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/examples/langchain-chroma"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/examples/streamlit-bot"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/examples/k8sgpt"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/examples/kubernetes"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/examples/langchain"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/examples/semantic-todo"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/examples/telegram-bot"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -230,6 +230,12 @@ jobs:
|
||||
make protogen-go
|
||||
make backends/llama-cpp-darwin
|
||||
|
||||
- name: Build CachyLLaMA backend (Darwin Metal)
|
||||
if: inputs.backend == 'cachyllama'
|
||||
run: |
|
||||
make protogen-go
|
||||
make backends/cachyllama-darwin
|
||||
|
||||
- name: Build ds4 backend (Darwin Metal)
|
||||
if: inputs.backend == 'ds4'
|
||||
run: |
|
||||
@@ -256,7 +262,7 @@ jobs:
|
||||
make backends/audio-cpp-darwin
|
||||
|
||||
- name: Build ${{ inputs.backend }}-darwin
|
||||
if: inputs.backend != 'llama-cpp' && inputs.backend != 'ds4' && inputs.backend != 'privacy-filter' && inputs.backend != 'audio-cpp'
|
||||
if: inputs.backend != 'llama-cpp' && inputs.backend != 'cachyllama' && inputs.backend != 'ds4' && inputs.backend != 'privacy-filter' && inputs.backend != 'audio-cpp'
|
||||
run: |
|
||||
make protogen-go
|
||||
BACKEND=${{ inputs.backend }} BUILD_TYPE=${{ inputs.build-type }} USE_PIP=${{ inputs.use-pip }} make build-darwin-${{ inputs.lang }}-backend
|
||||
|
||||
@@ -22,6 +22,10 @@ jobs:
|
||||
variable: "TURBOQUANT_VERSION"
|
||||
branch: "feature/turboquant-kv-cache"
|
||||
file: "backend/cpp/turboquant/Makefile"
|
||||
- repository: "fewtarius/CachyLLama"
|
||||
variable: "CACHYLLAMA_VERSION"
|
||||
branch: "master"
|
||||
file: "backend/cpp/cachyllama/Makefile"
|
||||
- repository: "PrismML-Eng/llama.cpp"
|
||||
variable: "BONSAI_VERSION"
|
||||
branch: "prism"
|
||||
@@ -166,7 +170,7 @@ jobs:
|
||||
push-to-fork: ci-forks/LocalAI
|
||||
commit-message: ':arrow_up: Update ${{ matrix.repository }}'
|
||||
title: 'chore: :arrow_up: Update ${{ matrix.repository }} to `${{ steps.bump.outputs.commit }}`'
|
||||
branch: "bump/${{ matrix.variable }}"
|
||||
branch: "update/${{ matrix.variable }}"
|
||||
body: ${{ steps.bump.outputs.message }}
|
||||
signoff: true
|
||||
|
||||
@@ -203,7 +207,7 @@ jobs:
|
||||
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: "bump/VLLM_VERSION"
|
||||
branch: "update/VLLM_VERSION"
|
||||
body: ${{ steps.bump.outputs.message }}
|
||||
signoff: true
|
||||
|
||||
@@ -241,6 +245,6 @@ jobs:
|
||||
push-to-fork: ci-forks/LocalAI
|
||||
commit-message: ':arrow_up: Update vllm-project/vllm-metal (darwin)'
|
||||
title: 'chore: :arrow_up: Update vllm-metal (darwin) to `${{ steps.bump.outputs.commit }}`'
|
||||
branch: "bump/VLLM_METAL_VERSION"
|
||||
branch: "update/VLLM_METAL_VERSION"
|
||||
body: ${{ steps.bump.outputs.message }}
|
||||
signoff: true
|
||||
@@ -31,14 +31,13 @@ jobs:
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: "Write a Discord message with a bullet point summary of the release notes. Keep the complete message under 1800 characters."
|
||||
content: "Write a discord message with a bullet point summary of the release notes."
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: $input
|
||||
}
|
||||
],
|
||||
max_tokens: 450
|
||||
]
|
||||
}')
|
||||
|
||||
# Send the request to LocalAI API
|
||||
@@ -47,7 +46,7 @@ jobs:
|
||||
-d "$json_payload")
|
||||
|
||||
# Extract the summary from the response
|
||||
summary=$(printf '%s' "$response" | jq -er '.choices[0].message.content | strings | .[0:1800]')
|
||||
summary=$(echo $response | jq -r '.choices[0].message.content')
|
||||
|
||||
# Print the summary
|
||||
# -H "Authorization: Bearer $API_KEY" \
|
||||
|
||||
@@ -14,7 +14,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: refresh-site-counters
|
||||
@@ -31,25 +30,15 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: ./.github/ci/refresh-site-counters.sh
|
||||
|
||||
- name: Show changes
|
||||
- name: Commit only if something moved
|
||||
run: |
|
||||
if git diff --quiet -- website/data/stats.yaml; then
|
||||
echo "counters unchanged"
|
||||
else
|
||||
git diff --unified=0 -- website/data/stats.yaml
|
||||
echo "counters unchanged, nothing to commit"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
- name: Create pull request when counters moved
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
token: ${{ secrets.UPDATE_BOT_TOKEN }}
|
||||
push-to-fork: ci-forks/LocalAI
|
||||
commit-message: "chore(website): refresh the counters"
|
||||
title: "chore(website): refresh the counters"
|
||||
body: |
|
||||
Weekly refresh of the landing-page counters from the GitHub API.
|
||||
|
||||
This PR was created automatically by the `refresh-site-counters` workflow.
|
||||
branch: update/site-counters
|
||||
delete-branch: true
|
||||
labels: automated
|
||||
git diff --unified=0 -- website/data/stats.yaml
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add website/data/stats.yaml
|
||||
git commit -m "chore(website): refresh the counters"
|
||||
git push
|
||||
@@ -526,7 +526,6 @@ jobs:
|
||||
- name: Build llama-cpp backend image and run gRPC e2e tests
|
||||
run: |
|
||||
make test-extra-backend-llama-cpp
|
||||
make test-extra-backend-llama-cpp-embeddings
|
||||
tests-llama-cpp-grpc-transcription:
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.llama-cpp == 'true' || needs.detect-changes.outputs.run-all == 'true'
|
||||
|
||||
@@ -65,12 +65,6 @@ jobs:
|
||||
- name: Test (with coverage gate)
|
||||
run: |
|
||||
PATH="$PATH:/root/go/bin" make --jobs 5 --output-sync=target test-coverage-check
|
||||
# tests/integration is outside the coverage roots because its store specs
|
||||
# need a live backend. test-stores builds and installs local-store before
|
||||
# running the complete suite, so new local-store specs are collected
|
||||
# automatically without adding another workflow entry.
|
||||
- name: Test local-store integration
|
||||
run: PATH="$PATH:$HOME/go/bin" make test-stores
|
||||
- name: Upload coverage report
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -80,13 +74,8 @@ jobs:
|
||||
coverage/coverage.out
|
||||
coverage/coverage.html
|
||||
if-no-files-found: ignore
|
||||
# tmate keeps the runner busy until the 6 hour job limit, so a single
|
||||
# failure costs a whole runner slot. Only open a session when someone
|
||||
# asked for one by labelling the pull request `ci-debug`, and cap the
|
||||
# session so a forgotten label cannot idle a runner either.
|
||||
- name: Setup tmate session if tests fail
|
||||
if: ${{ failure() && contains(github.event.pull_request.labels.*.name, 'ci-debug') }}
|
||||
timeout-minutes: 30
|
||||
if: ${{ failure() }}
|
||||
uses: mxschmitt/action-tmate@v3.23
|
||||
with:
|
||||
detached: true
|
||||
@@ -130,13 +119,8 @@ jobs:
|
||||
export PATH="/opt/homebrew/opt/make/libexec/gnubin:$PATH"
|
||||
PATH="$PATH:$HOME/go/bin" make protogen-go
|
||||
PATH="$PATH:$HOME/go/bin" BUILD_TYPE="GITHUB_CI_HAS_BROKEN_METAL" CMAKE_ARGS="-DGGML_F16C=OFF -DGGML_AVX512=OFF -DGGML_AVX2=OFF -DGGML_FMA=OFF" make --jobs 4 --output-sync=target test
|
||||
# tmate keeps the runner busy until the 6 hour job limit, so a single
|
||||
# failure costs a whole runner slot. Only open a session when someone
|
||||
# asked for one by labelling the pull request `ci-debug`, and cap the
|
||||
# session so a forgotten label cannot idle a runner either.
|
||||
- name: Setup tmate session if tests fail
|
||||
if: ${{ failure() && contains(github.event.pull_request.labels.*.name, 'ci-debug') }}
|
||||
timeout-minutes: 30
|
||||
if: ${{ failure() }}
|
||||
uses: mxschmitt/action-tmate@v3.23
|
||||
with:
|
||||
detached: true
|
||||
|
||||
@@ -77,13 +77,8 @@ jobs:
|
||||
- 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
|
||||
# tmate keeps the runner busy until the 6 hour job limit, so a single
|
||||
# failure costs a whole runner slot. Only open a session when someone
|
||||
# asked for one by labelling the pull request `ci-debug`, and cap the
|
||||
# session so a forgotten label cannot idle a runner either.
|
||||
- name: Setup tmate session if tests fail
|
||||
if: ${{ failure() && contains(github.event.pull_request.labels.*.name, 'ci-debug') }}
|
||||
timeout-minutes: 30
|
||||
if: ${{ failure() }}
|
||||
uses: mxschmitt/action-tmate@v3.23
|
||||
with:
|
||||
detached: true
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
---
|
||||
name: 'E2E Distributed Tests'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
# The suite's dependency graph is 99 packages, so an allowlist of paths
|
||||
# silently stops guarding the moment code moves. At ~75s the job is cheap
|
||||
# enough to run unless the diff is confined to paths it provably cannot
|
||||
# reach. See .agents/ci-caching.md.
|
||||
paths-ignore:
|
||||
- 'gallery/**'
|
||||
- 'docs/**'
|
||||
- 'examples/**'
|
||||
- '**/*.md'
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
concurrency:
|
||||
group: ci-tests-e2e-distributed-${{ github.event.pull_request.number || github.sha }}-${{ github.repository }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
tests-e2e-distributed:
|
||||
runs-on: ubuntu-latest
|
||||
# Advisory because it is deliberately not in branch protection, so a failure
|
||||
# is a visible red X rather than a blocked merge. Promoting it to a required
|
||||
# check is a repository-settings change, to be made once it has a track
|
||||
# record; a heavy suite made required on day one gets disabled instead of
|
||||
# fixed.
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: true
|
||||
- name: Configure apt mirror on runner
|
||||
uses: ./.github/actions/configure-apt-mirror
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.26.0'
|
||||
cache: false
|
||||
- name: Dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential libopus-dev
|
||||
- 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: Pre-pull test images
|
||||
# Pulling here rather than inside the suite keeps container-start timing
|
||||
# out of the spec timeouts and makes a registry outage read as a
|
||||
# setup failure instead of a test failure. This is the only image the
|
||||
# suite needs once the testcontainers reaper is disabled below: the
|
||||
# suite stands up no message broker, because nothing under test dials
|
||||
# one.
|
||||
run: |
|
||||
docker pull postgres:16-alpine
|
||||
- name: Distributed E2E
|
||||
# TESTCONTAINERS_RYUK_DISABLED keeps the pre-pull above meaningful. The
|
||||
# reaper exists to clean up leaked containers on a long-lived host, but
|
||||
# this runner is ephemeral and every container dies with the VM. Leaving
|
||||
# it enabled would pull a third, unpinned image (testcontainers/ryuk)
|
||||
# from Docker Hub mid-suite: exactly the registry dependency the
|
||||
# pre-pull step exists to remove.
|
||||
env:
|
||||
TESTCONTAINERS_RYUK_DISABLED: "true"
|
||||
run: |
|
||||
PATH="$PATH:$HOME/go/bin" make test-e2e-distributed
|
||||
- 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-cluster:
|
||||
runs-on: ubuntu-latest
|
||||
# Advisory for the same reason as the job above: master has no branch
|
||||
# protection, so a failure here is a visible red X rather than a blocked
|
||||
# merge. That is a repository-settings property, not a YAML key. The key
|
||||
# that looks like it says "advisory" instead flips the run's conclusion to
|
||||
# success, which hides the failure rather than flagging it, so it appears in
|
||||
# none of this repo's workflows and must not be added here.
|
||||
#
|
||||
# Separate job from tests-e2e-distributed so the fast in-process suite is
|
||||
# not held behind a Go build of local-ai. Serial on purpose: each Ginkgo
|
||||
# process would get its own PostgreSQL container and each spec spawns two or
|
||||
# three local-ai children, so --procs on an unmeasured runner is a change to
|
||||
# make with numbers, not by default.
|
||||
#
|
||||
# The two timeouts bound different things and are not alternatives. Ginkgo's
|
||||
# --timeout=20m bounds the SUITE only; this job timeout must additionally
|
||||
# cover setup, which here is the larger and more variable half: submodule
|
||||
# checkout, apt, protoc plus two go installs plus protogen-go, a cold-cache
|
||||
# module download (cache: false), a full go build of ./cmd/local-ai, and a
|
||||
# separate ginkgo test compile. That build alone is ~316s of CPU, so on a
|
||||
# 4-vCPU runner setup is realistically 8-12 minutes.
|
||||
#
|
||||
# 45 minutes therefore, matching the sibling job. A tighter number does not
|
||||
# make a hang fail faster, it just moves the kill from Ginkgo, which prints
|
||||
# which spec hung, to the runner, which prints nothing: a red job with no
|
||||
# evidence, which is how a suite gets disabled rather than fixed.
|
||||
#
|
||||
# The suite itself is about 8m30s over three consecutive runs (509.1s /
|
||||
# 509.8s / 512.3s, so 8m29s to 8m32s) on a developer box, and will be slower
|
||||
# here. Three specs sit at ~167s each because they wait out a 60s staleness
|
||||
# threshold plus a 15s health-check tick (HealthCheckInterval, in
|
||||
# core/config/distributed_config.go; core/services/nodes/health.go runs the
|
||||
# ticker on the unexported checkInterval, not one of the reconcilers). Do
|
||||
# not shorten those windows to make this job faster: the wait is what stops
|
||||
# the assertions from passing before the system could have reacted, which
|
||||
# was a real false green earlier on.
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: true
|
||||
- name: Configure apt mirror on runner
|
||||
uses: ./.github/actions/configure-apt-mirror
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.26.0'
|
||||
cache: false
|
||||
- name: Dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential libopus-dev
|
||||
- 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: Stub the embedded React UI
|
||||
# core/http/react-ui/dist is gitignored and built by Node, but this
|
||||
# suite drives the HTTP API and never the UI, which has its own e2e
|
||||
# suite. A single index.html satisfies the //go:embed react-ui/dist/*
|
||||
# in core/http/app.go, so the job skips a full Node and Vite install.
|
||||
# If a cluster spec ever asserts on a UI asset, this step must go and
|
||||
# the real build come back: a developer box has a real dist/, so such a
|
||||
# spec would pass locally and fail only here, or worse be served the
|
||||
# stub and pass in both places.
|
||||
run: |
|
||||
mkdir -p core/http/react-ui/dist
|
||||
printf '<!doctype html><title>stub</title>\n' > core/http/react-ui/dist/index.html
|
||||
- name: Pre-pull test images
|
||||
# Same reasoning as the job above: pulling here keeps container-start
|
||||
# timing out of the spec timeouts and makes a registry outage read as a
|
||||
# setup failure rather than a test failure.
|
||||
run: |
|
||||
docker pull postgres:16-alpine
|
||||
- name: Cluster E2E
|
||||
env:
|
||||
# No LOCALAI_E2E_BINARY and no separate build step: make test-e2e-cluster
|
||||
# builds ./local-ai itself, AFTER the protogen-go it also depends on.
|
||||
# Building it in a step of its own put the generated .pb.go files newer
|
||||
# than the binary, which the harness's staleness check reads, correctly,
|
||||
# as a binary that does not contain the tree it is about to be measured
|
||||
# against. One owner for the build is also what makes a local run and
|
||||
# this job exercise the same bytes.
|
||||
#
|
||||
# Must live under the workspace so the upload step below can reach it.
|
||||
# The harness defaults to GinkgoT().TempDir(), which lands under
|
||||
# TMPDIR and would leave the artifact glob matching nothing.
|
||||
LOCALAI_E2E_LOG_DIR: ${{ github.workspace }}/cluster-logs
|
||||
# Belt and braces: the harness already fails rather than skips when CI
|
||||
# is set, and GitHub Actions always sets CI. Stating it here means a
|
||||
# future edit to that default cannot silently turn this job into one
|
||||
# that passes without ever starting a cluster, since a skipped cluster
|
||||
# spec is indistinguishable from a passing one.
|
||||
LOCALAI_E2E_REQUIRE_BINARIES: "true"
|
||||
# See the job above: the runner is ephemeral, so the reaper buys
|
||||
# nothing and would pull a third, unpinned Docker Hub image mid-suite.
|
||||
TESTCONTAINERS_RYUK_DISABLED: "true"
|
||||
run: |
|
||||
PATH="$PATH:$HOME/go/bin" make test-e2e-cluster
|
||||
- name: Upload process logs
|
||||
# The per-process logs are the only way to read a cluster failure: the
|
||||
# Ginkgo output says which assertion failed, not what the four child
|
||||
# processes were doing. Without this a red job is undebuggable.
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: cluster-process-logs
|
||||
path: cluster-logs/**/*.log
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
- 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
|
||||
@@ -63,13 +63,8 @@ jobs:
|
||||
- name: Test Backend E2E
|
||||
run: |
|
||||
PATH="$PATH:$HOME/go/bin" make build-mock-backend test-e2e
|
||||
# tmate keeps the runner busy until the 6 hour job limit, so a single
|
||||
# failure costs a whole runner slot. Only open a session when someone
|
||||
# asked for one by labelling the pull request `ci-debug`, and cap the
|
||||
# session so a forgotten label cannot idle a runner either.
|
||||
- name: Setup tmate session if tests fail
|
||||
if: ${{ failure() && contains(github.event.pull_request.labels.*.name, 'ci-debug') }}
|
||||
timeout-minutes: 30
|
||||
if: ${{ failure() }}
|
||||
uses: mxschmitt/action-tmate@v3.23
|
||||
with:
|
||||
detached: true
|
||||
|
||||
@@ -88,13 +88,8 @@ jobs:
|
||||
# CPU and runs the token_classify capability spec (byte-offset contract).
|
||||
- name: Run live PII NER backend E2E
|
||||
run: PATH="$PATH:$HOME/go/bin" make test-extra-backend-privacy-filter
|
||||
# tmate keeps the runner busy until the 6 hour job limit, so a single
|
||||
# failure costs a whole runner slot. Only open a session when someone
|
||||
# asked for one by labelling the pull request `ci-debug`, and cap the
|
||||
# session so a forgotten label cannot idle a runner either.
|
||||
- name: Setup tmate session if tests fail
|
||||
if: ${{ failure() && contains(github.event.pull_request.labels.*.name, 'ci-debug') }}
|
||||
timeout-minutes: 30
|
||||
if: ${{ failure() }}
|
||||
uses: mxschmitt/action-tmate@v3.23
|
||||
with:
|
||||
detached: true
|
||||
|
||||
@@ -52,8 +52,6 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential libopus-dev
|
||||
- name: Run stale chunk recovery tests
|
||||
run: PATH="$PATH:$HOME/go/bin" make test-ui-stale-chunk
|
||||
# Builds an instrumented UI bundle, runs the Playwright specs, and fails
|
||||
# if line coverage regressed beyond the jitter tolerance (the gate is
|
||||
# in `make test-ui-coverage-check`). PLAYWRIGHT_CHROMIUM_PATH is unset
|
||||
@@ -75,13 +73,8 @@ jobs:
|
||||
path: core/http/react-ui/coverage/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
# tmate keeps the runner busy until the 6 hour job limit, so a single
|
||||
# failure costs a whole runner slot. Only open a session when someone
|
||||
# asked for one by labelling the pull request `ci-debug`, and cap the
|
||||
# session so a forgotten label cannot idle a runner either.
|
||||
- name: Setup tmate session if tests fail
|
||||
if: ${{ failure() && contains(github.event.pull_request.labels.*.name, 'ci-debug') }}
|
||||
timeout-minutes: 30
|
||||
if: ${{ failure() }}
|
||||
uses: mxschmitt/action-tmate@v3.23
|
||||
with:
|
||||
detached: true
|
||||
|
||||
@@ -19,22 +19,7 @@ linters:
|
||||
- staticcheck
|
||||
enable:
|
||||
- forbidigo
|
||||
# gocritic is enabled for ONE checker: ruleguard, which runs the rules in
|
||||
# hack/lint/. Every other gocritic check is off (disable-all below), so
|
||||
# this adds no style noise; it is here purely as the gate that catches a
|
||||
# gRPC backend wrapper written without Unwrap. See
|
||||
# hack/lint/backend_wrappers.go for why that cannot be a compile-time
|
||||
# assertion.
|
||||
- gocritic
|
||||
settings:
|
||||
gocritic:
|
||||
disable-all: true
|
||||
enabled-checks:
|
||||
- ruleguard
|
||||
settings:
|
||||
ruleguard:
|
||||
failOn: all
|
||||
rules: '${base-path}/hack/lint/backend_wrappers.go'
|
||||
forbidigo:
|
||||
forbid:
|
||||
- pattern: '^t\.Errorf$'
|
||||
@@ -141,10 +126,3 @@ linters:
|
||||
- path: ^backend/go/whisper/sources/
|
||||
text: 'http\.(DefaultClient|Get|Post|PostForm|Head)'
|
||||
linters: [forbidigo]
|
||||
# Test doubles embed grpc.Backend to inherit the interface's method set
|
||||
# over a NIL value; they decorate nothing, hold no inner client, and have
|
||||
# no transport answer to forward. The rule targets production wrappers,
|
||||
# which is where swallowing that answer deletes replica rows.
|
||||
# gocritic here is only the backend-wrapper ruleguard rule.
|
||||
- path: _test\.go$
|
||||
linters: [gocritic]
|
||||
@@ -1,21 +0,0 @@
|
||||
## Design Context
|
||||
|
||||
### Users
|
||||
|
||||
LocalAI serves both single-host users who want to install and try models quickly and experienced developers, ML engineers, system administrators, and DevOps operators who manage production hosts or distributed clusters. The interface must support first-time discovery without hiding the runtime state, configuration, and control that returning operators need.
|
||||
|
||||
### Brand Personality
|
||||
|
||||
Capable, easy to use, and trustworthy. The interface should make sophisticated local-AI infrastructure feel understandable and under control. It should be direct and calm rather than playful, ornamental, or intimidating.
|
||||
|
||||
### Aesthetic Direction
|
||||
|
||||
Use LocalAI's established technical, editorial design language: Geist typography, compact information density, sharp geometry, deep blue-black surfaces, action blue, mint for healthy/local/live state, and amber only for decisions requiring attention. Support both dark and light themes. Avoid generic card dashboards, decorative gradients, glass effects, and visual noise.
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. Use progressive disclosure to serve newcomers and operators in the same workflow: make the common path obvious, then reveal operational depth in context.
|
||||
2. Organize navigation around user intent and lifecycle state, not implementation concepts or nested containers.
|
||||
3. Give each resource one canonical home; expose discovery, installed state, and runtime state as clear views of that resource instead of duplicating management surfaces.
|
||||
4. Keep operational status visible and trustworthy through precise labels, explicit scope, and actionable state—not decoration.
|
||||
5. Preserve information density for expert use while flattening navigation and reducing repeated summaries, tabs, rails, and panels.
|
||||
@@ -27,7 +27,6 @@ To be removed, open a pull request deleting your row, or email
|
||||
|
||||
| Organisation | What they use it for | Status |
|
||||
|---|---|---|
|
||||
| [walcz.de](https://walcz.de) | Self-hosted appliance for a German B2B consultancy: local-only inference on AMD Strix Halo (gfx1151/ROCm), agents with MCP tools, RAG over an internal knowledge base, and a document/bookkeeping pipeline. | Production |
|
||||
| _Your organisation here_ | | |
|
||||
|
||||
## What this list is not
|
||||
|
||||
@@ -8,7 +8,7 @@ Human contributors: see [CONTRIBUTING.md](CONTRIBUTING.md) for the development w
|
||||
|
||||
LocalAI follows the Linux kernel project's [guidelines for AI coding assistants](https://docs.kernel.org/process/coding-assistants.html). Before submitting AI-assisted code, read [.agents/ai-coding-assistants.md](.agents/ai-coding-assistants.md). Key rules:
|
||||
|
||||
- **No `Signed-off-by` from AI.** Only the human submitter may sign off on the Developer Certificate of Origin. One exception: automation a maintainer operates signs off with *that maintainer's* identity, since no other human submitter exists to certify it. See [.agents/ai-coding-assistants.md](.agents/ai-coding-assistants.md).
|
||||
- **No `Signed-off-by` from AI.** Only the human submitter may sign off on the Developer Certificate of Origin.
|
||||
- **No `Co-Authored-By: <AI>` trailers.** The human contributor owns the change.
|
||||
- **Use an `Assisted-by:` trailer** to attribute AI involvement. Format: `Assisted-by: AGENT_NAME:MODEL_VERSION [TOOL1] [TOOL2]`.
|
||||
- **The human submitter is responsible** for reviewing, testing, and understanding every line of generated code.
|
||||
@@ -33,7 +33,6 @@ LocalAI follows the Linux kernel project's [guidelines for AI coding assistants]
|
||||
| [.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 |
|
||||
| [.agents/backend-signing.md](.agents/backend-signing.md) | Backend OCI image signing (keyless cosign + sigstore-go) — producer-side CI setup, consumer-side gallery `verification:` block, strict mode (`LOCALAI_REQUIRE_BACKEND_INTEGRITY`), revocation via `not_before` |
|
||||
| [.agents/preparing-a-release.md](.agents/preparing-a-release.md) | Cutting a release: PR labels, `RELEASE_NOTES_vX.Y.Z.md`, the blog post under `website/content/blog/`, and the demo clips under `website/static/media/` |
|
||||
| [.impeccable.md](.impeccable.md) | Design context for UI/UX work — users, brand personality, aesthetic direction, and design principles |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
|
||||
+1
-32
@@ -218,7 +218,7 @@ LocalAI follows the **same guidelines as the Linux kernel project** for AI-assis
|
||||
|
||||
The full policy for this repository lives in [`.agents/ai-coding-assistants.md`](.agents/ai-coding-assistants.md). Summary:
|
||||
|
||||
- **AI agents MUST NOT add `Signed-off-by` tags.** Only humans can certify the Developer Certificate of Origin. Automation operated by a maintainer is the one exception: it signs off with that maintainer's identity, because there is no other human submitter to certify it.
|
||||
- **AI agents MUST NOT add `Signed-off-by` tags.** Only humans can certify the Developer Certificate of Origin.
|
||||
- **AI agents MUST NOT add `Co-Authored-By` trailers** attributing themselves as co-authors.
|
||||
- **Attribute AI involvement with an `Assisted-by` trailer** in the commit message:
|
||||
|
||||
@@ -265,37 +265,6 @@ The e2e tests run LocalAI in a Docker container and exercise the API:
|
||||
make test-e2e
|
||||
```
|
||||
|
||||
### Running distributed-mode tests
|
||||
|
||||
Distributed mode (several frontend replicas, worker nodes and PostgreSQL) has two suites. Both bring up their PostgreSQL with testcontainers, so Docker has to be available. There is no message broker to bring up: a distributed deployment needs PostgreSQL and the frontends' own HTTP listener, and nothing else.
|
||||
|
||||
```bash
|
||||
make test-e2e-distributed # in-process: services wired directly into the test binary
|
||||
make test-e2e-cluster # process-level: real local-ai child processes
|
||||
```
|
||||
|
||||
`make test-e2e-distributed` is the fast one (231 specs in roughly 2m20s). It starts one PostgreSQL for the whole run and gives each spec its own database. It runs each spec exactly once, with no retry: `DISTRIBUTED_TEST_FLAKES` defaults to 1 and feeds ginkgo's `--flake-attempts`, which counts *total attempts*, not retries. That is deliberately below the repo-wide `TEST_FLAKES=5`, because this suite exists to catch nondeterministic cluster behaviour and a retry hides exactly the failure it is meant to catch. Raise it locally when bisecting something unrelated.
|
||||
|
||||
`make test-e2e-cluster` runs `local-ai` as real child processes, one per frontend replica and one per worker, so a spec can kill a replica and assert what the survivors do. Budget about 13m30s (one measurement: 802.5s of specs in a 13m26s target run, which includes building the binary): several of its 21 specs wait out real staleness and health-check windows. An earlier record of 6 specs and roughly 509s is superseded. It needs a built binary and the mock backend:
|
||||
|
||||
```bash
|
||||
make build build-mock-backend
|
||||
make test-e2e-cluster
|
||||
```
|
||||
|
||||
Two environment variables steer it:
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `LOCALAI_E2E_BINARY` | path to the `local-ai` binary (default: `local-ai` in the repository root) |
|
||||
| `LOCALAI_E2E_LOG_DIR` | directory for the per-process logs (default: a Ginkgo temp dir) |
|
||||
|
||||
Set `LOCALAI_E2E_LOG_DIR` when debugging. A cluster failure is unreadable without the individual frontend and worker logs, and Ginkgo only tells you which assertion failed.
|
||||
|
||||
A missing binary skips the cluster specs locally but fails them whenever `CI` is set, so a build problem cannot turn the CI job green without ever starting a cluster. `LOCALAI_E2E_REQUIRE_BINARIES=1` forces that failing behaviour anywhere; `LOCALAI_E2E_REQUIRE_BINARIES=0` forces the skip back on even under CI.
|
||||
|
||||
Both suites run in `.github/workflows/tests-e2e-distributed.yml`, on pull requests and on every push to `master`. The `paths-ignore` filter is on the pull-request trigger only, so a master push always runs them.
|
||||
|
||||
### React UI tests and coverage
|
||||
|
||||
The React UI (`core/http/react-ui/`) is covered by Playwright e2e specs, gated by a **monotonic line-coverage ratchet** (`make test-ui-coverage-check`, run in CI). The metric is non-deterministic — a fast local box reads higher than a slow CI runner for the same code — so a small tolerance is unavoidable.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Disable parallel execution for backend builds
|
||||
.NOTPARALLEL: backends/diffusers backends/llama-cpp backends/turboquant backends/bonsai backends/outetts backends/piper backends/stablediffusion-ggml backends/trellis2cpp backends/trellis2cpp-darwin backends/whisper backends/crispasr backends/parakeet-cpp backends/moss-transcribe-cpp backends/nemo-speech-cpp backends/faster-whisper backends/silero-vad backends/local-store backends/valkey-store backends/cloud-proxy backends/huggingface backends/rfdetr backends/rfdetr-cpp 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/longcat-video 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/moss-tts-cpp backends/magpie-tts-cpp backends/vllm-cpp backends/omnivoice-cpp backends/vibevoice-cpp backends/localvqe backends/tinygrad backends/sherpa-onnx backends/ds4 backends/ds4-darwin backends/liquid-audio backends/supertonic backends/depth-anything-cpp backends/privacy-filter backends/privacy-filter-darwin backends/audio-cpp backends/audio-cpp-darwin
|
||||
.NOTPARALLEL: backends/diffusers backends/llama-cpp backends/turboquant backends/cachyllama backends/cachyllama-darwin backends/bonsai backends/outetts backends/piper backends/stablediffusion-ggml backends/trellis2cpp backends/trellis2cpp-darwin backends/whisper backends/crispasr backends/parakeet-cpp backends/moss-transcribe-cpp backends/nemo-speech-cpp backends/faster-whisper backends/silero-vad backends/local-store backends/valkey-store backends/cloud-proxy backends/huggingface backends/rfdetr backends/rfdetr-cpp 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/longcat-video 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/moss-tts-cpp backends/magpie-tts-cpp backends/vllm-cpp backends/omnivoice-cpp backends/vibevoice-cpp backends/localvqe backends/tinygrad backends/sherpa-onnx backends/ds4 backends/ds4-darwin backends/liquid-audio backends/supertonic backends/depth-anything-cpp backends/privacy-filter backends/privacy-filter-darwin backends/audio-cpp backends/audio-cpp-darwin
|
||||
|
||||
GOCMD=go
|
||||
GOTEST=$(GOCMD) test
|
||||
@@ -34,11 +34,6 @@ TEST_FLAKES?=5
|
||||
RANDOM := $(shell bash -c 'echo $$RANDOM')
|
||||
|
||||
VERSION?=$(shell git describe --always --tags || echo "dev" )
|
||||
# fyne package only accepts numeric x[.y[.z]] app versions, so reduce git
|
||||
# describe output (v4.9.0, v4.9.0-14-gabc1234, or a bare sha on untagged
|
||||
# checkouts) to its numeric core; anything non-numeric falls back to 0.0.0.
|
||||
# Without this the packaged launcher reports itself as version 0.0.0 (#11673).
|
||||
LAUNCHER_APP_VERSION?=$(shell v=$$(echo "$(VERSION)" | sed -E 's/^v//; s/[+-].*$$//'); echo "$$v" | grep -qE '^[0-9]+(\.[0-9]+){0,2}$$' && echo "$$v" || echo "0.0.0")
|
||||
# go tool nm ./local-ai | grep Commit
|
||||
LD_FLAGS?=-s -w
|
||||
override LD_FLAGS += -X "github.com/mudler/LocalAI/internal.Version=$(VERSION)"
|
||||
@@ -108,7 +103,7 @@ COVERAGE_E2E_LABELS?=!real-models
|
||||
COVERAGE_EXCLUDE_RE?=grpc/proto/.*[.]pb[.]go
|
||||
|
||||
|
||||
.PHONY: all test test-coverage test-coverage-baseline test-coverage-check test-backend-cpp test-build-scripts test-ui test-ui-stale-chunk test-ui-coverage-baseline test-ui-coverage-check build vendor lint lint-all
|
||||
.PHONY: all test test-coverage test-coverage-baseline test-coverage-check test-backend-cpp test-build-scripts test-ui test-ui-coverage-baseline test-ui-coverage-check build vendor lint lint-all
|
||||
|
||||
all: help
|
||||
|
||||
@@ -240,7 +235,7 @@ test-ci-scripts:
|
||||
## pure stdlib on purpose so they run without any backend venv; the list is
|
||||
## explicit because their siblings (model_identity_test) import grpc and the
|
||||
## generated protobufs, which only exist inside a built backend.
|
||||
PYTHON_HELPER_TESTS?=python_utils_test vllm_utils_test model_utils_test mlx_utils_test parent_watch_test temp_utils_test
|
||||
PYTHON_HELPER_TESTS?=python_utils_test vllm_utils_test model_utils_test mlx_utils_test parent_watch_test
|
||||
test-python-helpers:
|
||||
cd backend/python/common && python3 -m unittest $(PYTHON_HELPER_TESTS)
|
||||
|
||||
@@ -340,108 +335,12 @@ run-e2e-aio: protogen-go
|
||||
@echo 'Running e2e AIO tests'
|
||||
$(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --flake-attempts $(TEST_FLAKES) -v -r ./tests/e2e-aio
|
||||
|
||||
# Total ginkgo attempts per spec for the distributed suite: --flake-attempts counts
|
||||
# attempts, not retries. Defaults to 1, so each spec runs once and is never retried,
|
||||
# unlike TEST_FLAKES=5. This suite exists to catch nondeterministic cluster behaviour,
|
||||
# and a retry hides exactly the failures it is meant to surface. Raise it locally if
|
||||
# you are bisecting something unrelated.
|
||||
DISTRIBUTED_TEST_FLAKES?=1
|
||||
|
||||
# Distributed architecture e2e (PostgreSQL via testcontainers). Requires Docker.
|
||||
# There is no broker container and no NatsJWT label any more: a distributed
|
||||
# deployment needs PostgreSQL and the frontends' own HTTP listener, and this
|
||||
# suite stands up exactly that.
|
||||
# Distributed architecture e2e (PostgreSQL + NATS via testcontainers).
|
||||
# Includes NatsJWT specs (JWT-enabled NATS). Requires Docker.
|
||||
# VLLMMultinode is excluded here; use test-e2e-vllm-multinode for that.
|
||||
# Cluster is excluded too and runs in test-e2e-cluster below, which needs a
|
||||
# built binary. The argument-validation specs under tests/e2e/distributed/cluster
|
||||
# carry Label("Distributed") only, so they run here and not there, on purpose.
|
||||
# -r stays because of those: they are in a subpackage this target must reach.
|
||||
# --fail-on-empty because ginkgo exits 0 when a label filter matches nothing, so
|
||||
# without it a rename of the label would turn this target into a silent no-op
|
||||
# that still reports "Test Suite Passed".
|
||||
#
|
||||
# This target needs NO built binary and therefore has no stale-binary hole:
|
||||
# ginkgo compiles the suite from the working tree on every run, and everything
|
||||
# these specs exercise runs in that process against containers. The one target
|
||||
# in this file that does exec a prebuilt binary is test-e2e-cluster below, and
|
||||
# the reason it now BUILDS it is written there.
|
||||
test-e2e-distributed: protogen-go
|
||||
@echo 'Running distributed e2e tests (label Distributed)'
|
||||
$(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --label-filter='Distributed && !VLLMMultinode && !Cluster' --fail-on-empty --flake-attempts $(DISTRIBUTED_TEST_FLAKES) --timeout=40m -v -r ./tests/e2e/distributed
|
||||
|
||||
# The local-ai binary the cluster e2e execs as its frontend and worker
|
||||
# processes.
|
||||
#
|
||||
# Deliberately not `make build`: that target also builds the embedded React UI,
|
||||
# which this suite never touches, and CI stubs core/http/react-ui/dist for
|
||||
# exactly that reason. What is left is the go build, which is incremental and
|
||||
# costs seconds when nothing changed, so making it a prerequisite of the suite
|
||||
# is cheap enough that nobody has a reason to route around it.
|
||||
#
|
||||
# LOCALAI_E2E_BINARY means the caller has its own binary and owns keeping it
|
||||
# current, so this builds nothing. The staleness check in the harness still
|
||||
# applies to it, which is the point: a binary this target did not produce is
|
||||
# precisely the one nothing else vouches for.
|
||||
.PHONY: e2e-binary
|
||||
e2e-binary: protogen-go
|
||||
@if [ -n "$$LOCALAI_E2E_BINARY" ]; then \
|
||||
echo "LOCALAI_E2E_BINARY=$$LOCALAI_E2E_BINARY is set; not building $(BINARY_NAME)"; \
|
||||
else \
|
||||
echo "Building $(BINARY_NAME) for the cluster e2e"; \
|
||||
CGO_LDFLAGS="$(CGO_LDFLAGS)" $(GOCMD) build -ldflags "$(LD_FLAGS)" -tags "$(GO_TAGS)" -o $(BINARY_NAME) ./cmd/local-ai; \
|
||||
fi
|
||||
|
||||
# Cluster e2e: runs local-ai as real child processes (frontend replicas +
|
||||
# workers) against PostgreSQL, and kills them to assert failover.
|
||||
# It BUILDS that binary rather than checking that a file by that name exists,
|
||||
# and that is a correctness fix rather than a convenience. This target used to
|
||||
# take ./local-ai as given, so an edit to core/ that was never rebuilt left the
|
||||
# whole suite exercising an older build while reporting on the working tree. The
|
||||
# failure mode is not theoretical and not loud: it surfaced as a spec failing on
|
||||
# caller line numbers that matched no line in any source file, and it would
|
||||
# otherwise have surfaced as a green run that proved nothing. build-mock-backend
|
||||
# already rebuilds unconditionally, so ./local-ai was the only gap.
|
||||
#
|
||||
# The harness carries the other half. Building here cannot help a run started
|
||||
# with LOCALAI_E2E_BINARY or by invoking ginkgo directly, so localAIBinary also
|
||||
# FAILS when the binary it is about to exec is older than the newest non-test Go
|
||||
# source in the tree. Together: this target makes the common path correct, and
|
||||
# the harness makes every other path loud.
|
||||
#
|
||||
# The argument-validation specs in tests/e2e/distributed/cluster deliberately
|
||||
# stay in test-e2e-distributed above: they need no binary and no PostgreSQL, so
|
||||
# no -r here and that package is simply out of scope.
|
||||
#
|
||||
# --fail-on-empty is load-bearing, not tidiness. Ginkgo exits 0 when a label
|
||||
# filter selects nothing, so without it a refactor that renames or drops
|
||||
# Label("Cluster") leaves this target reporting "Test Suite Passed" having
|
||||
# started no cluster at all. LOCALAI_E2E_REQUIRE_BINARIES does not cover this:
|
||||
# it only fires inside a spec that is actually running.
|
||||
#
|
||||
# --flake-attempts is pinned to 1 rather than $(DISTRIBUTED_TEST_FLAKES), and
|
||||
# should stay there: this suite exists to catch nondeterministic cluster
|
||||
# behaviour, and a retry turns exactly that signal into a green run.
|
||||
#
|
||||
# Budget: 26 specs, measured at 933.8 seconds of Ginkgo time (15m37s wall
|
||||
# including the compile) on a fast developer box. It was 591 to 612 seconds
|
||||
# before the phase 3 control-plane specs and 800 to 830 after them; the three
|
||||
# two-frontend two-worker specs in cluster_busless_test.go added 118 to 127
|
||||
# seconds (3s, 70s and 46 to 53s), nearly all of it in the churn spec, which
|
||||
# cannot be shortened: it waits for a killed replica to leave the live set
|
||||
# (cluster.InstanceLiveness is 30s, measured at 27s) before it may assert
|
||||
# anything, and then holds a window inside the reconnect grace. The two
|
||||
# cross-replica fan-out specs added 7.0 seconds of spec time (5.0s and 2.0s):
|
||||
# they run two frontends and no workers, so they pay for no registration, and
|
||||
# what they wait on is a broadcast rather than a threshold.
|
||||
#
|
||||
# --timeout is 30m rather than 20m because of that. The margin is not slack: a
|
||||
# Ginkgo timeout kills the suite mid-spec and reports a spec name rather than a
|
||||
# cause, and 20m on a loaded CI runner was one slow health tick away from
|
||||
# turning a green suite into an unreadable red one. At 15 minutes measured, 30m
|
||||
# is still twice the budget.
|
||||
test-e2e-cluster: protogen-go build-mock-backend e2e-binary
|
||||
@echo 'Running cluster e2e tests (label Cluster, real local-ai processes)'
|
||||
$(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --label-filter='Cluster' --fail-on-empty --flake-attempts 1 --timeout=30m -v ./tests/e2e/distributed
|
||||
@echo 'Running distributed e2e tests (label Distributed, incl. NatsJWT)'
|
||||
$(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --label-filter='Distributed && !VLLMMultinode' --flake-attempts $(TEST_FLAKES) -v -r ./tests/e2e/distributed
|
||||
|
||||
# vLLM multi-node DP smoke (CPU). Builds local-ai:tests and the
|
||||
# cpu-vllm backend from the current working tree, then drives a
|
||||
@@ -479,29 +378,19 @@ prepare-e2e:
|
||||
run-e2e-image:
|
||||
docker run -p 5390:8080 -e MODELS_PATH=/models -e THREADS=1 -e DEBUG=true -d --rm -v $(TEST_DIR):/models --name e2e-tests-$(RANDOM) localai-tests
|
||||
|
||||
# Distributed subpackages have separate targets that provision PostgreSQL and
|
||||
# build the host binaries; this target exercises the container-backed API only.
|
||||
test-e2e: build-mock-backend build-cloud-proxy-backend prepare-e2e run-e2e-image
|
||||
@echo 'Running e2e tests'
|
||||
BUILD_TYPE=$(BUILD_TYPE) \
|
||||
LOCALAI_API=http://$(E2E_BRIDGE_IP):5390 \
|
||||
$(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --flake-attempts $(TEST_FLAKES) -v ./tests/e2e
|
||||
$(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --flake-attempts $(TEST_FLAKES) -v -r ./tests/e2e
|
||||
$(MAKE) clean-mock-backend
|
||||
$(MAKE) clean-cloud-proxy-backend
|
||||
$(MAKE) teardown-e2e
|
||||
docker rmi localai-tests
|
||||
|
||||
# `docker stop` returns as soon as the container exits, but Docker reaps a
|
||||
# `--rm` container asynchronously after that. The `docker rmi localai-tests` in
|
||||
# test-e2e then loses the race against the reaper and fails on a still
|
||||
# referenced image, turning a green suite red. Removing the container ourselves
|
||||
# is synchronous, so the image reference is gone before we return. It also
|
||||
# covers the case where nothing is running, which `docker stop` could not
|
||||
# because it rejects an empty argument list.
|
||||
teardown-e2e:
|
||||
rm -rf $(TEST_DIR) || true
|
||||
@CONTAINERS=$$(docker ps -aq --filter ancestor=localai-tests 2>/dev/null); \
|
||||
if [ -n "$$CONTAINERS" ]; then docker rm -f $$CONTAINERS || true; fi
|
||||
docker stop $$(docker ps -q --filter ancestor=localai-tests)
|
||||
|
||||
########################################################
|
||||
## Integration and unit tests
|
||||
@@ -787,7 +676,6 @@ test-extra: prepare-test-extra
|
||||
## BACKEND_TEST_PROMPT Override the prompt used in predict/stream specs.
|
||||
## BACKEND_TEST_OPTIONS Comma-separated Options[] entries forwarded to LoadModel,
|
||||
## e.g. "tool_parser:hermes,reasoning_parser:qwen3".
|
||||
## BACKEND_TEST_EMBEDDING_LAYOUT Expected EmbeddingResult layout: "final" or "per_token".
|
||||
##
|
||||
## Direct usage (image already built, no docker-build-* dependency):
|
||||
##
|
||||
@@ -817,7 +705,6 @@ test-extra-backend: protogen-go
|
||||
BACKEND_TEST_CAPS="$$BACKEND_TEST_CAPS" \
|
||||
BACKEND_TEST_PROMPT="$$BACKEND_TEST_PROMPT" \
|
||||
BACKEND_TEST_OPTIONS="$$BACKEND_TEST_OPTIONS" \
|
||||
BACKEND_TEST_EMBEDDING_LAYOUT="$$BACKEND_TEST_EMBEDDING_LAYOUT" \
|
||||
BACKEND_TEST_TOOL_PROMPT="$$BACKEND_TEST_TOOL_PROMPT" \
|
||||
BACKEND_TEST_TOOL_NAME="$$BACKEND_TEST_TOOL_NAME" \
|
||||
BACKEND_TEST_CACHE_TYPE_K="$$BACKEND_TEST_CACHE_TYPE_K" \
|
||||
@@ -837,15 +724,6 @@ test-extra-backend-llama-cpp: docker-build-llama-cpp
|
||||
BACKEND_TEST_CAPS=health,load,predict,stream,logprobs,logit_bias \
|
||||
$(MAKE) test-extra-backend
|
||||
|
||||
## Raw llama.cpp embeddings are required by Go-side pooling. This exercises the
|
||||
## real C++ backend and verifies that it marks the flattened matrix per-token.
|
||||
test-extra-backend-llama-cpp-embeddings: docker-build-llama-cpp
|
||||
BACKEND_IMAGE=local-ai-backend:llama-cpp \
|
||||
BACKEND_TEST_CAPS=health,load,embeddings \
|
||||
BACKEND_TEST_OPTIONS=pooling:none \
|
||||
BACKEND_TEST_EMBEDDING_LAYOUT=per_token \
|
||||
$(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
|
||||
|
||||
@@ -935,7 +813,6 @@ test-extra-backend-tinygrad-embeddings: docker-build-tinygrad
|
||||
BACKEND_IMAGE=local-ai-backend:tinygrad \
|
||||
BACKEND_TEST_MODEL_NAME=Qwen/Qwen3-0.6B \
|
||||
BACKEND_TEST_CAPS=health,load,embeddings \
|
||||
BACKEND_TEST_EMBEDDING_LAYOUT=final \
|
||||
$(MAKE) test-extra-backend
|
||||
|
||||
## tinygrad — Stable Diffusion 1.5. The original CompVis/runwayml repos have
|
||||
@@ -1334,6 +1211,10 @@ backends/llama-cpp-darwin: build
|
||||
bash ./scripts/build/llama-cpp-darwin.sh
|
||||
./local-ai backends install "ocifile://$(abspath ./backend-images/llama-cpp.tar)"
|
||||
|
||||
backends/cachyllama-darwin: build
|
||||
bash ./scripts/build/cachyllama-darwin.sh
|
||||
./local-ai backends install "ocifile://$(abspath ./backend-images/cachyllama.tar)"
|
||||
|
||||
backends/ds4-darwin: build
|
||||
bash ./scripts/build/ds4-darwin.sh
|
||||
./local-ai backends install "ocifile://$(abspath ./backend-images/ds4.tar)"
|
||||
@@ -1391,6 +1272,8 @@ BACKEND_IK_LLAMA_CPP = ik-llama-cpp|ik-llama-cpp|.|false|false
|
||||
# turboquant is a llama.cpp fork with TurboQuant KV-cache quantization.
|
||||
# Reuses backend/cpp/llama-cpp grpc-server sources via a thin wrapper Makefile.
|
||||
BACKEND_TURBOQUANT = turboquant|turboquant|.|false|false
|
||||
# CachyLLaMA is a llama.cpp fork with persistent SSD prompt caching and APU tuning.
|
||||
BACKEND_CACHYLLAMA = cachyllama|cachyllama|.|false|false
|
||||
# bonsai is a llama.cpp fork (PrismML) adding the Q1_0 (1-bit) and Q2_0 (ternary)
|
||||
# weight-quant kernels the Bonsai / Ternary-Bonsai models ship in. Reuses
|
||||
# backend/cpp/llama-cpp grpc-server sources via a thin wrapper Makefile.
|
||||
@@ -1509,6 +1392,7 @@ endef
|
||||
$(eval $(call generate-docker-build-target,$(BACKEND_LLAMA_CPP)))
|
||||
$(eval $(call generate-docker-build-target,$(BACKEND_IK_LLAMA_CPP)))
|
||||
$(eval $(call generate-docker-build-target,$(BACKEND_TURBOQUANT)))
|
||||
$(eval $(call generate-docker-build-target,$(BACKEND_CACHYLLAMA)))
|
||||
$(eval $(call generate-docker-build-target,$(BACKEND_BONSAI)))
|
||||
$(eval $(call generate-docker-build-target,$(BACKEND_DS4)))
|
||||
$(eval $(call generate-docker-build-target,$(BACKEND_PRIVACY_FILTER)))
|
||||
@@ -1582,7 +1466,7 @@ $(eval $(call generate-docker-build-target,$(BACKEND_SUPERTONIC)))
|
||||
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-bonsai docker-build-ds4 docker-build-rerankers docker-build-vllm docker-build-vllm-omni docker-build-longcat-video docker-build-sglang docker-build-transformers docker-build-outetts docker-build-diffusers docker-build-kokoro docker-build-faster-whisper docker-build-crispasr docker-build-coqui docker-build-chatterbox docker-build-vibevoice docker-build-liquid-audio 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-rfdetr-cpp docker-build-qwen3-tts-cpp docker-build-moss-tts-cpp docker-build-magpie-tts-cpp docker-build-vllm-cpp docker-build-omnivoice-cpp docker-build-vibevoice-cpp docker-build-localvqe docker-build-insightface docker-build-speaker-recognition docker-build-sherpa-onnx docker-build-cloud-proxy docker-build-supertonic docker-build-depth-anything-cpp docker-build-moss-transcribe-cpp docker-build-nemo-speech-cpp docker-build-privacy-filter docker-build-trellis2cpp docker-build-valkey-store docker-build-audio-cpp
|
||||
docker-build-backends: docker-build-llama-cpp docker-build-ik-llama-cpp docker-build-turboquant docker-build-cachyllama docker-build-bonsai docker-build-ds4 docker-build-rerankers docker-build-vllm docker-build-vllm-omni docker-build-longcat-video docker-build-sglang docker-build-transformers docker-build-outetts docker-build-diffusers docker-build-kokoro docker-build-faster-whisper docker-build-crispasr docker-build-coqui docker-build-chatterbox docker-build-vibevoice docker-build-liquid-audio 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-rfdetr-cpp docker-build-qwen3-tts-cpp docker-build-moss-tts-cpp docker-build-magpie-tts-cpp docker-build-vllm-cpp docker-build-omnivoice-cpp docker-build-vibevoice-cpp docker-build-localvqe docker-build-insightface docker-build-speaker-recognition docker-build-sherpa-onnx docker-build-cloud-proxy docker-build-supertonic docker-build-depth-anything-cpp docker-build-moss-transcribe-cpp docker-build-nemo-speech-cpp docker-build-privacy-filter docker-build-trellis2cpp docker-build-valkey-store docker-build-audio-cpp
|
||||
|
||||
########################################################
|
||||
### Mock Backend for E2E Tests
|
||||
@@ -1628,13 +1512,6 @@ test-ui: build-mock-backend protogen-go
|
||||
$(GOCMD) build -o tests/e2e-ui/ui-test-server ./tests/e2e-ui
|
||||
cd core/http/react-ui && sh $(CURDIR)/scripts/ensure-playwright-browser.sh && bunx playwright test $(PLAYWRIGHT_WORKERS_FLAG)
|
||||
|
||||
## The stale-chunk specs need the production code-split bundle. The V8 coverage
|
||||
## bundle below inlines dynamic imports to keep every page in its denominator.
|
||||
test-ui-stale-chunk: build-mock-backend protogen-go
|
||||
cd core/http/react-ui && bun install && bun run build
|
||||
$(GOCMD) build -o tests/e2e-ui/ui-test-server ./tests/e2e-ui
|
||||
cd core/http/react-ui && sh $(CURDIR)/scripts/ensure-playwright-browser.sh && bunx playwright test --grep @production-chunks --workers=1
|
||||
|
||||
## React UI code coverage from the Playwright e2e suite. Builds a
|
||||
## NON-instrumented bundle with source maps (COVERAGE_V8=true), re-embeds it
|
||||
## into the ui-test-server (the dist is //go:embed'ed at compile time), runs the
|
||||
@@ -1650,7 +1527,7 @@ test-ui-coverage: build-mock-backend protogen-go
|
||||
$(GOCMD) build -o tests/e2e-ui/ui-test-server ./tests/e2e-ui && \
|
||||
( cd core/http/react-ui && rm -rf .nyc_output coverage && \
|
||||
sh $(CURDIR)/scripts/ensure-playwright-browser.sh && \
|
||||
PW_V8_COVERAGE=1 bunx playwright test --grep-invert @production-chunks $(PLAYWRIGHT_WORKERS_FLAG) && bun run coverage:report )
|
||||
PW_V8_COVERAGE=1 bunx playwright test $(PLAYWRIGHT_WORKERS_FLAG) && bun run coverage:report )
|
||||
|
||||
## UI coverage baseline (committed) and the strict gate that compares against
|
||||
## it — the React mirror of test-coverage-baseline / test-coverage-check.
|
||||
@@ -1733,7 +1610,7 @@ site-serve: site
|
||||
build-launcher-darwin:
|
||||
rm -rf dist/LocalAI.app cmd/launcher/LocalAI.app
|
||||
mkdir -p dist
|
||||
cd cmd/launcher && go run fyne.io/tools/cmd/fyne@latest package -os darwin -icon ../../core/http/static/logo.png --executable $(LAUNCHER_BINARY_NAME) --app-version $(LAUNCHER_APP_VERSION)
|
||||
cd cmd/launcher && go run fyne.io/tools/cmd/fyne@latest package -os darwin -icon ../../core/http/static/logo.png --executable $(LAUNCHER_BINARY_NAME)
|
||||
mv cmd/launcher/LocalAI.app dist/LocalAI.app
|
||||
bash contrib/macos/sign-and-notarize.sh sign dist/LocalAI.app
|
||||
|
||||
@@ -1760,4 +1637,4 @@ release-launcher-darwin: notarize-launcher-darwin
|
||||
@echo "dist/LocalAI.dmg is ready"
|
||||
|
||||
build-launcher-linux:
|
||||
cd cmd/launcher && go run fyne.io/tools/cmd/fyne@latest package -os linux -icon ../../core/http/static/logo.png --executable $(LAUNCHER_BINARY_NAME)-linux --app-version $(LAUNCHER_APP_VERSION) && mv LocalAI.tar.xz ../../$(LAUNCHER_BINARY_NAME)-linux.tar.xz
|
||||
cd cmd/launcher && go run fyne.io/tools/cmd/fyne@latest package -os linux -icon ../../core/http/static/logo.png --executable $(LAUNCHER_BINARY_NAME)-linux && mv LocalAI.tar.xz ../../$(LAUNCHER_BINARY_NAME)-linux.tar.xz
|
||||
@@ -5,6 +5,9 @@
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/go-skynet/LocalAI/stargazers" target="blank">
|
||||
<img src="https://img.shields.io/github/stars/go-skynet/LocalAI?style=for-the-badge" alt="LocalAI stars"/>
|
||||
</a>
|
||||
<a href='https://github.com/go-skynet/LocalAI/releases'>
|
||||
<img src='https://img.shields.io/github/release/go-skynet/LocalAI?&label=Latest&style=for-the-badge'>
|
||||
</a>
|
||||
@@ -209,7 +212,7 @@ For older news and full release notes, see [GitHub Releases](https://github.com/
|
||||
- [Object Detection](https://localai.io/features/object-detection/)
|
||||
- [Reranker API](https://localai.io/features/reranker/)
|
||||
- [P2P Inferencing](https://localai.io/features/distribute/)
|
||||
- [Distributed Mode](https://localai.io/features/distributed-mode/): horizontal scaling on PostgreSQL, with no message broker to run
|
||||
- [Distributed Mode](https://localai.io/features/distributed-mode/) — Horizontal scaling with PostgreSQL + NATS
|
||||
- [Model Context Protocol (MCP)](https://localai.io/docs/features/mcp/)
|
||||
- [Built-in Agents](https://localai.io/features/agents/) — Autonomous AI agents with tool use, RAG, skills, SSE streaming, and [Agent Hub](https://agenthub.localai.io)
|
||||
- [Backend Gallery](https://localai.io/backends/) — Install/remove backends on the fly via OCI images
|
||||
@@ -315,6 +318,10 @@ Past sponsors
|
||||
|
||||
A special thanks to individual sponsors, a full list is on [GitHub](https://github.com/sponsors/mudler) and [buymeacoffee](https://buymeacoffee.com/mudler). Special shout out to [drikster80](https://github.com/drikster80) for being generous. Thank you everyone!
|
||||
|
||||
## Star history
|
||||
|
||||
[](https://star-history.com/#go-skynet/LocalAI&Date)
|
||||
|
||||
## License
|
||||
|
||||
LocalAI is a community-driven project created by [Ettore Di Giacinto](https://github.com/mudler/) and maintained by the [LocalAI team](#team).
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
ARG BASE_IMAGE=ubuntu:24.04
|
||||
# BUILDER_BASE_IMAGE defaults to BASE_IMAGE so the Dockerfile parses even
|
||||
# when no prebuilt base is supplied. The builder-prebuilt stage is only
|
||||
# entered when BUILDER_TARGET=builder-prebuilt, so a "wrong" fallback
|
||||
# content here is harmless — BuildKit prunes the unreferenced builder.
|
||||
ARG BUILDER_BASE_IMAGE=${BASE_IMAGE}
|
||||
# BUILDER_TARGET selects which builder stage the final scratch image copies
|
||||
# package output from. Declared at global scope (before any FROM) so it's
|
||||
# usable in `FROM ${BUILDER_TARGET}` below. Default keeps local
|
||||
# `make backends/cachyllama` on the from-source path.
|
||||
ARG BUILDER_TARGET=builder-fromsource
|
||||
ARG APT_MIRROR=""
|
||||
ARG APT_PORTS_MIRROR=""
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Stage: builder-fromsource — self-contained build path.
|
||||
# Runs .docker/install-base-deps.sh (apt deps + cmake + protoc + gRPC +
|
||||
# conditional CUDA/ROCm/Vulkan), copies /opt/grpc to /usr/local, then
|
||||
# compiles the variant. Used when BUILDER_TARGET=builder-fromsource (the
|
||||
# default; local `make backends/cachyllama`).
|
||||
#
|
||||
# The install script is the same one that backend/Dockerfile.base-grpc-builder
|
||||
# runs, so the result is bit-equivalent to the prebuilt-base path
|
||||
# (builder-prebuilt below).
|
||||
# ============================================================================
|
||||
FROM ${BASE_IMAGE} AS builder-fromsource
|
||||
ARG BUILD_TYPE
|
||||
ARG CUDA_MAJOR_VERSION
|
||||
ARG CUDA_MINOR_VERSION
|
||||
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 GRPC_VERSION=v1.65.0
|
||||
ARG GRPC_MAKEFLAGS="-j4 -Otarget"
|
||||
ARG SKIP_DRIVERS=false
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG GO_VERSION=1.25.4
|
||||
ARG UBUNTU_VERSION=2404
|
||||
ARG APT_MIRROR
|
||||
ARG APT_PORTS_MIRROR
|
||||
ARG AMDGPU_TARGETS=""
|
||||
ARG BACKEND=rerankers
|
||||
# CUDA target archs, e.g. --build-arg CUDA_DOCKER_ARCH='75;86;89;120'
|
||||
ARG CUDA_DOCKER_ARCH
|
||||
ARG CMAKE_ARGS
|
||||
|
||||
ENV BUILD_TYPE=${BUILD_TYPE} \
|
||||
CUDA_MAJOR_VERSION=${CUDA_MAJOR_VERSION} \
|
||||
CUDA_MINOR_VERSION=${CUDA_MINOR_VERSION} \
|
||||
CMAKE_FROM_SOURCE=${CMAKE_FROM_SOURCE} \
|
||||
CMAKE_VERSION=${CMAKE_VERSION} \
|
||||
GRPC_VERSION=${GRPC_VERSION} \
|
||||
GRPC_MAKEFLAGS=${GRPC_MAKEFLAGS} \
|
||||
SKIP_DRIVERS=${SKIP_DRIVERS} \
|
||||
TARGETARCH=${TARGETARCH} \
|
||||
UBUNTU_VERSION=${UBUNTU_VERSION} \
|
||||
APT_MIRROR=${APT_MIRROR} \
|
||||
APT_PORTS_MIRROR=${APT_PORTS_MIRROR} \
|
||||
AMDGPU_TARGETS=${AMDGPU_TARGETS} \
|
||||
CUDA_DOCKER_ARCH=${CUDA_DOCKER_ARCH} \
|
||||
CMAKE_ARGS=${CMAKE_ARGS} \
|
||||
DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# CUDA on PATH (no-op when CUDA isn't installed)
|
||||
ENV PATH=/usr/local/cuda/bin:${PATH}
|
||||
# HipBLAS / ROCm on PATH (no-op when ROCm isn't installed)
|
||||
ENV PATH=/opt/rocm/bin:${PATH}
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Install everything via the shared script — the same one that
|
||||
# backend/Dockerfile.base-grpc-builder runs, so the prebuilt CI base and
|
||||
# this from-source path are bit-equivalent.
|
||||
RUN --mount=type=bind,source=.docker/install-base-deps.sh,target=/usr/local/sbin/install-base-deps \
|
||||
--mount=type=bind,source=.docker/apt-mirror.sh,target=/usr/local/sbin/apt-mirror \
|
||||
bash /usr/local/sbin/install-base-deps
|
||||
|
||||
# Mirror builder-prebuilt: copy gRPC from /opt/grpc to /usr/local so
|
||||
# CMake's find_package finds it at the canonical prefix the Makefile expects.
|
||||
RUN cp -a /opt/grpc/. /usr/local/
|
||||
|
||||
COPY . /LocalAI
|
||||
|
||||
# BuildKit cache mount for ccache. See Dockerfile.llama-cpp (commit 9228e5b4)
|
||||
# for rationale. cachyllama is a llama.cpp fork that reuses
|
||||
# backend/cpp/llama-cpp source via a thin wrapper Makefile, so MOST TUs
|
||||
# are content-identical to the upstream llama-cpp build. Sharing a cache
|
||||
# id with llama-cpp could give cross-fork hits — but for now keep them
|
||||
# separate so a regression in one doesn't poison the other. Revisit
|
||||
# sharing after measuring the actual hit rate.
|
||||
#
|
||||
# The compile body is shared with builder-prebuilt via .docker/cachyllama-compile.sh.
|
||||
RUN --mount=type=bind,source=.docker/cachyllama-compile.sh,target=/usr/local/sbin/compile.sh \
|
||||
--mount=type=cache,target=/root/.ccache,id=cachyllama-ccache-${TARGETARCH}-${BUILD_TYPE},sharing=locked \
|
||||
bash /usr/local/sbin/compile.sh
|
||||
|
||||
|
||||
# Copy libraries using a script to handle architecture differences
|
||||
RUN make -BC /LocalAI/backend/cpp/cachyllama package
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Stage: builder-prebuilt — uses the pre-built base from
|
||||
# quay.io/go-skynet/ci-cache:base-grpc-* (built by .github/workflows/base-images.yml).
|
||||
# That image already has gRPC at /opt/grpc + apt deps + CUDA/ROCm/Vulkan
|
||||
# pre-installed, so we just copy gRPC to /usr/local and compile. Used when
|
||||
# BUILDER_TARGET=builder-prebuilt (CI when the matrix entry sets
|
||||
# builder-base-image).
|
||||
# ============================================================================
|
||||
FROM ${BUILDER_BASE_IMAGE} AS builder-prebuilt
|
||||
|
||||
ARG BUILD_TYPE
|
||||
ENV BUILD_TYPE=${BUILD_TYPE}
|
||||
ARG CUDA_DOCKER_ARCH
|
||||
ENV CUDA_DOCKER_ARCH=${CUDA_DOCKER_ARCH}
|
||||
ARG CMAKE_ARGS
|
||||
ENV CMAKE_ARGS=${CMAKE_ARGS}
|
||||
# AMDGPU_TARGETS must be forwarded into the env here too — backend/cpp/llama-cpp/Makefile
|
||||
# (which the cachyllama Makefile reuses via a sibling build dir) errors out when the var
|
||||
# is empty on a hipblas build, and the prebuilt path is what CI exercises most of the
|
||||
# time. The builder-fromsource stage above already does this; mirror it here.
|
||||
ARG AMDGPU_TARGETS
|
||||
ENV AMDGPU_TARGETS=${AMDGPU_TARGETS}
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
|
||||
# The base-grpc-* image installs gRPC to /opt/grpc but doesn't copy it to
|
||||
# /usr/local. Mirror what the from-source path does so the compile step
|
||||
# can find gRPC at the canonical prefix the Makefile expects.
|
||||
RUN cp -a /opt/grpc/. /usr/local/
|
||||
|
||||
COPY . /LocalAI
|
||||
|
||||
RUN --mount=type=bind,source=.docker/cachyllama-compile.sh,target=/usr/local/sbin/compile.sh \
|
||||
--mount=type=cache,target=/root/.ccache,id=cachyllama-ccache-${TARGETARCH}-${BUILD_TYPE},sharing=locked \
|
||||
bash /usr/local/sbin/compile.sh
|
||||
|
||||
RUN make -BC /LocalAI/backend/cpp/cachyllama package
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Final stage — copies package output from one of the two builders.
|
||||
# BUILDER_TARGET selects which one. BuildKit prunes the unreferenced builder.
|
||||
#
|
||||
# BuildKit doesn't support variable expansion in `COPY --from=` directly,
|
||||
# so we resolve the ARG by aliasing the chosen builder to a fixed stage
|
||||
# name via `FROM ${BUILDER_TARGET} AS builder` and then COPY --from=builder.
|
||||
# BUILDER_TARGET itself is declared as a global ARG at the top of this
|
||||
# file (required for use in FROM), so we just re-import it into this
|
||||
# stage's scope before the FROM directive.
|
||||
# ============================================================================
|
||||
FROM ${BUILDER_TARGET} AS builder
|
||||
|
||||
FROM scratch
|
||||
|
||||
|
||||
# Copy all available binaries (the build process only creates the appropriate ones for the target architecture)
|
||||
COPY --from=builder /LocalAI/backend/cpp/cachyllama/package/. ./
|
||||
@@ -10,7 +10,6 @@ FROM ${BASE_IMAGE} AS builder
|
||||
ARG BUILD_TYPE
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG CUDA_MAJOR_VERSION
|
||||
|
||||
ENV BUILD_TYPE=${BUILD_TYPE} \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
@@ -36,8 +35,7 @@ RUN apt-get update && \
|
||||
COPY . /LocalAI
|
||||
|
||||
RUN --mount=type=cache,target=/root/.ccache,id=ds4-ccache-${TARGETARCH}-${BUILD_TYPE},sharing=locked \
|
||||
make -C /LocalAI/backend/cpp/ds4 BUILD_TYPE=${BUILD_TYPE} \
|
||||
CUDA_MAJOR_VERSION=${CUDA_MAJOR_VERSION} NATIVE=false grpc-server package
|
||||
make -C /LocalAI/backend/cpp/ds4 BUILD_TYPE=${BUILD_TYPE} NATIVE=false grpc-server package
|
||||
|
||||
FROM scratch
|
||||
COPY --from=builder /LocalAI/backend/cpp/ds4/package/. ./
|
||||
@@ -536,28 +536,8 @@ message Result {
|
||||
bool success = 2;
|
||||
}
|
||||
|
||||
// EmbeddingLayout describes whether embeddings contains one final vector or
|
||||
// a matrix of per-token vectors. Go-side pooling must never infer this from
|
||||
// tokens/dim alone: a one-token raw matrix and a final vector have the same
|
||||
// shape.
|
||||
enum EmbeddingLayout {
|
||||
EMBEDDING_LAYOUT_UNSPECIFIED = 0;
|
||||
EMBEDDING_LAYOUT_FINAL = 1;
|
||||
EMBEDDING_LAYOUT_PER_TOKEN = 2;
|
||||
}
|
||||
|
||||
message EmbeddingResult {
|
||||
repeated float embeddings = 1;
|
||||
// Shape of the payload above: dim is the embedding width, tokens is the
|
||||
// number of vectors packed into `embeddings` (1 when the backend pooled
|
||||
// server-side, N with pooling:none; total across prompts if a request
|
||||
// carried several). tokens=0/dim=0 means the backend predates shape
|
||||
// reporting. prompt_tokens is the number of prompt tokens evaluated, for
|
||||
// usage accounting.
|
||||
int32 tokens = 2;
|
||||
int32 dim = 3;
|
||||
int32 prompt_tokens = 4;
|
||||
EmbeddingLayout layout = 5;
|
||||
}
|
||||
|
||||
message TranscriptRequest {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
# recipe is a make target (not a prepare.sh) so 'make purge && make' is a clean
|
||||
# rebuild and so the bump bot can see the pin.
|
||||
|
||||
AUDIO_CPP_VERSION?=05e508a70e3600b01454c647cdb122133ba8e64c
|
||||
AUDIO_CPP_VERSION?=7efbb58def443722ea540d931dd3debee3e4d5e8
|
||||
AUDIO_CPP_REPO?=https://github.com/0xShug0/audio.cpp
|
||||
|
||||
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||
|
||||
@@ -29,7 +29,6 @@ const NamedTask kTaskNames[] = {
|
||||
{Task::VoiceDesign, "vdes"},
|
||||
{Task::SpeakerRecognition, "spk"},
|
||||
{Task::Svc, "svc"},
|
||||
{Task::Midi, "midi"},
|
||||
};
|
||||
|
||||
// Accepted on input but never emitted. "spkrec" was this backend's own earlier
|
||||
|
||||
@@ -25,7 +25,6 @@ enum class Task {
|
||||
VoiceDesign,
|
||||
SpeakerRecognition,
|
||||
Svc,
|
||||
Midi,
|
||||
};
|
||||
|
||||
// Mirrors engine::runtime::RunMode.
|
||||
|
||||
@@ -361,7 +361,7 @@ static void test_names_round_trip() {
|
||||
Task::SourceSeparation, Task::AudioGeneration, Task::Tts,
|
||||
Task::VoiceCloning, Task::VoiceConversion,
|
||||
Task::SpeechToSpeech, Task::Alignment, Task::VoiceDesign,
|
||||
Task::SpeakerRecognition, Task::Svc, Task::Midi};
|
||||
Task::SpeakerRecognition, Task::Svc};
|
||||
for (const Task t : all) {
|
||||
Task parsed = Task::Vad;
|
||||
const bool ok = parse_task_name(task_name(t), parsed);
|
||||
|
||||
@@ -69,8 +69,7 @@ static_assert(kEngine(engine::runtime::VoiceTaskKind::VoiceDesign) == 10, "Voice
|
||||
static_assert(kEngine(engine::runtime::VoiceTaskKind::SpeakerRecognition) == 11, "VoiceTaskKind drifted");
|
||||
// The last member. Pinning it pins the member count too, as long as the
|
||||
// enumerators stay contiguous and unassigned, which upstream's declaration is.
|
||||
static_assert(kEngine(engine::runtime::VoiceTaskKind::Svc) == 12, "VoiceTaskKind drifted");
|
||||
static_assert(kEngine(engine::runtime::VoiceTaskKind::Midi) == 13,
|
||||
static_assert(kEngine(engine::runtime::VoiceTaskKind::Svc) == 12,
|
||||
"engine::runtime::VoiceTaskKind gained, lost or reordered a member. "
|
||||
"audiocpp_backend::Task mirrors it positionally: update capability_routing.h, "
|
||||
"to_engine_task and from_engine_task together, then move this pin.");
|
||||
@@ -88,7 +87,6 @@ static_assert(kMirror(Task::Alignment) == 9, "Task drifted from VoiceTaskKind");
|
||||
static_assert(kMirror(Task::VoiceDesign) == 10, "Task drifted from VoiceTaskKind");
|
||||
static_assert(kMirror(Task::SpeakerRecognition) == 11, "Task drifted from VoiceTaskKind");
|
||||
static_assert(kMirror(Task::Svc) == 12, "Task drifted from VoiceTaskKind");
|
||||
static_assert(kMirror(Task::Midi) == 13, "Task drifted from VoiceTaskKind");
|
||||
|
||||
static_assert(static_cast<int>(engine::runtime::RunMode::Offline) == 0, "RunMode drifted");
|
||||
static_assert(static_cast<int>(engine::runtime::RunMode::Streaming) == 1,
|
||||
@@ -243,7 +241,6 @@ engine::runtime::VoiceTaskKind to_engine_task(Task task) {
|
||||
case Task::VoiceDesign: return K::VoiceDesign;
|
||||
case Task::SpeakerRecognition: return K::SpeakerRecognition;
|
||||
case Task::Svc: return K::Svc;
|
||||
case Task::Midi: return K::Midi;
|
||||
}
|
||||
// Unreachable for any valid enumerator. No `default:` label, so -Wswitch
|
||||
// still reports a member this switch stops covering.
|
||||
@@ -266,7 +263,6 @@ Task from_engine_task(engine::runtime::VoiceTaskKind kind) {
|
||||
case K::VoiceDesign: return Task::VoiceDesign;
|
||||
case K::SpeakerRecognition: return Task::SpeakerRecognition;
|
||||
case K::Svc: return Task::Svc;
|
||||
case K::Midi: return Task::Midi;
|
||||
}
|
||||
return Task::Vad;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
# Pinned to the HEAD of the `prism` branch on https://github.com/PrismML-Eng/llama.cpp.
|
||||
# Auto-bumped nightly by .github/workflows/bump_deps.yaml.
|
||||
BONSAI_VERSION?=312bb2a93ea2bf798333fa859614fbf913ecb9e2
|
||||
BONSAI_VERSION?=9ca265a57f85f2117942490f421f64a226dd9847
|
||||
LLAMA_REPO?=https://github.com/PrismML-Eng/llama.cpp
|
||||
|
||||
CMAKE_ARGS?=
|
||||
@@ -41,9 +41,7 @@ define bonsai-build
|
||||
# and are applied by apply-patches.sh below.
|
||||
rm -rf $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build/patches
|
||||
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build purge
|
||||
bash $(CURRENT_MAKEFILE_DIR)/patch-grpc-server.sh $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build/grpc-server.cpp
|
||||
bash $(LLAMA_CPP_DIR)/disable-score-task.sh $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build/grpc-server.cpp
|
||||
bash $(LLAMA_CPP_DIR)/disable-tts-task.sh $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build/grpc-server.cpp
|
||||
$(info $(GREEN)I bonsai build info:$(1)$(RESET))
|
||||
LLAMA_REPO=$(LLAMA_REPO) LLAMA_VERSION=$(BONSAI_VERSION) \
|
||||
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build llama.cpp
|
||||
@@ -80,9 +78,7 @@ bonsai-cpu-all:
|
||||
# and are applied by apply-patches.sh below.
|
||||
rm -rf $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build/patches
|
||||
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build purge
|
||||
bash $(CURRENT_MAKEFILE_DIR)/patch-grpc-server.sh $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build/grpc-server.cpp
|
||||
bash $(LLAMA_CPP_DIR)/disable-score-task.sh $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build/grpc-server.cpp
|
||||
bash $(LLAMA_CPP_DIR)/disable-tts-task.sh $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build/grpc-server.cpp
|
||||
$(info $(GREEN)I bonsai build info:cpu-all-variants$(RESET))
|
||||
LLAMA_REPO=$(LLAMA_REPO) LLAMA_VERSION=$(BONSAI_VERSION) \
|
||||
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build llama.cpp
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Adapt the shared llama.cpp gRPC source to the older JSON API in Bonsai.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "usage: $0 <grpc-server.cpp>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
SRC=$1
|
||||
if [[ ! -f "$SRC" ]]; then
|
||||
echo "grpc-server.cpp not found at $SRC" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if grep -q 'common_json_error' "$SRC"; then
|
||||
echo "==> patching $SRC to use the Bonsai JSON exception type"
|
||||
awk '{ gsub(/common_json_error/, "json::parse_error"); print }' "$SRC" > "$SRC.tmp"
|
||||
mv "$SRC.tmp" "$SRC"
|
||||
echo "==> Bonsai JSON exception patch OK"
|
||||
else
|
||||
echo "==> $SRC already uses a Bonsai-compatible JSON exception type, skipping"
|
||||
fi
|
||||
@@ -0,0 +1,99 @@
|
||||
|
||||
# Pinned to the HEAD of master on https://github.com/fewtarius/CachyLLama.
|
||||
# Auto-bumped nightly by .github/workflows/bump_deps.yaml.
|
||||
CACHYLLAMA_VERSION?=cea45f4222e1eea5c2c68388d7e75904a65b6778
|
||||
LLAMA_REPO?=https://github.com/fewtarius/CachyLLama
|
||||
|
||||
CMAKE_ARGS?=
|
||||
BUILD_TYPE?=
|
||||
NATIVE?=false
|
||||
ONEAPI_VARS?=/opt/intel/oneapi/setvars.sh
|
||||
TARGET?=--target grpc-server
|
||||
JOBS?=$(shell nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 1)
|
||||
ARCH?=$(shell uname -m)
|
||||
|
||||
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||
LLAMA_CPP_DIR := $(CURRENT_MAKEFILE_DIR)/../llama-cpp
|
||||
|
||||
GREEN := \033[0;32m
|
||||
RESET := \033[0m
|
||||
|
||||
# cachyllama is a llama.cpp fork. Rather than duplicating grpc-server.cpp / CMakeLists.txt /
|
||||
# prepare.sh we reuse the ones in backend/cpp/llama-cpp, and only swap which repo+sha the
|
||||
# fetch step pulls. Each flavor target copies ../llama-cpp into a sibling ../cachyllama-<flavor>-build
|
||||
# directory, then invokes llama-cpp's own build-llama-cpp-grpc-server with LLAMA_REPO/LLAMA_VERSION
|
||||
# overridden to point at the fork.
|
||||
# Each flavor target:
|
||||
# 1. copies backend/cpp/llama-cpp/ (grpc-server.cpp + prepare.sh + CMakeLists.txt + Makefile)
|
||||
# into a sibling cachyllama-<flavor>-build directory;
|
||||
# 2. clones the cachyllama fork into cachyllama-<flavor>-build/llama.cpp via the copy's
|
||||
# own `llama.cpp` target, overriding LLAMA_REPO/LLAMA_VERSION;
|
||||
# 3. runs the copy's `grpc-server` target, which produces the binary we copy up as
|
||||
# cachyllama-<flavor>.
|
||||
define cachyllama-build
|
||||
rm -rf $(CURRENT_MAKEFILE_DIR)/../cachyllama-$(1)-build
|
||||
cp -rf $(LLAMA_CPP_DIR) $(CURRENT_MAKEFILE_DIR)/../cachyllama-$(1)-build
|
||||
# Drop patches vendored for upstream llama.cpp; CachyLLaMA tracks upstream
|
||||
# closely but must not accidentally receive patches pinned to another SHA.
|
||||
rm -rf $(CURRENT_MAKEFILE_DIR)/../cachyllama-$(1)-build/patches
|
||||
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../cachyllama-$(1)-build purge
|
||||
bash $(LLAMA_CPP_DIR)/disable-score-task.sh $(CURRENT_MAKEFILE_DIR)/../cachyllama-$(1)-build/grpc-server.cpp
|
||||
$(info $(GREEN)I cachyllama build info:$(1)$(RESET))
|
||||
LLAMA_REPO=$(LLAMA_REPO) LLAMA_VERSION=$(CACHYLLAMA_VERSION) \
|
||||
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../cachyllama-$(1)-build llama.cpp
|
||||
CMAKE_ARGS="$(CMAKE_ARGS) $(2)" TARGET="$(3)" \
|
||||
LLAMA_REPO=$(LLAMA_REPO) LLAMA_VERSION=$(CACHYLLAMA_VERSION) \
|
||||
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../cachyllama-$(1)-build grpc-server
|
||||
cp -rfv $(CURRENT_MAKEFILE_DIR)/../cachyllama-$(1)-build/grpc-server cachyllama-$(1)
|
||||
endef
|
||||
|
||||
cachyllama-avx2:
|
||||
$(call cachyllama-build,avx2,-DGGML_AVX=on -DGGML_AVX2=on -DGGML_AVX512=off -DGGML_FMA=on -DGGML_F16C=on,--target grpc-server)
|
||||
|
||||
cachyllama-avx512:
|
||||
$(call cachyllama-build,avx512,-DGGML_AVX=on -DGGML_AVX2=off -DGGML_AVX512=on -DGGML_FMA=on -DGGML_F16C=on,--target grpc-server)
|
||||
|
||||
cachyllama-avx:
|
||||
$(call cachyllama-build,avx,-DGGML_AVX=on -DGGML_AVX2=off -DGGML_AVX512=off -DGGML_FMA=off -DGGML_F16C=off -DGGML_BMI2=off,--target grpc-server)
|
||||
|
||||
cachyllama-fallback:
|
||||
$(call cachyllama-build,fallback,-DGGML_AVX=off -DGGML_AVX2=off -DGGML_AVX512=off -DGGML_FMA=off -DGGML_F16C=off -DGGML_BMI2=off,--target grpc-server)
|
||||
|
||||
# Single-build CPU backend via ggml CPU_ALL_VARIANTS (mirrors llama-cpp-cpu-all).
|
||||
# cachyllama reuses backend/cpp/llama-cpp's CMakeLists.txt (hw_grpc_proto STATIC) and
|
||||
# Makefile (SHARED_LIBS make-var + EXTRA_CMAKE_ARGS), so this passes the same overrides
|
||||
# through to the copied build: SHARED_LIBS=ON, the DL flags, and --target ggml (which
|
||||
# pulls in the per-microarch libggml-cpu-*.so via ggml's add_dependencies). The .so set
|
||||
# is collected for package.sh to bundle into package/lib.
|
||||
cachyllama-cpu-all:
|
||||
rm -rf $(CURRENT_MAKEFILE_DIR)/../cachyllama-cpu-all-build
|
||||
cp -rf $(LLAMA_CPP_DIR) $(CURRENT_MAKEFILE_DIR)/../cachyllama-cpu-all-build
|
||||
# Drop patches vendored for upstream llama.cpp; they are SHA-specific.
|
||||
rm -rf $(CURRENT_MAKEFILE_DIR)/../cachyllama-cpu-all-build/patches
|
||||
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../cachyllama-cpu-all-build purge
|
||||
bash $(LLAMA_CPP_DIR)/disable-score-task.sh $(CURRENT_MAKEFILE_DIR)/../cachyllama-cpu-all-build/grpc-server.cpp
|
||||
$(info $(GREEN)I cachyllama build info:cpu-all-variants$(RESET))
|
||||
LLAMA_REPO=$(LLAMA_REPO) LLAMA_VERSION=$(CACHYLLAMA_VERSION) \
|
||||
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../cachyllama-cpu-all-build llama.cpp
|
||||
SHARED_LIBS=ON EXTRA_CMAKE_ARGS="-DGGML_BACKEND_DL=ON -DGGML_CPU_ALL_VARIANTS=ON" TARGET="--target grpc-server --target ggml" \
|
||||
LLAMA_REPO=$(LLAMA_REPO) LLAMA_VERSION=$(CACHYLLAMA_VERSION) \
|
||||
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../cachyllama-cpu-all-build grpc-server
|
||||
cp -rfv $(CURRENT_MAKEFILE_DIR)/../cachyllama-cpu-all-build/grpc-server cachyllama-cpu-all
|
||||
rm -rf ggml-shared-libs && mkdir -p ggml-shared-libs
|
||||
find $(CURRENT_MAKEFILE_DIR)/../cachyllama-cpu-all-build/llama.cpp/build \( -name '*.so*' -o -name '*.dylib' \) -exec cp -av {} ggml-shared-libs/ \;
|
||||
@echo "Collected ggml shared backends:" && ls -la ggml-shared-libs/
|
||||
|
||||
cachyllama-grpc:
|
||||
$(call cachyllama-build,grpc,-DGGML_RPC=ON -DGGML_AVX=off -DGGML_AVX2=off -DGGML_AVX512=off -DGGML_FMA=off -DGGML_F16C=off -DGGML_BMI2=off,--target grpc-server --target ggml-rpc-server)
|
||||
|
||||
cachyllama-rpc-server: cachyllama-grpc
|
||||
cp -rf $(CURRENT_MAKEFILE_DIR)/../cachyllama-grpc-build/llama.cpp/build/bin/ggml-rpc-server cachyllama-rpc-server
|
||||
|
||||
package:
|
||||
bash package.sh
|
||||
|
||||
purge:
|
||||
rm -rf $(CURRENT_MAKEFILE_DIR)/../cachyllama-*-build
|
||||
rm -rf cachyllama-* package
|
||||
|
||||
clean: purge
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to copy the appropriate libraries based on architecture
|
||||
# This script is used in the final stage of the Dockerfile
|
||||
|
||||
set -e
|
||||
|
||||
CURDIR=$(dirname "$(realpath $0)")
|
||||
REPO_ROOT="${CURDIR}/../../.."
|
||||
|
||||
# Create lib directory
|
||||
mkdir -p $CURDIR/package/lib
|
||||
|
||||
cp -avrf $CURDIR/cachyllama-* $CURDIR/package/
|
||||
cp -rfv $CURDIR/run.sh $CURDIR/package/
|
||||
|
||||
# Bundle the ggml shared backends from the CPU_ALL_VARIANTS build into package/lib. ggml
|
||||
# discovers the per-microarch libggml-cpu-*.so by scanning the executable directory, which
|
||||
# (via the bundled lib/ld.so that run.sh launches through) resolves to lib/. See the
|
||||
# matching comment in backend/cpp/llama-cpp/package.sh. No-op on the fallback/ROCm builds.
|
||||
if [ -d "$CURDIR/ggml-shared-libs" ]; then
|
||||
echo "Bundling ggml shared backends (CPU_ALL_VARIANTS)..."
|
||||
cp -avf $CURDIR/ggml-shared-libs/*.so* $CURDIR/package/lib/
|
||||
fi
|
||||
|
||||
# Detect architecture and copy appropriate libraries
|
||||
if [ -f "/lib64/ld-linux-x86-64.so.2" ]; then
|
||||
# x86_64 architecture
|
||||
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
|
||||
# ARM64 architecture
|
||||
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
|
||||
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/
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/bin/bash
|
||||
set -ex
|
||||
|
||||
# Get the absolute current dir where the script is located
|
||||
CURDIR=$(dirname "$(realpath "$0")")
|
||||
|
||||
cd /
|
||||
|
||||
echo "CPU info:"
|
||||
grep -e "model\sname" /proc/cpuinfo | head -1
|
||||
grep -e "flags" /proc/cpuinfo | head -1
|
||||
|
||||
BINARY=cachyllama-fallback
|
||||
|
||||
# x86/arm64 ship a single cachyllama-cpu-all built with ggml CPU_ALL_VARIANTS: ggml's
|
||||
# backend registry dlopens the best libggml-cpu-*.so for this host, so no shell-side
|
||||
# probing. ROCm ships only cachyllama-fallback, so fall back to it when cpu-all is absent.
|
||||
if [ -e "$CURDIR"/cachyllama-cpu-all ]; then
|
||||
BINARY=cachyllama-cpu-all
|
||||
fi
|
||||
|
||||
if [ -n "$LLAMACPP_GRPC_SERVERS" ]; then
|
||||
if [ -e "$CURDIR"/cachyllama-grpc ]; then
|
||||
BINARY=cachyllama-grpc
|
||||
fi
|
||||
fi
|
||||
|
||||
# Extend ld library path with the dir where this script is located/lib
|
||||
if [ "$(uname)" == "Darwin" ]; then
|
||||
export DYLD_LIBRARY_PATH="$CURDIR"/lib:$DYLD_LIBRARY_PATH
|
||||
else
|
||||
export LD_LIBRARY_PATH="$CURDIR"/lib:$LD_LIBRARY_PATH
|
||||
# Tell rocBLAS where to find TensileLibrary data (GPU kernel tuning files)
|
||||
if [ -d "$CURDIR/lib/rocblas/library" ]; then
|
||||
export ROCBLAS_TENSILE_LIBPATH="$CURDIR"/lib/rocblas/library
|
||||
fi
|
||||
# Same for hipBLASLt (rocblaslt): the bundled libhipblaslt.so resolves its
|
||||
# TensileLibrary_lazy_gfx*.dat kernel data relative to itself, so point it at
|
||||
# the bundled data or it falls back to slow generic kernels (issue #10660).
|
||||
if [ -d "$CURDIR/lib/hipblaslt/library" ]; then
|
||||
export HIPBLASLT_TENSILE_LIBPATH="$CURDIR"/lib/hipblaslt/library
|
||||
fi
|
||||
fi
|
||||
|
||||
# If there is a lib/ld.so, use it
|
||||
if [ -f "$CURDIR"/lib/ld.so ]; then
|
||||
echo "Using lib/ld.so"
|
||||
echo "Using binary: $BINARY"
|
||||
exec "$CURDIR"/lib/ld.so "$CURDIR"/$BINARY "$@"
|
||||
fi
|
||||
|
||||
echo "Using binary: $BINARY"
|
||||
exec "$CURDIR"/$BINARY "$@"
|
||||
|
||||
# We should never reach this point, however just in case we do, run fallback
|
||||
exec "$CURDIR"/cachyllama-fallback "$@"
|
||||
@@ -84,10 +84,9 @@ elseif(DS4_GPU STREQUAL "cpu")
|
||||
set(DS4_OBJS "${DS4_DIR}/ds4_cpu.o")
|
||||
endif()
|
||||
|
||||
# Upstream splits image preprocessing, distributed inference, tensor-parallel
|
||||
# transport, the SSD expert cache, and layer placement into GPU-agnostic
|
||||
# translation units. Link them regardless of DS4_GPU.
|
||||
list(APPEND DS4_OBJS "${DS4_DIR}/ds4_image.o")
|
||||
# Upstream splits distributed inference, tensor-parallel transport, the SSD
|
||||
# expert cache, and layer placement into GPU-agnostic translation units. Link
|
||||
# them regardless of DS4_GPU.
|
||||
list(APPEND DS4_OBJS "${DS4_DIR}/ds4_distributed.o")
|
||||
list(APPEND DS4_OBJS "${DS4_DIR}/ds4_tp.o")
|
||||
list(APPEND DS4_OBJS "${DS4_DIR}/ds4_ssd.o")
|
||||
|
||||
+11
-73
@@ -1,10 +1,10 @@
|
||||
# ds4 backend Makefile.
|
||||
#
|
||||
# Upstream pin lives below as DS4_VERSION?=6289c516273979173abbc062209a81dd3706b804
|
||||
# Upstream pin lives below as DS4_VERSION?=84cc882352757baf628a1776badf7cc54d584e28
|
||||
# (.github/bump_deps.sh) can find and update it - matches the
|
||||
# llama-cpp / ik-llama-cpp / turboquant convention.
|
||||
|
||||
DS4_VERSION?=6289c516273979173abbc062209a81dd3706b804
|
||||
DS4_VERSION?=84cc882352757baf628a1776badf7cc54d584e28
|
||||
DS4_REPO?=https://github.com/antirez/ds4
|
||||
|
||||
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||
@@ -18,83 +18,21 @@ UNAME_S := $(shell uname -s)
|
||||
|
||||
CMAKE_ARGS ?= -DCMAKE_BUILD_TYPE=Release
|
||||
|
||||
# nvcc must be told the target architecture explicitly for a cublas build, and
|
||||
# this is not a tuning knob. Upstream's Makefile leaves CUDA_ARCH empty and its
|
||||
# `cuda` target REFUSES to build without one, offering `cuda-spark`
|
||||
# (CUDA_ARCH=sm_121) and `cuda-generic` (CUDA_ARCH=native) instead. We drive its
|
||||
# object targets directly, which bypasses that guard: nvcc then compiles with no
|
||||
# -arch at all, and the kernels run as JIT'd PTX for its default architecture.
|
||||
# On GB10 (sm_121) that silently produced corrupt inference output above a
|
||||
# ~128-token prefill batch and ~77x slower prefill (4.21 t/s vs 325.70 t/s,
|
||||
# measured on the same box with the same model). No CI runner has a GPU, so
|
||||
# `native` has nothing to enumerate there.
|
||||
#
|
||||
# Upstream's CUDA_ARCH takes a SINGLE value (see its sm_120/sm_121 special cases
|
||||
# and the `-arch=$(CUDA_ARCH)` fallback), so it cannot express the fat binary
|
||||
# these images need. NVCC_ARCH_FLAGS is overridden instead: a command-line
|
||||
# assignment wins over the `:=` in upstream's Makefile, and its NVCCFLAGS
|
||||
# expands whatever we pass.
|
||||
#
|
||||
# The architecture lists are copied from backend/go/vllm-cpp/Makefile rather
|
||||
# than invented, so the two CUDA images cover the same GPUs: amd64 datacenter +
|
||||
# consumer, and l4t/arm64 covering Orin (87), Thor (110) and GB10 (121a).
|
||||
#
|
||||
# -DDS4_CUDA_HAVE_MXF4=1 is deliberately NOT set. Upstream only defines it for
|
||||
# single-arch sm_120/sm_121 builds and guards the code with a plain #ifdef
|
||||
# rather than __CUDA_ARCH__, so it cannot be combined with older archs in one
|
||||
# fat binary. It gates an optional MXFP4 indexer fast path whose #ifndef branch
|
||||
# returns 0 and falls back to the generic path, so omitting it costs some speed
|
||||
# on GB10, not correctness. Revisit if upstream adds __CUDA_ARCH__ guards.
|
||||
#
|
||||
# An EMPTY CUDA_MAJOR_VERSION means a local developer build, not CI: fall back
|
||||
# to upstream's own `native` handling, which needs a GPU present but is what a
|
||||
# developer building on their own machine wants. Both variables are `?=` so an
|
||||
# explicit value on the command line always wins.
|
||||
UNAME_M := $(shell uname -m)
|
||||
CUDA_MAJOR_VERSION ?=
|
||||
ifeq ($(BUILD_TYPE),cublas)
|
||||
ifeq ($(CUDA_MAJOR_VERSION),13)
|
||||
ifeq ($(UNAME_M),aarch64)
|
||||
DS4_NVCC_ARCH_FLAGS ?= -gencode arch=compute_87,code=sm_87 \
|
||||
-gencode arch=compute_90a,code=sm_90a \
|
||||
-gencode arch=compute_100a,code=sm_100a \
|
||||
-gencode arch=compute_110,code=sm_110 \
|
||||
-gencode arch=compute_121a,code=sm_121a
|
||||
else
|
||||
DS4_NVCC_ARCH_FLAGS ?= -gencode arch=compute_80,code=sm_80 \
|
||||
-gencode arch=compute_86,code=sm_86 \
|
||||
-gencode arch=compute_89,code=sm_89 \
|
||||
-gencode arch=compute_90a,code=sm_90a \
|
||||
-gencode arch=compute_100a,code=sm_100a \
|
||||
-gencode arch=compute_103a,code=sm_103a \
|
||||
-gencode arch=compute_120a,code=sm_120a \
|
||||
-gencode arch=compute_121a,code=sm_121a
|
||||
endif
|
||||
DS4_ARCH_MAKEVARS := NVCC_ARCH_FLAGS="$(DS4_NVCC_ARCH_FLAGS)"
|
||||
else ifeq ($(CUDA_MAJOR_VERSION),)
|
||||
# Local build: let upstream resolve the host GPU.
|
||||
DS4_ARCH_MAKEVARS := CUDA_ARCH=native
|
||||
else
|
||||
$(error CUDA_MAJOR_VERSION=$(CUDA_MAJOR_VERSION) has no architecture list here (13 does). Leave it empty for a native build, or pass DS4_NVCC_ARCH_FLAGS explicitly.)
|
||||
endif
|
||||
endif
|
||||
|
||||
# Upstream splits image preprocessing, distributed inference, tensor-parallel
|
||||
# transport, the SSD expert cache, and layer placement into GPU-agnostic
|
||||
# translation units. They are shared by every GPU mode, so append them
|
||||
# unconditionally below.
|
||||
# Upstream splits distributed inference, tensor-parallel transport, the SSD
|
||||
# expert cache, and layer placement into GPU-agnostic translation units. They
|
||||
# are shared by every GPU mode, so append them unconditionally below.
|
||||
ifeq ($(BUILD_TYPE),cublas)
|
||||
CMAKE_ARGS += -DDS4_GPU=cuda
|
||||
DS4_OBJ_TARGET := ds4.o ds4_image.o ds4_cuda.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o \
|
||||
DS4_OBJ_TARGET := ds4.o ds4_cuda.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o \
|
||||
cuda/mmq/ds4_ggml_stubs.o cuda/mmq/ds4_mmq.o cuda/mmq/ds4_mmq_d2r.o \
|
||||
cuda/mmq/quantize.o cuda/mmq/mmid.o cuda/mmq/mmvq.o cuda/mmq/ds4_repack.o
|
||||
else ifeq ($(UNAME_S),Darwin)
|
||||
CMAKE_ARGS += -DDS4_GPU=metal
|
||||
DS4_OBJ_TARGET := ds4.o ds4_image.o ds4_metal.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
|
||||
DS4_OBJ_TARGET := ds4.o ds4_metal.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
|
||||
else
|
||||
# CPU reference path (Linux only - macOS CPU path is broken by VM bug per ds4 README).
|
||||
CMAKE_ARGS += -DDS4_GPU=cpu
|
||||
DS4_OBJ_TARGET := ds4_cpu.o ds4_image.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
|
||||
DS4_OBJ_TARGET := ds4_cpu.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
|
||||
endif
|
||||
|
||||
ifneq ($(NATIVE),true)
|
||||
@@ -119,11 +57,11 @@ ds4:
|
||||
# the right per-platform compile flags (Objective-C/Metal on Darwin, nvcc on Linux+CUDA).
|
||||
ds4/ds4.o: ds4
|
||||
ifeq ($(BUILD_TYPE),cublas)
|
||||
+$(MAKE) -C ds4 $(DS4_ARCH_MAKEVARS) $(DS4_OBJ_TARGET)
|
||||
+$(MAKE) -C ds4 $(DS4_OBJ_TARGET)
|
||||
else ifeq ($(UNAME_S),Darwin)
|
||||
+$(MAKE) -C ds4 ds4.o ds4_image.o ds4_metal.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
|
||||
+$(MAKE) -C ds4 ds4.o ds4_metal.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
|
||||
else
|
||||
+$(MAKE) -C ds4 ds4_cpu.o ds4_image.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
|
||||
+$(MAKE) -C ds4 ds4_cpu.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
|
||||
endif
|
||||
|
||||
grpc-server: ds4/ds4.o
|
||||
|
||||
@@ -92,8 +92,7 @@ std::string json_escape(const std::string &in) {
|
||||
|
||||
} // namespace
|
||||
|
||||
DsmlParser::DsmlParser(bool starts_in_thinking)
|
||||
: state_(starts_in_thinking ? State::THINK : State::TEXT) {}
|
||||
DsmlParser::DsmlParser() = default;
|
||||
|
||||
bool DsmlParser::IsInDsmlStructural() const {
|
||||
switch (state_) {
|
||||
|
||||
@@ -17,9 +17,7 @@ struct ParserEvent {
|
||||
// Streaming parser. Stateless across instances; one per Predict call.
|
||||
class DsmlParser {
|
||||
public:
|
||||
// The chat prompt may already contain the opening thinking marker, so the
|
||||
// generated text can begin directly with reasoning bytes.
|
||||
explicit DsmlParser(bool starts_in_thinking = false);
|
||||
DsmlParser();
|
||||
|
||||
// Feed a chunk of raw model-emitted text. Appends classified events to
|
||||
// `out`. May buffer the tail of `chunk` internally if it looks like a
|
||||
@@ -45,7 +43,7 @@ public:
|
||||
|
||||
private:
|
||||
enum class State { TEXT, THINK, TOOL_CALLS, INVOKE, PARAM_VALUE };
|
||||
State state_;
|
||||
State state_ = State::TEXT;
|
||||
std::string buf_;
|
||||
std::string current_tool_name_;
|
||||
int tool_index_ = -1;
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Standalone regression tests for the DSML streaming parser.
|
||||
//
|
||||
// The repository's backend/cpp/run-unit-tests.sh harness compiles each
|
||||
// *_test.cpp as a single translation unit, so include the implementation here.
|
||||
|
||||
#include "dsml_parser.cpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
struct ParsedText {
|
||||
std::string content;
|
||||
std::string reasoning;
|
||||
};
|
||||
|
||||
int failures = 0;
|
||||
|
||||
void check_equal(const std::string &got, const std::string &want,
|
||||
const char *name) {
|
||||
if (got == want) return;
|
||||
std::fprintf(stderr, "FAIL %s: got \"%s\", want \"%s\"\n",
|
||||
name, got.c_str(), want.c_str());
|
||||
failures++;
|
||||
}
|
||||
|
||||
void collect_text(const std::vector<ds4cpp::ParserEvent> &events,
|
||||
ParsedText *parsed) {
|
||||
for (const auto &event : events) {
|
||||
if (event.type == ds4cpp::ParserEvent::CONTENT) {
|
||||
parsed->content += event.text;
|
||||
} else if (event.type == ds4cpp::ParserEvent::REASONING) {
|
||||
parsed->reasoning += event.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ParsedText parse_chunks(ds4cpp::DsmlParser *parser,
|
||||
const std::vector<std::string> &chunks) {
|
||||
ParsedText parsed;
|
||||
for (const auto &chunk : chunks) {
|
||||
std::vector<ds4cpp::ParserEvent> events;
|
||||
parser->Feed(chunk, events);
|
||||
collect_text(events, &parsed);
|
||||
}
|
||||
std::vector<ds4cpp::ParserEvent> events;
|
||||
parser->Flush(events);
|
||||
collect_text(events, &parsed);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
template <typename Parser>
|
||||
void test_reasoning_opened_by_prompt() {
|
||||
if constexpr (!std::is_constructible_v<Parser, bool>) {
|
||||
std::fprintf(stderr,
|
||||
"FAIL reasoning_opened_by_prompt: parser cannot start in thinking state\n");
|
||||
failures++;
|
||||
} else {
|
||||
Parser parser(true);
|
||||
ParsedText parsed = parse_chunks(
|
||||
&parser,
|
||||
{"We need to calculate factorial recursively.</think>Here is the answer."});
|
||||
check_equal(parsed.reasoning,
|
||||
"We need to calculate factorial recursively.",
|
||||
"reasoning_opened_by_prompt:reasoning");
|
||||
check_equal(parsed.content, "Here is the answer.",
|
||||
"reasoning_opened_by_prompt:content");
|
||||
}
|
||||
}
|
||||
|
||||
template <typename Parser>
|
||||
Parser text_parser() {
|
||||
if constexpr (std::is_constructible_v<Parser, bool>) {
|
||||
return Parser(false);
|
||||
} else {
|
||||
return Parser();
|
||||
}
|
||||
}
|
||||
|
||||
void test_reasoning_disabled() {
|
||||
auto parser = text_parser<ds4cpp::DsmlParser>();
|
||||
ParsedText parsed = parse_chunks(&parser, {"Here is the answer."});
|
||||
check_equal(parsed.reasoning, "", "reasoning_disabled:reasoning");
|
||||
check_equal(parsed.content, "Here is the answer.",
|
||||
"reasoning_disabled:content");
|
||||
}
|
||||
|
||||
void test_explicit_think_tag() {
|
||||
auto parser = text_parser<ds4cpp::DsmlParser>();
|
||||
ParsedText parsed = parse_chunks(
|
||||
&parser, {"<think>reasoning</think>answer"});
|
||||
check_equal(parsed.reasoning, "reasoning", "explicit_think_tag:reasoning");
|
||||
check_equal(parsed.content, "answer", "explicit_think_tag:content");
|
||||
}
|
||||
|
||||
template <typename Parser>
|
||||
void test_split_think_close_marker() {
|
||||
if constexpr (!std::is_constructible_v<Parser, bool>) {
|
||||
std::fprintf(stderr,
|
||||
"FAIL split_think_close_marker: parser cannot start in thinking state\n");
|
||||
failures++;
|
||||
} else {
|
||||
Parser parser(true);
|
||||
ParsedText parsed = parse_chunks(
|
||||
&parser,
|
||||
{"We need ", "to calculate ", "factorial", "</thi", "nk>",
|
||||
"Here is ", "the answer."});
|
||||
check_equal(parsed.reasoning, "We need to calculate factorial",
|
||||
"split_think_close_marker:reasoning");
|
||||
check_equal(parsed.content, "Here is the answer.",
|
||||
"split_think_close_marker:content");
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_reasoning_opened_by_prompt<ds4cpp::DsmlParser>();
|
||||
test_reasoning_disabled();
|
||||
test_explicit_think_tag();
|
||||
test_split_think_close_marker<ds4cpp::DsmlParser>();
|
||||
|
||||
if (failures == 0) {
|
||||
std::fprintf(stderr, "all dsml_parser checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::fprintf(stderr, "%d check(s) failed\n", failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace ds4cpp {
|
||||
|
||||
inline int EffectiveGenerationLimit(int requested, int context_size,
|
||||
int session_position) {
|
||||
const int limit = requested > 0 ? requested : 256;
|
||||
const int room = context_size - session_position;
|
||||
if (room <= 1) return 0;
|
||||
return std::min(limit, room - 1);
|
||||
}
|
||||
|
||||
inline int RemainingGenerationBudget(int effective_limit, int produced) {
|
||||
if (effective_limit <= produced) return 0;
|
||||
return effective_limit - produced;
|
||||
}
|
||||
|
||||
inline int SpeculativeAcceptedCapacity(int remaining, int draft_allowance,
|
||||
int buffer_capacity) {
|
||||
if (remaining <= 0 || draft_allowance < 0 || buffer_capacity <= 0) return 0;
|
||||
return std::min({remaining, draft_allowance + 1, buffer_capacity});
|
||||
}
|
||||
|
||||
} // namespace ds4cpp
|
||||
@@ -1,92 +0,0 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
#include "generation_limits.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
namespace {
|
||||
|
||||
int failures = 0;
|
||||
|
||||
void check_equal(int got, int want, const char *name) {
|
||||
if (got == want) return;
|
||||
std::fprintf(stderr, "FAIL %s: got %d, want %d\n", name, got, want);
|
||||
failures++;
|
||||
}
|
||||
|
||||
// Mutation caught: treating omitted or negative max_tokens as unlimited instead
|
||||
// of preserving DS4's legacy 256-token default.
|
||||
void test_nonpositive_uses_legacy_default_when_space_permits() {
|
||||
check_equal(ds4cpp::EffectiveGenerationLimit(0, 4096, 100), 256,
|
||||
"zero max_tokens uses legacy default");
|
||||
check_equal(ds4cpp::EffectiveGenerationLimit(-1, 4096, 100), 256,
|
||||
"negative max_tokens uses legacy default");
|
||||
}
|
||||
|
||||
// Mutation caught: applying the legacy default without clamping it to the
|
||||
// post-prefill context room and reserved slot.
|
||||
void test_legacy_default_is_clamped_by_context() {
|
||||
check_equal(ds4cpp::EffectiveGenerationLimit(0, 300, 100), 199,
|
||||
"legacy default is context-clamped");
|
||||
}
|
||||
|
||||
// Mutation caught: allowing an explicitly large request to overrun the
|
||||
// post-prefill context boundary.
|
||||
void test_large_positive_limit_is_clamped_to_context() {
|
||||
check_equal(ds4cpp::EffectiveGenerationLimit(32768, 32768, 100), 32667,
|
||||
"large positive is context-clamped");
|
||||
}
|
||||
|
||||
// Mutation caught: replacing every positive request with the legacy default
|
||||
// rather than preserving a smaller configured limit.
|
||||
void test_smaller_positive_limit_is_preserved() {
|
||||
check_equal(ds4cpp::EffectiveGenerationLimit(64, 4096, 100), 64,
|
||||
"smaller positive is preserved");
|
||||
}
|
||||
|
||||
// Mutation caught: consuming the final context slot instead of reserving it as
|
||||
// required by DS4's generation loop.
|
||||
void test_no_usable_room_returns_zero() {
|
||||
check_equal(ds4cpp::EffectiveGenerationLimit(32, 100, 99), 0,
|
||||
"one remaining context slot is not usable");
|
||||
}
|
||||
|
||||
// Mutation caught: sending the original generation limit to a later
|
||||
// speculative cycle instead of subtracting tokens already produced.
|
||||
void test_remaining_budget_accounts_for_produced_tokens() {
|
||||
check_equal(ds4cpp::RemainingGenerationBudget(10, 4), 6,
|
||||
"remaining budget subtracts produced tokens");
|
||||
check_equal(ds4cpp::RemainingGenerationBudget(10, 12), 0,
|
||||
"remaining budget never becomes negative");
|
||||
}
|
||||
|
||||
// Mutation caught: giving speculative evaluation capacity beyond either the
|
||||
// output budget, the draft allowance plus its first target token, or the fixed
|
||||
// accepted-token buffer.
|
||||
void test_speculative_capacity_obeys_all_bounds() {
|
||||
check_equal(ds4cpp::SpeculativeAcceptedCapacity(3, 8, 8), 3,
|
||||
"capacity respects remaining output budget");
|
||||
check_equal(ds4cpp::SpeculativeAcceptedCapacity(20, 4, 8), 5,
|
||||
"capacity includes one target token beyond draft allowance");
|
||||
check_equal(ds4cpp::SpeculativeAcceptedCapacity(20, 8, 6), 6,
|
||||
"capacity respects fixed buffer");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_nonpositive_uses_legacy_default_when_space_permits();
|
||||
test_legacy_default_is_clamped_by_context();
|
||||
test_large_positive_limit_is_clamped_to_context();
|
||||
test_smaller_positive_limit_is_preserved();
|
||||
test_no_usable_room_returns_zero();
|
||||
test_remaining_budget_accounts_for_produced_tokens();
|
||||
test_speculative_capacity_obeys_all_bounds();
|
||||
|
||||
if (failures == 0) {
|
||||
std::fprintf(stderr, "all generation limit checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::fprintf(stderr, "%d check(s) failed\n", failures);
|
||||
return 1;
|
||||
}
|
||||
+57
-186
@@ -10,9 +10,7 @@
|
||||
|
||||
#include "dsml_parser.h" // populated in Task 12
|
||||
#include "dsml_renderer.h" // populated in Task 16
|
||||
#include "generation_limits.h"
|
||||
#include "kv_cache.h" // populated in Task 17
|
||||
#include "request_lifecycle.h"
|
||||
|
||||
extern "C" {
|
||||
#include "ds4.h"
|
||||
@@ -37,7 +35,6 @@ extern "C" {
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
using grpc::Server;
|
||||
@@ -72,21 +69,6 @@ int g_route_timeout_sec = 60;
|
||||
|
||||
std::atomic<Server *> g_server{nullptr};
|
||||
|
||||
static bool server_context_cancelled(void *ud) {
|
||||
return static_cast<ServerContext *>(ud)->IsCancelled();
|
||||
}
|
||||
|
||||
static void set_session_cancel(void *target, ds4cpp::CancelCallback callback,
|
||||
void *userdata) noexcept {
|
||||
ds4_session_set_cancel(static_cast<ds4_session *>(target), callback, userdata);
|
||||
}
|
||||
|
||||
static bool request_should_continue(ds4cpp::RequestLifecycle *request,
|
||||
ServerContext *context) {
|
||||
request->ObserveContextCancellation(context->IsCancelled());
|
||||
return request->ShouldContinue();
|
||||
}
|
||||
|
||||
// Parse a "key:value" option string. Returns empty when no colon.
|
||||
static std::pair<std::string, std::string> split_option(const std::string &opt) {
|
||||
auto colon = opt.find(':');
|
||||
@@ -256,58 +238,37 @@ static bool apply_engine_option(ds4_engine_options *opt, const std::string &key,
|
||||
|
||||
// When acting as a distributed coordinator, block until the worker route
|
||||
// covers all layers (ds4_session_distributed_route_ready == 1) or the timeout
|
||||
// elapses. No-op when not distributed.
|
||||
// elapses. Returns an empty string on success, or an error message to return
|
||||
// to the client. No-op when not distributed.
|
||||
//
|
||||
// Takes the g_engine_mu lock by reference and RELEASES it during each poll
|
||||
// sleep. The wait can span up to g_route_timeout_sec seconds while workers
|
||||
// connect; holding g_engine_mu the whole time would block the Status/Health
|
||||
// readiness probes (they also lock g_engine_mu), making LocalAI's loader treat
|
||||
// a still-starting worker as hung.
|
||||
struct RouteWaitResult {
|
||||
ds4cpp::RouteWaitDecision decision;
|
||||
std::string error;
|
||||
};
|
||||
|
||||
static RouteWaitResult wait_route_ready(std::unique_lock<std::mutex> &lock,
|
||||
ServerContext *context) {
|
||||
if (!g_distributed) return {ds4cpp::RouteWaitDecision::Ready, ""};
|
||||
static std::string wait_route_ready(std::unique_lock<std::mutex> &lock) {
|
||||
if (!g_distributed) return "";
|
||||
char err[256] = {0};
|
||||
const int deadline_polls = g_route_timeout_sec * 10; // 100ms per poll
|
||||
for (int i = 0; i <= deadline_polls; ++i) {
|
||||
int ready = ds4_session_distributed_route_ready(g_session, err, sizeof(err));
|
||||
switch (ds4cpp::DecideRouteWait(ready, context->IsCancelled())) {
|
||||
case ds4cpp::RouteWaitDecision::Ready:
|
||||
return {ds4cpp::RouteWaitDecision::Ready, ""};
|
||||
case ds4cpp::RouteWaitDecision::Error:
|
||||
return {ds4cpp::RouteWaitDecision::Error,
|
||||
std::string("ds4 distributed route error: ") +
|
||||
(err[0] ? err : "unknown")};
|
||||
case ds4cpp::RouteWaitDecision::Cancelled:
|
||||
return {ds4cpp::RouteWaitDecision::Cancelled, ""};
|
||||
case ds4cpp::RouteWaitDecision::Pending:
|
||||
break;
|
||||
if (ready == 1) return "";
|
||||
if (ready < 0) {
|
||||
return std::string("ds4 distributed route error: ") +
|
||||
(err[0] ? err : "unknown");
|
||||
}
|
||||
if (i == deadline_polls) break;
|
||||
// Release the lock while sleeping so Status/Health and other RPCs can
|
||||
// interleave during worker startup.
|
||||
lock.unlock();
|
||||
struct timespec ts = {0, 100L * 1000L * 1000L}; // 100ms
|
||||
nanosleep(&ts, nullptr);
|
||||
lock.lock();
|
||||
if (context->IsCancelled()) {
|
||||
return {ds4cpp::RouteWaitDecision::Cancelled, ""};
|
||||
}
|
||||
// A concurrent Free() may have torn down the engine while we slept.
|
||||
if (!g_engine || !g_session) {
|
||||
return {ds4cpp::RouteWaitDecision::Error,
|
||||
"ds4: model unloaded while waiting for distributed route"};
|
||||
return "ds4: model unloaded while waiting for distributed route";
|
||||
}
|
||||
}
|
||||
if (context->IsCancelled()) {
|
||||
return {ds4cpp::RouteWaitDecision::Cancelled, ""};
|
||||
}
|
||||
return {ds4cpp::RouteWaitDecision::Error,
|
||||
"ds4 distributed route incomplete: workers not connected (layers uncovered)"};
|
||||
return "ds4 distributed route incomplete: workers not connected (layers uncovered)";
|
||||
}
|
||||
|
||||
static void append_token_text(ds4_engine *engine, int token, std::string &out) {
|
||||
@@ -380,9 +341,9 @@ static void collect_done(void *) {}
|
||||
struct StreamCtx {
|
||||
ds4_engine *engine;
|
||||
ServerWriter<backend::Reply> *writer;
|
||||
ds4cpp::RequestLifecycle *request;
|
||||
ds4cpp::DsmlParser parser;
|
||||
int tokens;
|
||||
bool aborted;
|
||||
// Track which tool indices we've seen TOOL_START for, so subsequent
|
||||
// ARGS deltas can elide the redundant id/name fields.
|
||||
std::vector<bool> tool_started;
|
||||
@@ -390,7 +351,7 @@ struct StreamCtx {
|
||||
|
||||
static void stream_emit(void *ud, int token) {
|
||||
auto *s = static_cast<StreamCtx *>(ud);
|
||||
if (!s->request->ShouldContinue()) return;
|
||||
if (s->aborted) return;
|
||||
if (token == ds4_token_eos(s->engine)) return;
|
||||
size_t len = 0;
|
||||
const char *text = ds4_token_text(s->engine, token, &len);
|
||||
@@ -440,7 +401,7 @@ static void stream_emit(void *ud, int token) {
|
||||
reply.set_message(chunk);
|
||||
reply.set_tokens(1);
|
||||
if (any_field) {
|
||||
s->request->ObserveStreamWrite(s->writer->Write(reply));
|
||||
if (!s->writer->Write(reply)) s->aborted = true;
|
||||
}
|
||||
s->tokens++;
|
||||
}
|
||||
@@ -796,30 +757,21 @@ public:
|
||||
return GStatus::OK;
|
||||
}
|
||||
|
||||
GStatus Predict(ServerContext *context, const backend::PredictOptions *request,
|
||||
GStatus Predict(ServerContext *, const backend::PredictOptions *request,
|
||||
backend::Reply *reply) override {
|
||||
std::unique_lock<std::mutex> lock(g_engine_mu);
|
||||
if (!g_engine || !g_session) {
|
||||
return GStatus(StatusCode::FAILED_PRECONDITION, "ds4: model not loaded");
|
||||
}
|
||||
if (GStatus id = check_model_identity(request); !id.ok()) return id;
|
||||
RouteWaitResult route = wait_route_ready(lock, context);
|
||||
if (route.decision == ds4cpp::RouteWaitDecision::Cancelled) {
|
||||
return GStatus(StatusCode::CANCELLED, "ds4 request cancelled");
|
||||
}
|
||||
if (route.decision == ds4cpp::RouteWaitDecision::Error) {
|
||||
return GStatus(StatusCode::UNAVAILABLE, route.error);
|
||||
if (std::string route_err = wait_route_ready(lock); !route_err.empty()) {
|
||||
return GStatus(StatusCode::UNAVAILABLE, route_err);
|
||||
}
|
||||
ds4_tokens prompt = {};
|
||||
build_prompt(g_engine, request, &prompt);
|
||||
int n_predict = request->tokens() > 0 ? request->tokens() : 256;
|
||||
|
||||
const bool think_enabled = ds4_think_mode_enabled(parse_think_mode(request));
|
||||
const bool starts_in_thinking = think_enabled &&
|
||||
request->usetokenizertemplate() && request->messages_size() > 0;
|
||||
CollectCtx collect = {
|
||||
g_engine, "", ds4cpp::DsmlParser(starts_in_thinking),
|
||||
reply, 0, {}, "", ""};
|
||||
ds4cpp::RequestLifecycle lifecycle;
|
||||
CollectCtx collect = {g_engine, "", {}, reply, 0, {}, "", ""};
|
||||
std::string cache_key = render_prompt_text(request);
|
||||
size_t cache_hit = maybe_load_cache(cache_key);
|
||||
(void)cache_hit; // future: skip prompt prefix if hit covers full prompt
|
||||
@@ -831,27 +783,15 @@ public:
|
||||
// Either way g_session advances so the disk KV cache picks up a
|
||||
// real checkpoint after the call (see maybe_save_cache below).
|
||||
char err[256] = {0};
|
||||
int rc;
|
||||
{
|
||||
ds4cpp::CancelCallbackScope cancel_scope(
|
||||
g_session, set_session_cancel, server_context_cancelled, context);
|
||||
rc = ds4_session_sync(g_session, &prompt, err, sizeof(err));
|
||||
}
|
||||
int rc = ds4_session_sync(g_session, &prompt, err, sizeof(err));
|
||||
int prompt_len = prompt.len;
|
||||
ds4_tokens_free(&prompt);
|
||||
if (rc == DS4_SESSION_SYNC_INTERRUPTED) {
|
||||
lifecycle.ObserveContextCancellation(true);
|
||||
}
|
||||
const bool generation_started = rc == 0;
|
||||
if (generation_started) {
|
||||
const int n_predict = ds4cpp::EffectiveGenerationLimit(
|
||||
request->tokens(), ds4_session_ctx(g_session),
|
||||
ds4_session_pos(g_session));
|
||||
if (rc == 0) {
|
||||
const int eos = ds4_token_eos(g_engine);
|
||||
const int draft_max = ds4_engine_mtp_draft_tokens(g_engine);
|
||||
const bool think_enabled = ds4_think_mode_enabled(parse_think_mode(request));
|
||||
int produced = 0;
|
||||
while (produced < n_predict) {
|
||||
if (!request_should_continue(&lifecycle, context)) break;
|
||||
SampleParams sp = compute_sample_params(request, collect.parser, think_enabled);
|
||||
int first;
|
||||
if (sp.temperature <= 0.0f) {
|
||||
@@ -866,20 +806,13 @@ public:
|
||||
if (draft_max > 0 && sp.temperature <= 0.0f) {
|
||||
constexpr int kAcceptedMax = 8;
|
||||
int accepted[kAcceptedMax];
|
||||
const int remaining = ds4cpp::RemainingGenerationBudget(
|
||||
n_predict, produced);
|
||||
const int cap = ds4cpp::SpeculativeAcceptedCapacity(
|
||||
remaining, draft_max, kAcceptedMax);
|
||||
int cap = std::min(kAcceptedMax, draft_max + 1);
|
||||
int n = ds4_session_eval_speculative_argmax(
|
||||
g_session, first, remaining, eos,
|
||||
g_session, first, draft_max, eos,
|
||||
accepted, cap, err, sizeof(err));
|
||||
if (n < 0) { rc = -1; break; }
|
||||
bool stop = false;
|
||||
for (int j = 0; j < n; ++j) {
|
||||
if (!request_should_continue(&lifecycle, context)) {
|
||||
stop = true;
|
||||
break;
|
||||
}
|
||||
if (accepted[j] == eos) { stop = true; break; }
|
||||
collect_emit(&collect, accepted[j]);
|
||||
if (++produced >= n_predict) { stop = true; break; }
|
||||
@@ -888,26 +821,12 @@ public:
|
||||
} else {
|
||||
collect_emit(&collect, first);
|
||||
if (++produced >= n_predict) break;
|
||||
if (!request_should_continue(&lifecycle, context)) break;
|
||||
rc = ds4_session_eval(g_session, first, err, sizeof(err));
|
||||
if (rc != 0) break;
|
||||
}
|
||||
}
|
||||
collect_done(&collect);
|
||||
}
|
||||
|
||||
request_should_continue(&lifecycle, context);
|
||||
ds4cpp::TerminalDecision terminal = ds4cpp::ResolveTerminalDecision(
|
||||
rc == DS4_SESSION_SYNC_INTERRUPTED, rc != 0,
|
||||
!lifecycle.ShouldFinalize());
|
||||
if (!terminal.should_finalize) {
|
||||
if (terminal.cause == ds4cpp::TerminalCause::EngineError) {
|
||||
return GStatus(StatusCode::INTERNAL,
|
||||
std::string("ds4 generation failed: ") + err);
|
||||
}
|
||||
return GStatus(StatusCode::CANCELLED,
|
||||
"ds4 request cancelled");
|
||||
}
|
||||
if (generation_started) collect_done(&collect);
|
||||
maybe_save_cache(cache_key);
|
||||
|
||||
// Flush any buffered parser state.
|
||||
@@ -915,7 +834,7 @@ public:
|
||||
collect.parser.Flush(events);
|
||||
apply_events(&collect, events);
|
||||
|
||||
if (terminal.cause == ds4cpp::TerminalCause::EngineError) {
|
||||
if (rc != 0) {
|
||||
return GStatus(StatusCode::INTERNAL,
|
||||
std::string("ds4 generation failed: ") + err);
|
||||
}
|
||||
@@ -938,30 +857,21 @@ public:
|
||||
return GStatus::OK;
|
||||
}
|
||||
|
||||
GStatus PredictStream(ServerContext *context, const backend::PredictOptions *request,
|
||||
GStatus PredictStream(ServerContext *, const backend::PredictOptions *request,
|
||||
ServerWriter<backend::Reply> *writer) override {
|
||||
std::unique_lock<std::mutex> lock(g_engine_mu);
|
||||
if (!g_engine || !g_session) {
|
||||
return GStatus(StatusCode::FAILED_PRECONDITION, "ds4: model not loaded");
|
||||
}
|
||||
if (GStatus id = check_model_identity(request); !id.ok()) return id;
|
||||
RouteWaitResult route = wait_route_ready(lock, context);
|
||||
if (route.decision == ds4cpp::RouteWaitDecision::Cancelled) {
|
||||
return GStatus(StatusCode::CANCELLED, "ds4 request cancelled");
|
||||
}
|
||||
if (route.decision == ds4cpp::RouteWaitDecision::Error) {
|
||||
return GStatus(StatusCode::UNAVAILABLE, route.error);
|
||||
if (std::string route_err = wait_route_ready(lock); !route_err.empty()) {
|
||||
return GStatus(StatusCode::UNAVAILABLE, route_err);
|
||||
}
|
||||
ds4_tokens prompt = {};
|
||||
build_prompt(g_engine, request, &prompt);
|
||||
int n_predict = request->tokens() > 0 ? request->tokens() : 256;
|
||||
|
||||
const bool think_enabled = ds4_think_mode_enabled(parse_think_mode(request));
|
||||
const bool starts_in_thinking = think_enabled &&
|
||||
request->usetokenizertemplate() && request->messages_size() > 0;
|
||||
ds4cpp::RequestLifecycle lifecycle;
|
||||
StreamCtx s = {
|
||||
g_engine, writer, &lifecycle,
|
||||
ds4cpp::DsmlParser(starts_in_thinking), 0, {}};
|
||||
StreamCtx s = {g_engine, writer, {}, 0, false, {}};
|
||||
std::string cache_key = render_prompt_text(request);
|
||||
size_t cache_hit = maybe_load_cache(cache_key);
|
||||
(void)cache_hit;
|
||||
@@ -969,26 +879,14 @@ public:
|
||||
// Manual loop on g_session - see Predict() above for the rationale.
|
||||
// MTP speculative path used when ds4_engine_mtp_draft_tokens > 0.
|
||||
char err[256] = {0};
|
||||
int rc;
|
||||
{
|
||||
ds4cpp::CancelCallbackScope cancel_scope(
|
||||
g_session, set_session_cancel, server_context_cancelled, context);
|
||||
rc = ds4_session_sync(g_session, &prompt, err, sizeof(err));
|
||||
}
|
||||
int rc = ds4_session_sync(g_session, &prompt, err, sizeof(err));
|
||||
ds4_tokens_free(&prompt);
|
||||
if (rc == DS4_SESSION_SYNC_INTERRUPTED) {
|
||||
lifecycle.ObserveContextCancellation(true);
|
||||
}
|
||||
const bool generation_started = rc == 0;
|
||||
if (generation_started) {
|
||||
const int n_predict = ds4cpp::EffectiveGenerationLimit(
|
||||
request->tokens(), ds4_session_ctx(g_session),
|
||||
ds4_session_pos(g_session));
|
||||
if (rc == 0) {
|
||||
const int eos = ds4_token_eos(g_engine);
|
||||
const int draft_max = ds4_engine_mtp_draft_tokens(g_engine);
|
||||
const bool think_enabled = ds4_think_mode_enabled(parse_think_mode(request));
|
||||
int produced = 0;
|
||||
while (produced < n_predict) {
|
||||
if (!request_should_continue(&lifecycle, context)) break;
|
||||
while (produced < n_predict && !s.aborted) {
|
||||
SampleParams sp = compute_sample_params(request, s.parser, think_enabled);
|
||||
int first;
|
||||
if (sp.temperature <= 0.0f) {
|
||||
@@ -1002,77 +900,50 @@ public:
|
||||
if (draft_max > 0 && sp.temperature <= 0.0f) {
|
||||
constexpr int kAcceptedMax = 8;
|
||||
int accepted[kAcceptedMax];
|
||||
const int remaining = ds4cpp::RemainingGenerationBudget(
|
||||
n_predict, produced);
|
||||
const int cap = ds4cpp::SpeculativeAcceptedCapacity(
|
||||
remaining, draft_max, kAcceptedMax);
|
||||
int cap = std::min(kAcceptedMax, draft_max + 1);
|
||||
int n = ds4_session_eval_speculative_argmax(
|
||||
g_session, first, remaining, eos,
|
||||
g_session, first, draft_max, eos,
|
||||
accepted, cap, err, sizeof(err));
|
||||
if (n < 0) { rc = -1; break; }
|
||||
bool stop = false;
|
||||
for (int j = 0; j < n; ++j) {
|
||||
if (!request_should_continue(&lifecycle, context)) {
|
||||
stop = true;
|
||||
break;
|
||||
}
|
||||
if (accepted[j] == eos) { stop = true; break; }
|
||||
stream_emit(&s, accepted[j]);
|
||||
if (!lifecycle.ShouldContinue()) { stop = true; break; }
|
||||
if (s.aborted) { stop = true; break; }
|
||||
if (++produced >= n_predict) { stop = true; break; }
|
||||
}
|
||||
if (stop) break;
|
||||
} else {
|
||||
stream_emit(&s, first);
|
||||
if (!lifecycle.ShouldContinue() || ++produced >= n_predict) break;
|
||||
if (!request_should_continue(&lifecycle, context)) break;
|
||||
if (s.aborted || ++produced >= n_predict) break;
|
||||
rc = ds4_session_eval(g_session, first, err, sizeof(err));
|
||||
if (rc != 0) break;
|
||||
}
|
||||
}
|
||||
stream_done(&s);
|
||||
}
|
||||
maybe_save_cache(cache_key);
|
||||
|
||||
// Flush parser state.
|
||||
std::vector<ds4cpp::ParserEvent> events;
|
||||
s.parser.Flush(events);
|
||||
if (!events.empty() && !s.aborted) {
|
||||
backend::Reply reply;
|
||||
auto *delta = reply.add_chat_deltas();
|
||||
for (const auto &e : events) {
|
||||
if (e.type == ds4cpp::ParserEvent::CONTENT) {
|
||||
delta->set_content(delta->content() + e.text);
|
||||
} else if (e.type == ds4cpp::ParserEvent::REASONING) {
|
||||
delta->set_reasoning_content(delta->reasoning_content() + e.text);
|
||||
}
|
||||
}
|
||||
s.writer->Write(reply);
|
||||
}
|
||||
|
||||
request_should_continue(&lifecycle, context);
|
||||
ds4cpp::TerminalDecision terminal = ds4cpp::ResolveTerminalDecision(
|
||||
rc == DS4_SESSION_SYNC_INTERRUPTED, rc != 0,
|
||||
!lifecycle.ShouldFinalize());
|
||||
terminal = ds4cpp::RunPostlude(
|
||||
terminal,
|
||||
[&]() {
|
||||
ds4cpp::DsmlParser staged_parser = s.parser;
|
||||
std::vector<ds4cpp::ParserEvent> events;
|
||||
staged_parser.Flush(events);
|
||||
bool write_succeeded = true;
|
||||
if (!events.empty()) {
|
||||
backend::Reply reply;
|
||||
auto *delta = reply.add_chat_deltas();
|
||||
for (const auto &e : events) {
|
||||
if (e.type == ds4cpp::ParserEvent::CONTENT) {
|
||||
delta->set_content(delta->content() + e.text);
|
||||
} else if (e.type == ds4cpp::ParserEvent::REASONING) {
|
||||
delta->set_reasoning_content(
|
||||
delta->reasoning_content() + e.text);
|
||||
}
|
||||
}
|
||||
write_succeeded = s.writer->Write(reply);
|
||||
}
|
||||
lifecycle.ObserveStreamWrite(write_succeeded);
|
||||
request_should_continue(&lifecycle, context);
|
||||
if (!lifecycle.ShouldFinalize()) return false;
|
||||
s.parser = std::move(staged_parser);
|
||||
if (generation_started) stream_done(&s);
|
||||
return true;
|
||||
},
|
||||
[&]() { maybe_save_cache(cache_key); });
|
||||
|
||||
if (terminal.cause == ds4cpp::TerminalCause::EngineError) {
|
||||
if (rc != 0 && !s.aborted) {
|
||||
return GStatus(StatusCode::INTERNAL,
|
||||
std::string("ds4 generation failed: ") + err);
|
||||
}
|
||||
if (terminal.cause == ds4cpp::TerminalCause::Cancelled) {
|
||||
return GStatus(StatusCode::CANCELLED,
|
||||
"ds4 request cancelled");
|
||||
}
|
||||
return GStatus::OK;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#pragma once
|
||||
|
||||
namespace ds4cpp {
|
||||
|
||||
using CancelCallback = bool (*)(void *);
|
||||
using CancelSetter = void (*)(void *, CancelCallback, void *) noexcept;
|
||||
|
||||
class CancelCallbackScope {
|
||||
public:
|
||||
CancelCallbackScope(void *target, CancelSetter setter,
|
||||
CancelCallback callback, void *userdata) noexcept
|
||||
: target_(target), setter_(setter) {
|
||||
setter_(target_, callback, userdata);
|
||||
}
|
||||
|
||||
~CancelCallbackScope() noexcept {
|
||||
setter_(target_, nullptr, nullptr);
|
||||
}
|
||||
|
||||
CancelCallbackScope(const CancelCallbackScope &) = delete;
|
||||
CancelCallbackScope &operator=(const CancelCallbackScope &) = delete;
|
||||
|
||||
private:
|
||||
void *target_;
|
||||
CancelSetter setter_;
|
||||
};
|
||||
|
||||
enum class RouteWaitDecision {
|
||||
Pending,
|
||||
Ready,
|
||||
Error,
|
||||
Cancelled,
|
||||
};
|
||||
|
||||
inline RouteWaitDecision DecideRouteWait(int route_status, bool cancelled) {
|
||||
if (cancelled) return RouteWaitDecision::Cancelled;
|
||||
if (route_status > 0) return RouteWaitDecision::Ready;
|
||||
if (route_status < 0) return RouteWaitDecision::Error;
|
||||
return RouteWaitDecision::Pending;
|
||||
}
|
||||
|
||||
enum class TerminalCause {
|
||||
Success,
|
||||
Cancelled,
|
||||
EngineError,
|
||||
};
|
||||
|
||||
inline TerminalCause DecideTerminalCause(bool sync_interrupted,
|
||||
bool engine_error,
|
||||
bool abandoned) {
|
||||
if (sync_interrupted) return TerminalCause::Cancelled;
|
||||
if (engine_error) return TerminalCause::EngineError;
|
||||
if (abandoned) return TerminalCause::Cancelled;
|
||||
return TerminalCause::Success;
|
||||
}
|
||||
|
||||
struct TerminalDecision {
|
||||
TerminalCause cause;
|
||||
bool should_finalize;
|
||||
};
|
||||
|
||||
inline TerminalDecision ResolveTerminalDecision(bool sync_interrupted,
|
||||
bool engine_error,
|
||||
bool abandoned) {
|
||||
return {
|
||||
DecideTerminalCause(sync_interrupted, engine_error, abandoned),
|
||||
!sync_interrupted && !abandoned,
|
||||
};
|
||||
}
|
||||
|
||||
template <typename Finalize, typename Persist>
|
||||
TerminalDecision RunPostlude(TerminalDecision terminal,
|
||||
Finalize transactional_finalize,
|
||||
Persist persist) {
|
||||
if (!terminal.should_finalize) return terminal;
|
||||
if (!transactional_finalize()) {
|
||||
terminal.should_finalize = false;
|
||||
if (terminal.cause != TerminalCause::EngineError) {
|
||||
terminal.cause = TerminalCause::Cancelled;
|
||||
}
|
||||
return terminal;
|
||||
}
|
||||
persist();
|
||||
return terminal;
|
||||
}
|
||||
|
||||
class RequestLifecycle {
|
||||
public:
|
||||
void ObserveContextCancellation(bool cancelled) {
|
||||
context_cancelled_ = context_cancelled_ || cancelled;
|
||||
}
|
||||
|
||||
void ObserveStreamWrite(bool succeeded) {
|
||||
stream_write_aborted_ = stream_write_aborted_ || !succeeded;
|
||||
}
|
||||
|
||||
bool ShouldContinue() const {
|
||||
return !context_cancelled_ && !stream_write_aborted_;
|
||||
}
|
||||
|
||||
bool ShouldFinalize() const {
|
||||
return ShouldContinue();
|
||||
}
|
||||
|
||||
private:
|
||||
bool context_cancelled_ = false;
|
||||
bool stream_write_aborted_ = false;
|
||||
};
|
||||
|
||||
} // namespace ds4cpp
|
||||
@@ -1,414 +0,0 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Standalone regression tests for DS4 request cancellation policy.
|
||||
|
||||
#include "request_lifecycle.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
namespace {
|
||||
|
||||
int failures = 0;
|
||||
|
||||
struct FakeCancelTarget {
|
||||
ds4cpp::CancelCallback callback = nullptr;
|
||||
void *userdata = nullptr;
|
||||
int installs = 0;
|
||||
int clears = 0;
|
||||
};
|
||||
|
||||
struct PostludeCounts {
|
||||
int finalize_attempts = 0;
|
||||
int finalize_commits = 0;
|
||||
int cache_persists = 0;
|
||||
bool cache_followed_commit = true;
|
||||
};
|
||||
|
||||
ds4cpp::TerminalDecision run_fake_postlude(
|
||||
ds4cpp::TerminalDecision terminal, bool finalize_succeeds,
|
||||
PostludeCounts *counts) {
|
||||
return ds4cpp::RunPostlude(
|
||||
terminal,
|
||||
[=]() {
|
||||
counts->finalize_attempts++;
|
||||
if (!finalize_succeeds) return false;
|
||||
counts->finalize_commits++;
|
||||
return true;
|
||||
},
|
||||
[=]() {
|
||||
counts->cache_followed_commit = counts->finalize_commits == 1;
|
||||
counts->cache_persists++;
|
||||
});
|
||||
}
|
||||
|
||||
bool fake_cancel(void *) {
|
||||
return false;
|
||||
}
|
||||
|
||||
void fake_set_cancel(void *target, ds4cpp::CancelCallback callback,
|
||||
void *userdata) noexcept {
|
||||
auto *fake = static_cast<FakeCancelTarget *>(target);
|
||||
fake->callback = callback;
|
||||
fake->userdata = userdata;
|
||||
if (callback) {
|
||||
fake->installs++;
|
||||
} else {
|
||||
fake->clears++;
|
||||
}
|
||||
}
|
||||
|
||||
void check(bool condition, const char *name) {
|
||||
if (condition) return;
|
||||
std::fprintf(stderr, "FAIL %s\n", name);
|
||||
failures++;
|
||||
}
|
||||
|
||||
// Production mutation caught: treating an active request as abandoned would
|
||||
// skip its parser finalization and cache save.
|
||||
void test_active_request_continues_and_finalizes() {
|
||||
ds4cpp::RequestLifecycle request;
|
||||
|
||||
check(request.ShouldContinue(), "active:continue");
|
||||
check(request.ShouldFinalize(), "active:finalize");
|
||||
}
|
||||
|
||||
// Production mutation caught: omitting the ServerContext cancellation branch
|
||||
// would continue decoding and finalize a partial response.
|
||||
void test_context_cancellation_stops_without_finalizing() {
|
||||
ds4cpp::RequestLifecycle request;
|
||||
|
||||
request.ObserveContextCancellation(true);
|
||||
|
||||
check(!request.ShouldContinue(), "context_cancelled:stop");
|
||||
check(!request.ShouldFinalize(), "context_cancelled:no_finalize");
|
||||
}
|
||||
|
||||
// Production mutation caught: ignoring ServerWriter::Write failure would keep
|
||||
// streaming and finalize a response whose client has gone away.
|
||||
void test_stream_write_abort_stops_without_finalizing() {
|
||||
ds4cpp::RequestLifecycle request;
|
||||
|
||||
request.ObserveStreamWrite(false);
|
||||
|
||||
check(!request.ShouldContinue(), "write_abort:stop");
|
||||
check(!request.ShouldFinalize(), "write_abort:no_finalize");
|
||||
}
|
||||
|
||||
// Production mutation caught: combining cancellation and write failure with
|
||||
// AND would fail to stop when either signal occurs on its own.
|
||||
void test_cancellation_and_write_abort_are_independent_or_conditions() {
|
||||
ds4cpp::RequestLifecycle cancelled;
|
||||
cancelled.ObserveContextCancellation(true);
|
||||
cancelled.ObserveStreamWrite(true);
|
||||
|
||||
ds4cpp::RequestLifecycle write_aborted;
|
||||
write_aborted.ObserveContextCancellation(false);
|
||||
write_aborted.ObserveStreamWrite(false);
|
||||
|
||||
check(!cancelled.ShouldContinue(), "or:context_only");
|
||||
check(!write_aborted.ShouldContinue(), "or:write_only");
|
||||
}
|
||||
|
||||
// Production mutation caught: treating an incomplete distributed route as an
|
||||
// error would return before workers have time to connect.
|
||||
void test_route_wait_pending() {
|
||||
check(ds4cpp::DecideRouteWait(0, false) ==
|
||||
ds4cpp::RouteWaitDecision::Pending,
|
||||
"route_wait:pending");
|
||||
}
|
||||
|
||||
// Production mutation caught: failing to recognize a complete route would
|
||||
// keep a ready inference request in the polling loop.
|
||||
void test_route_wait_ready() {
|
||||
check(ds4cpp::DecideRouteWait(1, false) ==
|
||||
ds4cpp::RouteWaitDecision::Ready,
|
||||
"route_wait:ready");
|
||||
}
|
||||
|
||||
// Production mutation caught: ignoring a route probe error would poll until a
|
||||
// misleading timeout instead of returning UNAVAILABLE promptly.
|
||||
void test_route_wait_error() {
|
||||
check(ds4cpp::DecideRouteWait(-1, false) ==
|
||||
ds4cpp::RouteWaitDecision::Error,
|
||||
"route_wait:error");
|
||||
}
|
||||
|
||||
// Production mutation caught: omitting cancellation from route waiting would
|
||||
// leave an abandoned request blocked until the distributed timeout.
|
||||
void test_route_wait_cancellation() {
|
||||
check(ds4cpp::DecideRouteWait(0, true) ==
|
||||
ds4cpp::RouteWaitDecision::Cancelled,
|
||||
"route_wait:cancelled");
|
||||
}
|
||||
|
||||
// Production mutation caught: checking route errors before cancellation would
|
||||
// report UNAVAILABLE for a request the client already abandoned.
|
||||
void test_route_wait_cancellation_precedes_error() {
|
||||
check(ds4cpp::DecideRouteWait(-1, true) ==
|
||||
ds4cpp::RouteWaitDecision::Cancelled,
|
||||
"route_wait:cancellation_precedence");
|
||||
}
|
||||
|
||||
// Production mutation caught: classifying a successful active request as a
|
||||
// terminal failure would suppress its normal response finalization.
|
||||
void test_terminal_success() {
|
||||
check(ds4cpp::DecideTerminalCause(false, false, false) ==
|
||||
ds4cpp::TerminalCause::Success,
|
||||
"terminal:success");
|
||||
}
|
||||
|
||||
// Production mutation caught: treating DS4's cooperative sync interruption
|
||||
// as an ordinary engine error would return INTERNAL instead of CANCELLED.
|
||||
void test_terminal_sync_interruption_is_cancelled() {
|
||||
check(ds4cpp::DecideTerminalCause(true, true, true) ==
|
||||
ds4cpp::TerminalCause::Cancelled,
|
||||
"terminal:sync_interrupted");
|
||||
}
|
||||
|
||||
// Production mutation caught: treating every nonzero engine result as client
|
||||
// abandonment would hide genuine DS4 failures behind CANCELLED.
|
||||
void test_terminal_engine_error() {
|
||||
check(ds4cpp::DecideTerminalCause(false, true, false) ==
|
||||
ds4cpp::TerminalCause::EngineError,
|
||||
"terminal:engine_error");
|
||||
}
|
||||
|
||||
// Production mutation caught: ignoring an rc==0 context cancellation would
|
||||
// finalize and cache an abandoned request.
|
||||
void test_terminal_context_abandonment() {
|
||||
ds4cpp::RequestLifecycle request;
|
||||
request.ObserveContextCancellation(true);
|
||||
|
||||
check(ds4cpp::DecideTerminalCause(
|
||||
false, false, !request.ShouldFinalize()) ==
|
||||
ds4cpp::TerminalCause::Cancelled,
|
||||
"terminal:context_abandonment");
|
||||
}
|
||||
|
||||
// Production mutation caught: ignoring an rc==0 stream write failure would
|
||||
// finalize and cache an abandoned streaming request.
|
||||
void test_terminal_write_abandonment() {
|
||||
ds4cpp::RequestLifecycle request;
|
||||
request.ObserveStreamWrite(false);
|
||||
|
||||
check(ds4cpp::DecideTerminalCause(
|
||||
false, false, !request.ShouldFinalize()) ==
|
||||
ds4cpp::TerminalCause::Cancelled,
|
||||
"terminal:write_abandonment");
|
||||
}
|
||||
|
||||
// Production mutation caught: checking late cancellation or write failure
|
||||
// before a determined ordinary DS4 error would replace INTERNAL with CANCELLED.
|
||||
void test_terminal_engine_error_precedes_late_abandonment() {
|
||||
ds4cpp::RequestLifecycle cancelled;
|
||||
cancelled.ObserveContextCancellation(true);
|
||||
ds4cpp::RequestLifecycle write_aborted;
|
||||
write_aborted.ObserveStreamWrite(false);
|
||||
|
||||
check(ds4cpp::DecideTerminalCause(
|
||||
false, true, !cancelled.ShouldFinalize()) ==
|
||||
ds4cpp::TerminalCause::EngineError,
|
||||
"terminal:engine_error_precedes_cancellation");
|
||||
check(ds4cpp::DecideTerminalCause(
|
||||
false, true, !write_aborted.ShouldFinalize()) ==
|
||||
ds4cpp::TerminalCause::EngineError,
|
||||
"terminal:engine_error_precedes_write_abort");
|
||||
}
|
||||
|
||||
// Production mutation caught: using status precedence alone to gate side
|
||||
// effects would finalize and persist an engine-error request abandoned later.
|
||||
void test_abandoned_engine_error_keeps_internal_without_finalizing() {
|
||||
ds4cpp::RequestLifecycle request;
|
||||
request.ObserveContextCancellation(true);
|
||||
|
||||
ds4cpp::TerminalDecision terminal = ds4cpp::ResolveTerminalDecision(
|
||||
false, true, !request.ShouldFinalize());
|
||||
|
||||
check(terminal.cause == ds4cpp::TerminalCause::EngineError,
|
||||
"terminal_decision:abandoned_engine_error_status");
|
||||
check(!terminal.should_finalize,
|
||||
"terminal_decision:abandoned_engine_error_no_finalize");
|
||||
}
|
||||
|
||||
// Production mutation caught: suppressing side effects for every engine error
|
||||
// would change the existing finalization and cache behavior of active failures.
|
||||
void test_active_engine_error_still_finalizes() {
|
||||
ds4cpp::RequestLifecycle request;
|
||||
|
||||
ds4cpp::TerminalDecision terminal = ds4cpp::ResolveTerminalDecision(
|
||||
false, true, !request.ShouldFinalize());
|
||||
|
||||
check(terminal.cause == ds4cpp::TerminalCause::EngineError,
|
||||
"terminal_decision:active_engine_error_status");
|
||||
check(terminal.should_finalize,
|
||||
"terminal_decision:active_engine_error_finalize");
|
||||
}
|
||||
|
||||
// Production mutation caught: persisting before committed finalization would
|
||||
// cache a state whose final buffered stream reply was never completed.
|
||||
void test_postlude_active_success_commits_then_persists() {
|
||||
PostludeCounts counts;
|
||||
|
||||
ds4cpp::TerminalDecision terminal = run_fake_postlude(
|
||||
{ds4cpp::TerminalCause::Success, true}, true, &counts);
|
||||
|
||||
check(terminal.cause == ds4cpp::TerminalCause::Success,
|
||||
"postlude:success_outcome");
|
||||
check(terminal.should_finalize, "postlude:success_committed");
|
||||
check(counts.finalize_attempts == 1, "postlude:success_attempts");
|
||||
check(counts.finalize_commits == 1, "postlude:success_commits");
|
||||
check(counts.cache_persists == 1, "postlude:success_cache");
|
||||
check(counts.cache_followed_commit, "postlude:success_cache_order");
|
||||
}
|
||||
|
||||
// Production mutation caught: starting the postlude for an already-cancelled
|
||||
// request would flush buffered parser state or persist an abandoned session.
|
||||
void test_postlude_cancellation_skips_all_side_effects() {
|
||||
PostludeCounts counts;
|
||||
|
||||
ds4cpp::TerminalDecision terminal = run_fake_postlude(
|
||||
{ds4cpp::TerminalCause::Cancelled, false}, true, &counts);
|
||||
|
||||
check(terminal.cause == ds4cpp::TerminalCause::Cancelled,
|
||||
"postlude:cancelled_outcome");
|
||||
check(counts.finalize_attempts == 0, "postlude:cancelled_attempts");
|
||||
check(counts.finalize_commits == 0, "postlude:cancelled_commits");
|
||||
check(counts.cache_persists == 0, "postlude:cancelled_cache");
|
||||
}
|
||||
|
||||
// Production mutation caught: committing the live parser or cache after a
|
||||
// failed final Write would publish an abandoned streaming postlude.
|
||||
void test_postlude_finalize_failure_cancels_without_commit_or_cache() {
|
||||
PostludeCounts counts;
|
||||
|
||||
ds4cpp::TerminalDecision terminal = run_fake_postlude(
|
||||
{ds4cpp::TerminalCause::Success, true}, false, &counts);
|
||||
|
||||
check(terminal.cause == ds4cpp::TerminalCause::Cancelled,
|
||||
"postlude:write_failure_outcome");
|
||||
check(!terminal.should_finalize, "postlude:write_failure_not_committed");
|
||||
check(counts.finalize_attempts == 1, "postlude:write_failure_attempts");
|
||||
check(counts.finalize_commits == 0, "postlude:write_failure_commits");
|
||||
check(counts.cache_persists == 0, "postlude:write_failure_cache");
|
||||
}
|
||||
|
||||
// Production mutation caught: skipping the postlude for every engine error
|
||||
// would change active internal-error finalization and cache behavior.
|
||||
void test_postlude_active_engine_error_finalizes_and_persists() {
|
||||
PostludeCounts counts;
|
||||
|
||||
ds4cpp::TerminalDecision terminal = run_fake_postlude(
|
||||
{ds4cpp::TerminalCause::EngineError, true}, true, &counts);
|
||||
|
||||
check(terminal.cause == ds4cpp::TerminalCause::EngineError,
|
||||
"postlude:engine_error_outcome");
|
||||
check(counts.finalize_attempts == 1, "postlude:engine_error_attempts");
|
||||
check(counts.finalize_commits == 1, "postlude:engine_error_commits");
|
||||
check(counts.cache_persists == 1, "postlude:engine_error_cache");
|
||||
check(counts.cache_followed_commit, "postlude:engine_error_cache_order");
|
||||
}
|
||||
|
||||
// Production mutation caught: replacing every failed transactional finalize
|
||||
// with cancellation would hide an already-determined engine error.
|
||||
void test_postlude_engine_error_finalize_failure_preserves_internal() {
|
||||
PostludeCounts counts;
|
||||
|
||||
ds4cpp::TerminalDecision terminal = run_fake_postlude(
|
||||
{ds4cpp::TerminalCause::EngineError, true}, false, &counts);
|
||||
|
||||
check(terminal.cause == ds4cpp::TerminalCause::EngineError,
|
||||
"postlude:engine_error_write_failure_outcome");
|
||||
check(!terminal.should_finalize,
|
||||
"postlude:engine_error_write_failure_not_committed");
|
||||
check(counts.finalize_attempts == 1,
|
||||
"postlude:engine_error_write_failure_attempts");
|
||||
check(counts.finalize_commits == 0,
|
||||
"postlude:engine_error_write_failure_commits");
|
||||
check(counts.cache_persists == 0,
|
||||
"postlude:engine_error_write_failure_cache");
|
||||
}
|
||||
|
||||
// Production mutation caught: status precedence must not grant side-effect
|
||||
// permission to an engine-error request that was also abandoned.
|
||||
void test_postlude_abandoned_engine_error_skips_all_side_effects() {
|
||||
PostludeCounts counts;
|
||||
|
||||
ds4cpp::TerminalDecision terminal = run_fake_postlude(
|
||||
{ds4cpp::TerminalCause::EngineError, false}, true, &counts);
|
||||
|
||||
check(terminal.cause == ds4cpp::TerminalCause::EngineError,
|
||||
"postlude:abandoned_engine_error_outcome");
|
||||
check(counts.finalize_attempts == 0,
|
||||
"postlude:abandoned_engine_error_attempts");
|
||||
check(counts.finalize_commits == 0,
|
||||
"postlude:abandoned_engine_error_commits");
|
||||
check(counts.cache_persists == 0,
|
||||
"postlude:abandoned_engine_error_cache");
|
||||
}
|
||||
|
||||
// Production mutation caught: failing to install the request callback would
|
||||
// make DS4 prompt synchronization unable to observe client cancellation.
|
||||
void test_cancel_callback_scope_installs_callback() {
|
||||
FakeCancelTarget target;
|
||||
int request_context = 42;
|
||||
|
||||
{
|
||||
ds4cpp::CancelCallbackScope scope(
|
||||
&target, fake_set_cancel, fake_cancel, &request_context);
|
||||
check(target.callback == fake_cancel, "cancel_scope:callback_installed");
|
||||
check(target.userdata == &request_context, "cancel_scope:userdata_installed");
|
||||
check(target.installs == 1, "cancel_scope:installed_once");
|
||||
}
|
||||
}
|
||||
|
||||
// Production mutation caught: failing to clear the callback at every scope
|
||||
// exit would leave DS4 pointing at a destroyed stack-owned ServerContext.
|
||||
void test_cancel_callback_scope_clears_callback() {
|
||||
FakeCancelTarget target;
|
||||
int request_context = 42;
|
||||
|
||||
{
|
||||
ds4cpp::CancelCallbackScope scope(
|
||||
&target, fake_set_cancel, fake_cancel, &request_context);
|
||||
}
|
||||
|
||||
check(target.callback == nullptr, "cancel_scope:callback_cleared");
|
||||
check(target.userdata == nullptr, "cancel_scope:userdata_cleared");
|
||||
check(target.clears == 1, "cancel_scope:cleared_once");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_active_request_continues_and_finalizes();
|
||||
test_context_cancellation_stops_without_finalizing();
|
||||
test_stream_write_abort_stops_without_finalizing();
|
||||
test_cancellation_and_write_abort_are_independent_or_conditions();
|
||||
test_route_wait_pending();
|
||||
test_route_wait_ready();
|
||||
test_route_wait_error();
|
||||
test_route_wait_cancellation();
|
||||
test_route_wait_cancellation_precedes_error();
|
||||
test_terminal_success();
|
||||
test_terminal_sync_interruption_is_cancelled();
|
||||
test_terminal_engine_error();
|
||||
test_terminal_context_abandonment();
|
||||
test_terminal_write_abandonment();
|
||||
test_terminal_engine_error_precedes_late_abandonment();
|
||||
test_abandoned_engine_error_keeps_internal_without_finalizing();
|
||||
test_active_engine_error_still_finalizes();
|
||||
test_postlude_active_success_commits_then_persists();
|
||||
test_postlude_cancellation_skips_all_side_effects();
|
||||
test_postlude_finalize_failure_cancels_without_commit_or_cache();
|
||||
test_postlude_active_engine_error_finalizes_and_persists();
|
||||
test_postlude_engine_error_finalize_failure_preserves_internal();
|
||||
test_postlude_abandoned_engine_error_skips_all_side_effects();
|
||||
test_cancel_callback_scope_installs_callback();
|
||||
test_cancel_callback_scope_clears_callback();
|
||||
|
||||
if (failures == 0) {
|
||||
std::fprintf(stderr, "all request_lifecycle checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::fprintf(stderr, "%d check(s) failed\n", failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
IK_LLAMA_VERSION?=fe215a8ccdce6b844d2a3a3bbde08ae76a6284bf
|
||||
IK_LLAMA_VERSION?=a7c81affa48c6800d63111bdb33469a01d062daa
|
||||
LLAMA_REPO?=https://github.com/ikawrakow/ik_llama.cpp
|
||||
|
||||
CMAKE_ARGS?=
|
||||
|
||||
@@ -2565,7 +2565,6 @@ public:
|
||||
grpc::Status Embedding(ServerContext* context, const backend::PredictOptions* request, backend::EmbeddingResult* embeddingResult) {
|
||||
auto identity = checkModelIdentity(request);
|
||||
if (!identity.ok()) return identity;
|
||||
embeddingResult->set_layout(backend::EMBEDDING_LAYOUT_FINAL);
|
||||
json data = parse_options(false, request, llama);
|
||||
const int task_id = llama.queue_tasks.get_new_id();
|
||||
llama.queue_results.add_waiting_task_id(task_id);
|
||||
|
||||
@@ -115,14 +115,4 @@ if(LLAMA_GRPC_BUILD_TESTS)
|
||||
target_include_directories(passthrough_options_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
target_compile_features(passthrough_options_test PRIVATE cxx_std_17)
|
||||
add_test(NAME passthrough_options_test COMMAND passthrough_options_test)
|
||||
|
||||
add_executable(tts_request_options_test tts_request_options_test.cpp tts_request_options.h)
|
||||
target_include_directories(tts_request_options_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
target_compile_features(tts_request_options_test PRIVATE cxx_std_17)
|
||||
add_test(NAME tts_request_options_test COMMAND tts_request_options_test)
|
||||
|
||||
add_executable(thread_params_test thread_params_test.cpp thread_params.h)
|
||||
target_include_directories(thread_params_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
target_compile_features(thread_params_test PRIVATE cxx_std_17)
|
||||
add_test(NAME thread_params_test COMMAND thread_params_test)
|
||||
endif()
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
LLAMA_VERSION?=67672dc5b76f8bc17785a19d3dc6d1463fc2902c
|
||||
LLAMA_VERSION?=221f0f6356efe2260023208365705ec5d5a7c8f5
|
||||
LLAMA_REPO?=https://github.com/ggerganov/llama.cpp
|
||||
|
||||
CMAKE_ARGS?=
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Mark a copied gRPC server as targeting a llama.cpp fork that does not carry
|
||||
# LocalAI's SERVER_TASK_TYPE_TTS patch. The RPCs remain present in the shared
|
||||
# protobuf service, but respond with UNIMPLEMENTED instead of referencing
|
||||
# server task types and mtmd gen-audio APIs absent from those forks.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "usage: $0 <grpc-server.cpp>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
SRC=$1
|
||||
|
||||
if [[ ! -f "$SRC" ]]; then
|
||||
echo "grpc-server.cpp not found at $SRC" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if grep -q '^#define LOCALAI_LLAMA_CPP_NO_TTS_TASK' "$SRC"; then
|
||||
echo "==> $SRC already disables the LocalAI TTS task, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
awk '
|
||||
!done && /^#include/ {
|
||||
print "#define LOCALAI_LLAMA_CPP_NO_TTS_TASK 1"
|
||||
print "// ^ injected by disable-tts-task.sh for an unpatched llama.cpp fork"
|
||||
print ""
|
||||
done = 1
|
||||
}
|
||||
{ print }
|
||||
END {
|
||||
if (!done) {
|
||||
print "disable-tts-task.sh: no #include anchor found" > "/dev/stderr"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
' "$SRC" > "$SRC.tmp"
|
||||
mv "$SRC.tmp" "$SRC"
|
||||
|
||||
echo "==> LocalAI TTS task disabled in $SRC"
|
||||
@@ -43,6 +43,15 @@
|
||||
#if __has_include("server-stream.cpp")
|
||||
#include "server-stream.cpp"
|
||||
#endif
|
||||
// CachyLLaMA splits its persistent prompt-cache implementation out of
|
||||
// server-context.cpp. Pull both implementation files into this monolithic
|
||||
// adapter TU when present; older llama.cpp pins do not ship them.
|
||||
#if __has_include("server-context-ssd-cache.cpp")
|
||||
#include "server-context-ssd-cache.cpp"
|
||||
#endif
|
||||
#if __has_include("server-context-page-manager.cpp")
|
||||
#include "server-context-page-manager.cpp"
|
||||
#endif
|
||||
#include "server-context.cpp"
|
||||
|
||||
// LocalAI
|
||||
@@ -53,10 +62,8 @@
|
||||
#include "arg.h"
|
||||
#include "chat-auto-parser.h"
|
||||
#include "llama_compat.h" // fork-skew switches, generated by prepare.sh
|
||||
#include "thread_params.h"
|
||||
#include "message_content.h"
|
||||
#include "passthrough_options.h"
|
||||
#include "tts_request_options.h"
|
||||
#include <getopt.h>
|
||||
#include <grpcpp/ext/proto_server_reflection_plugin.h>
|
||||
#include <grpcpp/grpcpp.h>
|
||||
@@ -67,7 +74,6 @@
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <list>
|
||||
@@ -88,12 +94,6 @@ using grpc::ServerBuilder;
|
||||
using grpc::ServerContext;
|
||||
using grpc::Status;
|
||||
|
||||
#if LOCALAI_HAS_MTMD_INIT_OPT
|
||||
#define LOCALAI_MTMD_INIT_OPT_ARG(value) , value
|
||||
#else
|
||||
#define LOCALAI_MTMD_INIT_OPT_ARG(value)
|
||||
#endif
|
||||
|
||||
// gRPC bearer token auth for distributed mode.
|
||||
// Reads LOCALAI_GRPC_AUTH_TOKEN from the environment. When set, rejects
|
||||
// requests without a matching "authorization: Bearer <token>" metadata header.
|
||||
@@ -242,15 +242,7 @@ json parse_options(bool streaming, const backend::PredictOptions* predict, const
|
||||
data["typical_p"] = predict->typicalp();
|
||||
data["temperature"] = predict->temperature();
|
||||
data["repeat_last_n"] = predict->repeat();
|
||||
// PredictOptions.Penalty is a bare proto float, so a caller that names no
|
||||
// repetition penalty sends 0 rather than omitting the field. Since
|
||||
// llama.cpp 9de0fcf2b, common_sampler_init() rejects a non-positive
|
||||
// penalty_repeat outright (it would divide logits by zero), which turned
|
||||
// every such request into "Failed to initialize samplers". Treat 0 as
|
||||
// "unset" and leave llama.cpp's own neutral default in place.
|
||||
if (predict->penalty() > 0.0f) {
|
||||
data["repeat_penalty"] = predict->penalty();
|
||||
}
|
||||
data["repeat_penalty"] = predict->penalty();
|
||||
data["frequency_penalty"] = predict->frequencypenalty();
|
||||
data["presence_penalty"] = predict->presencepenalty();
|
||||
data["mirostat"] = predict->mirostat();
|
||||
@@ -300,7 +292,7 @@ json parse_options(bool streaming, const backend::PredictOptions* predict, const
|
||||
} else {
|
||||
SRV_WRN("[TOOLS DEBUG] parse_options: Parsed tools JSON is not an array: %s\n", tools_json.dump().c_str());
|
||||
}
|
||||
} catch (const common_json_error& e) {
|
||||
} catch (const json::parse_error& e) {
|
||||
SRV_WRN("Failed to parse tools JSON from proto: %s\n", e.what());
|
||||
SRV_WRN("[TOOLS DEBUG] parse_options: Tools string that failed to parse: %s\n", predict->tools().c_str());
|
||||
}
|
||||
@@ -330,7 +322,7 @@ json parse_options(bool streaming, const backend::PredictOptions* predict, const
|
||||
SRV_DBG("[TOOLS DEBUG] Received tool_choice object from Go layer: %s\n", tool_choice_json.dump().c_str());
|
||||
}
|
||||
SRV_INF("Extracted tool_choice from proto: %s\n", predict->toolchoice().c_str());
|
||||
} catch (const common_json_error& e) {
|
||||
} catch (const json::parse_error& e) {
|
||||
// If parsing fails, treat as string
|
||||
data["tool_choice"] = predict->toolchoice();
|
||||
SRV_INF("Extracted tool_choice as string: %s\n", predict->toolchoice().c_str());
|
||||
@@ -359,7 +351,7 @@ json parse_options(bool streaming, const backend::PredictOptions* predict, const
|
||||
// Add to data - llama.cpp server expects it as an object (map)
|
||||
data["logit_bias"] = logit_bias_json;
|
||||
SRV_INF("Using logit_bias: %s\n", predict->logitbias().c_str());
|
||||
} catch (const common_json_error& e) {
|
||||
} catch (const json::parse_error& e) {
|
||||
SRV_ERR("Failed to parse logit_bias JSON from proto: %s\n", e.what());
|
||||
}
|
||||
}
|
||||
@@ -404,10 +396,7 @@ json parse_options(bool streaming, const backend::PredictOptions* predict, const
|
||||
});
|
||||
}
|
||||
|
||||
data["stop"] = json::array();
|
||||
for (const auto & stop : predict->stopprompts()) {
|
||||
data["stop"].push_back(stop);
|
||||
}
|
||||
data["stop"] = predict->stopprompts();
|
||||
// data["n_probs"] = predict->nprobs();
|
||||
//TODO: images,
|
||||
|
||||
@@ -1125,16 +1114,14 @@ static void params_parse(server_context& /*ctx_server*/, const backend::ModelOpt
|
||||
try {
|
||||
int n = std::stoi(optval_str);
|
||||
if (n < 0) n = 0;
|
||||
#if LOCALAI_HAS_N_CPU_FFN_HELPER
|
||||
llm_add_n_cpu_ffn_overrides(n, LLM_FFN_EXPS_REGEX, params.speculative.draft.tensor_buft_overrides);
|
||||
#else
|
||||
// Keep override-name storage alive for the lifetime of the params struct
|
||||
// (mirrors upstream arg.cpp behavior with a function-local static).
|
||||
static std::list<std::string> buft_overrides_draft;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
buft_overrides_draft.push_back(llm_ffn_exps_block_regex(i));
|
||||
params.speculative.draft.tensor_buft_overrides.push_back(
|
||||
{buft_overrides_draft.back().c_str(), ggml_backend_cpu_buffer_type()});
|
||||
}
|
||||
#endif
|
||||
} catch (...) {}
|
||||
}
|
||||
|
||||
@@ -1152,16 +1139,14 @@ static void params_parse(server_context& /*ctx_server*/, const backend::ModelOpt
|
||||
try {
|
||||
int n = std::stoi(optval_str);
|
||||
if (n < 0) n = 0;
|
||||
#if LOCALAI_HAS_N_CPU_FFN_HELPER
|
||||
llm_add_n_cpu_ffn_overrides(n, LLM_FFN_EXPS_REGEX, params.tensor_buft_overrides);
|
||||
#else
|
||||
// Keep override-name storage alive for the lifetime of the
|
||||
// params struct (mirrors upstream arg.cpp's function-local static).
|
||||
static std::list<std::string> buft_overrides_main;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
buft_overrides_main.push_back(llm_ffn_exps_block_regex(i));
|
||||
params.tensor_buft_overrides.push_back(
|
||||
{buft_overrides_main.back().c_str(), ggml_backend_cpu_buffer_type()});
|
||||
}
|
||||
#endif
|
||||
} catch (...) {}
|
||||
}
|
||||
|
||||
@@ -1426,12 +1411,6 @@ static void params_parse(server_context& /*ctx_server*/, const backend::ModelOpt
|
||||
passthrough_draft_gpu_layers);
|
||||
}
|
||||
|
||||
// The library initializer now creates both threadpools before the server
|
||||
// can apply llama_context's fallback for the -1 batch-thread sentinel.
|
||||
params.cpuparams_batch.n_threads = llama_grpc::resolve_batch_threads(
|
||||
params.cpuparams_batch.n_threads,
|
||||
params.cpuparams.n_threads);
|
||||
|
||||
#ifndef LOCALAI_LLAMA_CPP_NO_SCORE_TASK
|
||||
// Score-task suffix forking: reserve seq ids (and recurrent-state cells)
|
||||
// beyond the slots so one scoring call decodes all candidate tails in a
|
||||
@@ -1475,26 +1454,6 @@ static void params_parse(server_context& /*ctx_server*/, const backend::ModelOpt
|
||||
}
|
||||
|
||||
|
||||
#ifndef LOCALAI_LLAMA_CPP_NO_TTS_TASK
|
||||
// MTMD_HELPER_GEN_AUDIO_OUTTYPE_PCM hands back raw float32 samples, but the
|
||||
// WAV header core/backend/tts.go builds around the streamed chunks announces
|
||||
// 16-bit samples, so the wire has to carry s16 or the client decodes floats as
|
||||
// integers and hears noise. The scaling matches write_wav16() in
|
||||
// tools/mtmd/mtmd-helper-gen.cpp, which is what the non-streaming path writes.
|
||||
static std::string tts_pcm_f32_to_s16(const std::string & samples) {
|
||||
const size_t n = samples.size() / sizeof(float);
|
||||
std::string out;
|
||||
out.resize(n * sizeof(int16_t));
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
float v = 0.0f;
|
||||
std::memcpy(&v, samples.data() + i * sizeof(float), sizeof(float));
|
||||
const int16_t s = (int16_t) (std::max(-1.0f, std::min(1.0f, v)) * 32767.0f);
|
||||
std::memcpy(&out[i * sizeof(int16_t)], &s, sizeof(int16_t));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
#endif
|
||||
|
||||
// GRPC Server start
|
||||
class BackendServiceImpl final : public backend::Backend::Service {
|
||||
private:
|
||||
@@ -1808,7 +1767,7 @@ public:
|
||||
for (int j = 0; j < request->audios_size(); j++) rin.audios.push_back(request->audios(j));
|
||||
for (int j = 0; j < request->videos_size(); j++) rin.videos.push_back(request->videos(j));
|
||||
}
|
||||
messages_json.push_back(json::parse(llama_grpc::build_reconstructed_message(rin).dump()));
|
||||
messages_json.push_back(llama_grpc::build_reconstructed_message(rin));
|
||||
}
|
||||
|
||||
// Final safety check: Ensure no message has null content (Jinja templates require strings)
|
||||
@@ -2001,7 +1960,7 @@ public:
|
||||
if (!body_json.contains("chat_template_kwargs")) {
|
||||
body_json["chat_template_kwargs"] = json::object();
|
||||
}
|
||||
for (auto el : ctk.items()) {
|
||||
for (auto& el : ctk.items()) {
|
||||
body_json["chat_template_kwargs"][el.key()] = el.value();
|
||||
}
|
||||
}
|
||||
@@ -2087,27 +2046,30 @@ public:
|
||||
// If not using chat templates, extract files from image_data/audio_data fields
|
||||
// (If using chat templates, files were already extracted by oaicompat_chat_params_parse)
|
||||
if (!request->usetokenizertemplate() || request->messages_size() == 0 || ctx_server.impl->chat_params.tmpls == nullptr) {
|
||||
if (data.contains("image_data") && data.at("image_data").is_array())
|
||||
const auto &images_data = data.find("image_data");
|
||||
if (images_data != data.end() && images_data->is_array())
|
||||
{
|
||||
for (const auto &img : data.at("image_data"))
|
||||
for (const auto &img : *images_data)
|
||||
{
|
||||
auto decoded_data = base64_decode(img["data"].get<std::string>());
|
||||
files.push_back(decoded_data);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.contains("audio_data") && data.at("audio_data").is_array())
|
||||
const auto &audio_data = data.find("audio_data");
|
||||
if (audio_data != data.end() && audio_data->is_array())
|
||||
{
|
||||
for (const auto &audio : data.at("audio_data"))
|
||||
for (const auto &audio : *audio_data)
|
||||
{
|
||||
auto decoded_data = base64_decode(audio["data"].get<std::string>());
|
||||
files.push_back(decoded_data);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.contains("video_data") && data.at("video_data").is_array())
|
||||
const auto &video_data = data.find("video_data");
|
||||
if (video_data != data.end() && video_data->is_array())
|
||||
{
|
||||
for (const auto &video : data.at("video_data"))
|
||||
for (const auto &video : *video_data)
|
||||
{
|
||||
auto decoded_data = base64_decode(video["data"].get<std::string>());
|
||||
files.push_back(decoded_data);
|
||||
@@ -2121,10 +2083,10 @@ public:
|
||||
std::vector<server_tokens> inputs;
|
||||
if (has_mtmd) {
|
||||
// multimodal
|
||||
inputs.push_back(process_mtmd_prompt(ctx_server.impl->mctx, prompt_str, files LOCALAI_MTMD_INIT_OPT_ARG(ctx_server.impl->init_opt)));
|
||||
inputs.push_back(process_mtmd_prompt(ctx_server.impl->mctx, prompt_str, files));
|
||||
} else {
|
||||
// Everything else, including multimodal completions.
|
||||
inputs = tokenize_input_prompts(ctx_server.impl->vocab, ctx_server.impl->mctx, prompt_str, true, true LOCALAI_MTMD_INIT_OPT_ARG(ctx_server.impl->init_opt));
|
||||
inputs = tokenize_input_prompts(ctx_server.impl->vocab, ctx_server.impl->mctx, prompt_str, true, true);
|
||||
}
|
||||
|
||||
tasks.reserve(inputs.size());
|
||||
@@ -2136,23 +2098,15 @@ public:
|
||||
|
||||
task.tokens = std::move(inputs[i]);
|
||||
#ifdef LOCALAI_HAS_SERVER_SCHEMA
|
||||
// The schema evaluator no longer takes the per-slot n_ctx: upstream
|
||||
// dropped the parameter and server-schema stopped consulting n_ctx at
|
||||
// all, leaving the context bound to the slot. Forks that predate the
|
||||
// server-schema split still expect it, so only this branch loses it.
|
||||
task.params = server_schema::eval_llama_cmpl_schema(
|
||||
ctx_server.impl->vocab,
|
||||
params_base,
|
||||
ctx_server.get_meta().logit_bias_eog,
|
||||
data);
|
||||
#else
|
||||
task.params = server_task::params_from_json_cmpl(
|
||||
#endif
|
||||
ctx_server.impl->vocab,
|
||||
params_base,
|
||||
ctx_server.get_meta().slot_n_ctx,
|
||||
ctx_server.get_meta().logit_bias_eog,
|
||||
data);
|
||||
#endif
|
||||
task.id_slot = json_value(data, "id_slot", -1);
|
||||
|
||||
// OAI-compat: enable autoparser (PEG-based chat parsing) so that
|
||||
@@ -2380,7 +2334,7 @@ public:
|
||||
for (int j = 0; j < request->audios_size(); j++) rin.audios.push_back(request->audios(j));
|
||||
for (int j = 0; j < request->videos_size(); j++) rin.videos.push_back(request->videos(j));
|
||||
}
|
||||
messages_json.push_back(json::parse(llama_grpc::build_reconstructed_message(rin).dump()));
|
||||
messages_json.push_back(llama_grpc::build_reconstructed_message(rin));
|
||||
}
|
||||
|
||||
// Final safety check: Ensure no message has null content (Jinja templates require strings)
|
||||
@@ -2573,7 +2527,7 @@ public:
|
||||
if (!body_json.contains("chat_template_kwargs")) {
|
||||
body_json["chat_template_kwargs"] = json::object();
|
||||
}
|
||||
for (auto el : ctk.items()) {
|
||||
for (auto& el : ctk.items()) {
|
||||
body_json["chat_template_kwargs"][el.key()] = el.value();
|
||||
}
|
||||
}
|
||||
@@ -2659,10 +2613,11 @@ public:
|
||||
// If not using chat templates, extract files from image_data/audio_data fields
|
||||
// (If using chat templates, files were already extracted by oaicompat_chat_params_parse)
|
||||
if (!request->usetokenizertemplate() || request->messages_size() == 0 || ctx_server.impl->chat_params.tmpls == nullptr) {
|
||||
if (data.contains("image_data") && data.at("image_data").is_array())
|
||||
const auto &images_data = data.find("image_data");
|
||||
if (images_data != data.end() && images_data->is_array())
|
||||
{
|
||||
std::cout << "[PREDICT] Processing " << data.at("image_data").size() << " images" << std::endl;
|
||||
for (const auto &img : data.at("image_data"))
|
||||
std::cout << "[PREDICT] Processing " << images_data->size() << " images" << std::endl;
|
||||
for (const auto &img : *images_data)
|
||||
{
|
||||
std::cout << "[PREDICT] Processing image" << std::endl;
|
||||
auto decoded_data = base64_decode(img["data"].get<std::string>());
|
||||
@@ -2670,18 +2625,20 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
if (data.contains("audio_data") && data.at("audio_data").is_array())
|
||||
const auto &audio_data = data.find("audio_data");
|
||||
if (audio_data != data.end() && audio_data->is_array())
|
||||
{
|
||||
for (const auto &audio : data.at("audio_data"))
|
||||
for (const auto &audio : *audio_data)
|
||||
{
|
||||
auto decoded_data = base64_decode(audio["data"].get<std::string>());
|
||||
files.push_back(decoded_data);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.contains("video_data") && data.at("video_data").is_array())
|
||||
const auto &video_data = data.find("video_data");
|
||||
if (video_data != data.end() && video_data->is_array())
|
||||
{
|
||||
for (const auto &video : data.at("video_data"))
|
||||
for (const auto &video : *video_data)
|
||||
{
|
||||
auto decoded_data = base64_decode(video["data"].get<std::string>());
|
||||
files.push_back(decoded_data);
|
||||
@@ -2696,10 +2653,10 @@ public:
|
||||
std::vector<server_tokens> inputs;
|
||||
if (has_mtmd) {
|
||||
// multimodal
|
||||
inputs.push_back(process_mtmd_prompt(ctx_server.impl->mctx, prompt_str, files LOCALAI_MTMD_INIT_OPT_ARG(ctx_server.impl->init_opt)));
|
||||
inputs.push_back(process_mtmd_prompt(ctx_server.impl->mctx, prompt_str, files));
|
||||
} else {
|
||||
// Everything else, including multimodal completions.
|
||||
inputs = tokenize_input_prompts(ctx_server.impl->vocab, ctx_server.impl->mctx, prompt_str, true, true LOCALAI_MTMD_INIT_OPT_ARG(ctx_server.impl->init_opt));
|
||||
inputs = tokenize_input_prompts(ctx_server.impl->vocab, ctx_server.impl->mctx, prompt_str, true, true);
|
||||
}
|
||||
|
||||
tasks.reserve(inputs.size());
|
||||
@@ -2711,23 +2668,15 @@ public:
|
||||
|
||||
task.tokens = std::move(inputs[i]);
|
||||
#ifdef LOCALAI_HAS_SERVER_SCHEMA
|
||||
// The schema evaluator no longer takes the per-slot n_ctx: upstream
|
||||
// dropped the parameter and server-schema stopped consulting n_ctx at
|
||||
// all, leaving the context bound to the slot. Forks that predate the
|
||||
// server-schema split still expect it, so only this branch loses it.
|
||||
task.params = server_schema::eval_llama_cmpl_schema(
|
||||
ctx_server.impl->vocab,
|
||||
params_base,
|
||||
ctx_server.get_meta().logit_bias_eog,
|
||||
data);
|
||||
#else
|
||||
task.params = server_task::params_from_json_cmpl(
|
||||
#endif
|
||||
ctx_server.impl->vocab,
|
||||
params_base,
|
||||
ctx_server.get_meta().slot_n_ctx,
|
||||
ctx_server.get_meta().logit_bias_eog,
|
||||
data);
|
||||
#endif
|
||||
task.id_slot = json_value(data, "id_slot", -1);
|
||||
|
||||
// OAI-compat: enable autoparser (PEG-based chat parsing) so that
|
||||
@@ -2886,7 +2835,7 @@ public:
|
||||
json prompt = body.at("embeddings");
|
||||
|
||||
|
||||
auto tokenized_prompts = tokenize_input_prompts(ctx_server.impl->vocab, ctx_server.impl->mctx, prompt, true, true LOCALAI_MTMD_INIT_OPT_ARG(ctx_server.impl->init_opt));
|
||||
auto tokenized_prompts = tokenize_input_prompts(ctx_server.impl->vocab, ctx_server.impl->mctx, prompt, true, true);
|
||||
for (const auto & tokens : tokenized_prompts) {
|
||||
// this check is necessary for models that do not add BOS token to the input
|
||||
if (tokens.empty()) {
|
||||
@@ -2925,40 +2874,42 @@ public:
|
||||
return grpc::Status(grpc::StatusCode::INTERNAL, all_results.error->to_json().value("message", "Error in receiving results"));
|
||||
}
|
||||
|
||||
// Extract the embeddings typed, straight from the task results (no
|
||||
// JSON round-trip), and report the payload shape alongside the same
|
||||
// flat float array as before: dim is the embedding width, tokens the
|
||||
// number of vectors packed into `embeddings` (1 per prompt when the
|
||||
// server pooled, one per token with pooling:none; summed across
|
||||
// prompts if the request carried several), prompt_tokens the prompt
|
||||
// tokens evaluated, for usage accounting. Consumers seeing 0/0 know
|
||||
// the backend predates shape reporting.
|
||||
int32_t n_vectors = 0;
|
||||
int32_t dim = 0;
|
||||
int32_t prompt_tokens = 0;
|
||||
// Collect responses
|
||||
json responses = json::array();
|
||||
for (auto & res : all_results.results) {
|
||||
auto * embd_res = dynamic_cast<server_task_result_embd*>(res.get());
|
||||
GGML_ASSERT(embd_res != nullptr);
|
||||
prompt_tokens += embd_res->n_tokens;
|
||||
for (const auto & vec : embd_res->embedding) {
|
||||
for (const float value : vec) {
|
||||
embeddingResult->add_embeddings(value);
|
||||
GGML_ASSERT(dynamic_cast<server_task_result_embd*>(res.get()) != nullptr);
|
||||
responses.push_back(res->to_json());
|
||||
}
|
||||
|
||||
std::cout << "[DEBUG] Responses size: " << responses.size() << std::endl;
|
||||
|
||||
// Process the responses and extract embeddings
|
||||
for (const auto & response_elem : responses) {
|
||||
// Check if the response has an "embedding" field
|
||||
if (response_elem.contains("embedding")) {
|
||||
json embedding_data = json_value(response_elem, "embedding", json::array());
|
||||
|
||||
if (embedding_data.is_array() && !embedding_data.empty()) {
|
||||
for (const auto & embedding_vector : embedding_data) {
|
||||
if (embedding_vector.is_array()) {
|
||||
for (const auto & embedding_value : embedding_vector) {
|
||||
embeddingResult->add_embeddings(embedding_value.get<float>());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!vec.empty()) {
|
||||
n_vectors++;
|
||||
dim = (int32_t) vec.size();
|
||||
} else {
|
||||
// Check if the response itself contains the embedding data directly
|
||||
if (response_elem.is_array()) {
|
||||
for (const auto & embedding_value : response_elem) {
|
||||
embeddingResult->add_embeddings(embedding_value.get<float>());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
embeddingResult->set_tokens(n_vectors);
|
||||
embeddingResult->set_dim(dim);
|
||||
embeddingResult->set_prompt_tokens(prompt_tokens);
|
||||
embeddingResult->set_layout(
|
||||
llama_pooling_type(ctx_server.get_llama_context()) == LLAMA_POOLING_TYPE_NONE
|
||||
? backend::EMBEDDING_LAYOUT_PER_TOKEN
|
||||
: backend::EMBEDDING_LAYOUT_FINAL);
|
||||
|
||||
std::cout << "[DEBUG] Embedding vectors: " << n_vectors << " x " << dim << std::endl;
|
||||
|
||||
|
||||
|
||||
return grpc::Status::OK;
|
||||
}
|
||||
@@ -2991,7 +2942,7 @@ public:
|
||||
|
||||
tasks.reserve(documents.size());
|
||||
for (size_t i = 0; i < documents.size(); i++) {
|
||||
auto tmp = format_prompt_rerank(ctx_server.impl->model_tgt, ctx_server.impl->vocab, ctx_server.impl->mctx, request->query(), documents[i] LOCALAI_MTMD_INIT_OPT_ARG(ctx_server.impl->init_opt));
|
||||
auto tmp = format_prompt_rerank(ctx_server.impl->model_tgt, ctx_server.impl->vocab, ctx_server.impl->mctx, request->query(), documents[i]);
|
||||
server_task task = server_task(SERVER_TASK_TYPE_RERANK);
|
||||
task.id = rd.queue_tasks.get_new_id();
|
||||
task.index = i;
|
||||
@@ -3012,7 +2963,7 @@ public:
|
||||
}
|
||||
|
||||
// Collect responses
|
||||
std::vector<json> responses;
|
||||
json responses = json::array();
|
||||
for (auto & res : all_results.results) {
|
||||
GGML_ASSERT(dynamic_cast<server_task_result_rerank*>(res.get()) != nullptr);
|
||||
responses.push_back(res->to_json());
|
||||
@@ -3025,7 +2976,7 @@ public:
|
||||
// Crop results by request.top_n if specified
|
||||
int top_n = request->top_n();
|
||||
if (top_n > 0 && top_n < static_cast<int>(responses.size())) {
|
||||
responses.resize(top_n);
|
||||
responses = json(responses.begin(), responses.begin() + top_n);
|
||||
}
|
||||
// Set usage information
|
||||
backend::Usage* usage = rerankResult->mutable_usage();
|
||||
@@ -3052,229 +3003,6 @@ public:
|
||||
return grpc::Status::OK;
|
||||
}
|
||||
|
||||
#ifndef LOCALAI_LLAMA_CPP_NO_TTS_TASK
|
||||
// Builds the shared TTS task from a request. Returns a non-OK status and
|
||||
// leaves `task` untouched when the request is malformed or the loaded model
|
||||
// cannot synthesise audio.
|
||||
grpc::Status prepareTTSTask(const backend::TTSRequest* request, bool stream, server_task & task) {
|
||||
if (!ctx_server.get_meta().has_cap_tts) {
|
||||
return grpc::Status(grpc::StatusCode::UNIMPLEMENTED,
|
||||
"the loaded model does not support audio generation (no gen-audio mmproj)");
|
||||
}
|
||||
|
||||
std::map<std::string, std::string> params(request->params().begin(), request->params().end());
|
||||
const auto opts = llama_grpc::parse_tts_request_options(
|
||||
request->text(),
|
||||
request->voice(),
|
||||
request->has_language() ? request->language() : std::string(),
|
||||
params);
|
||||
if (!opts.ok) {
|
||||
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, opts.error);
|
||||
}
|
||||
|
||||
auto wrapper = mtmd_helper_bitmap_init_from_file(ctx_server.impl->mctx, opts.voice_path.c_str(), false LOCALAI_MTMD_INIT_OPT_ARG(ctx_server.impl->init_opt));
|
||||
if (!wrapper.bitmap) {
|
||||
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT,
|
||||
"failed to read speaker reference audio: " + opts.voice_path);
|
||||
}
|
||||
|
||||
task.tts_inp.set_prompt(opts.text);
|
||||
// core/backend/tts.go always sets TTSRequest.language, so has_language()
|
||||
// is true even when the caller named no language and the string is empty.
|
||||
// gen_audio::inp::get() already maps a stored blank to nullptr, so this
|
||||
// guard is behavior-preserving rather than behavior-fixing. It is kept
|
||||
// so the "unset" intent is visible at the call site instead of resting
|
||||
// on a detail of the helper.
|
||||
if (!opts.language.empty()) {
|
||||
task.tts_inp.set_lang(opts.language);
|
||||
}
|
||||
task.tts_inp.set_speaker_ref(mtmd::bitmap_ptr(wrapper.bitmap));
|
||||
task.tts_inp.data.top_k = opts.top_k;
|
||||
task.tts_inp.data.top_p = opts.top_p;
|
||||
task.tts_inp.data.stream = stream;
|
||||
task.tts_inp.data.out_type = stream
|
||||
? MTMD_HELPER_GEN_AUDIO_OUTTYPE_PCM // Go prepends its own WAV header, see core/backend/tts.go
|
||||
: MTMD_HELPER_GEN_AUDIO_OUTTYPE_WAV;
|
||||
|
||||
task.params.stream = stream;
|
||||
// -1 keeps upstream's 512-frame default. The model does not always emit
|
||||
// its codec EOS, so a short input can otherwise generate the full cap.
|
||||
task.params.n_predict = opts.max_frames > 0 ? opts.max_frames : -1;
|
||||
task.params.sampling = params_base.sampling;
|
||||
// Both values mirror upstream's draft POST /tts handler. Note that the
|
||||
// pair is INERT at this pin: llama_sampler_init_penalties() clamps
|
||||
// penalty_last_n with std::max(penalty_last_n, 0), so -1 means "off",
|
||||
// not "the whole generation", and the penalty sampler is then built
|
||||
// disabled. No repetition penalty is actually applied.
|
||||
//
|
||||
// That is deliberate. Dropping the second line lets the sampling
|
||||
// default of 64 apply and genuinely engages the 1.05 penalty, which was
|
||||
// measured here against the model's habit of never emitting its codec
|
||||
// EOS and running to the frame cap: 0 of 15 short requests ran away
|
||||
// with the penalty inert, 1 of 15 with it active over the last 64
|
||||
// tokens. It does not fix the runaway, so the line stays for parity
|
||||
// with the draft. Use max_frames to bound the output instead.
|
||||
task.params.sampling.penalty_repeat = 1.05f;
|
||||
task.params.sampling.penalty_last_n = -1;
|
||||
if (opts.top_k > 0) {
|
||||
task.params.sampling.top_k = opts.top_k;
|
||||
}
|
||||
if (opts.top_p > 0) {
|
||||
task.params.sampling.top_p = opts.top_p;
|
||||
}
|
||||
|
||||
return grpc::Status::OK;
|
||||
}
|
||||
|
||||
grpc::Status TTS(ServerContext* context, const backend::TTSRequest* request, backend::Result* result) override {
|
||||
auto auth = checkAuth(context);
|
||||
if (!auth.ok()) return auth;
|
||||
auto identity = checkModelIdentity(request);
|
||||
if (!identity.ok()) return identity;
|
||||
if (params_base.model.path.empty()) {
|
||||
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
|
||||
}
|
||||
if (request->dst().empty()) {
|
||||
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, "dst must name an output file path");
|
||||
}
|
||||
|
||||
server_task task(SERVER_TASK_TYPE_TTS);
|
||||
auto prepared = prepareTTSTask(request, /* stream= */ false, task);
|
||||
if (!prepared.ok()) return prepared;
|
||||
|
||||
auto rd = ctx_server.get_response_reader();
|
||||
task.id = rd.get_new_id();
|
||||
rd.post_task(std::move(task));
|
||||
|
||||
auto should_stop = [context]() { return context->IsCancelled(); };
|
||||
|
||||
std::string audio;
|
||||
while (true) {
|
||||
auto res = rd.next(should_stop);
|
||||
if (!res) {
|
||||
return grpc::Status(grpc::StatusCode::CANCELLED, "TTS request cancelled");
|
||||
}
|
||||
if (res->is_error()) {
|
||||
return grpc::Status(grpc::StatusCode::INTERNAL, res->to_json().dump());
|
||||
}
|
||||
auto * tts_res = dynamic_cast<server_task_result_tts *>(res.get());
|
||||
if (tts_res == nullptr) {
|
||||
return grpc::Status(grpc::StatusCode::INTERNAL, "unexpected result type for a TTS task");
|
||||
}
|
||||
audio.append(tts_res->audio);
|
||||
if (tts_res->final) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
std::ofstream out(request->dst(), std::ios::binary | std::ios::trunc);
|
||||
if (!out) {
|
||||
return grpc::Status(grpc::StatusCode::INTERNAL, "failed to open output file: " + request->dst());
|
||||
}
|
||||
out.write(audio.data(), (std::streamsize) audio.size());
|
||||
if (!out) {
|
||||
return grpc::Status(grpc::StatusCode::INTERNAL, "failed to write output file: " + request->dst());
|
||||
}
|
||||
// Buffered data is flushed here, so a full disk or a failing device can
|
||||
// surface for the first time on close. Reporting success then would
|
||||
// leave a truncated file behind under the name the caller will read.
|
||||
out.close();
|
||||
if (!out) {
|
||||
return grpc::Status(grpc::StatusCode::INTERNAL, "failed to close output file: " + request->dst());
|
||||
}
|
||||
|
||||
result->set_success(true);
|
||||
result->set_message("TTS audio generated");
|
||||
return grpc::Status::OK;
|
||||
}
|
||||
|
||||
grpc::Status TTSStream(ServerContext* context, const backend::TTSRequest* request, grpc::ServerWriter<backend::Reply>* writer) override {
|
||||
auto auth = checkAuth(context);
|
||||
if (!auth.ok()) return auth;
|
||||
auto identity = checkModelIdentity(request);
|
||||
if (!identity.ok()) return identity;
|
||||
if (params_base.model.path.empty()) {
|
||||
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
|
||||
}
|
||||
|
||||
server_task task(SERVER_TASK_TYPE_TTS);
|
||||
auto prepared = prepareTTSTask(request, /* stream= */ true, task);
|
||||
if (!prepared.ok()) return prepared;
|
||||
|
||||
auto rd = ctx_server.get_response_reader();
|
||||
task.id = rd.get_new_id();
|
||||
rd.post_task(std::move(task));
|
||||
|
||||
auto should_stop = [context]() { return context->IsCancelled(); };
|
||||
|
||||
// core/backend/tts.go:ModelTTSStream builds the WAV header itself from
|
||||
// the sample rate in the first reply's Message, then concatenates every
|
||||
// Reply.Audio verbatim. So the rate goes out once, up front, and the
|
||||
// chunks stay raw PCM.
|
||||
//
|
||||
// Send it before draining rather than off the first audio result: a
|
||||
// chunk needs a whole 72-frame window, about 5.8 s of audio and far
|
||||
// longer in wall time on CPU, and the Go side cannot emit the WAV
|
||||
// header until this reply lands. Waiting would hold the client at zero
|
||||
// bytes for that entire stretch. The rate is a property of the loaded
|
||||
// model, available synchronously, so there is nothing to wait for.
|
||||
{
|
||||
backend::Reply header;
|
||||
const json info = { {"sample_rate", mtmd_gen_audio_get_info(ctx_server.impl->mctx).sample_rate} };
|
||||
header.set_message(info.dump());
|
||||
if (!writer->Write(header)) {
|
||||
return grpc::Status(grpc::StatusCode::CANCELLED, "client closed the TTS stream");
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
auto res = rd.next(should_stop);
|
||||
if (!res) {
|
||||
return grpc::Status(grpc::StatusCode::CANCELLED, "TTS request cancelled");
|
||||
}
|
||||
if (res->is_error()) {
|
||||
return grpc::Status(grpc::StatusCode::INTERNAL, res->to_json().dump());
|
||||
}
|
||||
auto * tts_res = dynamic_cast<server_task_result_tts *>(res.get());
|
||||
if (tts_res == nullptr) {
|
||||
return grpc::Status(grpc::StatusCode::INTERNAL, "unexpected result type for a TTS task");
|
||||
}
|
||||
|
||||
if (!tts_res->audio.empty()) {
|
||||
backend::Reply chunk;
|
||||
chunk.set_audio(tts_pcm_f32_to_s16(tts_res->audio));
|
||||
if (!writer->Write(chunk)) {
|
||||
return grpc::Status(grpc::StatusCode::CANCELLED, "client closed the TTS stream");
|
||||
}
|
||||
}
|
||||
|
||||
if (tts_res->final) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return grpc::Status::OK;
|
||||
}
|
||||
#else
|
||||
grpc::Status TTS(ServerContext* context, const backend::TTSRequest* request, backend::Result* result) override {
|
||||
auto auth = checkAuth(context);
|
||||
if (!auth.ok()) return auth;
|
||||
(void) request;
|
||||
(void) result;
|
||||
return grpc::Status(grpc::StatusCode::UNIMPLEMENTED,
|
||||
"TTS is unavailable in this llama.cpp fork backend");
|
||||
}
|
||||
|
||||
grpc::Status TTSStream(ServerContext* context, const backend::TTSRequest* request, grpc::ServerWriter<backend::Reply>* writer) override {
|
||||
auto auth = checkAuth(context);
|
||||
if (!auth.ok()) return auth;
|
||||
(void) request;
|
||||
(void) writer;
|
||||
return grpc::Status(grpc::StatusCode::UNIMPLEMENTED,
|
||||
"TTSStream is unavailable in this llama.cpp fork backend");
|
||||
}
|
||||
#endif
|
||||
|
||||
// Score returns the model's joint log-probability of each candidate
|
||||
// continuation given a shared prompt.
|
||||
//
|
||||
@@ -3609,15 +3337,9 @@ public:
|
||||
// Populate the response with metrics
|
||||
response->set_slot_id(0);
|
||||
response->set_prompt_json_for_slot("");
|
||||
#if LOCALAI_HAS_SERVER_METRICS
|
||||
response->set_tokens_per_second(res_metrics->metrics.prompt_bucket.n_per_second());
|
||||
response->set_tokens_generated(res_metrics->metrics.predict.count);
|
||||
response->set_prompt_tokens_processed(res_metrics->metrics.prompt.count);
|
||||
#else
|
||||
response->set_tokens_per_second(res_metrics->n_prompt_tokens_processed ? 1.e3 / res_metrics->t_prompt_processing * res_metrics->n_prompt_tokens_processed : 0.);
|
||||
response->set_tokens_generated(res_metrics->n_tokens_predicted_total);
|
||||
response->set_prompt_tokens_processed(res_metrics->n_prompt_tokens_processed_total);
|
||||
#endif
|
||||
|
||||
|
||||
return grpc::Status::OK;
|
||||
|
||||
@@ -52,15 +52,14 @@ inline nlohmann::ordered_json normalize_message_content(const std::string& role,
|
||||
// (#7528). A multimodal user message legitimately carries a typed-part array
|
||||
// ({type:text}, {type:image_url}, ...), which must be left intact. Shared by the
|
||||
// streaming and non-streaming paths so this invariant cannot drift between them.
|
||||
template <typename Json>
|
||||
inline void normalize_template_message(Json& msg) {
|
||||
inline void normalize_template_message(nlohmann::ordered_json& msg) {
|
||||
if (!msg.contains("content")) {
|
||||
msg["content"] = ""; // templates expect the field to exist
|
||||
return;
|
||||
}
|
||||
auto& content = msg["content"];
|
||||
nlohmann::ordered_json& content = msg["content"];
|
||||
const std::string role = (msg.contains("role") && msg["role"].is_string())
|
||||
? msg["role"].template get<std::string>()
|
||||
? msg["role"].get<std::string>()
|
||||
: std::string();
|
||||
if (content.is_null()) {
|
||||
content = ""; // #7324: null would crash content[:N] slicing
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
From 75220a0d74892e3315f4042274b1efa6195868d8 Mon Sep 17 00:00:00 2001
|
||||
From: Codex <codex@local>
|
||||
Date: Mon, 10 Aug 2026 23:05:52 +0000
|
||||
Subject: [PATCH 1/2] score-patch
|
||||
|
||||
---
|
||||
common/common.cpp | 6 +-
|
||||
common/common.h | 3 +
|
||||
tools/server/server-context.cpp | 358 +++++++++++++++++++++++++++++++-
|
||||
tools/server/server-task.h | 47 +++++
|
||||
4 files changed, 405 insertions(+), 9 deletions(-)
|
||||
|
||||
diff --git a/common/common.cpp b/common/common.cpp
|
||||
index 2e3f14c..0cec0dc 100644
|
||||
index 8f13217..fc584e1 100644
|
||||
--- a/common/common.cpp
|
||||
+++ b/common/common.cpp
|
||||
@@ -1636,8 +1636,10 @@ struct llama_context_params common_context_params_to_llama(const common_params &
|
||||
@@ -1591,8 +1591,10 @@ struct llama_context_params common_context_params_to_llama(const common_params &
|
||||
auto cparams = llama_context_default_params();
|
||||
|
||||
cparams.n_ctx = params.n_ctx;
|
||||
@@ -25,13 +13,13 @@ index 2e3f14c..0cec0dc 100644
|
||||
+ cparams.n_seq_max = params.n_parallel + params.n_seq_score_forks;
|
||||
+ cparams.n_rs_seq = std::max(params.speculative.need_n_rs_seq(), (uint32_t) std::max(0, params.n_rs_seq));
|
||||
cparams.n_outputs_max = std::max(params.n_outputs_max, 0);
|
||||
cparams.n_outputs_max_per_seq = std::max(params.n_outputs_max_per_seq, 0);
|
||||
cparams.n_batch = params.n_batch;
|
||||
cparams.n_ubatch = params.n_ubatch;
|
||||
diff --git a/common/common.h b/common/common.h
|
||||
index 878534d..4001df2 100644
|
||||
index bffc176..e313bd6 100644
|
||||
--- a/common/common.h
|
||||
+++ b/common/common.h
|
||||
@@ -445,6 +445,9 @@ struct common_params {
|
||||
@@ -455,6 +455,9 @@ struct common_params {
|
||||
int32_t n_keep = 0; // number of tokens to keep from initial prompt
|
||||
int32_t n_chunks = -1; // max number of chunks to process (-1 = unlimited)
|
||||
int32_t n_parallel = 1; // number of parallel sequences to decode
|
||||
@@ -40,26 +28,39 @@ index 878534d..4001df2 100644
|
||||
+ bool score_enabled = false; // reserve server resources for the Score task type
|
||||
int32_t n_sequences = 1; // number of sequences to decode
|
||||
int32_t n_outputs_max = 0; // max outputs in a batch (0 = n_batch)
|
||||
int32_t n_outputs_max_per_seq = 1; // max outputs per sequence
|
||||
int32_t grp_attn_n = 1; // group-attention factor
|
||||
diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt
|
||||
index 780df32..1d2fe8f 100644
|
||||
--- a/tools/CMakeLists.txt
|
||||
+++ b/tools/CMakeLists.txt
|
||||
@@ -41,3 +41,4 @@ else()
|
||||
add_subdirectory(fit-params)
|
||||
add_subdirectory(results)
|
||||
endif()
|
||||
+add_subdirectory(grpc-server)
|
||||
diff --git a/tools/server/server-context.cpp b/tools/server/server-context.cpp
|
||||
index 3b5f6a1..d0e18e6 100644
|
||||
index 715477e..de5bed8 100644
|
||||
--- a/tools/server/server-context.cpp
|
||||
+++ b/tools/server/server-context.cpp
|
||||
@@ -48,6 +48,13 @@ static common_speculative_output_limits server_output_limits(const common_params
|
||||
auto result = common_speculative_get_output_limits(
|
||||
params.n_batch, params.n_parallel, common_speculative_n_max(¶ms.speculative));
|
||||
@@ -49,7 +49,16 @@ static uint32_t server_n_outputs_max(const common_params & params) {
|
||||
|
||||
+ // Score tasks output logits for every candidate token, so reserve room
|
||||
+ // for a bounded candidate tail per parallel slot.
|
||||
+ if (params.score_enabled) {
|
||||
+ result.per_seq = std::max<int32_t>(result.per_seq, 1 + SERVER_SCORE_MAX_CAND_TOKENS);
|
||||
+ result.total = std::min<int32_t>(params.n_batch, params.n_parallel * result.per_seq);
|
||||
const uint32_t n_outputs_per_seq = 1 + common_speculative_n_max(¶ms.speculative);
|
||||
|
||||
- const uint64_t n_outputs = (uint64_t) params.n_parallel * n_outputs_per_seq;
|
||||
+ // score tasks (SERVER_TASK_TYPE_SCORE) output logits for every candidate
|
||||
+ // token, so reserve room for a bounded candidate tail per parallel slot
|
||||
+ if (!params.score_enabled) {
|
||||
+ return std::max<uint32_t>(1, std::min<uint64_t>(n_batch,
|
||||
+ (uint64_t) params.n_parallel * n_outputs_per_seq));
|
||||
+ }
|
||||
+
|
||||
result.total = std::max<int32_t>(1, result.total);
|
||||
result.per_seq = std::max<int32_t>(1, result.per_seq);
|
||||
return result;
|
||||
@@ -239,6 +246,26 @@ struct server_slot {
|
||||
+ const uint32_t n_outputs_score_seq = 1 + SERVER_SCORE_MAX_CAND_TOKENS;
|
||||
+
|
||||
+ const uint64_t n_outputs = (uint64_t) params.n_parallel * std::max(n_outputs_per_seq, n_outputs_score_seq);
|
||||
|
||||
return std::max<uint32_t>(1, std::min<uint64_t>(n_batch, n_outputs));
|
||||
}
|
||||
@@ -202,6 +211,26 @@ struct server_slot {
|
||||
|
||||
std::vector<completion_token_output> generated_token_probs;
|
||||
|
||||
@@ -86,7 +87,7 @@ index 3b5f6a1..d0e18e6 100644
|
||||
bool has_next_token = true;
|
||||
bool has_new_line = false;
|
||||
bool truncated = false;
|
||||
@@ -341,6 +368,10 @@ struct server_slot {
|
||||
@@ -311,6 +340,10 @@ struct server_slot {
|
||||
}
|
||||
generated_tokens.clear();
|
||||
generated_token_probs.clear();
|
||||
@@ -96,8 +97,8 @@ index 3b5f6a1..d0e18e6 100644
|
||||
+ score_divergence = -1;
|
||||
json_schema = json();
|
||||
|
||||
task_prev = std::move(task);
|
||||
@@ -2271,6 +2302,229 @@ private:
|
||||
// clear speculative decoding stats
|
||||
@@ -2205,6 +2238,229 @@ private:
|
||||
queue_results.send(std::move(res));
|
||||
}
|
||||
|
||||
@@ -327,7 +328,7 @@ index 3b5f6a1..d0e18e6 100644
|
||||
//
|
||||
// Functions to process the task
|
||||
//
|
||||
@@ -2407,6 +2661,7 @@ private:
|
||||
@@ -2341,6 +2597,7 @@ private:
|
||||
case SERVER_TASK_TYPE_INFILL:
|
||||
case SERVER_TASK_TYPE_EMBEDDING:
|
||||
case SERVER_TASK_TYPE_RERANK:
|
||||
@@ -335,7 +336,7 @@ index 3b5f6a1..d0e18e6 100644
|
||||
{
|
||||
// special case: if input is provided via CLI, tokenize it first
|
||||
// otherwise, no need to tokenize as it's already done inside the HTTP thread
|
||||
@@ -2903,6 +3158,13 @@ private:
|
||||
@@ -2832,6 +3089,13 @@ private:
|
||||
break; // stop any further processing
|
||||
}
|
||||
}
|
||||
@@ -349,7 +350,7 @@ index 3b5f6a1..d0e18e6 100644
|
||||
}
|
||||
|
||||
void pre_decode() {
|
||||
@@ -3222,6 +3484,16 @@ private:
|
||||
@@ -3154,6 +3418,16 @@ private:
|
||||
n_past = std::min(n_past, slot.alora_invocation_start - 1);
|
||||
}
|
||||
|
||||
@@ -366,7 +367,7 @@ index 3b5f6a1..d0e18e6 100644
|
||||
const auto n_cache_reuse = slot.task->params.n_cache_reuse;
|
||||
|
||||
const bool can_cache_reuse =
|
||||
@@ -3455,8 +3727,12 @@ private:
|
||||
@@ -3395,8 +3669,12 @@ private:
|
||||
|
||||
bool do_checkpoint = params_base.n_ctx_checkpoints > 0;
|
||||
|
||||
@@ -381,7 +382,7 @@ index 3b5f6a1..d0e18e6 100644
|
||||
|
||||
// make a checkpoint of the parts of the memory that cannot be rolled back.
|
||||
// checkpoints are created only if:
|
||||
@@ -3444,9 +3720,16 @@ private:
|
||||
@@ -3463,10 +3741,17 @@ private:
|
||||
// embedding requires all tokens in the batch to be output;
|
||||
// MTP also wants logits at every prompt position so the
|
||||
// streaming hook can mirror t_h_nextn into ctx_dft.
|
||||
@@ -394,12 +395,16 @@ index 3b5f6a1..d0e18e6 100644
|
||||
+ slot.prompt.n_tokens() + 1 < slot.task->n_tokens();
|
||||
add_ok &= batch.add(slot.id,
|
||||
cur_tok,
|
||||
/* pos = */ slot.prompt.tokens.pos_next(),
|
||||
- /* output = */ slot.need_embd(),
|
||||
+ /* output = */ slot.need_embd() || need_score_logit,
|
||||
/* is_prompt = */ true);
|
||||
slot.prompt.tokens.pos_next(),
|
||||
- slot.need_embd());
|
||||
+ slot.need_embd() || need_score_logit);
|
||||
slot.prompt.tokens.push_back(cur_tok);
|
||||
@@ -3454,2 +3737,28 @@ private:
|
||||
|
||||
slot.n_prompt_tokens_processed++;
|
||||
@@ -3481,6 +3766,32 @@ private:
|
||||
}
|
||||
}
|
||||
|
||||
+ // score tasks: break at the shared-prompt boundary so the checkpoint
|
||||
+ // below lands exactly there — the other candidates of the same
|
||||
+ // scoring call re-process only their own tokens. Also break at the
|
||||
@@ -426,9 +431,10 @@ index 3b5f6a1..d0e18e6 100644
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
// break at the last user message, or at user messages at least min step past the last checkpoint
|
||||
if (do_checkpoint && spans.is_user_start(slot.prompt.n_tokens())) {
|
||||
@@ -3573,6 +3882,15 @@ private:
|
||||
// process the last few tokens of the prompt separately in order to allow for a checkpoint to be created.
|
||||
// create checkpoints that many tokens before the end of the prompt:
|
||||
// - 4 + n_ubatch
|
||||
@@ -3513,6 +3824,15 @@ private:
|
||||
const bool is_user_start = spans.is_user_start(n_tokens_start);
|
||||
const bool is_last_user_message = n_tokens_start == last_user_pos;
|
||||
|
||||
@@ -444,7 +450,7 @@ index 3b5f6a1..d0e18e6 100644
|
||||
// entire prompt has been processed
|
||||
if (slot.prompt.n_tokens() == slot.task->n_tokens()) {
|
||||
slot.state = SLOT_STATE_DONE_PROMPT;
|
||||
@@ -3588,8 +3906,8 @@ private:
|
||||
@@ -3528,8 +3848,8 @@ private:
|
||||
slot.init_sampler();
|
||||
} else {
|
||||
// skip ordinary mid-prompt checkpoints, unless the batch starts a user
|
||||
@@ -455,7 +461,7 @@ index 3b5f6a1..d0e18e6 100644
|
||||
do_checkpoint = false;
|
||||
}
|
||||
}
|
||||
@@ -3606,10 +3924,10 @@ private:
|
||||
@@ -3546,10 +3866,10 @@ private:
|
||||
// do not checkpoint after mtmd chunks
|
||||
do_checkpoint = do_checkpoint && !has_mtmd;
|
||||
|
||||
@@ -468,7 +474,7 @@ index 3b5f6a1..d0e18e6 100644
|
||||
n_tokens_start > slot.prompt.checkpoints.back().n_tokens + params_base.checkpoint_min_step);
|
||||
SLT_DBG(slot, "main/do_checkpoint = %s, pos_min = %d, pos_max = %d\n", do_checkpoint ? "yes" : "no", pos_min, pos_max);
|
||||
|
||||
@@ -3772,6 +4090,13 @@ private:
|
||||
@@ -3703,6 +4023,13 @@ private:
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,7 +488,7 @@ index 3b5f6a1..d0e18e6 100644
|
||||
if (!is_inside_view(slot.i_batch)) {
|
||||
// the required token not in this sub-batch, skip
|
||||
return;
|
||||
@@ -3793,6 +4118,25 @@ private:
|
||||
@@ -3724,6 +4051,25 @@ private:
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -509,7 +515,7 @@ index 3b5f6a1..d0e18e6 100644
|
||||
|
||||
// prompt evaluated for next-token prediction
|
||||
diff --git a/tools/server/server-task.h b/tools/server/server-task.h
|
||||
index 6275ec7..5bedf19 100644
|
||||
index c3eea2e..fb3c178 100644
|
||||
--- a/tools/server/server-task.h
|
||||
+++ b/tools/server/server-task.h
|
||||
@@ -13,10 +13,25 @@
|
||||
@@ -591,5 +597,3 @@ index 6275ec7..5bedf19 100644
|
||||
struct server_task_result_error : server_task_result {
|
||||
error_type err_type = ERROR_TYPE_SERVER;
|
||||
std::string err_msg;
|
||||
--
|
||||
2.39.5
|
||||
@@ -1,845 +0,0 @@
|
||||
diff --git a/tools/mtmd/mtmd-helper-gen.cpp b/tools/mtmd/mtmd-helper-gen.cpp
|
||||
index 1c58d3ae1..196cbd433 100644
|
||||
--- a/tools/mtmd/mtmd-helper-gen.cpp
|
||||
+++ b/tools/mtmd/mtmd-helper-gen.cpp
|
||||
@@ -50,29 +50,38 @@ static llama_token find_special_token(const llama_vocab * vocab, const std::stri
|
||||
return LLAMA_TOKEN_NULL;
|
||||
}
|
||||
|
||||
+static void put_bytes(std::vector<char> & buf, const void * p, size_t n) {
|
||||
+ const char * c = (const char *) p;
|
||||
+ buf.insert(buf.end(), c, c + n);
|
||||
+}
|
||||
+
|
||||
+// data_sz == UINT32_MAX writes the "unknown length" sentinel (streaming), same as ffmpeg does on a pipe
|
||||
+static void write_wav16_header(std::vector<char> & buf, uint32_t data_sz, int32_t rate) {
|
||||
+ const uint32_t riff_sz = data_sz == UINT32_MAX ? UINT32_MAX : 36 + data_sz;
|
||||
+ const uint32_t fmt_sz = 16, byte_rate = (uint32_t) rate * 2;
|
||||
+ const uint16_t fmt = 1, ch = 1, align = 2, bits = 16;
|
||||
+ const uint32_t rate32 = (uint32_t) rate;
|
||||
+ put_bytes(buf, "RIFF", 4); put_bytes(buf, &riff_sz, 4); put_bytes(buf, "WAVE", 4);
|
||||
+ put_bytes(buf, "fmt ", 4); put_bytes(buf, &fmt_sz, 4);
|
||||
+ put_bytes(buf, &fmt, 2); put_bytes(buf, &ch, 2); put_bytes(buf, &rate32, 4);
|
||||
+ put_bytes(buf, &byte_rate, 4); put_bytes(buf, &align, 2); put_bytes(buf, &bits, 2);
|
||||
+ put_bytes(buf, "data", 4); put_bytes(buf, &data_sz, 4);
|
||||
+}
|
||||
+
|
||||
+static void append_wav16_pcm(std::vector<char> & buf, const float * pcm, size_t n) {
|
||||
+ for (size_t i = 0; i < n; i++) {
|
||||
+ int16_t s = (int16_t) (std::max(-1.0f, std::min(1.0f, pcm[i])) * 32767.0f);
|
||||
+ put_bytes(buf, &s, 2);
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
static bool write_wav16(std::vector<char> & buf, const std::vector<float> & pcm, int32_t rate) {
|
||||
// RIFF chunk sizes are 32-bit; refuse to emit a file with a truncated header
|
||||
if (pcm.size() > ((size_t) UINT32_MAX - 36) / 2) {
|
||||
return false;
|
||||
}
|
||||
- const uint32_t data_sz = (uint32_t) (pcm.size() * 2);
|
||||
- const uint32_t riff_sz = 36 + data_sz;
|
||||
- const uint32_t fmt_sz = 16, byte_rate = (uint32_t) rate * 2;
|
||||
- const uint16_t fmt = 1, ch = 1, align = 2, bits = 16;
|
||||
- const uint32_t rate32 = (uint32_t) rate;
|
||||
- auto put = [&](const void * p, size_t n) {
|
||||
- const char * c = (const char *) p;
|
||||
- buf.insert(buf.end(), c, c + n);
|
||||
- };
|
||||
- put("RIFF", 4); put(&riff_sz, 4); put("WAVE", 4);
|
||||
- put("fmt ", 4); put(&fmt_sz, 4);
|
||||
- put(&fmt, 2); put(&ch, 2); put(&rate32, 4);
|
||||
- put(&byte_rate, 4); put(&align, 2); put(&bits, 2);
|
||||
- put("data", 4); put(&data_sz, 4);
|
||||
- for (float v : pcm) {
|
||||
- int16_t s = (int16_t) (std::max(-1.0f, std::min(1.0f, v)) * 32767.0f);
|
||||
- put(&s, 2);
|
||||
- }
|
||||
+ write_wav16_header(buf, (uint32_t) (pcm.size() * 2), rate);
|
||||
+ append_wav16_pcm(buf, pcm.data(), pcm.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -92,6 +101,8 @@ public:
|
||||
// set out_stop on end-of-speech, h_state_out must be null if no frame is generated
|
||||
virtual int32_t step_gen(llama_token sampled, const float * h_state_in, const float ** h_state_out, bool * out_stop) = 0;
|
||||
virtual int32_t get_output(int32_t * out_sample_rate, const char ** out_data, size_t * out_data_len, int64_t * out_n_samples) = 0;
|
||||
+ // forces any buffered codes through code2wav now, regardless of window_frames
|
||||
+ virtual int32_t flush() { return 0; }
|
||||
|
||||
protected:
|
||||
llama_context * lctx;
|
||||
@@ -121,6 +132,9 @@ public:
|
||||
prompt_batch.reset();
|
||||
n_prompt = 0;
|
||||
prompt_pos = 0;
|
||||
+ stream = false;
|
||||
+ pcm_sent = 0;
|
||||
+ wav_header_sent = false;
|
||||
}
|
||||
|
||||
int32_t set_input(const mtmd_helper_gen_audio_inp * inp) override {
|
||||
@@ -208,6 +222,7 @@ public:
|
||||
top_p = inp->top_p > 0 ? inp->top_p : def.top_p;
|
||||
seed = inp->seed;
|
||||
out_type = inp->out_type;
|
||||
+ stream = inp->stream;
|
||||
|
||||
// the prompt above holds the whole text stream up to tts_eos, so every generated
|
||||
// frame adds tts_pad on top of the codes embedding
|
||||
@@ -302,31 +317,60 @@ public:
|
||||
}
|
||||
|
||||
int32_t get_output(int32_t * out_sample_rate, const char ** out_data, size_t * out_data_len, int64_t * out_n_samples) override {
|
||||
- if (!flush_gen_wav()) {
|
||||
- return 1;
|
||||
+ *out_sample_rate = info.sample_rate;
|
||||
+
|
||||
+ if (!stream) {
|
||||
+ // one-shot call: force out whatever's left, regardless of window_frames
|
||||
+ if (!flush_gen_wav()) {
|
||||
+ return 1;
|
||||
+ }
|
||||
+ if (out_n_samples) {
|
||||
+ *out_n_samples = (int64_t) audio_pcm.size();
|
||||
+ }
|
||||
+ if (out_type == MTMD_HELPER_GEN_AUDIO_OUTTYPE_PCM) {
|
||||
+ *out_data = (const char *) audio_pcm.data();
|
||||
+ *out_data_len = audio_pcm.size() * sizeof(float);
|
||||
+ return 0;
|
||||
+ }
|
||||
+ out_buf.clear();
|
||||
+ if (!write_wav16(out_buf, audio_pcm, info.sample_rate)) {
|
||||
+ LOG_ERR("mtmd_helper_gen_audio: output too large for WAV\n");
|
||||
+ return 1;
|
||||
+ }
|
||||
+ *out_data = out_buf.data();
|
||||
+ *out_data_len = out_buf.size();
|
||||
+ return 0;
|
||||
}
|
||||
|
||||
- *out_sample_rate = info.sample_rate;
|
||||
+ // streaming: only return audio produced since the previous call
|
||||
+ const size_t n_new = audio_pcm.size() - pcm_sent;
|
||||
if (out_n_samples) {
|
||||
- *out_n_samples = (int64_t) audio_pcm.size();
|
||||
+ *out_n_samples = (int64_t) n_new;
|
||||
}
|
||||
|
||||
if (out_type == MTMD_HELPER_GEN_AUDIO_OUTTYPE_PCM) {
|
||||
- *out_data = (const char *) audio_pcm.data();
|
||||
- *out_data_len = audio_pcm.size() * sizeof(float);
|
||||
+ *out_data = (const char *) (audio_pcm.data() + pcm_sent);
|
||||
+ *out_data_len = n_new * sizeof(float);
|
||||
+ pcm_sent = audio_pcm.size();
|
||||
return 0;
|
||||
}
|
||||
|
||||
out_buf.clear();
|
||||
- if (!write_wav16(out_buf, audio_pcm, info.sample_rate)) {
|
||||
- LOG_ERR("mtmd_helper_gen_audio: output too large for WAV\n");
|
||||
- return 1;
|
||||
+ if (!wav_header_sent) {
|
||||
+ write_wav16_header(out_buf, UINT32_MAX, info.sample_rate);
|
||||
+ wav_header_sent = true;
|
||||
}
|
||||
+ append_wav16_pcm(out_buf, audio_pcm.data() + pcm_sent, n_new);
|
||||
+ pcm_sent = audio_pcm.size();
|
||||
*out_data = out_buf.data();
|
||||
*out_data_len = out_buf.size();
|
||||
return 0;
|
||||
}
|
||||
|
||||
+ int32_t flush() override {
|
||||
+ return flush_gen_wav() ? 0 : 1;
|
||||
+ }
|
||||
+
|
||||
private:
|
||||
bool ensure_cache() {
|
||||
if (specials_ok) {
|
||||
@@ -370,7 +414,7 @@ private:
|
||||
LOG_ERR("mtmd_helper_gen_audio: mmproj has no speaker/audio encoder\n");
|
||||
return false;
|
||||
}
|
||||
- const std::string marker = mtmd_default_marker();
|
||||
+ const std::string marker = mtmd_get_marker(mctx);
|
||||
mtmd_input_text text{ marker.c_str(), marker.size(), false, true };
|
||||
mtmd_input_chunks * chunks = mtmd_input_chunks_init();
|
||||
const mtmd_bitmap * bptr = bitmap;
|
||||
@@ -456,6 +500,9 @@ private:
|
||||
std::vector<float> h_state_buf;
|
||||
mtmd_helper_gen_audio_outtype out_type = MTMD_HELPER_GEN_AUDIO_OUTTYPE_WAV;
|
||||
std::vector<char> out_buf;
|
||||
+ bool stream = false;
|
||||
+ size_t pcm_sent = 0; // samples already returned by get_output()
|
||||
+ bool wav_header_sent = false;
|
||||
};
|
||||
|
||||
// settings that only live in the reference's per-pack yaml, not in the checkpoint
|
||||
@@ -1024,6 +1071,14 @@ void mtmd_helper_gen_audio_reset(mtmd_helper_gen_audio * ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
+struct mtmd_helper_gen_audio_inp mtmd_helper_gen_audio_inp_default(void) {
|
||||
+ mtmd_helper_gen_audio_inp inp{};
|
||||
+ inp.top_k = 50;
|
||||
+ inp.top_p = 1.0f;
|
||||
+ inp.out_type = MTMD_HELPER_GEN_AUDIO_OUTTYPE_WAV;
|
||||
+ return inp;
|
||||
+}
|
||||
+
|
||||
int32_t mtmd_helper_gen_audio_set_input(mtmd_helper_gen_audio * ctx, const mtmd_helper_gen_audio_inp * inp) {
|
||||
if (!ctx->pipeline) {
|
||||
LOG_ERR("mtmd_helper_gen_audio: unsupported or missing gen-audio pipeline\n");
|
||||
@@ -1060,3 +1115,10 @@ int32_t mtmd_helper_gen_audio_get_output(mtmd_helper_gen_audio * ctx, int32_t *
|
||||
}
|
||||
return ctx->pipeline->get_output(out_sample_rate, out_data, out_data_len, out_n_samples);
|
||||
}
|
||||
+
|
||||
+int32_t mtmd_helper_gen_audio_flush(mtmd_helper_gen_audio * ctx) {
|
||||
+ if (!ctx->pipeline) {
|
||||
+ return 1;
|
||||
+ }
|
||||
+ return ctx->pipeline->flush();
|
||||
+}
|
||||
diff --git a/tools/mtmd/mtmd-helper.h b/tools/mtmd/mtmd-helper.h
|
||||
index 832f7171a..3eaa01aab 100644
|
||||
--- a/tools/mtmd/mtmd-helper.h
|
||||
+++ b/tools/mtmd/mtmd-helper.h
|
||||
@@ -175,6 +175,7 @@ enum mtmd_helper_gen_audio_outtype {
|
||||
MTMD_HELPER_GEN_AUDIO_OUTTYPE_WAV, // WAV PCM 16-bit LE, mono
|
||||
};
|
||||
struct mtmd_helper_gen_audio_inp {
|
||||
+ bool stream; // if true, output() must be called after each step_gen()
|
||||
llama_seq_id seq_id;
|
||||
|
||||
const char * prompt;
|
||||
@@ -190,6 +191,8 @@ struct mtmd_helper_gen_audio_inp {
|
||||
enum mtmd_helper_gen_audio_outtype out_type;
|
||||
};
|
||||
|
||||
+MTMD_API struct mtmd_helper_gen_audio_inp mtmd_helper_gen_audio_inp_default(void);
|
||||
+
|
||||
MTMD_API mtmd_helper_gen_audio * mtmd_helper_gen_audio_init(
|
||||
struct llama_context * lctx,
|
||||
struct mtmd_context * mctx);
|
||||
@@ -221,6 +224,8 @@ MTMD_API int32_t mtmd_helper_gen_audio_step_gen(
|
||||
|
||||
// out_data valid until next get_output() or reset() call
|
||||
// out_n_samples (optional, can be NULL) receives the number of generated PCM samples
|
||||
+// if inp->stream is true: returns only audio produced since the previous call, and
|
||||
+// *out_data_len == 0 whenever a full window_frames batch hasn't accumulated yet
|
||||
MTMD_API int32_t mtmd_helper_gen_audio_get_output(
|
||||
mtmd_helper_gen_audio * ctx,
|
||||
int32_t * out_sample_rate,
|
||||
@@ -228,6 +233,10 @@ MTMD_API int32_t mtmd_helper_gen_audio_get_output(
|
||||
size_t * out_data_len,
|
||||
int64_t * out_n_samples);
|
||||
|
||||
+// forces any buffered codes through code2wav now, regardless of window_frames;
|
||||
+// call once when generation has ended, before the last get_output() in stream mode
|
||||
+MTMD_API int32_t mtmd_helper_gen_audio_flush(mtmd_helper_gen_audio * ctx);
|
||||
+
|
||||
#ifdef __cplusplus
|
||||
} // extern "C"
|
||||
#endif
|
||||
@@ -254,8 +263,41 @@ struct mtmd_helper_gen_audio_deleter {
|
||||
};
|
||||
using gen_audio_ptr = std::unique_ptr<mtmd_helper_gen_audio, mtmd_helper_gen_audio_deleter>;
|
||||
struct gen_audio {
|
||||
+
|
||||
+ // sub-struct, RAII wrapper for mtmd_helper_gen_audio_inp
|
||||
+ struct inp {
|
||||
+ mtmd_helper_gen_audio_inp data = mtmd_helper_gen_audio_inp_default();
|
||||
+ std::string prompt_str;
|
||||
+ std::string lang_str;
|
||||
+ mtmd::bitmap_ptr speaker_ref_ptr;
|
||||
+
|
||||
+ inp() = default;
|
||||
+ inp(inp &&) = default;
|
||||
+ inp & operator=(inp &&) = default;
|
||||
+ inp(const inp &) = delete;
|
||||
+ inp & operator=(const inp &) = delete;
|
||||
+
|
||||
+ void set_prompt (std::string p) { prompt_str = std::move(p); }
|
||||
+ void set_lang (std::string l) { lang_str = std::move(l); }
|
||||
+ void set_speaker_ref(mtmd::bitmap_ptr bmp) { speaker_ref_ptr = std::move(bmp); }
|
||||
+
|
||||
+ // pointers are only valid as long as *this is alive
|
||||
+ const mtmd_helper_gen_audio_inp * get() {
|
||||
+ data.prompt = prompt_str.c_str();
|
||||
+ data.prompt_len = prompt_str.size();
|
||||
+ data.lang = lang_str.empty() ? nullptr : lang_str.c_str();
|
||||
+ data.speaker_ref = speaker_ref_ptr.get();
|
||||
+ return &data;
|
||||
+ }
|
||||
+ };
|
||||
+
|
||||
gen_audio_ptr ctx;
|
||||
- gen_audio(struct llama_context * lctx, struct mtmd_context * mctx) : ctx(mtmd_helper_gen_audio_init(lctx, mctx)) {}
|
||||
+ void init(struct llama_context * lctx, struct mtmd_context * mctx) {
|
||||
+ ctx.reset(mtmd_helper_gen_audio_init(lctx, mctx));
|
||||
+ }
|
||||
+ bool valid() const {
|
||||
+ return ctx.get() != nullptr;
|
||||
+ }
|
||||
void reset() {
|
||||
mtmd_helper_gen_audio_reset(ctx.get());
|
||||
}
|
||||
@@ -271,6 +313,9 @@ struct gen_audio {
|
||||
int32_t get_output(int32_t * out_sample_rate, const char ** out_data, size_t * out_data_len, int64_t * out_n_samples = nullptr) {
|
||||
return mtmd_helper_gen_audio_get_output(ctx.get(), out_sample_rate, out_data, out_data_len, out_n_samples);
|
||||
}
|
||||
+ int32_t flush() {
|
||||
+ return mtmd_helper_gen_audio_flush(ctx.get());
|
||||
+ }
|
||||
};
|
||||
|
||||
} // namespace mtmd_helper
|
||||
diff --git a/tools/server/server-context.cpp b/tools/server/server-context.cpp
|
||||
index 9069463fe..b7fa1e534 100644
|
||||
--- a/tools/server/server-context.cpp
|
||||
+++ b/tools/server/server-context.cpp
|
||||
@@ -16,6 +16,7 @@
|
||||
#include "speculative.h"
|
||||
#include "mtmd.h"
|
||||
#include "mtmd-helper.h"
|
||||
+#include "base64.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
@@ -41,8 +42,9 @@ constexpr int HTTP_POLLING_SECONDS = 1;
|
||||
|
||||
static common_speculative_output_limits server_output_limits(const common_params & params) {
|
||||
if (params.embedding ||
|
||||
- (params.pooling_type != LLAMA_POOLING_TYPE_UNSPECIFIED && params.pooling_type != LLAMA_POOLING_TYPE_NONE)) {
|
||||
- return { params.n_batch, 1 };
|
||||
+ (params.pooling_type != LLAMA_POOLING_TYPE_UNSPECIFIED && params.pooling_type != LLAMA_POOLING_TYPE_NONE) ||
|
||||
+ !params.mmproj.path.empty()) { // gen-audio (TTS) capability isn't known until the mmproj loads, size generously
|
||||
+ return { params.n_batch, params.n_batch };
|
||||
}
|
||||
|
||||
auto result = common_speculative_get_output_limits(
|
||||
@@ -212,6 +214,30 @@ struct server_slot {
|
||||
mtmd_context * mctx = nullptr;
|
||||
mtmd::batch_ptr mbatch = nullptr;
|
||||
|
||||
+ struct tts_ctx {
|
||||
+ mtmd_helper::gen_audio ctx;
|
||||
+ const float * h_state;
|
||||
+ llama_token sampled;
|
||||
+ int32_t n_decoded;
|
||||
+ bool is_supported() const {
|
||||
+ return ctx.valid();
|
||||
+ }
|
||||
+ void reset() {
|
||||
+ // mtmd_helper_gen_audio_reset() dereferences its argument before it
|
||||
+ // null-checks the pipeline, and the pipeline is only allocated for
|
||||
+ // models that actually carry a gen-audio mmproj. server_slot::reset()
|
||||
+ // runs for every slot of every model, so without this guard any
|
||||
+ // non-TTS model segfaults during slot initialization.
|
||||
+ if (is_supported()) {
|
||||
+ ctx.reset();
|
||||
+ }
|
||||
+ h_state = nullptr;
|
||||
+ sampled = LLAMA_TOKEN_NULL;
|
||||
+ n_decoded = 0;
|
||||
+ }
|
||||
+ };
|
||||
+ tts_ctx tts;
|
||||
+
|
||||
// speculative decoding
|
||||
common_speculative * spec;
|
||||
|
||||
@@ -391,6 +417,8 @@ struct server_slot {
|
||||
|
||||
// clear multimodal state
|
||||
mbatch.reset();
|
||||
+
|
||||
+ tts.reset();
|
||||
}
|
||||
|
||||
void init_sampler() const {
|
||||
@@ -829,6 +857,14 @@ public:
|
||||
mtmd_context * mctx = nullptr;
|
||||
const llama_vocab * vocab = nullptr;
|
||||
|
||||
+ bool has_cap_tts() const {
|
||||
+ return mctx != nullptr && mtmd_gen_audio_get_info(mctx).type != MTMD_GEN_AUDIO_TYPE_NONE;
|
||||
+ }
|
||||
+
|
||||
+ bool has_cap_chat() const {
|
||||
+ return mctx == nullptr || mtmd_helper_model_can_chat(ctx_tgt, mctx);
|
||||
+ }
|
||||
+
|
||||
server_queue queue_tasks;
|
||||
server_response queue_results;
|
||||
|
||||
@@ -1288,6 +1324,10 @@ private:
|
||||
slot.mctx = mctx;
|
||||
slot.prompt.tokens.has_mtmd = mctx != nullptr;
|
||||
|
||||
+ if (has_cap_tts()) {
|
||||
+ slot.tts.ctx.init(ctx_tgt, mctx);
|
||||
+ }
|
||||
+
|
||||
SLT_TRC(slot, "new slot, n_ctx = %d\n", slot.n_ctx);
|
||||
|
||||
slot.callback_on_release = [this](int id_slot) {
|
||||
@@ -1748,6 +1788,28 @@ private:
|
||||
|
||||
SLT_DBG(slot, "launching slot : %s\n", safe_json_to_str(slot.to_json()).c_str());
|
||||
|
||||
+ if (task.type == SERVER_TASK_TYPE_TTS) {
|
||||
+ GGML_ASSERT(has_cap_tts()); // should already checked in route handler
|
||||
+ if (!slot.tts.is_supported()) {
|
||||
+ slot.tts.ctx.init(ctx_tgt, slot.mctx);
|
||||
+ }
|
||||
+
|
||||
+ // TTS slots never enter the shared batch: pre_decode() returns early for
|
||||
+ // them and process_tts_slots() drives them instead, so they skip the
|
||||
+ // prompt-cache bookkeeping that clears this sequence between requests.
|
||||
+ // The gen-audio pipeline always decodes from position 0, and its own
|
||||
+ // reset() only clears host-side buffers, so without this the second and
|
||||
+ // later tasks on a slot decode over the previous request's tokens and
|
||||
+ // step_prompt() fails immediately.
|
||||
+ slot.prompt_clear();
|
||||
+
|
||||
+ task.tts_inp.data.seq_id = slot.id;
|
||||
+ if (slot.tts.ctx.set_input(task.tts_inp.get()) != 0) {
|
||||
+ send_error(task, "failed to process TTS prompt", ERROR_TYPE_SERVER);
|
||||
+ return false;
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
// initialize samplers
|
||||
if (task.need_sampling()) {
|
||||
try {
|
||||
@@ -1765,6 +1827,9 @@ private:
|
||||
// TODO: getting pre sampling logits is not yet supported with backend sampling
|
||||
use_backend_sampling &= !need_pre_sample_logits;
|
||||
|
||||
+ // TODO: check verify if this actually works with TTS
|
||||
+ use_backend_sampling &= task.type != SERVER_TASK_TYPE_TTS;
|
||||
+
|
||||
// TODO: tmp until backend sampling is fully implemented
|
||||
if (use_backend_sampling) {
|
||||
llama_set_sampler(ctx_tgt, slot.id, common_sampler_get(slot.smpl.get()));
|
||||
@@ -1783,9 +1848,13 @@ private:
|
||||
|
||||
slot.task = std::make_unique<const server_task>(std::move(task));
|
||||
|
||||
- slot.state = slot.task->is_child()
|
||||
- ? SLOT_STATE_WAIT_OTHER // wait for the parent to process prompt
|
||||
- : SLOT_STATE_STARTED;
|
||||
+ if (slot.task->type == SERVER_TASK_TYPE_TTS) {
|
||||
+ slot.state = SLOT_STATE_PROCESSING_PROMPT;
|
||||
+ } else {
|
||||
+ slot.state = slot.task->is_child()
|
||||
+ ? SLOT_STATE_WAIT_OTHER // wait for the parent to process prompt
|
||||
+ : SLOT_STATE_STARTED;
|
||||
+ }
|
||||
|
||||
// reset server kill-switch counter
|
||||
n_empty_consecutive = 0;
|
||||
@@ -2050,6 +2119,18 @@ private:
|
||||
queue_results.send(std::move(res));
|
||||
}
|
||||
|
||||
+ void send_tts_result(server_slot & slot, int32_t sample_rate, const char * data, size_t data_len, bool final) {
|
||||
+ auto res = std::make_unique<server_task_result_tts>();
|
||||
+
|
||||
+ res->id = slot.task->id;
|
||||
+ res->index = slot.task->index;
|
||||
+ res->sample_rate = sample_rate;
|
||||
+ res->audio.assign(data, data_len);
|
||||
+ res->final = final;
|
||||
+
|
||||
+ queue_results.send(std::move(res));
|
||||
+ }
|
||||
+
|
||||
void send_final_response(server_slot & slot) {
|
||||
auto res = std::make_unique<server_task_result_cmpl_final>();
|
||||
|
||||
@@ -2556,6 +2637,7 @@ private:
|
||||
case SERVER_TASK_TYPE_EMBEDDING:
|
||||
case SERVER_TASK_TYPE_RERANK:
|
||||
case SERVER_TASK_TYPE_SCORE:
|
||||
+ case SERVER_TASK_TYPE_TTS:
|
||||
{
|
||||
// special case: if input is provided via CLI, tokenize it first
|
||||
// otherwise, no need to tokenize as it's already done inside the HTTP thread
|
||||
@@ -3007,1 +3089,9 @@ private:
|
||||
+ // note: TTS slots bypass the shared batch entirely
|
||||
+ try {
|
||||
+ process_tts_slots();
|
||||
+ } catch (const std::exception & e) {
|
||||
+ SRV_ERR("process_tts_slots() failed: %s\n", e.what());
|
||||
+ abort_all_slots("process_tts_slots() failed: " + std::string(e.what()));
|
||||
+ }
|
||||
+
|
||||
GGML_ASSERT(batch.slot_batched || batch.size() == 0);
|
||||
@@ -3074,10 +3164,77 @@ private:
|
||||
}
|
||||
}
|
||||
|
||||
+ void process_tts_slots() {
|
||||
+ iterate(slots, [&](server_slot & slot) {
|
||||
+ if (!slot.is_processing() || slot.task->type != SERVER_TASK_TYPE_TTS) {
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ llama_set_embeddings(ctx_tgt, true);
|
||||
+
|
||||
+ if (slot.state == SLOT_STATE_PROCESSING_PROMPT) {
|
||||
+ const int32_t ret = slot.tts.ctx.step_prompt(llama_n_batch(ctx_tgt));
|
||||
+ if (ret < 0) {
|
||||
+ send_error(slot, "TTS prompt processing failed", ERROR_TYPE_SERVER);
|
||||
+ slot.release();
|
||||
+ } else if (ret == 0) {
|
||||
+ slot.tts.sampled = common_sampler_sample(slot.smpl.get(), ctx_tgt, -1);
|
||||
+ common_sampler_accept(slot.smpl.get(), slot.tts.sampled, true);
|
||||
+ slot.tts.h_state = llama_get_embeddings_ith(ctx_tgt, -1);
|
||||
+ slot.state = SLOT_STATE_GENERATING;
|
||||
+ }
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ const int32_t n_predict = slot.task->params.n_predict > 0 ? slot.task->params.n_predict : 512;
|
||||
+ if (slot.tts.n_decoded >= n_predict || llama_vocab_is_eog(vocab, slot.tts.sampled)) {
|
||||
+ int32_t sample_rate = 0;
|
||||
+ const char * data = nullptr;
|
||||
+ size_t data_len = 0;
|
||||
+ // generation truly ends here: force out any sub-window remainder still buffered
|
||||
+ if (slot.tts.ctx.flush() != 0 || slot.tts.ctx.get_output(&sample_rate, &data, &data_len) != 0) {
|
||||
+ send_error(slot, "failed to finalize TTS output", ERROR_TYPE_SERVER);
|
||||
+ } else {
|
||||
+ send_tts_result(slot, sample_rate, data, data_len, true);
|
||||
+ }
|
||||
+ slot.release();
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ const float * h_state_next = nullptr;
|
||||
+ if (slot.tts.ctx.step_gen(slot.tts.sampled, slot.tts.h_state, &h_state_next) != 0) {
|
||||
+ send_error(slot, "TTS generation failed", ERROR_TYPE_SERVER);
|
||||
+ slot.release();
|
||||
+ return;
|
||||
+ }
|
||||
+ slot.tts.h_state = h_state_next;
|
||||
+ slot.tts.n_decoded++;
|
||||
+
|
||||
+ slot.tts.sampled = common_sampler_sample(slot.smpl.get(), ctx_tgt, -1);
|
||||
+ common_sampler_accept(slot.smpl.get(), slot.tts.sampled, true);
|
||||
+
|
||||
+ if (slot.task->params.stream) {
|
||||
+ int32_t sample_rate = 0;
|
||||
+ const char * data = nullptr;
|
||||
+ size_t data_len = 0;
|
||||
+ if (slot.tts.ctx.get_output(&sample_rate, &data, &data_len) != 0) {
|
||||
+ send_error(slot, "TTS streaming output failed", ERROR_TYPE_SERVER);
|
||||
+ slot.release();
|
||||
+ } else if (data_len > 0) {
|
||||
+ send_tts_result(slot, sample_rate, data, data_len, false);
|
||||
+ }
|
||||
+ }
|
||||
+ });
|
||||
+ }
|
||||
+
|
||||
void pre_decode() {
|
||||
// apply context-shift if needed
|
||||
// TODO: simplify and improve
|
||||
iterate(slots, [&](server_slot & slot) {
|
||||
+ if (slot.task && slot.task->type == SERVER_TASK_TYPE_TTS) {
|
||||
+ // TTS slots drive their own decode loop in process_tts_slots(), never enter the shared batch
|
||||
+ return;
|
||||
+ }
|
||||
if (slot.state == SLOT_STATE_GENERATING && slot.prompt.n_tokens() + 1 >= slot.n_ctx) {
|
||||
if (!params_base.ctx_shift) {
|
||||
// this check is redundant (for good)
|
||||
@@ -3150,7 +3307,7 @@ private:
|
||||
|
||||
// determine which slots are generating and drafting
|
||||
iterate(slots, [&](server_slot & slot) {
|
||||
- if (slot.state != SLOT_STATE_GENERATING) {
|
||||
+ if (slot.state != SLOT_STATE_GENERATING || slot.task->type == SERVER_TASK_TYPE_TTS) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3284,7 +3441,7 @@ private:
|
||||
return; // batch is full, skip remaining slots
|
||||
}
|
||||
|
||||
- if (!slot.is_processing()) {
|
||||
+ if (!slot.is_processing() || slot.task->type == SERVER_TASK_TYPE_TTS) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4433,6 +4590,8 @@ server_context_meta server_context::get_meta() const {
|
||||
/* has_inp_image */ impl->chat_params.allow_image,
|
||||
/* has_inp_audio */ impl->chat_params.allow_audio,
|
||||
/* has_inp_video */ impl->chat_params.allow_video,
|
||||
+ /* has_cap_chat */ impl->has_cap_chat(),
|
||||
+ /* has_cap_tts */ impl->has_cap_tts(),
|
||||
/* json_ui_settings */ impl->json_ui_settings,
|
||||
/* slot_n_ctx */ impl->get_slot_n_ctx(),
|
||||
/* pooling_type */ llama_pooling_type(impl->ctx_tgt),
|
||||
@@ -4512,6 +4671,11 @@ std::unique_ptr<server_res_generator> server_routes::handle_completions_impl(
|
||||
|
||||
res->set_req(&req); // will also set spipe if needed
|
||||
|
||||
+ if (!ctx_server.has_cap_chat()) {
|
||||
+ res->error(format_error_response("this server does not support chat/completions", ERROR_TYPE_NOT_SUPPORTED));
|
||||
+ return res;
|
||||
+ }
|
||||
+
|
||||
int32_t sse_ping_interval = params.sse_ping_interval;
|
||||
|
||||
try {
|
||||
@@ -5399,6 +5563,150 @@ void server_routes::init_routes() {
|
||||
return res;
|
||||
};
|
||||
|
||||
+ this->post_tts = [this](const server_http_req & req) {
|
||||
+ auto res = create_response();
|
||||
+ res->set_req(&req); // will also set spipe if needed
|
||||
+
|
||||
+ if (!ctx_server.has_cap_tts()) {
|
||||
+ res->error(format_error_response("this server does not support audio generation", ERROR_TYPE_NOT_SUPPORTED));
|
||||
+ return res;
|
||||
+ }
|
||||
+
|
||||
+ const json body = json::parse(req.body);
|
||||
+
|
||||
+ std::string prompt = json_value(body, "input", json_value(body, "prompt", std::string()));
|
||||
+ if (prompt.empty()) {
|
||||
+ res->error(format_error_response("\"input\" must be a non-empty string", ERROR_TYPE_INVALID_REQUEST));
|
||||
+ return res;
|
||||
+ }
|
||||
+
|
||||
+ const std::string response_format = json_value(body, "response_format", std::string("wav"));
|
||||
+ const bool stream = json_value(body, "stream", false);
|
||||
+
|
||||
+ server_task task(SERVER_TASK_TYPE_TTS);
|
||||
+ task.tts_inp.set_prompt(prompt);
|
||||
+ task.tts_inp.set_lang(json_value(body, "lang", std::string()));
|
||||
+ task.tts_inp.data.top_k = json_value(body, "top_k", 0);
|
||||
+ task.tts_inp.data.top_p = json_value(body, "top_p", 0.0f);
|
||||
+ task.tts_inp.data.stream = stream;
|
||||
+ task.tts_inp.data.out_type = response_format == "pcm"
|
||||
+ ? MTMD_HELPER_GEN_AUDIO_OUTTYPE_PCM
|
||||
+ : MTMD_HELPER_GEN_AUDIO_OUTTYPE_WAV;
|
||||
+ task.params.stream = stream;
|
||||
+ task.params.n_predict = json_value(body, "n_predict", -1);
|
||||
+ task.params.sampling = params.sampling; // baseline defaults, then apply overrides below
|
||||
+ task.params.sampling.penalty_repeat = json_value(body, "repeat_penalty", 1.05f);
|
||||
+ task.params.sampling.penalty_last_n = -1;
|
||||
+ if (task.tts_inp.data.top_k > 0) {
|
||||
+ task.params.sampling.top_k = task.tts_inp.data.top_k;
|
||||
+ }
|
||||
+ if (task.tts_inp.data.top_p > 0) {
|
||||
+ task.params.sampling.top_p = task.tts_inp.data.top_p;
|
||||
+ }
|
||||
+
|
||||
+ // speaker reference: either an uploaded form file ("speaker_ref") or a base64 JSON field ("speaker_ref_b64")
|
||||
+ const unsigned char * speaker_ref_data = nullptr;
|
||||
+ size_t speaker_ref_len = 0;
|
||||
+ std::string speaker_ref_b64_decoded;
|
||||
+
|
||||
+ auto speaker_ref_file = req.files.find("speaker_ref");
|
||||
+ if (speaker_ref_file != req.files.end()) {
|
||||
+ speaker_ref_data = speaker_ref_file->second.data.data();
|
||||
+ speaker_ref_len = speaker_ref_file->second.data.size();
|
||||
+ } else {
|
||||
+ std::string speaker_ref_b64 = json_value(body, "speaker_ref_b64", std::string());
|
||||
+ if (!speaker_ref_b64.empty()) {
|
||||
+ speaker_ref_b64_decoded = base64::decode(speaker_ref_b64);
|
||||
+ speaker_ref_data = (const unsigned char *) speaker_ref_b64_decoded.data();
|
||||
+ speaker_ref_len = speaker_ref_b64_decoded.size();
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ if (speaker_ref_len > 0) {
|
||||
+ auto wrapper = mtmd_helper_bitmap_init_from_buf(ctx_server.mctx, speaker_ref_data, speaker_ref_len, false, ctx_server.init_opt);
|
||||
+ if (!wrapper.bitmap) {
|
||||
+ res->error(format_error_response("failed to decode \"speaker_ref\"", ERROR_TYPE_INVALID_REQUEST));
|
||||
+ return res;
|
||||
+ }
|
||||
+ task.tts_inp.set_speaker_ref(mtmd::bitmap_ptr(wrapper.bitmap));
|
||||
+ } else {
|
||||
+ // SRV_WRN expands __VA_ARGS__ without the GNU comma-elision extension,
|
||||
+ // so a bare format string leaves a trailing comma and will not compile
|
||||
+ SRV_WRN("%s", "no speaker reference provided, the model may behave randomly\n");
|
||||
+ }
|
||||
+
|
||||
+ auto & rd = res->rd;
|
||||
+ task.id = rd.get_new_id();
|
||||
+ rd.post_task(std::move(task));
|
||||
+
|
||||
+ const std::string content_type = response_format == "pcm" ? "audio/L16" : "audio/wav";
|
||||
+
|
||||
+ if (!stream) {
|
||||
+ auto result = rd.next(req.should_stop);
|
||||
+ if (!result) {
|
||||
+ GGML_ASSERT(req.should_stop());
|
||||
+ return res; // connection is closed
|
||||
+ }
|
||||
+ if (result->is_error()) {
|
||||
+ res->error(result->to_json());
|
||||
+ return res;
|
||||
+ }
|
||||
+ auto * tts_res = dynamic_cast<server_task_result_tts *>(result.get());
|
||||
+ GGML_ASSERT(tts_res != nullptr);
|
||||
+ res->status = 200;
|
||||
+ res->content_type = content_type;
|
||||
+ res->data = std::move(tts_res->audio);
|
||||
+ return res;
|
||||
+ } else {
|
||||
+ auto first_result = rd.next(req.should_stop);
|
||||
+ if (!first_result) {
|
||||
+ GGML_ASSERT(req.should_stop());
|
||||
+ return res; // connection is closed
|
||||
+ }
|
||||
+ if (first_result->is_error()) {
|
||||
+ res->error(first_result->to_json());
|
||||
+ return res;
|
||||
+ }
|
||||
+ auto * first_tts_res = dynamic_cast<server_task_result_tts *>(first_result.get());
|
||||
+ GGML_ASSERT(first_tts_res != nullptr);
|
||||
+
|
||||
+ res->status = 200;
|
||||
+ res->content_type = content_type;
|
||||
+ res->data = std::move(first_tts_res->audio);
|
||||
+ bool is_done = first_tts_res->final;
|
||||
+
|
||||
+ res->set_next([res_this = res.get(), is_done](std::string & output) mutable -> bool {
|
||||
+ if (is_done) {
|
||||
+ return false;
|
||||
+ }
|
||||
+ if (res_this->should_stop()) {
|
||||
+ return false;
|
||||
+ }
|
||||
+ if (!res_this->data.empty()) {
|
||||
+ output = std::move(res_this->data);
|
||||
+ res_this->data.clear();
|
||||
+ return true;
|
||||
+ }
|
||||
+
|
||||
+ server_response_reader & rd = res_this->rd;
|
||||
+ if (!rd.has_next()) {
|
||||
+ return false;
|
||||
+ }
|
||||
+ auto result = rd.next([&res_this]() { return res_this->should_stop(); });
|
||||
+ if (!result || result->is_error()) {
|
||||
+ return false;
|
||||
+ }
|
||||
+ auto * tts_res = dynamic_cast<server_task_result_tts *>(result.get());
|
||||
+ GGML_ASSERT(tts_res != nullptr);
|
||||
+ output = std::move(tts_res->audio);
|
||||
+ is_done = tts_res->final;
|
||||
+ return true;
|
||||
+ });
|
||||
+ }
|
||||
+
|
||||
+ return res;
|
||||
+ };
|
||||
+
|
||||
this->get_lora_adapters = [this](const server_http_req & req) {
|
||||
auto res = create_response();
|
||||
|
||||
diff --git a/tools/server/server-context.h b/tools/server/server-context.h
|
||||
index f9ab1132b..610512678 100644
|
||||
--- a/tools/server/server-context.h
|
||||
+++ b/tools/server/server-context.h
|
||||
@@ -22,6 +22,8 @@ struct server_context_meta {
|
||||
bool has_inp_image;
|
||||
bool has_inp_audio;
|
||||
bool has_inp_video;
|
||||
+ bool has_cap_chat;
|
||||
+ bool has_cap_tts;
|
||||
json json_ui_settings;
|
||||
int slot_n_ctx;
|
||||
enum llama_pooling_type pooling_type;
|
||||
@@ -151,6 +153,7 @@ struct server_routes {
|
||||
server_http_context::handler_t post_embeddings;
|
||||
server_http_context::handler_t post_embeddings_oai;
|
||||
server_http_context::handler_t post_rerank;
|
||||
+ server_http_context::handler_t post_tts;
|
||||
server_http_context::handler_t get_lora_adapters;
|
||||
server_http_context::handler_t post_lora_adapters;
|
||||
|
||||
diff --git a/tools/server/server-task.cpp b/tools/server/server-task.cpp
|
||||
index 1ee677553..939630b8b 100644
|
||||
--- a/tools/server/server-task.cpp
|
||||
+++ b/tools/server/server-task.cpp
|
||||
@@ -1497,6 +1497,17 @@ json server_task_result_rerank::to_json() {
|
||||
};
|
||||
}
|
||||
|
||||
+//
|
||||
+// server_task_result_tts
|
||||
+//
|
||||
+json server_task_result_tts::to_json() {
|
||||
+ return json {
|
||||
+ {"sample_rate", sample_rate},
|
||||
+ {"n_bytes", audio.size()},
|
||||
+ {"final", final},
|
||||
+ };
|
||||
+}
|
||||
+
|
||||
//
|
||||
// server_task_result_error
|
||||
//
|
||||
diff --git a/tools/server/server-task.h b/tools/server/server-task.h
|
||||
index 5bedf1987..e6ca67a65 100644
|
||||
--- a/tools/server/server-task.h
|
||||
+++ b/tools/server/server-task.h
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
// TODO: prevent including the whole server-common.h as we only use server_tokens
|
||||
#include "server-common.h"
|
||||
+#include "mtmd-helper.h"
|
||||
|
||||
using json = nlohmann::ordered_json;
|
||||
|
||||
@@ -42,6 +43,7 @@ enum server_task_type {
|
||||
SERVER_TASK_TYPE_SLOT_ERASE,
|
||||
SERVER_TASK_TYPE_GET_LORA,
|
||||
SERVER_TASK_TYPE_SET_LORA,
|
||||
+ SERVER_TASK_TYPE_TTS,
|
||||
};
|
||||
|
||||
// TODO: change this to more generic "response_format" to replace the "format_response_*" in server-common
|
||||
@@ -202,6 +204,9 @@ struct server_task {
|
||||
// used by SERVER_TASK_TYPE_SET_LORA
|
||||
std::map<int, float> set_lora; // mapping adapter ID -> scale
|
||||
|
||||
+ // used by SERVER_TASK_TYPE_TTS
|
||||
+ mtmd_helper::gen_audio::inp tts_inp;
|
||||
+
|
||||
server_task() = default;
|
||||
|
||||
server_task(server_task_type type) : type(type) {}
|
||||
@@ -235,6 +240,7 @@ struct server_task {
|
||||
switch (type) {
|
||||
case SERVER_TASK_TYPE_COMPLETION:
|
||||
case SERVER_TASK_TYPE_INFILL:
|
||||
+ case SERVER_TASK_TYPE_TTS:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
@@ -494,5 +500,15 @@ struct server_task_result_embd : server_task_result {
|
||||
json to_json_oaicompat();
|
||||
};
|
||||
|
||||
+struct server_task_result_tts : server_task_result {
|
||||
+ std::string audio; // raw bytes for this chunk (WAV or PCM, per request's out_type)
|
||||
+ int32_t sample_rate = 0;
|
||||
+ bool final = false; // true for the last chunk of a request
|
||||
+
|
||||
+ virtual bool is_stop() override { return final; }
|
||||
+
|
||||
+ virtual json to_json() override;
|
||||
+};
|
||||
+
|
||||
struct server_task_result_rerank : server_task_result {
|
||||
float score = -1e6;
|
||||
@@ -15,30 +15,6 @@ if [ -d "patches" ]; then
|
||||
done
|
||||
fi
|
||||
|
||||
## Apple RDMA link fixup.
|
||||
|
||||
## ggml-rpc hands Apple's librdma to the linker with
|
||||
## target_link_options(ggml-rpc PRIVATE "LINKER:-weak_library,..."). Link options are not
|
||||
## a usage requirement of a static library, so in our BUILD_SHARED_LIBS=OFF build the flag
|
||||
## dies with libggml-rpc.a and every ibv_* symbol transport-apple.cpp reaches for comes out
|
||||
## undefined when grpc-server and ggml-rpc-server link. Re-declare the same weak link as
|
||||
## INTERFACE so it travels to whoever links the static library.
|
||||
##
|
||||
## Guarded on the marker so a second prepare.sh over the same checkout is a no-op, and on
|
||||
## GGML_RPC_RDMA_APPLE so forks that branched before the Apple RDMA transport (turboquant,
|
||||
## bonsai) are left alone.
|
||||
RPC_CMAKE=llama.cpp/ggml/src/ggml-rpc/CMakeLists.txt
|
||||
if [ -f "$RPC_CMAKE" ] && grep -q "GGML_RPC_RDMA_APPLE" "$RPC_CMAKE" && ! grep -q "LOCALAI_RDMA_IFACE" "$RPC_CMAKE"; then
|
||||
echo "==> ggml-rpc carries the Apple RDMA transport, re-declaring its weak librdma link as INTERFACE"
|
||||
cat >> "$RPC_CMAKE" <<'EOF'
|
||||
|
||||
# LOCALAI_RDMA_IFACE: added by backend/cpp/llama-cpp/prepare.sh
|
||||
if (GGML_RPC_RDMA AND APPLE AND NOT BUILD_SHARED_LIBS)
|
||||
target_link_options(ggml-rpc INTERFACE "LINKER:-weak_library,${RDMA_LIB}")
|
||||
endif()
|
||||
EOF
|
||||
fi
|
||||
|
||||
for file in $(ls llama.cpp/tools/server/); do
|
||||
cp -rfv llama.cpp/tools/server/$file llama.cpp/tools/grpc-server/
|
||||
done
|
||||
@@ -52,13 +28,6 @@ cp -r message_content_test.cpp llama.cpp/tools/grpc-server/
|
||||
# Generic passthrough parser staging and its standalone regression test.
|
||||
cp -r passthrough_options.h llama.cpp/tools/grpc-server/
|
||||
cp -r passthrough_options_test.cpp llama.cpp/tools/grpc-server/
|
||||
# TTS request validation (included by grpc-server.cpp) and its standalone
|
||||
# regression test.
|
||||
cp -r tts_request_options.h llama.cpp/tools/grpc-server/
|
||||
cp -r tts_request_options_test.cpp llama.cpp/tools/grpc-server/
|
||||
# Thread-count default normalization and its standalone regression test.
|
||||
cp -r thread_params.h llama.cpp/tools/grpc-server/
|
||||
cp -r thread_params_test.cpp llama.cpp/tools/grpc-server/
|
||||
# Parent-death watcher (included by grpc-server.cpp) and its standalone unit
|
||||
# test (run via backend/cpp/run-unit-tests.sh; also buildable under ctest).
|
||||
cp -r parent_watch.h llama.cpp/tools/grpc-server/
|
||||
@@ -80,28 +49,10 @@ else
|
||||
echo "==> llama.cpp predates the load-mode enum, using the legacy mmap/mlock/direct-io booleans"
|
||||
LEGACY_LOAD_MODE=1
|
||||
fi
|
||||
if grep -q "server_metrics metrics;" llama.cpp/tools/server/server-task.h; then
|
||||
HAS_SERVER_METRICS=1
|
||||
else
|
||||
HAS_SERVER_METRICS=0
|
||||
fi
|
||||
if grep -q "mtmd_helper_init_opt" llama.cpp/tools/mtmd/mtmd-helper.h; then
|
||||
HAS_MTMD_INIT_OPT=1
|
||||
else
|
||||
HAS_MTMD_INIT_OPT=0
|
||||
fi
|
||||
if grep -q "llm_add_n_cpu_ffn_overrides" llama.cpp/common/common.h; then
|
||||
HAS_N_CPU_FFN_HELPER=1
|
||||
else
|
||||
HAS_N_CPU_FFN_HELPER=0
|
||||
fi
|
||||
cat > llama.cpp/tools/grpc-server/llama_compat.h <<EOF
|
||||
// Generated by backend/cpp/llama-cpp/prepare.sh. Do not edit.
|
||||
#pragma once
|
||||
#define LOCALAI_LEGACY_LOAD_MODE ${LEGACY_LOAD_MODE}
|
||||
#define LOCALAI_HAS_SERVER_METRICS ${HAS_SERVER_METRICS}
|
||||
#define LOCALAI_HAS_MTMD_INIT_OPT ${HAS_MTMD_INIT_OPT}
|
||||
#define LOCALAI_HAS_N_CPU_FFN_HELPER ${HAS_N_CPU_FFN_HELPER}
|
||||
EOF
|
||||
|
||||
set +e
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace llama_grpc {
|
||||
|
||||
inline int32_t resolve_batch_threads(int32_t batch_threads, int32_t inference_threads) {
|
||||
return batch_threads < 0 ? inference_threads : batch_threads;
|
||||
}
|
||||
|
||||
} // namespace llama_grpc
|
||||
@@ -1,15 +0,0 @@
|
||||
#include "thread_params.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
int main() {
|
||||
if (llama_grpc::resolve_batch_threads(-1, 4) != 4) {
|
||||
std::fprintf(stderr, "default batch threads did not inherit inference threads\n");
|
||||
return 1;
|
||||
}
|
||||
if (llama_grpc::resolve_batch_threads(2, 4) != 2) {
|
||||
std::fprintf(stderr, "explicit batch threads were overwritten\n");
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <exception>
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
namespace llama_grpc {
|
||||
|
||||
// Validated, parsed form of a backend::TTSRequest, kept free of llama.cpp,
|
||||
// mtmd and gRPC headers so backend/cpp/run-unit-tests.sh can compile it as a
|
||||
// standalone translation unit. grpc-server.cpp turns this into a
|
||||
// mtmd_helper::gen_audio::inp.
|
||||
struct tts_request_options {
|
||||
bool ok = false;
|
||||
std::string error;
|
||||
|
||||
std::string text;
|
||||
std::string voice_path;
|
||||
std::string language;
|
||||
|
||||
// 0 / 0.0f mean "unset": upstream only overrides the sampler defaults when
|
||||
// the value is strictly positive.
|
||||
int32_t top_k = 0;
|
||||
float top_p = 0.0f;
|
||||
|
||||
// Upper bound on generated audio frames, exposed because the model does not
|
||||
// always emit its codec EOS and will otherwise run to the 512-frame default,
|
||||
// which is roughly 41 s at the 12.5 Hz frame rate. 0 means unset, leaving
|
||||
// that default in place.
|
||||
int32_t max_frames = 0;
|
||||
};
|
||||
|
||||
namespace detail {
|
||||
|
||||
// Strict whole-string numeric parsing. std::stoi/stof accept trailing garbage
|
||||
// ("40abc" -> 40), which would silently honour a typo'd request.
|
||||
inline bool parse_whole_int32(const std::string & value, int32_t & out) {
|
||||
if (value.empty()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
size_t consumed = 0;
|
||||
const long parsed = std::stol(value, &consumed);
|
||||
if (consumed != value.size()) {
|
||||
return false;
|
||||
}
|
||||
if (parsed < INT32_MIN || parsed > INT32_MAX) {
|
||||
return false;
|
||||
}
|
||||
out = static_cast<int32_t>(parsed);
|
||||
return true;
|
||||
} catch (const std::exception &) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
inline bool parse_whole_float(const std::string & value, float & out) {
|
||||
if (value.empty()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
size_t consumed = 0;
|
||||
const float parsed = std::stof(value, &consumed);
|
||||
if (consumed != value.size()) {
|
||||
return false;
|
||||
}
|
||||
out = parsed;
|
||||
return true;
|
||||
} catch (const std::exception &) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
inline tts_request_options reject(const std::string & message) {
|
||||
tts_request_options opts;
|
||||
opts.ok = false;
|
||||
opts.error = message;
|
||||
return opts;
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
|
||||
inline tts_request_options parse_tts_request_options(
|
||||
const std::string & text,
|
||||
const std::string & voice,
|
||||
const std::string & language,
|
||||
const std::map<std::string, std::string> & params) {
|
||||
if (text.empty()) {
|
||||
return detail::reject("text must be a non-empty string");
|
||||
}
|
||||
|
||||
// The Qwen3-TTS Base checkpoints have no built-in speaker. Without a
|
||||
// reference clip the model picks an arbitrary voice, so an unset voice is
|
||||
// a request error rather than a defaulted one.
|
||||
if (voice.empty()) {
|
||||
return detail::reject("voice must name a speaker reference audio file");
|
||||
}
|
||||
|
||||
tts_request_options opts;
|
||||
opts.text = text;
|
||||
opts.voice_path = voice;
|
||||
opts.language = language;
|
||||
|
||||
// Both values are range-checked here rather than left to the caller: the
|
||||
// consumer copies them straight into mtmd_helper::gen_audio::inp, and only
|
||||
// its separate sampler assignment is guarded by "> 0". An out-of-range or
|
||||
// non-finite value would slip past that guard and reach llama.cpp.
|
||||
const auto top_k_it = params.find("top_k");
|
||||
if (top_k_it != params.end()) {
|
||||
if (!detail::parse_whole_int32(top_k_it->second, opts.top_k)) {
|
||||
return detail::reject("top_k must be an integer, got \"" + top_k_it->second + "\"");
|
||||
}
|
||||
if (opts.top_k < 0) {
|
||||
return detail::reject("top_k must be >= 0, got \"" + top_k_it->second + "\"");
|
||||
}
|
||||
}
|
||||
|
||||
const auto top_p_it = params.find("top_p");
|
||||
if (top_p_it != params.end()) {
|
||||
if (!detail::parse_whole_float(top_p_it->second, opts.top_p)) {
|
||||
return detail::reject("top_p must be a number, got \"" + top_p_it->second + "\"");
|
||||
}
|
||||
// Phrased as a negated in-range test, not "p < 0.0f || p > 1.0f",
|
||||
// because every comparison against NaN is false: the obvious form
|
||||
// would accept NaN, and NaN then defeats the consumer's "> 0" guard
|
||||
// too, since that comparison is false as well.
|
||||
if (!(opts.top_p >= 0.0f && opts.top_p <= 1.0f)) {
|
||||
return detail::reject("top_p must be between 0.0 and 1.0, got \"" + top_p_it->second + "\"");
|
||||
}
|
||||
}
|
||||
|
||||
const auto max_frames_it = params.find("max_frames");
|
||||
if (max_frames_it != params.end()) {
|
||||
if (!detail::parse_whole_int32(max_frames_it->second, opts.max_frames)) {
|
||||
return detail::reject("max_frames must be an integer, got \"" + max_frames_it->second + "\"");
|
||||
}
|
||||
if (opts.max_frames < 0) {
|
||||
return detail::reject("max_frames must be >= 0, got \"" + max_frames_it->second + "\"");
|
||||
}
|
||||
}
|
||||
|
||||
opts.ok = true;
|
||||
return opts;
|
||||
}
|
||||
|
||||
} // namespace llama_grpc
|
||||
@@ -1,209 +0,0 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
#include <cstdio>
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
#include "tts_request_options.h"
|
||||
|
||||
static int failures = 0;
|
||||
|
||||
static void check(bool ok, const char * name) {
|
||||
if (!ok) {
|
||||
++failures;
|
||||
std::fprintf(stderr, "FAIL: %s\n", name);
|
||||
}
|
||||
}
|
||||
|
||||
static void test_accepts_a_minimal_valid_request() {
|
||||
const auto opts = llama_grpc::parse_tts_request_options(
|
||||
"Hello world", "/models/voices/ref.wav", "en", {});
|
||||
|
||||
check(opts.ok, "minimal request is accepted");
|
||||
check(opts.error.empty(), "minimal request has no error");
|
||||
check(opts.text == "Hello world", "text passes through");
|
||||
check(opts.voice_path == "/models/voices/ref.wav", "voice path passes through");
|
||||
check(opts.language == "en", "language passes through");
|
||||
check(opts.top_k == 0, "top_k defaults to the unset sentinel");
|
||||
check(opts.top_p == 0.0f, "top_p defaults to the unset sentinel");
|
||||
check(opts.max_frames == 0, "max_frames defaults to the unset sentinel");
|
||||
}
|
||||
|
||||
static void test_rejects_empty_text() {
|
||||
const auto opts = llama_grpc::parse_tts_request_options(
|
||||
"", "/models/voices/ref.wav", "en", {});
|
||||
|
||||
check(!opts.ok, "empty text is rejected");
|
||||
check(opts.error.find("text") != std::string::npos, "empty-text error names the field");
|
||||
}
|
||||
|
||||
static void test_rejects_missing_speaker_reference() {
|
||||
// Qwen3-TTS Base has no built-in speaker; without a reference it produces
|
||||
// an arbitrary voice, so this must be a hard error rather than a surprise.
|
||||
const auto opts = llama_grpc::parse_tts_request_options(
|
||||
"Hello world", "", "en", {});
|
||||
|
||||
check(!opts.ok, "missing voice is rejected");
|
||||
check(opts.error.find("voice") != std::string::npos, "missing-voice error names the field");
|
||||
}
|
||||
|
||||
static void test_parses_sampling_params() {
|
||||
const std::map<std::string, std::string> params{
|
||||
{"top_k", "40"},
|
||||
{"top_p", "0.85"},
|
||||
};
|
||||
const auto opts = llama_grpc::parse_tts_request_options(
|
||||
"Hello world", "/models/voices/ref.wav", "", params);
|
||||
|
||||
check(opts.ok, "sampling params are accepted");
|
||||
check(opts.top_k == 40, "top_k is parsed");
|
||||
check(opts.top_p > 0.849f && opts.top_p < 0.851f, "top_p is parsed");
|
||||
check(opts.language.empty(), "absent language stays empty");
|
||||
}
|
||||
|
||||
static void test_rejects_malformed_sampling_params() {
|
||||
const auto bad_top_k = llama_grpc::parse_tts_request_options(
|
||||
"Hello world", "/models/voices/ref.wav", "", {{"top_k", "forty"}});
|
||||
check(!bad_top_k.ok, "non-numeric top_k is rejected");
|
||||
check(bad_top_k.error.find("top_k") != std::string::npos, "top_k error names the field");
|
||||
|
||||
const auto bad_top_p = llama_grpc::parse_tts_request_options(
|
||||
"Hello world", "/models/voices/ref.wav", "", {{"top_p", ""}});
|
||||
check(!bad_top_p.ok, "empty top_p is rejected");
|
||||
|
||||
const auto trailing = llama_grpc::parse_tts_request_options(
|
||||
"Hello world", "/models/voices/ref.wav", "", {{"top_k", "40abc"}});
|
||||
check(!trailing.ok, "top_k with trailing garbage is rejected");
|
||||
|
||||
const auto trailing_float = llama_grpc::parse_tts_request_options(
|
||||
"Hello world", "/models/voices/ref.wav", "", {{"top_p", "0.8abc"}});
|
||||
check(!trailing_float.ok, "top_p with trailing garbage is rejected");
|
||||
|
||||
// std::stol returns a long, which is wider than int32_t on 64-bit hosts, so
|
||||
// an in-range-for-long value still has to be caught before the narrowing.
|
||||
const auto overflow_top_k = llama_grpc::parse_tts_request_options(
|
||||
"Hello world", "/models/voices/ref.wav", "", {{"top_k", "99999999999"}});
|
||||
check(!overflow_top_k.ok, "top_k beyond int32 range is rejected");
|
||||
check(overflow_top_k.error.find("top_k") != std::string::npos,
|
||||
"top_k overflow error names the field");
|
||||
}
|
||||
|
||||
static void test_rejects_out_of_range_sampling_params() {
|
||||
// These reach mtmd_helper::gen_audio::inp unconditionally downstream, where
|
||||
// the "> 0" sampler guard does not screen them, so they must die here.
|
||||
const auto negative_top_k = llama_grpc::parse_tts_request_options(
|
||||
"Hello world", "/models/voices/ref.wav", "", {{"top_k", "-5"}});
|
||||
check(!negative_top_k.ok, "negative top_k is rejected");
|
||||
check(negative_top_k.error.find("top_k") != std::string::npos,
|
||||
"negative top_k error names the field");
|
||||
|
||||
const auto negative_top_p = llama_grpc::parse_tts_request_options(
|
||||
"Hello world", "/models/voices/ref.wav", "", {{"top_p", "-0.1"}});
|
||||
check(!negative_top_p.ok, "negative top_p is rejected");
|
||||
check(negative_top_p.error.find("top_p") != std::string::npos,
|
||||
"negative top_p error names the field");
|
||||
|
||||
const auto large_top_p = llama_grpc::parse_tts_request_options(
|
||||
"Hello world", "/models/voices/ref.wav", "", {{"top_p", "1.5"}});
|
||||
check(!large_top_p.ok, "top_p above 1.0 is rejected");
|
||||
|
||||
// NaN survives a naive "p < 0.0f || p > 1.0f" range test because every
|
||||
// comparison against NaN is false. This case pins the correct form.
|
||||
const auto nan_top_p = llama_grpc::parse_tts_request_options(
|
||||
"Hello world", "/models/voices/ref.wav", "", {{"top_p", "nan"}});
|
||||
check(!nan_top_p.ok, "NaN top_p is rejected");
|
||||
|
||||
const auto inf_top_p = llama_grpc::parse_tts_request_options(
|
||||
"Hello world", "/models/voices/ref.wav", "", {{"top_p", "inf"}});
|
||||
check(!inf_top_p.ok, "infinite top_p is rejected");
|
||||
}
|
||||
|
||||
static void test_accepts_sampling_param_boundaries() {
|
||||
const auto zero_top_p = llama_grpc::parse_tts_request_options(
|
||||
"Hello world", "/models/voices/ref.wav", "", {{"top_p", "0.0"}});
|
||||
check(zero_top_p.ok, "top_p of 0.0 is accepted");
|
||||
check(zero_top_p.top_p == 0.0f, "top_p of 0.0 round-trips");
|
||||
|
||||
const auto one_top_p = llama_grpc::parse_tts_request_options(
|
||||
"Hello world", "/models/voices/ref.wav", "", {{"top_p", "1.0"}});
|
||||
check(one_top_p.ok, "top_p of 1.0 is accepted");
|
||||
check(one_top_p.top_p == 1.0f, "top_p of 1.0 round-trips");
|
||||
|
||||
const auto zero_top_k = llama_grpc::parse_tts_request_options(
|
||||
"Hello world", "/models/voices/ref.wav", "", {{"top_k", "0"}});
|
||||
check(zero_top_k.ok, "top_k of 0 is accepted");
|
||||
}
|
||||
|
||||
static void test_parses_max_frames() {
|
||||
// The consumer maps a positive value onto n_predict and leaves upstream's
|
||||
// 512-frame default in place when it is unset, so the sentinel matters as
|
||||
// much as the parsed value.
|
||||
const auto opts = llama_grpc::parse_tts_request_options(
|
||||
"Hello world", "/models/voices/ref.wav", "", {{"max_frames", "120"}});
|
||||
|
||||
check(opts.ok, "max_frames is accepted");
|
||||
check(opts.max_frames == 120, "max_frames is parsed");
|
||||
|
||||
const auto absent = llama_grpc::parse_tts_request_options(
|
||||
"Hello world", "/models/voices/ref.wav", "", {{"top_k", "40"}});
|
||||
check(absent.ok, "a request without max_frames is accepted");
|
||||
check(absent.max_frames == 0, "absent max_frames leaves the unset sentinel");
|
||||
|
||||
const auto zero = llama_grpc::parse_tts_request_options(
|
||||
"Hello world", "/models/voices/ref.wav", "", {{"max_frames", "0"}});
|
||||
check(zero.ok, "max_frames of 0 is accepted");
|
||||
check(zero.max_frames == 0, "max_frames of 0 means unset");
|
||||
}
|
||||
|
||||
static void test_rejects_malformed_max_frames() {
|
||||
const auto negative = llama_grpc::parse_tts_request_options(
|
||||
"Hello world", "/models/voices/ref.wav", "", {{"max_frames", "-1"}});
|
||||
check(!negative.ok, "negative max_frames is rejected");
|
||||
check(negative.error.find("max_frames") != std::string::npos,
|
||||
"negative max_frames error names the field");
|
||||
|
||||
const auto non_numeric = llama_grpc::parse_tts_request_options(
|
||||
"Hello world", "/models/voices/ref.wav", "", {{"max_frames", "many"}});
|
||||
check(!non_numeric.ok, "non-numeric max_frames is rejected");
|
||||
check(non_numeric.error.find("max_frames") != std::string::npos,
|
||||
"non-numeric max_frames error names the field");
|
||||
|
||||
const auto trailing = llama_grpc::parse_tts_request_options(
|
||||
"Hello world", "/models/voices/ref.wav", "", {{"max_frames", "120abc"}});
|
||||
check(!trailing.ok, "max_frames with trailing garbage is rejected");
|
||||
|
||||
const auto empty = llama_grpc::parse_tts_request_options(
|
||||
"Hello world", "/models/voices/ref.wav", "", {{"max_frames", ""}});
|
||||
check(!empty.ok, "empty max_frames is rejected");
|
||||
|
||||
const auto overflow = llama_grpc::parse_tts_request_options(
|
||||
"Hello world", "/models/voices/ref.wav", "", {{"max_frames", "99999999999"}});
|
||||
check(!overflow.ok, "max_frames beyond int32 range is rejected");
|
||||
}
|
||||
|
||||
static void test_ignores_unknown_params() {
|
||||
// Unknown keys are backend-specific knobs meant for other TTS engines. A
|
||||
// request routed here must not fail just because it carries them.
|
||||
const auto opts = llama_grpc::parse_tts_request_options(
|
||||
"Hello world", "/models/voices/ref.wav", "", {{"exaggeration", "0.7"}});
|
||||
|
||||
check(opts.ok, "unknown params are ignored, not rejected");
|
||||
}
|
||||
|
||||
int main() {
|
||||
test_accepts_a_minimal_valid_request();
|
||||
test_rejects_empty_text();
|
||||
test_rejects_missing_speaker_reference();
|
||||
test_parses_sampling_params();
|
||||
test_rejects_malformed_sampling_params();
|
||||
test_rejects_out_of_range_sampling_params();
|
||||
test_accepts_sampling_param_boundaries();
|
||||
test_parses_max_frames();
|
||||
test_rejects_malformed_max_frames();
|
||||
test_ignores_unknown_params();
|
||||
|
||||
if (failures == 0) {
|
||||
std::printf("tts_request_options_test: all checks passed\n");
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
@@ -48,7 +48,6 @@ define turboquant-build
|
||||
# stays compiling against vanilla upstream.
|
||||
bash $(CURRENT_MAKEFILE_DIR)/patch-grpc-server.sh $(CURRENT_MAKEFILE_DIR)/../turboquant-$(1)-build/grpc-server.cpp
|
||||
bash $(LLAMA_CPP_DIR)/disable-score-task.sh $(CURRENT_MAKEFILE_DIR)/../turboquant-$(1)-build/grpc-server.cpp
|
||||
bash $(LLAMA_CPP_DIR)/disable-tts-task.sh $(CURRENT_MAKEFILE_DIR)/../turboquant-$(1)-build/grpc-server.cpp
|
||||
$(info $(GREEN)I turboquant build info:$(1)$(RESET))
|
||||
LLAMA_REPO=$(LLAMA_REPO) LLAMA_VERSION=$(TURBOQUANT_VERSION) \
|
||||
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../turboquant-$(1)-build llama.cpp
|
||||
@@ -87,7 +86,6 @@ turboquant-cpu-all:
|
||||
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../turboquant-cpu-all-build purge
|
||||
bash $(CURRENT_MAKEFILE_DIR)/patch-grpc-server.sh $(CURRENT_MAKEFILE_DIR)/../turboquant-cpu-all-build/grpc-server.cpp
|
||||
bash $(LLAMA_CPP_DIR)/disable-score-task.sh $(CURRENT_MAKEFILE_DIR)/../turboquant-cpu-all-build/grpc-server.cpp
|
||||
bash $(LLAMA_CPP_DIR)/disable-tts-task.sh $(CURRENT_MAKEFILE_DIR)/../turboquant-cpu-all-build/grpc-server.cpp
|
||||
$(info $(GREEN)I turboquant build info:cpu-all-variants$(RESET))
|
||||
LLAMA_REPO=$(LLAMA_REPO) LLAMA_VERSION=$(TURBOQUANT_VERSION) \
|
||||
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../turboquant-cpu-all-build llama.cpp
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
# so the grpc-server option parser skips the two references to
|
||||
# common_params::checkpoint_min_step (the default and the option handler).
|
||||
# That field does not exist in the fork yet; drop this once it does.
|
||||
# 3. Use nlohmann's parse_error type in JSON catch clauses because the fork
|
||||
# predates upstream's common_json_error wrapper.
|
||||
#
|
||||
# The fork used to lag upstream on the whole common_params_speculative refactor
|
||||
# (ggml-org/llama.cpp#22397/#22838/#22964), the model_tgt rename (#22838) and
|
||||
@@ -102,16 +100,4 @@ else
|
||||
echo "==> LOCALAI_TURBOQUANT_NO_CHECKPOINT_MIN_STEP define OK"
|
||||
fi
|
||||
|
||||
# 3. The shared source follows current upstream and catches common_json_error.
|
||||
# TurboQuant still exposes nlohmann::json directly, so its equivalent parse
|
||||
# failures use json::parse_error instead.
|
||||
if grep -q 'common_json_error' "$SRC"; then
|
||||
echo "==> patching $SRC to use the TurboQuant JSON exception type"
|
||||
awk '{ gsub(/common_json_error/, "json::parse_error"); print }' "$SRC" > "$SRC.tmp"
|
||||
mv "$SRC.tmp" "$SRC"
|
||||
echo "==> TurboQuant JSON exception patch OK"
|
||||
else
|
||||
echo "==> $SRC already uses a TurboQuant-compatible JSON exception type, skipping"
|
||||
fi
|
||||
|
||||
echo "==> all patches applied"
|
||||
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
|
||||
|
||||
# CrispASR version (release tag)
|
||||
CRISPASR_REPO?=https://github.com/CrispStrobe/CrispASR
|
||||
CRISPASR_VERSION?=301acd87b036764973b8bfba71e0a21818036d33
|
||||
CRISPASR_VERSION?=17a6cc99422bfafadf7161e96dd7294c89da9c36
|
||||
SO_TARGET?=libgocrispasr.so
|
||||
|
||||
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
|
||||
|
||||
@@ -615,10 +615,10 @@ func (w *CrispASR) TTSStream(req *pb.TTSRequest, results chan []byte) error {
|
||||
return fmt.Errorf("crispasr: tempfile: %w", err)
|
||||
}
|
||||
dst := tmp.Name()
|
||||
defer func() { _ = os.Remove(dst) }()
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("crispasr: close tempfile: %w", err)
|
||||
}
|
||||
defer func() { _ = os.Remove(dst) }()
|
||||
|
||||
if err := writeWAV(dst, pcm, w.sampleRate); err != nil {
|
||||
return err
|
||||
|
||||
@@ -14,7 +14,7 @@ JOBS?=$(shell nproc --ignore=1)
|
||||
# It is kept alive by the upstream tag da2-support (survives a squash-merge);
|
||||
# repoint to the master merge commit once mudler/depth-anything.cpp PR #1 lands.
|
||||
DEPTHANYTHING_REPO?=https://github.com/mudler/depth-anything.cpp.git
|
||||
DEPTHANYTHING_VERSION?=14f7461d1f704761a038ac9f50dbde8fdb7275e2
|
||||
DEPTHANYTHING_VERSION?=2028b47ac75a8659c6a9aa617baf09be193eb55f
|
||||
|
||||
ifeq ($(NATIVE),false)
|
||||
CMAKE_ARGS+=-DGGML_NATIVE=OFF
|
||||
|
||||
@@ -38,9 +38,8 @@ type Store struct {
|
||||
// keysAreNormalized stays true until any non-unit-magnitude key
|
||||
// is added; once false, the magnitude-aware fallback path is
|
||||
// used by Find. Re-evaluated only at Set time, never again on
|
||||
// its own — a partial deletion of the offending key does NOT flip
|
||||
// it back to true (the bookkeeping cost would dominate the gain).
|
||||
// An empty store returns to its initial state.
|
||||
// its own — a deletion of the offending key does NOT flip it
|
||||
// back to true (the bookkeeping cost would dominate the gain).
|
||||
keysAreNormalized bool
|
||||
|
||||
// keyLen is the dimension of every stored key. -1 means "no
|
||||
@@ -143,10 +142,6 @@ func (s *Store) StoresDelete(opts *pb.StoresDeleteOptions) error {
|
||||
mergedV = append(mergedV, tailV...)
|
||||
s.keys = mergedK
|
||||
s.values = mergedV
|
||||
if len(s.keys) == 0 {
|
||||
s.keyLen = -1
|
||||
s.keysAreNormalized = true
|
||||
}
|
||||
assert(slices.IsSortedFunc(s.keys, slices.Compare[[]float32]), "Delete: s.keys not sorted post-merge")
|
||||
assert(len(s.keys) == len(s.values), "Delete: keys/values length skew")
|
||||
return nil
|
||||
|
||||
@@ -105,46 +105,6 @@ var _ = Describe("StoresDelete", func() {
|
||||
})).To(Succeed(), "delete of missing key should succeed")
|
||||
Expect(s.keys).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("reopens the dimension after deleting every key", func() {
|
||||
s := NewStore()
|
||||
oldKey := []float32{2, 0, 0}
|
||||
mustSet(s, [][]float32{oldKey}, [][]byte{[]byte("3d")})
|
||||
Expect(s.keysAreNormalized).To(BeFalse())
|
||||
|
||||
Expect(s.StoresDelete(&pb.StoresDeleteOptions{
|
||||
Keys: wrapKeys([][]float32{oldKey}),
|
||||
})).To(Succeed())
|
||||
Expect(s.keys).To(BeEmpty())
|
||||
Expect(s.keyLen).To(Equal(-1))
|
||||
Expect(s.keysAreNormalized).To(BeTrue())
|
||||
|
||||
newKey := normalizeVec([]float32{1, 1})
|
||||
mustSet(s, [][]float32{newKey}, [][]byte{[]byte("2d")})
|
||||
res, err := s.StoresFind(&pb.StoresFindOptions{
|
||||
Key: &pb.StoresKey{Floats: newKey},
|
||||
TopK: 1,
|
||||
})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res.Values).To(HaveLen(1))
|
||||
Expect(string(res.Values[0].Bytes)).To(Equal("2d"))
|
||||
})
|
||||
|
||||
It("retains the dimension after a partial delete", func() {
|
||||
s := NewStore()
|
||||
mustSet(s,
|
||||
[][]float32{{1, 0, 0}, {0, 1, 0}},
|
||||
[][]byte{[]byte("x"), []byte("y")},
|
||||
)
|
||||
Expect(s.StoresDelete(&pb.StoresDeleteOptions{
|
||||
Keys: wrapKeys([][]float32{{1, 0, 0}}),
|
||||
})).To(Succeed())
|
||||
Expect(s.keyLen).To(Equal(3))
|
||||
Expect(s.StoresSet(&pb.StoresSetOptions{
|
||||
Keys: wrapKeys([][]float32{{1, 0}}),
|
||||
Values: wrapValues([][]byte{[]byte("2d")}),
|
||||
})).NotTo(Succeed())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("StoresFind", func() {
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"unsafe"
|
||||
|
||||
"github.com/mudler/LocalAI/pkg/grpc/base"
|
||||
@@ -110,25 +109,30 @@ func (r *LocateAnythingCpp) Detect(opts *pb.DetectOptions) (pb.DetectResponse, e
|
||||
return pb.DetectResponse{}, fmt.Errorf("locate-anything-cpp: a text prompt is required (open-vocabulary detection)")
|
||||
}
|
||||
|
||||
// Decode base64 image and write to temp file.
|
||||
imgData, err := base64.StdEncoding.DecodeString(opts.Src)
|
||||
if err != nil {
|
||||
return pb.DetectResponse{}, fmt.Errorf("locate-anything-cpp: failed to decode base64 image: %w", err)
|
||||
}
|
||||
if len(imgData) == 0 {
|
||||
return pb.DetectResponse{}, fmt.Errorf("locate-anything-cpp: decoded image is empty")
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "locate-anything-*.img")
|
||||
if err != nil {
|
||||
return pb.DetectResponse{}, fmt.Errorf("locate-anything-cpp: failed to create temp file: %w", err)
|
||||
}
|
||||
defer func() { _ = os.Remove(tmpFile.Name()) }()
|
||||
|
||||
if _, err := tmpFile.Write(imgData); err != nil {
|
||||
_ = tmpFile.Close()
|
||||
return pb.DetectResponse{}, fmt.Errorf("locate-anything-cpp: failed to write temp file: %w", err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
return pb.DetectResponse{}, fmt.Errorf("locate-anything-cpp: failed to close temp file: %w", err)
|
||||
}
|
||||
|
||||
// mode 0 = hybrid (Parallel Box Decoding). The JSON return value is unused:
|
||||
// structured detections are read via the accessor functions. Still must
|
||||
// free the returned string.
|
||||
jsonPtr := CapiLocateBuffer(
|
||||
r.handle,
|
||||
uintptr(unsafe.Pointer(unsafe.SliceData(imgData))),
|
||||
uintptr(len(imgData)),
|
||||
prompt,
|
||||
0,
|
||||
)
|
||||
runtime.KeepAlive(imgData)
|
||||
jsonPtr := CapiLocatePath(r.handle, tmpFile.Name(), prompt, 0)
|
||||
if jsonPtr != 0 {
|
||||
CapiFreeString(jsonPtr)
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"path/filepath"
|
||||
|
||||
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("LocateAnythingCpp detection input", func() {
|
||||
It("detects from memory when the temporary directory is unavailable", func() {
|
||||
originalLocateBuffer := CapiLocateBuffer
|
||||
originalLocatePath := CapiLocatePath
|
||||
originalGetNDetections := CapiGetNDetections
|
||||
defer func() {
|
||||
CapiLocateBuffer = originalLocateBuffer
|
||||
CapiLocatePath = originalLocatePath
|
||||
CapiGetNDetections = originalGetNDetections
|
||||
}()
|
||||
|
||||
image := []byte("encoded-image")
|
||||
var receivedData uintptr
|
||||
var receivedLength uintptr
|
||||
CapiLocateBuffer = func(_ uintptr, data uintptr, length uintptr, _ string, _ int32) uintptr {
|
||||
receivedData = data
|
||||
receivedLength = length
|
||||
return 0
|
||||
}
|
||||
CapiLocatePath = func(_ uintptr, _ string, _ string, _ int32) uintptr {
|
||||
Fail("path-based detection must not be called")
|
||||
return 0
|
||||
}
|
||||
CapiGetNDetections = func(uintptr) int32 { return 0 }
|
||||
GinkgoT().Setenv("TMPDIR", filepath.Join(GinkgoT().TempDir(), "missing"))
|
||||
|
||||
result, err := (&LocateAnythingCpp{handle: 1}).Detect(&pb.DetectOptions{
|
||||
Src: base64.StdEncoding.EncodeToString(image),
|
||||
Prompt: "the object",
|
||||
})
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(result.Detections).To(BeEmpty())
|
||||
Expect(receivedData).NotTo(BeZero())
|
||||
Expect(receivedLength).To(Equal(uintptr(len(image))))
|
||||
})
|
||||
|
||||
It("rejects an empty decoded image", func() {
|
||||
_, err := (&LocateAnythingCpp{handle: 1}).Detect(&pb.DetectOptions{Prompt: "the object"})
|
||||
|
||||
Expect(err).To(MatchError("locate-anything-cpp: decoded image is empty"))
|
||||
})
|
||||
})
|
||||
@@ -12,7 +12,7 @@
|
||||
# runs 'make -C backend/go/$(BACKEND) build' and then copies package/), so it
|
||||
# has to produce the binary and the package, not just the shared libraries.
|
||||
|
||||
NEMO_SPEECH_VERSION?=a5b6953c4a579a2bbd1c0913ad8a85c2a4d99953
|
||||
NEMO_SPEECH_VERSION?=2e12e2def8a98ed06666f7ee3ca94e7193e04be4
|
||||
NEMO_SPEECH_REPO?=https://github.com/NVIDIA/NeMo-Speech.cpp
|
||||
|
||||
GOCMD?=go
|
||||
@@ -88,18 +88,6 @@ ITN_LIB_DIR=$(ITN_PREFIX)/lib
|
||||
ITN_MARKER=$(ITN_LIB_DIR)/libsparrowhawk.so
|
||||
ITN_FST_HEADER=$(ITN_PREFIX)/include/fst/fst.h
|
||||
|
||||
# SentencePiece became a core ASR dependency in 5be7bfb: RNNT context biasing
|
||||
# uses it even when Flashlight and text normalization are disabled. Build the
|
||||
# pinned static archive provided by upstream so every platform gets the same
|
||||
# dependency instead of relying on an undeclared system package.
|
||||
SENTENCEPIECE_PREFIX=sources/NeMo-Speech.cpp/.deps/sentencepiece
|
||||
SENTENCEPIECE_MARKER=$(SENTENCEPIECE_PREFIX)/lib/libsentencepiece.a
|
||||
|
||||
# Linux's ASR CMake block looks in NEMO_SPEECH_DEPENDENCY_PREFIX directly, but
|
||||
# the Apple branch uses generic find_library()/find_path(). Put the same private
|
||||
# prefix on CMake's search path so Darwin consumes the archive built above too.
|
||||
CMAKE_ARGS+=-DCMAKE_PREFIX_PATH=$(abspath $(SENTENCEPIECE_PREFIX))
|
||||
|
||||
ITN_CC?=gcc-12
|
||||
ITN_CXX?=g++-12
|
||||
|
||||
@@ -164,7 +152,7 @@ else
|
||||
endif
|
||||
CMAKE_ARGS+=-DNEMO_SPEECH_GGML_PATCHED=$(GGML_PATCHED)
|
||||
|
||||
.PHONY: nemo-speech-cpp-grpc package build clean purge test all stage-libs patch-ggml engine itn sentencepiece patch-itn-headers
|
||||
.PHONY: nemo-speech-cpp-grpc package build clean purge test all stage-libs patch-ggml engine itn patch-itn-headers
|
||||
|
||||
all: nemo-speech-cpp-grpc package
|
||||
|
||||
@@ -278,28 +266,11 @@ patch-itn-headers:
|
||||
|
||||
itn: $(ITN_MARKER)
|
||||
|
||||
$(SENTENCEPIECE_MARKER): | sources/NeMo-Speech.cpp
|
||||
# Upstream's license copies use GNU install's -D flag, which BSD install
|
||||
# does not support. Homebrew CMake 4 also rejects SentencePiece's old policy
|
||||
# floor. Patch both incompatibilities before running the helper on Darwin.
|
||||
@if [ "$(shell uname -s)" = Darwin ]; then \
|
||||
cd sources/NeMo-Speech.cpp && \
|
||||
mkdir -p .deps/sentencepiece/share/licenses/nemo-speech/third_party/sentencepiece && \
|
||||
perl -pi \
|
||||
-e 's/install -Dm0644/install -m 0644/g;' \
|
||||
-e 's/-DCMAKE_BUILD_TYPE=Release /-DCMAKE_BUILD_TYPE=Release -DCMAKE_POLICY_VERSION_MINIMUM=3.5 /;' \
|
||||
scripts/build_sentencepiece_static.sh; \
|
||||
fi
|
||||
cd sources/NeMo-Speech.cpp && JOBS=$(JOBS) scripts/build_sentencepiece_static.sh
|
||||
|
||||
sentencepiece: $(SENTENCEPIECE_MARKER)
|
||||
|
||||
# Only a WITH_NORM=ON build needs the ITN stack, and it must exist before cmake
|
||||
# configures, since the WITH_NORM cmake block find_library()s into the prefix
|
||||
# with REQUIRED.
|
||||
NEMO_RUNTIME_PREREQS=$(SENTENCEPIECE_MARKER)
|
||||
ifeq ($(WITH_NORM),ON)
|
||||
NEMO_RUNTIME_PREREQS+=$(ITN_MARKER)
|
||||
NEMO_RUNTIME_PREREQS=$(ITN_MARKER)
|
||||
endif
|
||||
|
||||
# Upstream sets CMAKE_LIBRARY_OUTPUT_DIRECTORY to ${CMAKE_BINARY_DIR}/bin, so the
|
||||
|
||||
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
|
||||
|
||||
# omnivoice.cpp version
|
||||
OMNIVOICE_REPO?=https://github.com/ServeurpersoCom/omnivoice.cpp
|
||||
OMNIVOICE_VERSION?=040c8b344d8c670ce1475194751d119b5ef82c78
|
||||
OMNIVOICE_VERSION?=4f33af825d66e6ef1cb185e87b4589cacf747291
|
||||
SO_TARGET?=libgomnivoicecpp.so
|
||||
|
||||
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# parakeet-cpp backend Makefile.
|
||||
#
|
||||
# Upstream pin lives below as PARAKEET_VERSION?=e75de9b6b9b688fd293aa22f7e27aa724ea286f8
|
||||
# Upstream pin lives below as PARAKEET_VERSION?=1bfbebfaaf493866f49597cd3b7901959d395c60
|
||||
# (.github/bump_deps.sh) can find and update it - matches the
|
||||
# whisper.cpp / ds4 / vibevoice-cpp convention.
|
||||
#
|
||||
@@ -15,7 +15,7 @@
|
||||
# That's what the L0 smoke test uses. The default target below does the
|
||||
# proper clone-at-pin + cmake build so CI doesn't need a side-checkout.
|
||||
|
||||
PARAKEET_VERSION?=e75de9b6b9b688fd293aa22f7e27aa724ea286f8
|
||||
PARAKEET_VERSION?=1bfbebfaaf493866f49597cd3b7901959d395c60
|
||||
PARAKEET_REPO?=https://github.com/mudler/parakeet.cpp
|
||||
|
||||
GOCMD?=go
|
||||
@@ -49,8 +49,6 @@ else ifeq ($(BUILD_TYPE),hipblas)
|
||||
CMAKE_ARGS+=-DPARAKEET_GGML_HIP=ON
|
||||
else ifeq ($(BUILD_TYPE),vulkan)
|
||||
CMAKE_ARGS+=-DPARAKEET_GGML_VULKAN=ON
|
||||
else ifeq ($(BUILD_TYPE),metal)
|
||||
CMAKE_ARGS+=-DPARAKEET_GGML_METAL=ON
|
||||
endif
|
||||
|
||||
.PHONY: parakeet-cpp-grpc package build clean purge test all
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"unsafe"
|
||||
|
||||
@@ -103,12 +102,24 @@ func (r *RFDetrCpp) Detect(opts *pb.DetectOptions) (pb.DetectResponse, error) {
|
||||
return pb.DetectResponse{}, fmt.Errorf("rfdetr-cpp: model not loaded")
|
||||
}
|
||||
|
||||
// Decode base64 image and write to temp file.
|
||||
imgData, err := base64.StdEncoding.DecodeString(opts.Src)
|
||||
if err != nil {
|
||||
return pb.DetectResponse{}, fmt.Errorf("rfdetr-cpp: failed to decode base64 image: %w", err)
|
||||
}
|
||||
if len(imgData) == 0 {
|
||||
return pb.DetectResponse{}, fmt.Errorf("rfdetr-cpp: decoded image is empty")
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "rfdetr-*.img")
|
||||
if err != nil {
|
||||
return pb.DetectResponse{}, fmt.Errorf("rfdetr-cpp: failed to create temp file: %w", err)
|
||||
}
|
||||
defer func() { _ = os.Remove(tmpFile.Name()) }()
|
||||
|
||||
if _, err := tmpFile.Write(imgData); err != nil {
|
||||
_ = tmpFile.Close()
|
||||
return pb.DetectResponse{}, fmt.Errorf("rfdetr-cpp: failed to write temp file: %w", err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
return pb.DetectResponse{}, fmt.Errorf("rfdetr-cpp: failed to close temp file: %w", err)
|
||||
}
|
||||
|
||||
threshold := opts.Threshold
|
||||
@@ -116,18 +127,10 @@ func (r *RFDetrCpp) Detect(opts *pb.DetectOptions) (pb.DetectResponse, error) {
|
||||
threshold = 0.5
|
||||
}
|
||||
|
||||
// JSON output from the detection ABI is unused: we read structured detections via
|
||||
// JSON output from detect_path is unused: we read structured detections via
|
||||
// the accessor functions. Still must free the returned string.
|
||||
var jsonPtr uintptr
|
||||
rc := CapiDetectBuffer(
|
||||
r.handle,
|
||||
uintptr(unsafe.Pointer(unsafe.SliceData(imgData))),
|
||||
uintptr(len(imgData)),
|
||||
threshold,
|
||||
uint32(defaultTopK),
|
||||
&jsonPtr,
|
||||
)
|
||||
runtime.KeepAlive(imgData)
|
||||
rc := CapiDetectPath(r.handle, tmpFile.Name(), threshold, uint32(defaultTopK), &jsonPtr)
|
||||
if jsonPtr != 0 {
|
||||
CapiFreeString(jsonPtr)
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"path/filepath"
|
||||
|
||||
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("RFDetrCpp detection input", func() {
|
||||
It("detects from memory when the temporary directory is unavailable", func() {
|
||||
originalDetectBuffer := CapiDetectBuffer
|
||||
originalDetectPath := CapiDetectPath
|
||||
originalFreeString := CapiFreeString
|
||||
originalGetNDetections := CapiGetNDetections
|
||||
defer func() {
|
||||
CapiDetectBuffer = originalDetectBuffer
|
||||
CapiDetectPath = originalDetectPath
|
||||
CapiFreeString = originalFreeString
|
||||
CapiGetNDetections = originalGetNDetections
|
||||
}()
|
||||
|
||||
image := []byte("encoded-image")
|
||||
var receivedData uintptr
|
||||
var receivedLength uintptr
|
||||
CapiDetectBuffer = func(_ uintptr, data uintptr, length uintptr, _ float32, _ uint32, _ *uintptr) int32 {
|
||||
receivedData = data
|
||||
receivedLength = length
|
||||
return 0
|
||||
}
|
||||
CapiDetectPath = func(_ uintptr, _ string, _ float32, _ uint32, _ *uintptr) int32 {
|
||||
Fail("path-based detection must not be called")
|
||||
return -1
|
||||
}
|
||||
CapiFreeString = func(uintptr) {}
|
||||
CapiGetNDetections = func(uintptr) int32 { return 0 }
|
||||
GinkgoT().Setenv("TMPDIR", filepath.Join(GinkgoT().TempDir(), "missing"))
|
||||
|
||||
result, err := (&RFDetrCpp{handle: 1}).Detect(&pb.DetectOptions{
|
||||
Src: base64.StdEncoding.EncodeToString(image),
|
||||
})
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(result.Detections).To(BeEmpty())
|
||||
Expect(receivedData).NotTo(BeZero())
|
||||
Expect(receivedLength).To(Equal(uintptr(len(image))))
|
||||
})
|
||||
|
||||
It("rejects an empty decoded image", func() {
|
||||
_, err := (&RFDetrCpp{handle: 1}).Detect(&pb.DetectOptions{})
|
||||
|
||||
Expect(err).To(MatchError("rfdetr-cpp: decoded image is empty"))
|
||||
})
|
||||
})
|
||||
@@ -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?=d04e8950c1ec8d30248cbe996682b3182fb1adf6
|
||||
STABLEDIFFUSION_GGML_VERSION?=c6beeef35526c6dc94b74a7fb69f9d2e6a2a7a12
|
||||
|
||||
CMAKE_ARGS+=-DGGML_MAX_NAME=128
|
||||
|
||||
@@ -38,16 +38,17 @@ else ifeq ($(BUILD_TYPE),hipblas)
|
||||
ROCM_PATH ?= /opt/rocm
|
||||
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
|
||||
# SD_HIPBLAS turns on ggml's HIP backend itself; GGML_HIPBLAS is the name ggml
|
||||
# used before it was renamed to GGML_HIP, so passing it here only produced an
|
||||
# unused-variable warning.
|
||||
CMAKE_ARGS+=-DSD_HIPBLAS=ON -DAMDGPU_TARGETS=$(AMDGPU_TARGETS)
|
||||
AMDGPU_TARGETS?=gfx908,gfx90a,gfx942,gfx950,gfx1030,gfx1100,gfx1101,gfx1102,gfx1200,gfx1201
|
||||
CMAKE_ARGS+=-DSD_HIPBLAS=ON -DGGML_HIPBLAS=ON -DAMDGPU_TARGETS=$(AMDGPU_TARGETS)
|
||||
else ifeq ($(BUILD_TYPE),vulkan)
|
||||
CMAKE_ARGS+=-DSD_VULKAN=ON -DGGML_VULKAN=ON
|
||||
else ifeq ($(BUILD_TYPE),metal)
|
||||
CMAKE_ARGS+=-DSD_METAL=ON -DGGML_METAL=ON
|
||||
CMAKE_ARGS+=-DGGML_METAL_EMBED_LIBRARY=ON
|
||||
else ifeq ($(OS),Darwin)
|
||||
ifneq ($(BUILD_TYPE),metal)
|
||||
CMAKE_ARGS+=-DSD_METAL=OFF -DGGML_METAL=OFF
|
||||
else
|
||||
CMAKE_ARGS+=-DSD_METAL=ON -DGGML_METAL=ON
|
||||
CMAKE_ARGS+=-DGGML_METAL_EMBED_LIBRARY=ON
|
||||
endif
|
||||
endif
|
||||
|
||||
ifeq ($(BUILD_TYPE),sycl_f16)
|
||||
@@ -71,6 +72,7 @@ sources/stablediffusion-ggml.cpp:
|
||||
git checkout $(STABLEDIFFUSION_GGML_VERSION) && \
|
||||
git submodule update --init --recursive --depth 1 --single-branch
|
||||
|
||||
# Detect OS
|
||||
UNAME_S := $(shell uname -s)
|
||||
|
||||
# Only build CPU variants on Linux
|
||||
@@ -132,4 +134,4 @@ libgosd-custom: CMakeLists.txt cpp/gosd.cpp cpp/gosd.h
|
||||
(mv build-$(SO_TARGET)/libgosd.so ./$(SO_TARGET) 2>/dev/null || \
|
||||
mv build-$(SO_TARGET)/libgosd.dylib ./$(SO_TARGET) 2>/dev/null)
|
||||
|
||||
all: stablediffusion-ggml package
|
||||
all: stablediffusion-ggml package
|
||||
@@ -401,6 +401,7 @@ int load_model(const char *model, char *model_path, char* options[], int threads
|
||||
const char *params_backend_arg = "";
|
||||
const char *rpc_servers_arg = "";
|
||||
const char *max_vram_arg = "";
|
||||
bool stream_layers = false;
|
||||
|
||||
int n_threads = threads;
|
||||
enum sd_type_t wtype = SD_TYPE_COUNT;
|
||||
@@ -509,10 +510,7 @@ int load_model(const char *model, char *model_path, char* options[], int threads
|
||||
if (!strcmp(optname, "params_backend")) params_backend_arg = strdup(optval);
|
||||
if (!strcmp(optname, "rpc_servers")) rpc_servers_arg = strdup(optval);
|
||||
if (!strcmp(optname, "max_vram")) max_vram_arg = strdup(optval);
|
||||
if (!strcmp(optname, "stream_layers")) {
|
||||
// Retained as a no-op for existing configurations. Upstream now
|
||||
// selects segmented weight streaming automatically.
|
||||
}
|
||||
if (!strcmp(optname, "stream_layers")) stream_layers = (strcmp(optval, "true") == 0 || strcmp(optval, "1") == 0);
|
||||
|
||||
// vae_decode_only is still accepted for backwards compatibility with
|
||||
// existing gallery configs, but upstream dropped the option (the model
|
||||
@@ -652,9 +650,11 @@ int load_model(const char *model, char *model_path, char* options[], int threads
|
||||
ctx_params.rpc_servers = env_rpc_servers;
|
||||
}
|
||||
}
|
||||
// max_vram is an optional GiB budget or per-backend spec for automatic
|
||||
// graph-cut execution. A zero value uses the live free-VRAM budget.
|
||||
// max_vram: GiB budget or per-backend spec for graph-cut segmented param
|
||||
// offload ("0" = disabled, "-1" = auto). stream_layers only has effect when
|
||||
// max_vram is set.
|
||||
if (strlen(max_vram_arg) > 0) ctx_params.max_vram = max_vram_arg;
|
||||
ctx_params.stream_layers = stream_layers;
|
||||
ctx_params.diffusion_flash_attn = diffusion_flash_attn;
|
||||
ctx_params.tae_preview_only = tae_preview_only;
|
||||
ctx_params.diffusion_conv_direct = diffusion_conv_direct;
|
||||
@@ -1144,25 +1144,17 @@ static uint8_t* load_and_resize_image(const char* path, int target_width, int ta
|
||||
// Write sd.cpp's audio buffer to a temp WAV file (IEEE float, interleaved).
|
||||
// sd_audio_t.data is planar (all channel 0 samples, then channel 1, etc.) — we
|
||||
// interleave on the fly so ffmpeg's standard wav demuxer can read it directly.
|
||||
// Returns 0 on success and fills wav_path.
|
||||
// Returns 0 on success and fills wav_path (must be at least 64 bytes).
|
||||
static int write_planar_float_wav(const sd_audio_t* a, char* wav_path, size_t wav_path_sz) {
|
||||
if (!a || !a->data || a->sample_count == 0 || a->channels == 0 || a->sample_rate == 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const char* temp_dir = getenv("TMPDIR");
|
||||
if (!temp_dir || temp_dir[0] == '\0') {
|
||||
temp_dir = "/tmp";
|
||||
}
|
||||
int path_len = snprintf(wav_path, wav_path_sz, "%s/gosd-audio-XXXXXX.wav", temp_dir);
|
||||
if (path_len < 0 || (size_t)path_len >= wav_path_sz) {
|
||||
fprintf(stderr, "temporary directory path is too long\n");
|
||||
return -1;
|
||||
}
|
||||
snprintf(wav_path, wav_path_sz, "/tmp/gosd-audio-XXXXXX.wav");
|
||||
int fd = mkstemps(wav_path, 4);
|
||||
if (fd < 0) { perror("mkstemps wav"); return -1; }
|
||||
FILE* f = fdopen(fd, "wb");
|
||||
if (!f) { perror("fdopen wav"); close(fd); unlink(wav_path); return -1; }
|
||||
if (!f) { perror("fdopen wav"); close(fd); return -1; }
|
||||
|
||||
uint64_t frames = a->sample_count;
|
||||
uint32_t channels = a->channels;
|
||||
@@ -1229,7 +1221,7 @@ static int ffmpeg_mux_raw_to_mp4(sd_image_t* frames, int num_frames, int fps,
|
||||
snprintf(fps_str, sizeof(fps_str), "%d", fps);
|
||||
|
||||
// Optional audio: write a temp WAV file if the model produced audio.
|
||||
char wav_path[4096] = {0};
|
||||
char wav_path[64] = {0};
|
||||
bool have_audio = false;
|
||||
if (audio && audio->data && audio->sample_count > 0 && audio->channels > 0 && audio->sample_rate > 0) {
|
||||
if (write_planar_float_wav(audio, wav_path, sizeof(wav_path)) == 0) {
|
||||
@@ -1446,3 +1438,4 @@ int unload() {
|
||||
free_sd_ctx(sd_c);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ JOBS?=$(shell nproc --ignore=1 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || e
|
||||
|
||||
# vllm.cpp version
|
||||
VLLM_CPP_REPO?=https://github.com/mudler/vllm.cpp
|
||||
VLLM_CPP_VERSION?=6bf3abb580982f4fd2e4525ef37802ee0ce28981
|
||||
VLLM_CPP_VERSION?=2b08dd246e04b3f0a4bf1f276170fd28004ced01
|
||||
|
||||
# MLX GEMM provider (darwin/metal only; see the metal branch below for why).
|
||||
# Consumed as the prebuilt pip wheel: building MLX from source needs `xcrun
|
||||
@@ -47,35 +47,26 @@ CMAKE_ARGS+=-DCMAKE_BUILD_TYPE=Release
|
||||
UNAME_M := $(shell uname -m)
|
||||
|
||||
ifeq ($(BUILD_TYPE),cublas)
|
||||
# Every CUDA architecture upstream builds that the platform can actually
|
||||
# host, split by where the silicon exists: Jetson (87 Orin, 110 Thor) is
|
||||
# arm64-only, desktop 120a is amd64-only, and 90a/100a appear on both
|
||||
# because of the SBSA parts (GH200, GB200).
|
||||
#
|
||||
# This deliberately matches vllm.cpp's own release archive rather than
|
||||
# narrowing to the boxes we benchmark on. A narrower list does not degrade
|
||||
# on an unlisted card, it dies at the first request with "no kernel image
|
||||
# is available for execution on the device", long after `backends install`
|
||||
# reported success -- so an arch we merely lack numbers for still belongs
|
||||
# in the binary.
|
||||
#
|
||||
# Triton-AOT stays ON for both. A fat build is supported on the BUILDER
|
||||
# path: it embeds every vendored cubin tree (sm_80/86/89/90a/100a/121a) and
|
||||
# selects by exact SM at runtime, so the arches with no tree (87, 103a,
|
||||
# 110, 120a) take the portable CUDA kernels and can never load a
|
||||
# neighbouring cubin. Only maintainer REGEN needs a single pinned arch.
|
||||
# See vllm.cpp cmake/TritonAOT.cmake `_triton_aot_arch_names`.
|
||||
#
|
||||
# CUDA builds REQUIRE the CUDA 13 toolchain: 12.x nvcc lacks compute_121a
|
||||
# (GB10) and its ptxas rejects the sm_120a NVFP4 MMA kernels ("Vector type
|
||||
# too large"), so no cuda-12 variant is shipped.
|
||||
ifeq ($(CUDA_MAJOR_VERSION),12)
|
||||
$(error vllm.cpp needs the CUDA 13 toolchain: CUDA 12.x cannot compile the Blackwell fp4 kernels)
|
||||
endif
|
||||
# Blackwell-family targets only: other CUDA arches are build-supported
|
||||
# upstream but have no runtime-proven fast path. amd64 gets the consumer
|
||||
# (120a) + GB10 (121a) fat binary; arm64 CUDA (l4t-style images, DGX
|
||||
# Spark) is GB10 only. Triton-AOT GDN cubins are vendored per-arch, no
|
||||
# Python needed to consume them.
|
||||
ifeq ($(UNAME_M),x86_64)
|
||||
CMAKE_ARGS+=-DVLLM_CPP_CUDA=ON "-DVLLM_CPP_CUDA_ARCHITECTURES=80;86;89;90a;100a;103a;120a;121a" -DVLLM_CPP_TRITON=ON
|
||||
# NO -DVLLM_CPP_TRITON on fat builds: the vendored Triton-AOT cubin
|
||||
# trees are per-arch and the engine refuses a multi-arch build unless
|
||||
# pinned to one tree (unsound for the other arch). The non-AOT GDN
|
||||
# path serves the fat binary; single-arch builds keep the cubins.
|
||||
#
|
||||
# CUDA builds REQUIRE the CUDA 13 toolchain: 12.x nvcc lacks
|
||||
# compute_121a (GB10) and its ptxas rejects the sm_120a NVFP4 MMA
|
||||
# kernels ("Vector type too large"), so no cuda-12 variant is shipped.
|
||||
ifeq ($(CUDA_MAJOR_VERSION),12)
|
||||
$(error vllm.cpp needs the CUDA 13 toolchain: CUDA 12.x cannot compile the Blackwell fp4 kernels)
|
||||
endif
|
||||
CMAKE_ARGS+=-DVLLM_CPP_CUDA=ON "-DVLLM_CPP_CUDA_ARCHITECTURES=120a;121a"
|
||||
else
|
||||
CMAKE_ARGS+=-DVLLM_CPP_CUDA=ON "-DVLLM_CPP_CUDA_ARCHITECTURES=87;90a;100a;110;121a" -DVLLM_CPP_TRITON=ON
|
||||
CMAKE_ARGS+=-DVLLM_CPP_CUDA=ON -DVLLM_CPP_CUDA_ARCHITECTURES=121a -DVLLM_CPP_TRITON=ON
|
||||
endif
|
||||
else ifeq ($(BUILD_TYPE),vulkan)
|
||||
CMAKE_ARGS+=-DVLLM_CPP_VULKAN=ON -DVLLM_CPP_CUDA=OFF
|
||||
|
||||
@@ -9,7 +9,7 @@ It serves two things: text generation, and MiniMax-H3 joint video+audio
|
||||
generation.
|
||||
|
||||
The backend dlopens the engine's stable C ABI (`libvllm`, `include/vllm.h`,
|
||||
ABI v20) through purego:
|
||||
ABI v16) through purego:
|
||||
|
||||
- `Load` -> `vllm_engine_load`: accepts a `.gguf` file or a HF-style model
|
||||
directory (`config.json` + safetensors). `context_size` maps to
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package main
|
||||
|
||||
// purego bindings for the vllm.cpp stable C ABI (include/vllm.h, ABI v23).
|
||||
// purego bindings for the vllm.cpp stable C ABI (include/vllm.h, ABI v16).
|
||||
//
|
||||
// The structs below are hand-mirrored PODs of the C declarations, with
|
||||
// explicit padding so the Go layout matches the C layout on linux/darwin
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
// the header of the VLLM_CPP_VERSION pinned in the Makefile: the build checks
|
||||
// the two against each other, because a mismatch is only caught at runtime by
|
||||
// registerLib, where it takes the backend down on every load (issue #11379).
|
||||
const abiVersion = 23
|
||||
const abiVersion = 17
|
||||
|
||||
// The ABI's tri-state toggles (enable_prefix_caching ABI v7,
|
||||
// enable_jump_forward ABI v10) share one encoding: 0 is NOT "off", it is
|
||||
@@ -69,7 +69,6 @@ type cModelParams struct {
|
||||
MaxNumBatchedTokens int32 // <= 0 = per-arch default (ABI v9)
|
||||
SchedulingPolicy uintptr // const char*; NULL = "fcfs" (ABI v9)
|
||||
KVTransferConfig uintptr // const char* JSON; NULL = no connector (ABI v9)
|
||||
OffloadConfig uintptr // const char* JSON; NULL = no weight offload
|
||||
EnableJumpForward int32 // tri-state 0/1/2 (ABI v10)
|
||||
// v14/v16 tail. LocalAI sets none of these (0 is "auto" for the device and
|
||||
// "unset" for both sizing knobs, i.e. the pre-v14 engine byte for byte), but
|
||||
@@ -80,10 +79,6 @@ type cModelParams struct {
|
||||
Device int32 // 0 auto, 1 cpu, 2 cuda (ABI v14)
|
||||
GPUMemoryUtil float64 // 0 => 0.92 (ABI v16)
|
||||
KVCacheMemoryBytes int64 // 0 => unset (ABI v16)
|
||||
LanguageModelOnly int32 // 0 = multimodal inputs enabled (ABI v19)
|
||||
_ [4]byte
|
||||
LimitMMPerPrompt uintptr // const char* JSON; NULL = default limits (ABI v19)
|
||||
MMProjPath uintptr // const char*; NULL = no GGUF projector (ABI v22)
|
||||
}
|
||||
|
||||
// cSamplingParams mirrors vllm_sampling_params (structured fields included).
|
||||
@@ -152,11 +147,6 @@ type cVideoModelParams struct {
|
||||
Device int32 // 0 cpu, 1 cuda
|
||||
DequantBf16 int32 // 0 keep-quant, 1 dequant/stream bf16
|
||||
Fp4Resident int32 // NVFP4+cuda: keep FP4 packed, Marlin W4A16
|
||||
_ [4]byte
|
||||
Family uintptr // const char*; NULL = detect (ABI v18)
|
||||
ExtraKeys uintptr // const char* const* (ABI v18)
|
||||
ExtraValues uintptr // const char* const* (ABI v18)
|
||||
NExtras int32 // 0 = none (ABI v18)
|
||||
_ [4]byte // trailing pad to the struct's 8-byte alignment
|
||||
}
|
||||
|
||||
@@ -164,26 +154,22 @@ type cVideoModelParams struct {
|
||||
// `steps` pair up into 8-byte slots; the uint64 seed forces the alignment after
|
||||
// them, and the float noise_aug leaves a pad before output_dir.
|
||||
type cVideoParams struct {
|
||||
Prompt uintptr // const char*
|
||||
Width int32
|
||||
Height int32
|
||||
NumFrames int32 // <= 1 => per-task default (124 for t2va/fl2va)
|
||||
Steps int32 // <= 0 => the H3 default (50)
|
||||
Seed uint64
|
||||
HasSeed int32
|
||||
_ [4]byte
|
||||
FirstFrame uintptr // const char*; fl2va keyframe, binary PPM (P6)
|
||||
LastFrame uintptr // const char*
|
||||
RefImage uintptr // const char*; ref2va only
|
||||
RefVideo uintptr // const char*; ref2va only, a frame_%06d.ppm DIRECTORY
|
||||
RefAudio uintptr // const char*; ref2va only, 16-bit PCM WAV
|
||||
NoiseAug float32 // <= 0 => 1.0
|
||||
_ [4]byte
|
||||
OutputDir uintptr // const char*; REQUIRED
|
||||
ExtraKeys uintptr // const char* const* (ABI v18)
|
||||
ExtraValues uintptr // const char* const* (ABI v18)
|
||||
NExtras int32 // 0 = none (ABI v18)
|
||||
_ [4]byte
|
||||
Prompt uintptr // const char*
|
||||
Width int32
|
||||
Height int32
|
||||
NumFrames int32 // <= 1 => per-task default (124 for t2va/fl2va)
|
||||
Steps int32 // <= 0 => the H3 default (50)
|
||||
Seed uint64
|
||||
HasSeed int32
|
||||
_ [4]byte
|
||||
FirstFrame uintptr // const char*; fl2va keyframe, binary PPM (P6)
|
||||
LastFrame uintptr // const char*
|
||||
RefImage uintptr // const char*; ref2va only
|
||||
RefVideo uintptr // const char*; ref2va only, a frame_%06d.ppm DIRECTORY
|
||||
RefAudio uintptr // const char*; ref2va only, 16-bit PCM WAV
|
||||
NoiseAug float32 // <= 0 => 1.0
|
||||
_ [4]byte
|
||||
OutputDir uintptr // const char*; REQUIRED
|
||||
}
|
||||
|
||||
// cVideoResult mirrors vllm_video_result. Every member is library-allocated and
|
||||
|
||||
@@ -128,40 +128,9 @@ func parseOptions(opts *pb.ModelOptions) loadOptions {
|
||||
lo := loadOptions{}
|
||||
applyOptionsList(&lo, opts.GetOptions())
|
||||
applyEngineArgs(&lo, opts.GetEngineArgs())
|
||||
applyDraftModelOption(&lo, opts.GetOptions())
|
||||
return lo
|
||||
}
|
||||
|
||||
// applyDraftModelOption binds a managed companion snapshot after engine_args
|
||||
// has supplied the speculative document. Companion paths do not exist until
|
||||
// LocalAI materializes the artifact, so they must replace the gallery's static
|
||||
// repository reference without disturbing the method or token budget.
|
||||
func applyDraftModelOption(lo *loadOptions, options []string) {
|
||||
if strings.TrimSpace(lo.speculativeConfig) == "" {
|
||||
return
|
||||
}
|
||||
var draftModel string
|
||||
for _, option := range options {
|
||||
key, value, found := strings.Cut(option, ":")
|
||||
if found && strings.TrimSpace(key) == "draft_model" {
|
||||
draftModel = strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
if draftModel == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var spec map[string]any
|
||||
if err := json.Unmarshal([]byte(lo.speculativeConfig), &spec); err != nil {
|
||||
return
|
||||
}
|
||||
spec["model"] = draftModel
|
||||
encoded, err := json.Marshal(spec)
|
||||
if err == nil {
|
||||
lo.speculativeConfig = string(encoded)
|
||||
}
|
||||
}
|
||||
|
||||
// applyOptionsList reads the legacy free-form "key:value" list. strings.Cut
|
||||
// splits on the FIRST colon only, so a JSON object value survives intact.
|
||||
func applyOptionsList(lo *loadOptions, options []string) {
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
||||
)
|
||||
|
||||
var _ = Describe("managed DFlash companion options", func() {
|
||||
It("replaces only the draft model in an existing speculative configuration", func() {
|
||||
managedPath := ".artifacts/huggingface/0123456789abcdef/snapshot"
|
||||
lo := parseOptions(&pb.ModelOptions{
|
||||
Options: []string{"draft_model:" + managedPath},
|
||||
EngineArgs: `{
|
||||
"speculative_config": {
|
||||
"method": "dflash",
|
||||
"model": "Mia-AiLab/Qwen3.8-27B-DFlash2-EXL3-5.0bpw",
|
||||
"num_speculative_tokens": 7
|
||||
}
|
||||
}`,
|
||||
})
|
||||
|
||||
Expect(lo.speculativeConfig).To(MatchJSON(`{
|
||||
"method": "dflash",
|
||||
"model": ".artifacts/huggingface/0123456789abcdef/snapshot",
|
||||
"num_speculative_tokens": 7
|
||||
}`))
|
||||
})
|
||||
|
||||
It("ignores a draft companion when speculative decoding is not configured", func() {
|
||||
lo := parseOptions(&pb.ModelOptions{
|
||||
Options: []string{"draft_model:.artifacts/huggingface/0123456789abcdef/snapshot"},
|
||||
})
|
||||
|
||||
Expect(lo.speculativeConfig).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
@@ -28,11 +28,7 @@ var _ = Describe("C ABI video struct mirrors", func() {
|
||||
Expect(unsafe.Offsetof(p.Device)).To(Equal(uintptr(72)))
|
||||
Expect(unsafe.Offsetof(p.DequantBf16)).To(Equal(uintptr(76)))
|
||||
Expect(unsafe.Offsetof(p.Fp4Resident)).To(Equal(uintptr(80)))
|
||||
Expect(unsafe.Offsetof(p.Family)).To(Equal(uintptr(88)))
|
||||
Expect(unsafe.Offsetof(p.ExtraKeys)).To(Equal(uintptr(96)))
|
||||
Expect(unsafe.Offsetof(p.ExtraValues)).To(Equal(uintptr(104)))
|
||||
Expect(unsafe.Offsetof(p.NExtras)).To(Equal(uintptr(112)))
|
||||
Expect(unsafe.Sizeof(p)).To(Equal(uintptr(120)))
|
||||
Expect(unsafe.Sizeof(p)).To(Equal(uintptr(88)))
|
||||
})
|
||||
|
||||
It("cVideoParams matches vllm_video_params", func() {
|
||||
@@ -51,10 +47,7 @@ var _ = Describe("C ABI video struct mirrors", func() {
|
||||
Expect(unsafe.Offsetof(p.RefAudio)).To(Equal(uintptr(72)))
|
||||
Expect(unsafe.Offsetof(p.NoiseAug)).To(Equal(uintptr(80)))
|
||||
Expect(unsafe.Offsetof(p.OutputDir)).To(Equal(uintptr(88)))
|
||||
Expect(unsafe.Offsetof(p.ExtraKeys)).To(Equal(uintptr(96)))
|
||||
Expect(unsafe.Offsetof(p.ExtraValues)).To(Equal(uintptr(104)))
|
||||
Expect(unsafe.Offsetof(p.NExtras)).To(Equal(uintptr(112)))
|
||||
Expect(unsafe.Sizeof(p)).To(Equal(uintptr(120)))
|
||||
Expect(unsafe.Sizeof(p)).To(Equal(uintptr(96)))
|
||||
})
|
||||
|
||||
It("cVideoResult matches vllm_video_result", func() {
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestVllmCpp(t *testing.T) {
|
||||
RunSpecs(t, "vllm-cpp suite")
|
||||
}
|
||||
|
||||
// The Go POD mirrors must match the C struct layout of vllm.h (ABI v23)
|
||||
// The Go POD mirrors must match the C struct layout of vllm.h (ABI v16)
|
||||
// byte-for-byte: these offsets are the C offsets on LP64 (linux/darwin
|
||||
// amd64+arm64). A failure here means govllmcpp.go drifted from vllm.h.
|
||||
var _ = Describe("C ABI struct mirrors", func() {
|
||||
@@ -24,7 +24,7 @@ var _ = Describe("C ABI struct mirrors", func() {
|
||||
// VLLM_ABI_VERSION in the vllm.h of VLLM_CPP_VERSION (Makefile).
|
||||
// Moving the pin past this without growing the mirrors below ships a
|
||||
// backend that refuses every load at startup (issue #11379).
|
||||
Expect(abiVersion).To(Equal(23))
|
||||
Expect(abiVersion).To(Equal(16))
|
||||
})
|
||||
|
||||
It("cModelParams matches vllm_model_params", func() {
|
||||
@@ -42,17 +42,13 @@ var _ = Describe("C ABI struct mirrors", func() {
|
||||
Expect(unsafe.Offsetof(p.MaxNumBatchedTokens)).To(Equal(uintptr(60)))
|
||||
Expect(unsafe.Offsetof(p.SchedulingPolicy)).To(Equal(uintptr(64)))
|
||||
Expect(unsafe.Offsetof(p.KVTransferConfig)).To(Equal(uintptr(72)))
|
||||
Expect(unsafe.Offsetof(p.OffloadConfig)).To(Equal(uintptr(80)))
|
||||
Expect(unsafe.Offsetof(p.EnableJumpForward)).To(Equal(uintptr(88)))
|
||||
Expect(unsafe.Offsetof(p.Device)).To(Equal(uintptr(92)))
|
||||
// 96: gpu_memory_utilization is a double, so it takes the next
|
||||
Expect(unsafe.Offsetof(p.EnableJumpForward)).To(Equal(uintptr(80)))
|
||||
Expect(unsafe.Offsetof(p.Device)).To(Equal(uintptr(84)))
|
||||
// 88, not 92: gpu_memory_utilization is a double, so it takes the next
|
||||
// 8-aligned slot after the int32 pair. Go pads identically.
|
||||
Expect(unsafe.Offsetof(p.GPUMemoryUtil)).To(Equal(uintptr(96)))
|
||||
Expect(unsafe.Offsetof(p.KVCacheMemoryBytes)).To(Equal(uintptr(104)))
|
||||
Expect(unsafe.Offsetof(p.LanguageModelOnly)).To(Equal(uintptr(112)))
|
||||
Expect(unsafe.Offsetof(p.LimitMMPerPrompt)).To(Equal(uintptr(120)))
|
||||
Expect(unsafe.Offsetof(p.MMProjPath)).To(Equal(uintptr(128)))
|
||||
Expect(unsafe.Sizeof(p)).To(Equal(uintptr(136)))
|
||||
Expect(unsafe.Offsetof(p.GPUMemoryUtil)).To(Equal(uintptr(88)))
|
||||
Expect(unsafe.Offsetof(p.KVCacheMemoryBytes)).To(Equal(uintptr(96)))
|
||||
Expect(unsafe.Sizeof(p)).To(Equal(uintptr(104)))
|
||||
})
|
||||
|
||||
It("cSamplingParams matches vllm_sampling_params (ABI v8)", func() {
|
||||
|
||||
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
|
||||
|
||||
# whisper.cpp version
|
||||
WHISPER_REPO?=https://github.com/ggml-org/whisper.cpp
|
||||
WHISPER_CPP_VERSION?=c44b60b8053bbf2a5c1e014f11323fb3f2485177
|
||||
WHISPER_CPP_VERSION?=592feef04a1802b18cbeffd0fd0eb5d02570c2ec
|
||||
SO_TARGET?=libgowhisper.so
|
||||
|
||||
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
|
||||
|
||||
+60
-14
@@ -11,8 +11,6 @@
|
||||
- https://github.com/ggerganov/llama.cpp
|
||||
tags:
|
||||
- text-to-text
|
||||
- text-to-speech
|
||||
- TTS
|
||||
- LLM
|
||||
- CPU
|
||||
- GPU
|
||||
@@ -74,6 +72,27 @@
|
||||
nvidia-cuda-12: "cuda12-turboquant"
|
||||
nvidia-l4t-cuda-12: "nvidia-l4t-arm64-turboquant"
|
||||
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-turboquant"
|
||||
- &cachyllama
|
||||
name: "cachyllama"
|
||||
alias: "cachyllama"
|
||||
license: mit
|
||||
description: |
|
||||
llama.cpp fork for lower-spec and shared-memory systems, with persistent
|
||||
SSD-backed prompt caching and Vulkan APU tuning.
|
||||
urls:
|
||||
- https://github.com/fewtarius/CachyLLama
|
||||
tags:
|
||||
- text-to-text
|
||||
- LLM
|
||||
- CPU
|
||||
- GPU
|
||||
- Vulkan
|
||||
- Metal
|
||||
- kv-cache
|
||||
capabilities:
|
||||
default: "cpu-cachyllama"
|
||||
vulkan: "vulkan-cachyllama"
|
||||
metal: "metal-cachyllama"
|
||||
- &bonsai
|
||||
name: "bonsai"
|
||||
alias: "bonsai"
|
||||
@@ -510,7 +529,7 @@
|
||||
default: "cpu-stablediffusion-ggml"
|
||||
nvidia: "cuda12-stablediffusion-ggml"
|
||||
intel: "intel-sycl-f16-stablediffusion-ggml"
|
||||
amd: "rocm-stablediffusion-ggml"
|
||||
# amd: "rocm-stablediffusion-ggml"
|
||||
vulkan: "vulkan-stablediffusion-ggml"
|
||||
nvidia-l4t: "nvidia-l4t-arm64-stablediffusion-ggml"
|
||||
metal: "metal-stablediffusion-ggml"
|
||||
@@ -2072,6 +2091,12 @@
|
||||
nvidia-cuda-12: "cuda12-turboquant-development"
|
||||
nvidia-l4t-cuda-12: "nvidia-l4t-arm64-turboquant-development"
|
||||
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-turboquant-development"
|
||||
- !!merge <<: *cachyllama
|
||||
name: "cachyllama-development"
|
||||
capabilities:
|
||||
default: "cpu-cachyllama-development"
|
||||
vulkan: "vulkan-cachyllama-development"
|
||||
metal: "metal-cachyllama-development"
|
||||
- !!merge <<: *bonsai
|
||||
name: "bonsai-development"
|
||||
capabilities:
|
||||
@@ -2109,7 +2134,7 @@
|
||||
default: "cpu-stablediffusion-ggml-development"
|
||||
nvidia: "cuda12-stablediffusion-ggml-development"
|
||||
intel: "intel-sycl-f16-stablediffusion-ggml-development"
|
||||
amd: "rocm-stablediffusion-ggml-development"
|
||||
# amd: "rocm-stablediffusion-ggml-development"
|
||||
vulkan: "vulkan-stablediffusion-ggml-development"
|
||||
nvidia-l4t: "nvidia-l4t-arm64-stablediffusion-ggml-development"
|
||||
metal: "metal-stablediffusion-ggml-development"
|
||||
@@ -2828,6 +2853,37 @@
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-nvidia-l4t-cuda-13-arm64-turboquant"
|
||||
mirrors:
|
||||
- localai/localai-backends:master-nvidia-l4t-cuda-13-arm64-turboquant
|
||||
## cachyllama
|
||||
- !!merge <<: *cachyllama
|
||||
name: "cpu-cachyllama"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:latest-cpu-cachyllama"
|
||||
mirrors:
|
||||
- localai/localai-backends:latest-cpu-cachyllama
|
||||
- !!merge <<: *cachyllama
|
||||
name: "cpu-cachyllama-development"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-cpu-cachyllama"
|
||||
mirrors:
|
||||
- localai/localai-backends:master-cpu-cachyllama
|
||||
- !!merge <<: *cachyllama
|
||||
name: "vulkan-cachyllama"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-vulkan-cachyllama"
|
||||
mirrors:
|
||||
- localai/localai-backends:latest-gpu-vulkan-cachyllama
|
||||
- !!merge <<: *cachyllama
|
||||
name: "vulkan-cachyllama-development"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-vulkan-cachyllama"
|
||||
mirrors:
|
||||
- localai/localai-backends:master-gpu-vulkan-cachyllama
|
||||
- !!merge <<: *cachyllama
|
||||
name: "metal-cachyllama"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:latest-metal-darwin-arm64-cachyllama"
|
||||
mirrors:
|
||||
- localai/localai-backends:latest-metal-darwin-arm64-cachyllama
|
||||
- !!merge <<: *cachyllama
|
||||
name: "metal-cachyllama-development"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-metal-darwin-arm64-cachyllama"
|
||||
mirrors:
|
||||
- localai/localai-backends:master-metal-darwin-arm64-cachyllama
|
||||
## bonsai
|
||||
- !!merge <<: *bonsai
|
||||
name: "cpu-bonsai"
|
||||
@@ -3904,11 +3960,6 @@
|
||||
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-nvidia-cuda-12-stablediffusion-ggml"
|
||||
mirrors:
|
||||
- localai/localai-backends:latest-gpu-nvidia-cuda-12-stablediffusion-ggml
|
||||
- !!merge <<: *stablediffusionggml
|
||||
name: "rocm-stablediffusion-ggml"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-rocm-hipblas-stablediffusion-ggml"
|
||||
mirrors:
|
||||
- localai/localai-backends:latest-gpu-rocm-hipblas-stablediffusion-ggml
|
||||
- !!merge <<: *stablediffusionggml
|
||||
name: "intel-sycl-f32-stablediffusion-ggml"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-intel-sycl-f32-stablediffusion-ggml"
|
||||
@@ -3922,11 +3973,6 @@
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-nvidia-cuda-12-stablediffusion-ggml"
|
||||
mirrors:
|
||||
- localai/localai-backends:master-gpu-nvidia-cuda-12-stablediffusion-ggml
|
||||
- !!merge <<: *stablediffusionggml
|
||||
name: "rocm-stablediffusion-ggml-development"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-rocm-hipblas-stablediffusion-ggml"
|
||||
mirrors:
|
||||
- localai/localai-backends:master-gpu-rocm-hipblas-stablediffusion-ggml
|
||||
- !!merge <<: *stablediffusionggml
|
||||
name: "intel-sycl-f32-stablediffusion-ggml-development"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-intel-sycl-f32-stablediffusion-ggml"
|
||||
|
||||
@@ -19,7 +19,6 @@ import grpc
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'common'))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'common'))
|
||||
from grpc_auth import get_auth_interceptors
|
||||
from temp_utils import cleanup_paths
|
||||
|
||||
import tempfile
|
||||
|
||||
@@ -116,6 +115,11 @@ def merge_audio_files(audio_files, output_path, sample_rate):
|
||||
# Save the merged audio
|
||||
ta.save(output_path, merged_waveform, sample_rate)
|
||||
|
||||
# Clean up temporary files
|
||||
for audio_file in audio_files:
|
||||
if os.path.exists(audio_file):
|
||||
os.remove(audio_file)
|
||||
|
||||
_ONE_DAY_IN_SECONDS = 60 * 60 * 24
|
||||
|
||||
# If MAX_WORKERS are specified in the environment use it, otherwise default to 1
|
||||
@@ -222,20 +226,19 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
|
||||
text_chunks = split_text_at_word_boundary(request.text, max_length=250)
|
||||
print(f"Splitting text into chunks of 250 characters: {len(text_chunks)}", file=sys.stderr)
|
||||
# Generate audio for each chunk
|
||||
with cleanup_paths() as temp_audio_files:
|
||||
for i, chunk in enumerate(text_chunks):
|
||||
# Generate audio for this chunk
|
||||
wav = self.model.generate(chunk, **kwargs)
|
||||
|
||||
# Register ownership before saving so a partial write is
|
||||
# removed too when generation or encoding fails.
|
||||
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.wav')
|
||||
temp_file.close()
|
||||
temp_audio_files.append(temp_file.name)
|
||||
ta.save(temp_file.name, wav, self.model.sr)
|
||||
|
||||
# Merge all audio files
|
||||
merge_audio_files(temp_audio_files, request.dst, self.model.sr)
|
||||
temp_audio_files = []
|
||||
for i, chunk in enumerate(text_chunks):
|
||||
# Generate audio for this chunk
|
||||
wav = self.model.generate(chunk, **kwargs)
|
||||
|
||||
# Create temporary file for this chunk
|
||||
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.wav')
|
||||
temp_file.close()
|
||||
ta.save(temp_file.name, wav, self.model.sr)
|
||||
temp_audio_files.append(temp_file.name)
|
||||
|
||||
# Merge all audio files
|
||||
merge_audio_files(temp_audio_files, request.dst, self.model.sr)
|
||||
else:
|
||||
# Generate audio using ChatterboxTTS for short text
|
||||
wav = self.model.generate(request.text, **kwargs)
|
||||
|
||||
@@ -37,46 +37,6 @@ def parse_options(options_list):
|
||||
return opts
|
||||
|
||||
|
||||
def attach_media_parts(messages_dicts, n_images=0, n_videos=0):
|
||||
"""Rebuild the last user message as content *parts* carrying media markers.
|
||||
|
||||
Backends that let the tokenizer do the templating hand plain string content
|
||||
to ``apply_chat_template``, but a chat template only emits the model's own
|
||||
media tokens (``<|vision_start|><|image_pad|><|vision_end|>`` for the
|
||||
Qwen-VL family, and the equivalents elsewhere) when the content is a list
|
||||
of parts. Without those markers the engine's multimodal processor finds
|
||||
nothing to substitute and silently discards the pixels, even though they
|
||||
were forwarded correctly out of band.
|
||||
|
||||
Returns a new list whose last user message has
|
||||
``[{"type": "image"} * n_images, {"type": "video"} * n_videos, text]`` as
|
||||
its content, or ``None`` when there is nothing to attach - no media, no
|
||||
user turn, or content that is already a list of parts - so the caller can
|
||||
keep using the original string-content list.
|
||||
"""
|
||||
if not n_images and not n_videos:
|
||||
return None
|
||||
idx = next(
|
||||
(
|
||||
i
|
||||
for i in reversed(range(len(messages_dicts)))
|
||||
if messages_dicts[i].get("role") == "user"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if idx is None:
|
||||
return None
|
||||
text = messages_dicts[idx].get("content") or ""
|
||||
if not isinstance(text, str):
|
||||
return None
|
||||
parts = [{"type": "image"}] * n_images + [{"type": "video"}] * n_videos
|
||||
if text:
|
||||
parts.append({"type": "text", "text": text})
|
||||
patched = list(messages_dicts)
|
||||
patched[idx] = dict(patched[idx], content=parts)
|
||||
return patched
|
||||
|
||||
|
||||
def messages_to_dicts(proto_messages):
|
||||
"""Convert proto ``Message`` objects to dicts suitable for ``apply_chat_template``.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import json
|
||||
import types
|
||||
import unittest
|
||||
|
||||
from python_utils import attach_media_parts, messages_to_dicts, parse_options
|
||||
from python_utils import messages_to_dicts, parse_options
|
||||
|
||||
|
||||
def _msg(**fields):
|
||||
@@ -118,63 +118,5 @@ class TestMessagesToDicts(unittest.TestCase):
|
||||
self.assertNotIn("tool_calls", out[0])
|
||||
|
||||
|
||||
class TestAttachMediaParts(unittest.TestCase):
|
||||
def test_image_marker_added_to_last_user_turn(self):
|
||||
messages = [
|
||||
{"role": "system", "content": "be brief"},
|
||||
{"role": "user", "content": "first"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
{"role": "user", "content": "how high is the water?"},
|
||||
]
|
||||
out = attach_media_parts(messages, n_images=1)
|
||||
self.assertEqual(
|
||||
out[3]["content"],
|
||||
[{"type": "image"}, {"type": "text", "text": "how high is the water?"}],
|
||||
)
|
||||
# Earlier turns and the input list itself are untouched.
|
||||
self.assertEqual(out[:3], messages[:3])
|
||||
self.assertEqual(messages[3]["content"], "how high is the water?")
|
||||
|
||||
def test_counts_and_order_images_then_videos(self):
|
||||
out = attach_media_parts(
|
||||
[{"role": "user", "content": "describe"}], n_images=2, n_videos=1
|
||||
)
|
||||
self.assertEqual(
|
||||
out[0]["content"],
|
||||
[
|
||||
{"type": "image"},
|
||||
{"type": "image"},
|
||||
{"type": "video"},
|
||||
{"type": "text", "text": "describe"},
|
||||
],
|
||||
)
|
||||
|
||||
def test_empty_text_yields_media_only_parts(self):
|
||||
out = attach_media_parts([{"role": "user", "content": ""}], n_images=1)
|
||||
self.assertEqual(out[0]["content"], [{"type": "image"}])
|
||||
|
||||
def test_other_message_keys_are_preserved(self):
|
||||
out = attach_media_parts(
|
||||
[{"role": "user", "content": "hi", "name": "bob"}], n_images=1
|
||||
)
|
||||
self.assertEqual(out[0]["name"], "bob")
|
||||
|
||||
def test_no_media_is_a_no_op(self):
|
||||
self.assertIsNone(attach_media_parts([{"role": "user", "content": "hi"}]))
|
||||
|
||||
def test_no_user_turn_is_a_no_op(self):
|
||||
self.assertIsNone(
|
||||
attach_media_parts([{"role": "system", "content": "hi"}], n_images=1)
|
||||
)
|
||||
|
||||
def test_content_already_parts_is_a_no_op(self):
|
||||
self.assertIsNone(
|
||||
attach_media_parts(
|
||||
[{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
|
||||
n_images=1,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,36 +0,0 @@
|
||||
import base64
|
||||
import contextlib
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def materialize_base64(data, suffix=""):
|
||||
"""Materialize base64 data for a path-only library and always remove it."""
|
||||
descriptor, path = tempfile.mkstemp(prefix="localai-media-", suffix=suffix)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as output:
|
||||
descriptor = None
|
||||
output.write(base64.b64decode(data))
|
||||
yield path
|
||||
finally:
|
||||
if descriptor is not None:
|
||||
os.close(descriptor)
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def cleanup_paths():
|
||||
"""Collect temporary paths and remove them on success or failure."""
|
||||
paths = []
|
||||
try:
|
||||
yield paths
|
||||
finally:
|
||||
for path in paths:
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
@@ -1,41 +0,0 @@
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from temp_utils import cleanup_paths, materialize_base64
|
||||
|
||||
|
||||
class MaterializeBase64Test(unittest.TestCase):
|
||||
def test_removes_materialized_file_after_success(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with mock.patch.object(tempfile, "tempdir", directory):
|
||||
with materialize_base64("aGVsbG8=", suffix=".data") as path:
|
||||
with open(path, "rb") as materialized:
|
||||
self.assertEqual(materialized.read(), b"hello")
|
||||
self.assertFalse(os.path.exists(path))
|
||||
|
||||
def test_removes_materialized_file_when_consumer_fails(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with mock.patch.object(tempfile, "tempdir", directory):
|
||||
with self.assertRaisesRegex(RuntimeError, "decode failed"):
|
||||
with materialize_base64("aGVsbG8="):
|
||||
raise RuntimeError("decode failed")
|
||||
self.assertEqual(os.listdir(directory), [])
|
||||
|
||||
|
||||
class CleanupPathsTest(unittest.TestCase):
|
||||
def test_removes_every_registered_path_after_failure(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
paths = [os.path.join(directory, name) for name in ("one.wav", "two.wav")]
|
||||
with self.assertRaisesRegex(RuntimeError, "merge failed"):
|
||||
with cleanup_paths() as registered:
|
||||
for path in paths:
|
||||
open(path, "wb").close()
|
||||
registered.append(path)
|
||||
raise RuntimeError("merge failed")
|
||||
self.assertEqual(os.listdir(directory), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,4 +1,4 @@
|
||||
--extra-index-url https://download.pytorch.org/whl/xpu
|
||||
torch==2.14.0+xpu
|
||||
torch==2.13.0+xpu
|
||||
oneccl_bind_pt==2.8.0+xpu
|
||||
optimum[openvino]
|
||||
@@ -1,3 +1,3 @@
|
||||
grpcio==1.83.1
|
||||
grpcio==1.82.1
|
||||
protobuf
|
||||
grpcio-tools
|
||||
Loaded 100 of 992 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user