Compare commits

..

1 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
1c073f6640 Initial plan 2026-01-10 00:10:07 +00:00
102 changed files with 278 additions and 13438 deletions

View File

@@ -105,19 +105,6 @@ jobs:
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "9"
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-nvidia-cuda-12-pocket-tts'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "pocket-tts"
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "0"
@@ -137,7 +124,7 @@ jobs:
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-nvidia-cuda-12-llama-cpp'
runs-on: 'bigger-runner'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "llama-cpp"
@@ -353,19 +340,6 @@ jobs:
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-nvidia-cuda-13-pocket-tts'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "pocket-tts"
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
@@ -431,19 +405,6 @@ jobs:
backend: "vibevoice"
dockerfile: "./backend/Dockerfile.python"
context: "./"
- build-type: 'l4t'
cuda-major-version: "13"
cuda-minor-version: "0"
platforms: 'linux/arm64'
tag-latest: 'auto'
tag-suffix: '-nvidia-l4t-cuda-13-arm64-pocket-tts'
runs-on: 'ubuntu-24.04-arm'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
ubuntu-version: '2404'
backend: "pocket-tts"
dockerfile: "./backend/Dockerfile.python"
context: "./"
- build-type: 'l4t'
cuda-major-version: "13"
cuda-minor-version: "0"
@@ -680,26 +641,13 @@ jobs:
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: 'hipblas'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-rocm-hipblas-pocket-tts'
runs-on: 'arc-runner-set'
base-image: "rocm/dev-ubuntu-24.04:6.4.4"
skip-drivers: 'false'
backend: "pocket-tts"
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: 'hipblas'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-rocm-hipblas-faster-whisper'
runs-on: 'bigger-runner'
runs-on: 'ubuntu-latest'
base-image: "rocm/dev-ubuntu-24.04:6.4.4"
skip-drivers: 'false'
backend: "faster-whisper"
@@ -712,7 +660,7 @@ jobs:
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-rocm-hipblas-coqui'
runs-on: 'bigger-runner'
runs-on: 'ubuntu-latest'
base-image: "rocm/dev-ubuntu-24.04:6.4.4"
skip-drivers: 'false'
backend: "coqui"
@@ -824,19 +772,6 @@ jobs:
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2204'
- build-type: 'l4t'
cuda-major-version: "12"
cuda-minor-version: "0"
platforms: 'linux/arm64'
tag-latest: 'auto'
tag-suffix: '-nvidia-l4t-pocket-tts'
runs-on: 'ubuntu-24.04-arm'
base-image: "nvcr.io/nvidia/l4t-jetpack:r36.4.0"
skip-drivers: 'true'
backend: "pocket-tts"
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2204'
- build-type: 'l4t'
cuda-major-version: "12"
cuda-minor-version: "0"
@@ -890,19 +825,6 @@ jobs:
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: 'intel'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-intel-pocket-tts'
runs-on: 'arc-runner-set'
base-image: "intel/oneapi-basekit:2025.3.0-0-devel-ubuntu24.04"
skip-drivers: 'false'
backend: "pocket-tts"
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: 'intel'
cuda-major-version: ""
cuda-minor-version: ""
@@ -963,7 +885,7 @@ jobs:
platforms: 'linux/amd64,linux/arm64'
tag-latest: 'auto'
tag-suffix: '-cpu-llama-cpp'
runs-on: 'bigger-runner'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "llama-cpp"
@@ -989,7 +911,7 @@ jobs:
platforms: 'linux/amd64,linux/arm64'
tag-latest: 'auto'
tag-suffix: '-gpu-vulkan-llama-cpp'
runs-on: 'bigger-runner'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "llama-cpp"
@@ -1330,6 +1252,19 @@ jobs:
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: 'l4t'
cuda-major-version: "12"
cuda-minor-version: "0"
platforms: 'linux/arm64'
skip-drivers: 'true'
tag-latest: 'auto'
tag-suffix: '-nvidia-l4t-arm64-neutts'
base-image: "nvcr.io/nvidia/l4t-jetpack:r36.4.0"
runs-on: 'ubuntu-24.04-arm'
backend: "neutts"
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2204'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
@@ -1343,19 +1278,6 @@ jobs:
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64,linux/arm64'
tag-latest: 'auto'
tag-suffix: '-cpu-pocket-tts'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "pocket-tts"
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
backend-jobs-darwin:
uses: ./.github/workflows/backend_build_darwin.yml
strategy:

View File

@@ -265,23 +265,4 @@ jobs:
- name: Test moonshine
run: |
make --jobs=5 --output-sync=target -C backend/python/moonshine
make --jobs=5 --output-sync=target -C backend/python/moonshine test
tests-pocket-tts:
runs-on: ubuntu-latest
steps:
- name: Clone
uses: actions/checkout@v6
with:
submodules: true
- name: Dependencies
run: |
sudo apt-get update
sudo apt-get install build-essential ffmpeg
sudo apt-get install -y ca-certificates cmake curl patch python3-pip
# Install UV
curl -LsSf https://astral.sh/uv/install.sh | sh
pip install --user --no-cache-dir grpcio-tools==1.64.1
- name: Test pocket-tts
run: |
make --jobs=5 --output-sync=target -C backend/python/pocket-tts
make --jobs=5 --output-sync=target -C backend/python/pocket-tts test
make --jobs=5 --output-sync=target -C backend/python/moonshine test

View File

@@ -42,22 +42,22 @@ RUN <<EOT bash
ocaml-core ninja-build pkg-config libxml2-dev wayland-protocols python3-jsonschema \
clang-format qtbase5-dev qt6-base-dev libxcb-glx0-dev sudo xz-utils mesa-vulkan-drivers
if [ "amd64" = "$TARGETARCH" ]; then
wget "https://sdk.lunarg.com/sdk/download/1.4.335.0/linux/vulkansdk-linux-x86_64-1.4.335.0.tar.xz" && \
tar -xf vulkansdk-linux-x86_64-1.4.335.0.tar.xz && \
rm vulkansdk-linux-x86_64-1.4.335.0.tar.xz && \
wget "https://sdk.lunarg.com/sdk/download/1.4.328.1/linux/vulkansdk-linux-x86_64-1.4.328.1.tar.xz" && \
tar -xf vulkansdk-linux-x86_64-1.4.328.1.tar.xz && \
rm vulkansdk-linux-x86_64-1.4.328.1.tar.xz && \
mkdir -p /opt/vulkan-sdk && \
mv 1.4.335.0 /opt/vulkan-sdk/ && \
cd /opt/vulkan-sdk/1.4.335.0 && \
mv 1.4.328.1 /opt/vulkan-sdk/ && \
cd /opt/vulkan-sdk/1.4.328.1 && \
./vulkansdk --no-deps --maxjobs \
vulkan-loader \
vulkan-validationlayers \
vulkan-extensionlayer \
vulkan-tools \
shaderc && \
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/bin/* /usr/bin/ && \
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/lib/* /usr/lib/x86_64-linux-gnu/ && \
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/include/* /usr/include/ && \
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/share/* /usr/share/ && \
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/bin/* /usr/bin/ && \
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/lib/* /usr/lib/x86_64-linux-gnu/ && \
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/include/* /usr/include/ && \
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/share/* /usr/share/ && \
rm -rf /opt/vulkan-sdk
fi
if [ "arm64" = "$TARGETARCH" ]; then

View File

@@ -1,5 +1,5 @@
# Disable parallel execution for backend builds
.NOTPARALLEL: backends/diffusers backends/llama-cpp backends/piper backends/stablediffusion-ggml backends/whisper backends/faster-whisper backends/silero-vad backends/local-store backends/huggingface backends/rfdetr 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/stablediffusion-ggml-darwin backends/vllm backends/moonshine backends/pocket-tts
.NOTPARALLEL: backends/diffusers backends/llama-cpp backends/piper backends/stablediffusion-ggml backends/whisper backends/faster-whisper backends/silero-vad backends/local-store backends/huggingface backends/rfdetr 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/stablediffusion-ggml-darwin backends/vllm backends/moonshine
GOCMD=go
GOTEST=$(GOCMD) test
@@ -9,7 +9,7 @@ LAUNCHER_BINARY_NAME=local-ai-launcher
CUDA_MAJOR_VERSION?=13
CUDA_MINOR_VERSION?=0
UBUNTU_VERSION?=2404
UBUNTU_VERSION?=2204
UBUNTU_CODENAME?=noble
GORELEASER?=
@@ -316,7 +316,6 @@ prepare-test-extra: protogen-python
$(MAKE) -C backend/python/vllm
$(MAKE) -C backend/python/vibevoice
$(MAKE) -C backend/python/moonshine
$(MAKE) -C backend/python/pocket-tts
test-extra: prepare-test-extra
$(MAKE) -C backend/python/transformers test
@@ -325,7 +324,6 @@ test-extra: prepare-test-extra
$(MAKE) -C backend/python/vllm test
$(MAKE) -C backend/python/vibevoice test
$(MAKE) -C backend/python/moonshine test
$(MAKE) -C backend/python/pocket-tts test
DOCKER_IMAGE?=local-ai
DOCKER_AIO_IMAGE?=local-ai-aio
@@ -449,16 +447,17 @@ BACKEND_FASTER_WHISPER = faster-whisper|python|.|false|true
BACKEND_COQUI = coqui|python|.|false|true
BACKEND_BARK = bark|python|.|false|true
BACKEND_EXLLAMA2 = exllama2|python|.|false|true
BACKEND_RFDETR = rfdetr|python|.|false|true
BACKEND_KITTEN_TTS = kitten-tts|python|.|false|true
BACKEND_NEUTTS = neutts|python|.|false|true
BACKEND_KOKORO = kokoro|python|.|false|true
BACKEND_VLLM = vllm|python|.|false|true
BACKEND_DIFFUSERS = diffusers|python|.|--progress=plain|true
BACKEND_CHATTERBOX = chatterbox|python|.|false|true
BACKEND_VIBEVOICE = vibevoice|python|.|--progress=plain|true
BACKEND_MOONSHINE = moonshine|python|.|false|true
BACKEND_POCKET_TTS = pocket-tts|python|.|false|true
# Python backends with ./backend context
BACKEND_RFDETR = rfdetr|python|./backend|false|true
BACKEND_KITTEN_TTS = kitten-tts|python|./backend|false|true
BACKEND_NEUTTS = neutts|python|./backend|false|true
BACKEND_KOKORO = kokoro|python|./backend|false|true
BACKEND_VLLM = vllm|python|./backend|false|true
BACKEND_DIFFUSERS = diffusers|python|./backend|--progress=plain|true
BACKEND_CHATTERBOX = chatterbox|python|./backend|false|true
BACKEND_VIBEVOICE = vibevoice|python|./backend|--progress=plain|true
BACKEND_MOONSHINE = moonshine|python|./backend|false|true
# Helper function to build docker image for a backend
# Usage: $(call docker-build-backend,BACKEND_NAME,DOCKERFILE_TYPE,BUILD_CONTEXT,PROGRESS_FLAG,NEEDS_BACKEND_ARG)
@@ -504,13 +503,12 @@ $(eval $(call generate-docker-build-target,$(BACKEND_DIFFUSERS)))
$(eval $(call generate-docker-build-target,$(BACKEND_CHATTERBOX)))
$(eval $(call generate-docker-build-target,$(BACKEND_VIBEVOICE)))
$(eval $(call generate-docker-build-target,$(BACKEND_MOONSHINE)))
$(eval $(call generate-docker-build-target,$(BACKEND_POCKET_TTS)))
# Pattern rule for docker-save targets
docker-save-%: backend-images
docker save local-ai-backend:$* -o backend-images/$*.tar
docker-build-backends: docker-build-llama-cpp docker-build-rerankers docker-build-vllm docker-build-transformers docker-build-diffusers docker-build-kokoro docker-build-faster-whisper docker-build-coqui docker-build-bark docker-build-chatterbox docker-build-vibevoice docker-build-exllama2 docker-build-moonshine docker-build-pocket-tts
docker-build-backends: docker-build-llama-cpp docker-build-rerankers docker-build-vllm docker-build-transformers docker-build-diffusers docker-build-kokoro docker-build-faster-whisper docker-build-coqui docker-build-bark docker-build-chatterbox docker-build-vibevoice docker-build-exllama2 docker-build-moonshine
########################################################
### END Backends

View File

@@ -111,8 +111,6 @@
## 💻 Quickstart
> ⚠️ **Note:** The `install.sh` script is currently experiencing issues due to the heavy changes currently undergoing in LocalAI and may produce broken or misconfigured installations. Please use Docker installation (see below) or manual binary installation until [issue #8032](https://github.com/mudler/LocalAI/issues/8032) is resolved.
Run the installer script:
```bash
@@ -130,7 +128,7 @@ For more installation options, see [Installer Options](https://localai.io/instal
> Note: the DMGs are not signed by Apple as quarantined. See https://github.com/mudler/LocalAI/issues/6268 for a workaround, fix is tracked here: https://github.com/mudler/LocalAI/issues/6244
### Containers (Docker, podman, ...)
Or run with docker:
> **💡 Docker Run vs Docker Start**
>
@@ -139,13 +137,13 @@ For more installation options, see [Installer Options](https://localai.io/instal
>
> If you've already run LocalAI before and want to start it again, use: `docker start -i local-ai`
#### CPU only image:
### CPU only image:
```bash
docker run -ti --name local-ai -p 8080:8080 localai/localai:latest
```
#### NVIDIA GPU Images:
### NVIDIA GPU Images:
```bash
# CUDA 13.0
@@ -162,25 +160,25 @@ docker run -ti --name local-ai -p 8080:8080 --gpus all localai/localai:latest-nv
docker run -ti --name local-ai -p 8080:8080 --gpus all localai/localai:latest-nvidia-l4t-arm64-cuda-13
```
#### AMD GPU Images (ROCm):
### AMD GPU Images (ROCm):
```bash
docker run -ti --name local-ai -p 8080:8080 --device=/dev/kfd --device=/dev/dri --group-add=video localai/localai:latest-gpu-hipblas
```
#### Intel GPU Images (oneAPI):
### Intel GPU Images (oneAPI):
```bash
docker run -ti --name local-ai -p 8080:8080 --device=/dev/dri/card1 --device=/dev/dri/renderD128 localai/localai:latest-gpu-intel
```
#### Vulkan GPU Images:
### Vulkan GPU Images:
```bash
docker run -ti --name local-ai -p 8080:8080 localai/localai:latest-gpu-vulkan
```
#### AIO Images (pre-downloaded models):
### AIO Images (pre-downloaded models):
```bash
# CPU version
@@ -297,7 +295,6 @@ LocalAI supports a comprehensive range of AI backends with multiple acceleration
| **silero-vad** | Voice Activity Detection | CPU |
| **neutts** | Text-to-speech with voice cloning | CUDA 12/13, ROCm, CPU |
| **vibevoice** | Real-time TTS with voice cloning | CUDA 12/13, ROCm, Intel, CPU |
| **pocket-tts** | Lightweight CPU-based TTS | CUDA 12/13, ROCm, Intel, CPU |
### Image & Video Generation
| Backend | Description | Acceleration Support |
@@ -319,8 +316,8 @@ LocalAI supports a comprehensive range of AI backends with multiple acceleration
|-------------------|-------------------|------------------|
| **NVIDIA CUDA 12** | All CUDA-compatible backends | Nvidia hardware |
| **NVIDIA CUDA 13** | All CUDA-compatible backends | Nvidia hardware |
| **AMD ROCm** | llama.cpp, whisper, vllm, transformers, diffusers, rerankers, coqui, kokoro, bark, neutts, vibevoice, pocket-tts | AMD Graphics |
| **Intel oneAPI** | llama.cpp, whisper, stablediffusion, vllm, transformers, diffusers, rfdetr, rerankers, exllama2, coqui, kokoro, bark, vibevoice, pocket-tts | Intel Arc, Intel iGPUs |
| **AMD ROCm** | llama.cpp, whisper, vllm, transformers, diffusers, rerankers, coqui, kokoro, bark, neutts, vibevoice | AMD Graphics |
| **Intel oneAPI** | llama.cpp, whisper, stablediffusion, vllm, transformers, diffusers, rfdetr, rerankers, exllama2, coqui, kokoro, bark, vibevoice | Intel Arc, Intel iGPUs |
| **Apple Metal** | llama.cpp, whisper, diffusers, MLX, MLX-VLM, bark-cpp | Apple M1/M2/M3+ |
| **Vulkan** | llama.cpp, whisper, stablediffusion | Cross-platform GPUs |
| **NVIDIA Jetson (CUDA 12)** | llama.cpp, whisper, stablediffusion, diffusers, rfdetr | ARM64 embedded AI (AGX Orin, etc.) |

View File

@@ -47,22 +47,22 @@ RUN <<EOT bash
ocaml-core ninja-build pkg-config libxml2-dev wayland-protocols python3-jsonschema \
clang-format qtbase5-dev qt6-base-dev libxcb-glx0-dev sudo xz-utils
if [ "amd64" = "$TARGETARCH" ]; then
wget "https://sdk.lunarg.com/sdk/download/1.4.335.0/linux/vulkansdk-linux-x86_64-1.4.335.0.tar.xz" && \
tar -xf vulkansdk-linux-x86_64-1.4.335.0.tar.xz && \
rm vulkansdk-linux-x86_64-1.4.335.0.tar.xz && \
wget "https://sdk.lunarg.com/sdk/download/1.4.328.1/linux/vulkansdk-linux-x86_64-1.4.328.1.tar.xz" && \
tar -xf vulkansdk-linux-x86_64-1.4.328.1.tar.xz && \
rm vulkansdk-linux-x86_64-1.4.328.1.tar.xz && \
mkdir -p /opt/vulkan-sdk && \
mv 1.4.335.0 /opt/vulkan-sdk/ && \
cd /opt/vulkan-sdk/1.4.335.0 && \
mv 1.4.328.1 /opt/vulkan-sdk/ && \
cd /opt/vulkan-sdk/1.4.328.1 && \
./vulkansdk --no-deps --maxjobs \
vulkan-loader \
vulkan-validationlayers \
vulkan-extensionlayer \
vulkan-tools \
shaderc && \
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/bin/* /usr/bin/ && \
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/lib/* /usr/lib/x86_64-linux-gnu/ && \
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/include/* /usr/include/ && \
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/share/* /usr/share/ && \
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/bin/* /usr/bin/ && \
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/lib/* /usr/lib/x86_64-linux-gnu/ && \
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/include/* /usr/include/ && \
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/share/* /usr/share/ && \
rm -rf /opt/vulkan-sdk
fi
if [ "arm64" = "$TARGETARCH" ]; then

View File

@@ -104,22 +104,22 @@ RUN <<EOT bash
ocaml-core ninja-build pkg-config libxml2-dev wayland-protocols python3-jsonschema \
clang-format qtbase5-dev qt6-base-dev libxcb-glx0-dev sudo xz-utils
if [ "amd64" = "$TARGETARCH" ]; then
wget "https://sdk.lunarg.com/sdk/download/1.4.335.0/linux/vulkansdk-linux-x86_64-1.4.335.0.tar.xz" && \
tar -xf vulkansdk-linux-x86_64-1.4.335.0.tar.xz && \
rm vulkansdk-linux-x86_64-1.4.335.0.tar.xz && \
wget "https://sdk.lunarg.com/sdk/download/1.4.328.1/linux/vulkansdk-linux-x86_64-1.4.328.1.tar.xz" && \
tar -xf vulkansdk-linux-x86_64-1.4.328.1.tar.xz && \
rm vulkansdk-linux-x86_64-1.4.328.1.tar.xz && \
mkdir -p /opt/vulkan-sdk && \
mv 1.4.335.0 /opt/vulkan-sdk/ && \
cd /opt/vulkan-sdk/1.4.335.0 && \
mv 1.4.328.1 /opt/vulkan-sdk/ && \
cd /opt/vulkan-sdk/1.4.328.1 && \
./vulkansdk --no-deps --maxjobs \
vulkan-loader \
vulkan-validationlayers \
vulkan-extensionlayer \
vulkan-tools \
shaderc && \
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/bin/* /usr/bin/ && \
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/lib/* /usr/lib/x86_64-linux-gnu/ && \
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/include/* /usr/include/ && \
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/share/* /usr/share/ && \
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/bin/* /usr/bin/ && \
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/lib/* /usr/lib/x86_64-linux-gnu/ && \
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/include/* /usr/include/ && \
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/share/* /usr/share/ && \
rm -rf /opt/vulkan-sdk
fi
if [ "arm64" = "$TARGETARCH" ]; then

View File

@@ -61,22 +61,22 @@ RUN <<EOT bash
ocaml-core ninja-build pkg-config libxml2-dev wayland-protocols python3-jsonschema \
clang-format qtbase5-dev qt6-base-dev libxcb-glx0-dev sudo xz-utils
if [ "amd64" = "$TARGETARCH" ]; then
wget "https://sdk.lunarg.com/sdk/download/1.4.335.0/linux/vulkansdk-linux-x86_64-1.4.335.0.tar.xz" && \
tar -xf vulkansdk-linux-x86_64-1.4.335.0.tar.xz && \
rm vulkansdk-linux-x86_64-1.4.335.0.tar.xz && \
wget "https://sdk.lunarg.com/sdk/download/1.4.328.1/linux/vulkansdk-linux-x86_64-1.4.328.1.tar.xz" && \
tar -xf vulkansdk-linux-x86_64-1.4.328.1.tar.xz && \
rm vulkansdk-linux-x86_64-1.4.328.1.tar.xz && \
mkdir -p /opt/vulkan-sdk && \
mv 1.4.335.0 /opt/vulkan-sdk/ && \
cd /opt/vulkan-sdk/1.4.335.0 && \
mv 1.4.328.1 /opt/vulkan-sdk/ && \
cd /opt/vulkan-sdk/1.4.328.1 && \
./vulkansdk --no-deps --maxjobs \
vulkan-loader \
vulkan-validationlayers \
vulkan-extensionlayer \
vulkan-tools \
shaderc && \
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/bin/* /usr/bin/ && \
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/lib/* /usr/lib/x86_64-linux-gnu/ && \
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/include/* /usr/include/ && \
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/share/* /usr/share/ && \
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/bin/* /usr/bin/ && \
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/lib/* /usr/lib/x86_64-linux-gnu/ && \
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/include/* /usr/include/ && \
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/share/* /usr/share/ && \
rm -rf /opt/vulkan-sdk
fi
if [ "arm64" = "$TARGETARCH" ]; then

View File

@@ -1,5 +1,5 @@
LLAMA_VERSION?=959ecf7f234dc0bc0cd6829b25cb0ee1481aa78a
LLAMA_VERSION?=593da7fa49503b68f9f01700be9f508f1e528992
LLAMA_REPO?=https://github.com/ggerganov/llama.cpp
CMAKE_ARGS?=

View File

@@ -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?=a48b4a3ade9972faf0adcad47e51c6fc03f0e46d
STABLEDIFFUSION_GGML_VERSION?=0e52afc6513cc2dea9a1a017afc4a008d5acf2b0
CMAKE_ARGS+=-DGGML_MAX_NAME=128

View File

@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# whisper.cpp version
WHISPER_REPO?=https://github.com/ggml-org/whisper.cpp
WHISPER_CPP_VERSION?=f53dc74843e97f19f94a79241357f74ad5b691a6
WHISPER_CPP_VERSION?=679bdb53dbcbfb3e42685f50c7ff367949fd4d48
SO_TARGET?=libgowhisper.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF

View File

@@ -428,28 +428,6 @@
nvidia-l4t-cuda-12: "nvidia-l4t-vibevoice"
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-vibevoice"
icon: https://avatars.githubusercontent.com/u/6154722?s=200&v=4
- &pocket-tts
urls:
- https://github.com/kyutai-labs/pocket-tts
description: |
Pocket TTS is a lightweight text-to-speech model designed to run efficiently on CPUs.
tags:
- text-to-speech
- TTS
license: mit
name: "pocket-tts"
alias: "pocket-tts"
capabilities:
nvidia: "cuda12-pocket-tts"
intel: "intel-pocket-tts"
amd: "rocm-pocket-tts"
nvidia-l4t: "nvidia-l4t-pocket-tts"
default: "cpu-pocket-tts"
nvidia-cuda-13: "cuda13-pocket-tts"
nvidia-cuda-12: "cuda12-pocket-tts"
nvidia-l4t-cuda-12: "nvidia-l4t-pocket-tts"
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-pocket-tts"
icon: https://avatars.githubusercontent.com/u/6154722?s=200&v=4
- &piper
name: "piper"
uri: "quay.io/go-skynet/local-ai-backends:latest-piper"
@@ -537,14 +515,18 @@
default: "cpu-neutts"
nvidia: "cuda12-neutts"
amd: "rocm-neutts"
nvidia-l4t: "nvidia-l4t-neutts"
nvidia-cuda-12: "cuda12-neutts"
nvidia-l4t-cuda-12: "nvidia-l4t-arm64-neutts"
- !!merge <<: *neutts
name: "neutts-development"
capabilities:
default: "cpu-neutts-development"
nvidia: "cuda12-neutts-development"
amd: "rocm-neutts-development"
nvidia-l4t: "nvidia-l4t-neutts-development"
nvidia-cuda-12: "cuda12-neutts-development"
nvidia-l4t-cuda-12: "nvidia-l4t-arm64-neutts-development"
- !!merge <<: *llamacpp
name: "llama-cpp-development"
capabilities:
@@ -574,6 +556,11 @@
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-rocm-hipblas-neutts"
mirrors:
- localai/localai-backends:latest-gpu-rocm-hipblas-neutts
- !!merge <<: *neutts
name: "nvidia-l4t-arm64-neutts"
uri: "quay.io/go-skynet/local-ai-backends:latest-nvidia-l4t-arm64-neutts"
mirrors:
- localai/localai-backends:latest-nvidia-l4t-arm64-neutts
- !!merge <<: *neutts
name: "cpu-neutts-development"
uri: "quay.io/go-skynet/local-ai-backends:master-cpu-neutts"
@@ -589,6 +576,11 @@
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-rocm-hipblas-neutts"
mirrors:
- localai/localai-backends:master-gpu-rocm-hipblas-neutts
- !!merge <<: *neutts
name: "nvidia-l4t-arm64-neutts-development"
uri: "quay.io/go-skynet/local-ai-backends:master-nvidia-l4t-arm64-neutts"
mirrors:
- localai/localai-backends:master-nvidia-l4t-arm64-neutts
- !!merge <<: *mlx
name: "mlx-development"
uri: "quay.io/go-skynet/local-ai-backends:master-metal-darwin-arm64-mlx"
@@ -1613,86 +1605,3 @@
uri: "quay.io/go-skynet/local-ai-backends:master-nvidia-l4t-cuda-13-arm64-vibevoice"
mirrors:
- localai/localai-backends:master-nvidia-l4t-cuda-13-arm64-vibevoice
## pocket-tts
- !!merge <<: *pocket-tts
name: "pocket-tts-development"
capabilities:
nvidia: "cuda12-pocket-tts-development"
intel: "intel-pocket-tts-development"
amd: "rocm-pocket-tts-development"
nvidia-l4t: "nvidia-l4t-pocket-tts-development"
default: "cpu-pocket-tts-development"
nvidia-cuda-13: "cuda13-pocket-tts-development"
nvidia-cuda-12: "cuda12-pocket-tts-development"
nvidia-l4t-cuda-12: "nvidia-l4t-pocket-tts-development"
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-pocket-tts-development"
- !!merge <<: *pocket-tts
name: "cpu-pocket-tts"
uri: "quay.io/go-skynet/local-ai-backends:latest-cpu-pocket-tts"
mirrors:
- localai/localai-backends:latest-cpu-pocket-tts
- !!merge <<: *pocket-tts
name: "cpu-pocket-tts-development"
uri: "quay.io/go-skynet/local-ai-backends:master-cpu-pocket-tts"
mirrors:
- localai/localai-backends:master-cpu-pocket-tts
- !!merge <<: *pocket-tts
name: "cuda12-pocket-tts"
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-nvidia-cuda-12-pocket-tts"
mirrors:
- localai/localai-backends:latest-gpu-nvidia-cuda-12-pocket-tts
- !!merge <<: *pocket-tts
name: "cuda12-pocket-tts-development"
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-nvidia-cuda-12-pocket-tts"
mirrors:
- localai/localai-backends:master-gpu-nvidia-cuda-12-pocket-tts
- !!merge <<: *pocket-tts
name: "cuda13-pocket-tts"
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-nvidia-cuda-13-pocket-tts"
mirrors:
- localai/localai-backends:latest-gpu-nvidia-cuda-13-pocket-tts
- !!merge <<: *pocket-tts
name: "cuda13-pocket-tts-development"
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-nvidia-cuda-13-pocket-tts"
mirrors:
- localai/localai-backends:master-gpu-nvidia-cuda-13-pocket-tts
- !!merge <<: *pocket-tts
name: "intel-pocket-tts"
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-intel-pocket-tts"
mirrors:
- localai/localai-backends:latest-gpu-intel-pocket-tts
- !!merge <<: *pocket-tts
name: "intel-pocket-tts-development"
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-intel-pocket-tts"
mirrors:
- localai/localai-backends:master-gpu-intel-pocket-tts
- !!merge <<: *pocket-tts
name: "rocm-pocket-tts"
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-rocm-hipblas-pocket-tts"
mirrors:
- localai/localai-backends:latest-gpu-rocm-hipblas-pocket-tts
- !!merge <<: *pocket-tts
name: "rocm-pocket-tts-development"
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-rocm-hipblas-pocket-tts"
mirrors:
- localai/localai-backends:master-gpu-rocm-hipblas-pocket-tts
- !!merge <<: *pocket-tts
name: "nvidia-l4t-pocket-tts"
uri: "quay.io/go-skynet/local-ai-backends:latest-nvidia-l4t-pocket-tts"
mirrors:
- localai/localai-backends:latest-nvidia-l4t-pocket-tts
- !!merge <<: *pocket-tts
name: "nvidia-l4t-pocket-tts-development"
uri: "quay.io/go-skynet/local-ai-backends:master-nvidia-l4t-pocket-tts"
mirrors:
- localai/localai-backends:master-nvidia-l4t-pocket-tts
- !!merge <<: *pocket-tts
name: "cuda13-nvidia-l4t-arm64-pocket-tts"
uri: "quay.io/go-skynet/local-ai-backends:latest-nvidia-l4t-cuda-13-arm64-pocket-tts"
mirrors:
- localai/localai-backends:latest-nvidia-l4t-cuda-13-arm64-pocket-tts
- !!merge <<: *pocket-tts
name: "cuda13-nvidia-l4t-arm64-pocket-tts-development"
uri: "quay.io/go-skynet/local-ai-backends:master-nvidia-l4t-cuda-13-arm64-pocket-tts"
mirrors:
- localai/localai-backends:master-nvidia-l4t-cuda-13-arm64-pocket-tts

View File

@@ -17,9 +17,4 @@ if [ "x${BUILD_PROFILE}" == "xintel" ]; then
fi
EXTRA_PIP_INSTALL_FLAGS+=" --no-build-isolation"
if [ "x${BUILD_PROFILE}" == "xl4t12" ]; then
USE_PIP=true
fi
installRequirements

View File

@@ -1,5 +0,0 @@
# Build dependencies needed for packages installed from source (e.g., git dependencies)
# When using --no-build-isolation, these must be installed in the venv first
wheel
setuptools
packaging

View File

@@ -41,14 +41,6 @@ from optimum.quanto import freeze, qfloat8, quantize
from transformers import T5EncoderModel
from safetensors.torch import load_file
# Import LTX-2 specific utilities
try:
from diffusers.pipelines.ltx2.export_utils import encode_video as ltx2_encode_video
LTX2_AVAILABLE = True
except ImportError:
LTX2_AVAILABLE = False
ltx2_encode_video = None
_ONE_DAY_IN_SECONDS = 60 * 60 * 24
COMPEL = os.environ.get("COMPEL", "0") == "1"
XPU = os.environ.get("XPU", "0") == "1"
@@ -298,20 +290,6 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
pipe.enable_model_cpu_offload()
return pipe
# LTX2ImageToVideoPipeline - needs img2vid flag, CPU offload, and special handling
if pipeline_type == "LTX2ImageToVideoPipeline":
self.img2vid = True
self.ltx2_pipeline = True
pipe = load_diffusers_pipeline(
class_name="LTX2ImageToVideoPipeline",
model_id=request.Model,
torch_dtype=torchType,
variant=variant
)
if not DISABLE_CPU_OFFLOAD:
pipe.enable_model_cpu_offload()
return pipe
# ================================================================
# Dynamic pipeline loading - the default path for most pipelines
# Uses the dynamic loader to instantiate any pipeline by class name
@@ -426,7 +404,6 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
fromSingleFile = request.Model.startswith("http") or request.Model.startswith("/") or local
self.img2vid = False
self.txt2vid = False
self.ltx2_pipeline = False
# Load pipeline using dynamic loader
# Special cases that require custom initialization are handled first
@@ -709,44 +686,7 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
print(f"Generating video with {kwargs=}", file=sys.stderr)
# Generate video frames based on pipeline type
if self.ltx2_pipeline or self.PipelineType == "LTX2ImageToVideoPipeline":
# LTX-2 image-to-video generation with audio
if not LTX2_AVAILABLE:
return backend_pb2.Result(success=False, message="LTX-2 pipeline requires diffusers.pipelines.ltx2.export_utils")
# LTX-2 uses 'image' parameter instead of 'start_image'
if request.start_image:
image = load_image(request.start_image)
kwargs["image"] = image
# Remove start_image if it was added
kwargs.pop("start_image", None)
# LTX-2 uses 'frame_rate' instead of 'fps'
frame_rate = float(fps)
kwargs["frame_rate"] = frame_rate
# LTX-2 requires output_type="np" and return_dict=False
kwargs["output_type"] = "np"
kwargs["return_dict"] = False
# Generate video and audio
video, audio = self.pipe(**kwargs)
# Convert video to uint8 format
video = (video * 255).round().astype("uint8")
video = torch.from_numpy(video)
# Use LTX-2's encode_video function which handles audio
ltx2_encode_video(
video[0],
fps=frame_rate,
audio=audio[0].float().cpu(),
audio_sample_rate=self.pipe.vocoder.config.output_sampling_rate,
output_path=request.dst,
)
return backend_pb2.Result(message="Video generated successfully", success=True)
elif self.PipelineType == "WanPipeline":
if self.PipelineType == "WanPipeline":
# WAN2.2 text-to-video generation
output = self.pipe(**kwargs)
frames = output.frames[0] # WAN2.2 returns frames in this format
@@ -787,7 +727,7 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
else:
return backend_pb2.Result(success=False, message=f"Pipeline {self.PipelineType} does not support video generation")
# Export video (for non-LTX-2 pipelines)
# Export video
export_to_video(frames, request.dst, fps=fps)
return backend_pb2.Result(message="Video generated successfully", success=True)

View File

@@ -16,10 +16,6 @@ if [ "x${BUILD_PROFILE}" == "xintel" ]; then
EXTRA_PIP_INSTALL_FLAGS+=" --upgrade --index-strategy=unsafe-first-match"
fi
if [ "x${BUILD_PROFILE}" == "xl4t12" ]; then
USE_PIP=true
fi
# Use python 3.12 for l4t
if [ "x${BUILD_PROFILE}" == "xl4t13" ]; then
PYTHON_VERSION="3.12"

View File

@@ -16,8 +16,4 @@ if [ "x${BUILD_PROFILE}" == "xintel" ]; then
EXTRA_PIP_INSTALL_FLAGS+=" --upgrade --index-strategy=unsafe-first-match"
fi
if [ "x${BUILD_PROFILE}" == "xl4t12" ]; then
USE_PIP=true
fi
installRequirements

View File

@@ -26,12 +26,6 @@ fi
EXTRA_PIP_INSTALL_FLAGS+=" --no-build-isolation"
if [ "x${BUILD_PROFILE}" == "xl4t12" ]; then
USE_PIP=true
fi
git clone https://github.com/neuphonic/neutts-air neutts-air
cp -rfv neutts-air/neuttsair ./

View File

@@ -1,23 +0,0 @@
.PHONY: pocket-tts
pocket-tts:
bash install.sh
.PHONY: run
run: pocket-tts
@echo "Running pocket-tts..."
bash run.sh
@echo "pocket-tts run."
.PHONY: test
test: pocket-tts
@echo "Testing pocket-tts..."
bash test.sh
@echo "pocket-tts tested."
.PHONY: protogen-clean
protogen-clean:
$(RM) backend_pb2_grpc.py backend_pb2.py
.PHONY: clean
clean: protogen-clean
rm -rf venv __pycache__

View File

@@ -1,255 +0,0 @@
#!/usr/bin/env python3
"""
This is an extra gRPC server of LocalAI for Pocket TTS
"""
from concurrent import futures
import time
import argparse
import signal
import sys
import os
import traceback
import scipy.io.wavfile
import backend_pb2
import backend_pb2_grpc
import torch
from pocket_tts import TTSModel
import grpc
def is_float(s):
"""Check if a string can be converted to float."""
try:
float(s)
return True
except ValueError:
return False
def is_int(s):
"""Check if a string can be converted to int."""
try:
int(s)
return True
except ValueError:
return False
_ONE_DAY_IN_SECONDS = 60 * 60 * 24
# If MAX_WORKERS are specified in the environment use it, otherwise default to 1
MAX_WORKERS = int(os.environ.get('PYTHON_GRPC_MAX_WORKERS', '1'))
# Implement the BackendServicer class with the service methods
class BackendServicer(backend_pb2_grpc.BackendServicer):
"""
BackendServicer is the class that implements the gRPC service
"""
def Health(self, request, context):
return backend_pb2.Reply(message=bytes("OK", 'utf-8'))
def LoadModel(self, request, context):
# Get device
if torch.cuda.is_available():
print("CUDA is available", file=sys.stderr)
device = "cuda"
else:
print("CUDA is not available", file=sys.stderr)
device = "cpu"
mps_available = hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
if mps_available:
device = "mps"
if not torch.cuda.is_available() and request.CUDA:
return backend_pb2.Result(success=False, message="CUDA is not available")
# Normalize potential 'mpx' typo to 'mps'
if device == "mpx":
print("Note: device 'mpx' detected, treating it as 'mps'.", file=sys.stderr)
device = "mps"
# Validate mps availability if requested
if device == "mps" and not torch.backends.mps.is_available():
print("Warning: MPS not available. Falling back to CPU.", file=sys.stderr)
device = "cpu"
self.device = device
options = request.Options
# empty dict
self.options = {}
# The options are a list of strings in this form optname:optvalue
# We are storing all the options in a dict so we can use it later when
# generating the audio
for opt in options:
if ":" not in opt:
continue
key, value = opt.split(":", 1) # Split only on first colon
# if value is a number, convert it to the appropriate type
if is_float(value):
value = float(value)
elif is_int(value):
value = int(value)
elif value.lower() in ["true", "false"]:
value = value.lower() == "true"
self.options[key] = value
# Default voice for caching
self.default_voice_url = self.options.get("default_voice", None)
self._voice_cache = {}
try:
print("Loading Pocket TTS model", file=sys.stderr)
self.tts_model = TTSModel.load_model()
print(f"Model loaded successfully. Sample rate: {self.tts_model.sample_rate}", file=sys.stderr)
# Pre-load default voice if specified
if self.default_voice_url:
try:
print(f"Pre-loading default voice: {self.default_voice_url}", file=sys.stderr)
voice_state = self.tts_model.get_state_for_audio_prompt(self.default_voice_url)
self._voice_cache[self.default_voice_url] = voice_state
print("Default voice loaded successfully", file=sys.stderr)
except Exception as e:
print(f"Warning: Failed to pre-load default voice: {e}", file=sys.stderr)
except Exception as err:
return backend_pb2.Result(success=False, message=f"Unexpected {err=}, {type(err)=}")
return backend_pb2.Result(message="Model loaded successfully", success=True)
def _get_voice_state(self, voice_input):
"""
Get voice state from cache or load it.
voice_input can be:
- HuggingFace URL (e.g., hf://kyutai/tts-voices/alba-mackenna/casual.wav)
- Local file path
- None (use default)
"""
# Use default if no voice specified
if not voice_input:
voice_input = self.default_voice_url
if not voice_input:
return None
# Check cache first
if voice_input in self._voice_cache:
return self._voice_cache[voice_input]
# Load voice state
try:
print(f"Loading voice from: {voice_input}", file=sys.stderr)
voice_state = self.tts_model.get_state_for_audio_prompt(voice_input)
self._voice_cache[voice_input] = voice_state
return voice_state
except Exception as e:
print(f"Error loading voice from {voice_input}: {e}", file=sys.stderr)
return None
def TTS(self, request, context):
try:
# Determine voice input
# Priority: request.voice > AudioPath (from ModelOptions) > default
voice_input = None
if request.voice:
voice_input = request.voice
elif hasattr(request, 'AudioPath') and request.AudioPath:
# Use AudioPath as voice file
if os.path.isabs(request.AudioPath):
voice_input = request.AudioPath
elif hasattr(request, 'ModelFile') and request.ModelFile:
model_file_base = os.path.dirname(request.ModelFile)
voice_input = os.path.join(model_file_base, request.AudioPath)
elif hasattr(request, 'ModelPath') and request.ModelPath:
voice_input = os.path.join(request.ModelPath, request.AudioPath)
else:
voice_input = request.AudioPath
# Get voice state
voice_state = self._get_voice_state(voice_input)
if voice_state is None:
return backend_pb2.Result(
success=False,
message=f"Voice not found or failed to load: {voice_input}. Please provide a valid voice URL or file path."
)
# Prepare text
text = request.text.strip()
if not text:
return backend_pb2.Result(
success=False,
message="Text is empty"
)
print(f"Generating audio for text: {text[:50]}...", file=sys.stderr)
# Generate audio
audio = self.tts_model.generate_audio(voice_state, text)
# Audio is a 1D torch tensor containing PCM data
if audio is None or audio.numel() == 0:
return backend_pb2.Result(
success=False,
message="No audio generated"
)
# Save audio to file
output_path = request.dst
if not output_path:
output_path = "/tmp/pocket-tts-output.wav"
# Ensure output directory exists
output_dir = os.path.dirname(output_path)
if output_dir and not os.path.exists(output_dir):
os.makedirs(output_dir, exist_ok=True)
# Convert torch tensor to numpy and save
audio_numpy = audio.numpy()
scipy.io.wavfile.write(output_path, self.tts_model.sample_rate, audio_numpy)
print(f"Saved audio to {output_path}", file=sys.stderr)
except Exception as err:
print(f"Error in TTS: {err}", file=sys.stderr)
print(traceback.format_exc(), file=sys.stderr)
return backend_pb2.Result(success=False, message=f"Unexpected {err=}, {type(err)=}")
return backend_pb2.Result(success=True)
def serve(address):
server = grpc.server(futures.ThreadPoolExecutor(max_workers=MAX_WORKERS),
options=[
('grpc.max_message_length', 50 * 1024 * 1024), # 50MB
('grpc.max_send_message_length', 50 * 1024 * 1024), # 50MB
('grpc.max_receive_message_length', 50 * 1024 * 1024), # 50MB
])
backend_pb2_grpc.add_BackendServicer_to_server(BackendServicer(), server)
server.add_insecure_port(address)
server.start()
print("Server started. Listening on: " + address, file=sys.stderr)
# Define the signal handler function
def signal_handler(sig, frame):
print("Received termination signal. Shutting down...")
server.stop(0)
sys.exit(0)
# Set the signal handlers for SIGINT and SIGTERM
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
try:
while True:
time.sleep(_ONE_DAY_IN_SECONDS)
except KeyboardInterrupt:
server.stop(0)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Run the gRPC server.")
parser.add_argument(
"--addr", default="localhost:50051", help="The address to bind the server to."
)
args = parser.parse_args()
serve(args.addr)

View File

@@ -1,30 +0,0 @@
#!/bin/bash
set -e
backend_dir=$(dirname $0)
if [ -d $backend_dir/common ]; then
source $backend_dir/common/libbackend.sh
else
source $backend_dir/../common/libbackend.sh
fi
# This is here because the Intel pip index is broken and returns 200 status codes for every package name, it just doesn't return any package links.
# This makes uv think that the package exists in the Intel pip index, and by default it stops looking at other pip indexes once it finds a match.
# We need uv to continue falling through to the pypi default index to find optimum[openvino] in the pypi index
# the --upgrade actually allows us to *downgrade* torch to the version provided in the Intel pip index
if [ "x${BUILD_PROFILE}" == "xintel" ]; then
EXTRA_PIP_INSTALL_FLAGS+=" --upgrade --index-strategy=unsafe-first-match"
fi
# Use python 3.12 for l4t
if [ "x${BUILD_PROFILE}" == "xl4t13" ]; then
PYTHON_VERSION="3.12"
PYTHON_PATCH="12"
PY_STANDALONE_TAG="20251120"
fi
if [ "x${BUILD_PROFILE}" == "xl4t12" ]; then
USE_PIP=true
fi
installRequirements

View File

@@ -1,11 +0,0 @@
#!/bin/bash
set -e
backend_dir=$(dirname $0)
if [ -d $backend_dir/common ]; then
source $backend_dir/common/libbackend.sh
else
source $backend_dir/../common/libbackend.sh
fi
python3 -m grpc_tools.protoc -I../.. -I./ --python_out=. --grpc_python_out=. backend.proto

View File

@@ -1,4 +0,0 @@
--extra-index-url https://download.pytorch.org/whl/cpu
pocket-tts
scipy
torch

View File

@@ -1,4 +0,0 @@
--extra-index-url https://download.pytorch.org/whl/cu121
pocket-tts
scipy
torch

View File

@@ -1,4 +0,0 @@
--extra-index-url https://download.pytorch.org/whl/cu130
pocket-tts
scipy
torch

View File

@@ -1,4 +0,0 @@
--extra-index-url https://download.pytorch.org/whl/rocm6.3
pocket-tts
scipy
torch==2.7.1+rocm6.3

View File

@@ -1,4 +0,0 @@
--extra-index-url https://pytorch-extension.intel.com/release-whl/stable/xpu/us/
pocket-tts
scipy
torch==2.5.1+cxx11.abi

View File

@@ -1,4 +0,0 @@
--extra-index-url https://pypi.jetson-ai-lab.io/jp6/cu129/
pocket-tts
scipy
torch

View File

@@ -1,4 +0,0 @@
--extra-index-url https://download.pytorch.org/whl/cu130
pocket-tts
scipy
torch

View File

@@ -1,4 +0,0 @@
pocket-tts
scipy
torch==2.7.1
torchvision==0.22.1

View File

@@ -1,4 +0,0 @@
grpcio==1.71.0
protobuf
certifi
packaging==24.1

View File

@@ -1,9 +0,0 @@
#!/bin/bash
backend_dir=$(dirname $0)
if [ -d $backend_dir/common ]; then
source $backend_dir/common/libbackend.sh
else
source $backend_dir/../common/libbackend.sh
fi
startBackend $@

View File

@@ -1,141 +0,0 @@
"""
A test script to test the gRPC service
"""
import unittest
import subprocess
import time
import os
import tempfile
import backend_pb2
import backend_pb2_grpc
import grpc
class TestBackendServicer(unittest.TestCase):
"""
TestBackendServicer is the class that tests the gRPC service
"""
def setUp(self):
"""
This method sets up the gRPC service by starting the server
"""
self.service = subprocess.Popen(["python3", "backend.py", "--addr", "localhost:50051"])
time.sleep(30)
def tearDown(self) -> None:
"""
This method tears down the gRPC service by terminating the server
"""
self.service.terminate()
self.service.wait()
def test_server_startup(self):
"""
This method tests if the server starts up successfully
"""
try:
self.setUp()
with grpc.insecure_channel("localhost:50051") as channel:
stub = backend_pb2_grpc.BackendStub(channel)
response = stub.Health(backend_pb2.HealthMessage())
self.assertEqual(response.message, b'OK')
except Exception as err:
print(err)
self.fail("Server failed to start")
finally:
self.tearDown()
def test_load_model(self):
"""
This method tests if the model is loaded successfully
"""
try:
self.setUp()
with grpc.insecure_channel("localhost:50051") as channel:
stub = backend_pb2_grpc.BackendStub(channel)
response = stub.LoadModel(backend_pb2.ModelOptions())
print(response)
self.assertTrue(response.success)
self.assertEqual(response.message, "Model loaded successfully")
except Exception as err:
print(err)
self.fail("LoadModel service failed")
finally:
self.tearDown()
def test_tts_with_hf_voice(self):
"""
This method tests TTS generation with HuggingFace voice URL
"""
try:
self.setUp()
with grpc.insecure_channel("localhost:50051") as channel:
stub = backend_pb2_grpc.BackendStub(channel)
# Load model
response = stub.LoadModel(backend_pb2.ModelOptions())
self.assertTrue(response.success)
# Create temporary output file
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp_file:
output_path = tmp_file.name
# Test TTS with HuggingFace voice URL
tts_request = backend_pb2.TTSRequest(
text="Hello world, this is a test.",
dst=output_path,
voice="azelma"
)
tts_response = stub.TTS(tts_request)
self.assertTrue(tts_response.success)
# Verify output file exists and is not empty
self.assertTrue(os.path.exists(output_path))
self.assertGreater(os.path.getsize(output_path), 0)
# Cleanup
os.unlink(output_path)
except Exception as err:
print(err)
self.fail("TTS service failed")
finally:
self.tearDown()
def test_tts_with_default_voice(self):
"""
This method tests TTS generation with default voice (via AudioPath in LoadModel)
"""
try:
self.setUp()
with grpc.insecure_channel("localhost:50051") as channel:
stub = backend_pb2_grpc.BackendStub(channel)
# Load model with default voice
load_request = backend_pb2.ModelOptions(
Options=["default_voice:azelma"]
)
response = stub.LoadModel(load_request)
self.assertTrue(response.success)
# Create temporary output file
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp_file:
output_path = tmp_file.name
# Test TTS without specifying voice (should use default)
tts_request = backend_pb2.TTSRequest(
text="Hello world, this is a test.",
dst=output_path
)
tts_response = stub.TTS(tts_request)
self.assertTrue(tts_response.success)
# Verify output file exists and is not empty
self.assertTrue(os.path.exists(output_path))
self.assertGreater(os.path.getsize(output_path), 0)
# Cleanup
os.unlink(output_path)
except Exception as err:
print(err)
self.fail("TTS service with default voice failed")
finally:
self.tearDown()

View File

@@ -1,11 +0,0 @@
#!/bin/bash
set -e
backend_dir=$(dirname $0)
if [ -d $backend_dir/common ]; then
source $backend_dir/common/libbackend.sh
else
source $backend_dir/../common/libbackend.sh
fi
runUnittests

View File

@@ -6,4 +6,4 @@ transformers
bitsandbytes
outetts
sentence-transformers==5.2.0
protobuf==6.33.4
protobuf==6.33.2

View File

@@ -6,4 +6,4 @@ transformers
bitsandbytes
outetts
sentence-transformers==5.2.0
protobuf==6.33.4
protobuf==6.33.2

View File

@@ -6,4 +6,4 @@ transformers
bitsandbytes
outetts
sentence-transformers==5.2.0
protobuf==6.33.4
protobuf==6.33.2

View File

@@ -8,4 +8,4 @@ bitsandbytes
outetts
bitsandbytes
sentence-transformers==5.2.0
protobuf==6.33.4
protobuf==6.33.2

View File

@@ -10,4 +10,4 @@ intel-extension-for-transformers
bitsandbytes
outetts
sentence-transformers==5.2.0
protobuf==6.33.4
protobuf==6.33.2

View File

@@ -1,5 +1,5 @@
grpcio==1.76.0
protobuf==6.33.4
protobuf==6.33.2
certifi
setuptools
scipy==1.15.1

View File

@@ -23,10 +23,6 @@ if [ "x${BUILD_PROFILE}" == "xl4t13" ]; then
PY_STANDALONE_TAG="20251120"
fi
if [ "x${BUILD_PROFILE}" == "xl4t12" ]; then
USE_PIP=true
fi
installRequirements
git clone https://github.com/microsoft/VibeVoice.git

View File

@@ -56,7 +56,7 @@ type RunCMD struct {
UseSubtleKeyComparison bool `env:"LOCALAI_SUBTLE_KEY_COMPARISON" default:"false" help:"If true, API Key validation comparisons will be performed using constant-time comparisons rather than simple equality. This trades off performance on each request for resiliancy against timing attacks." group:"hardening"`
DisableApiKeyRequirementForHttpGet bool `env:"LOCALAI_DISABLE_API_KEY_REQUIREMENT_FOR_HTTP_GET" default:"false" help:"If true, a valid API key is not required to issue GET requests to portions of the web ui. This should only be enabled in secure testing environments" group:"hardening"`
DisableMetricsEndpoint bool `env:"LOCALAI_DISABLE_METRICS_ENDPOINT,DISABLE_METRICS_ENDPOINT" default:"false" help:"Disable the /metrics endpoint" group:"api"`
HttpGetExemptedEndpoints []string `env:"LOCALAI_HTTP_GET_EXEMPTED_ENDPOINTS" default:"^/$,^/browse/?$,^/talk/?$,^/p2p/?$,^/chat/?$,^/image/?$,^/text2image/?$,^/tts/?$,^/static/.*$,^/swagger.*$" help:"If LOCALAI_DISABLE_API_KEY_REQUIREMENT_FOR_HTTP_GET is overriden to true, this is the list of endpoints to exempt. Only adjust this in case of a security incident or as a result of a personal security posture review" group:"hardening"`
HttpGetExemptedEndpoints []string `env:"LOCALAI_HTTP_GET_EXEMPTED_ENDPOINTS" default:"^/$,^/browse/?$,^/talk/?$,^/p2p/?$,^/chat/?$,^/text2image/?$,^/tts/?$,^/static/.*$,^/swagger.*$" help:"If LOCALAI_DISABLE_API_KEY_REQUIREMENT_FOR_HTTP_GET is overriden to true, this is the list of endpoints to exempt. Only adjust this in case of a security incident or as a result of a personal security posture review" group:"hardening"`
Peer2Peer bool `env:"LOCALAI_P2P,P2P" name:"p2p" default:"false" help:"Enable P2P mode" group:"p2p"`
Peer2PeerDHTInterval int `env:"LOCALAI_P2P_DHT_INTERVAL,P2P_DHT_INTERVAL" default:"360" name:"p2p-dht-interval" help:"Interval for DHT refresh (used during token generation)" group:"p2p"`
Peer2PeerOTPInterval int `env:"LOCALAI_P2P_OTP_INTERVAL,P2P_OTP_INTERVAL" default:"9000" name:"p2p-otp-interval" help:"Interval for OTP refresh (used during token generation)" group:"p2p"`
@@ -83,7 +83,6 @@ type RunCMD struct {
EnableTracing bool `env:"LOCALAI_ENABLE_TRACING,ENABLE_TRACING" help:"Enable API tracing" group:"api"`
TracingMaxItems int `env:"LOCALAI_TRACING_MAX_ITEMS" default:"1024" help:"Maximum number of traces to keep" group:"api"`
AgentJobRetentionDays int `env:"LOCALAI_AGENT_JOB_RETENTION_DAYS,AGENT_JOB_RETENTION_DAYS" default:"30" help:"Number of days to keep agent job history (default: 30)" group:"api"`
OpenResponsesStoreTTL string `env:"LOCALAI_OPEN_RESPONSES_STORE_TTL,OPEN_RESPONSES_STORE_TTL" default:"0" help:"TTL for Open Responses store (e.g., 1h, 30m, 0 = no expiration)" group:"api"`
Version bool
}
@@ -250,15 +249,6 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
opts = append(opts, config.WithLRUEvictionRetryInterval(dur))
}
// Handle Open Responses store TTL
if r.OpenResponsesStoreTTL != "" && r.OpenResponsesStoreTTL != "0" {
dur, err := time.ParseDuration(r.OpenResponsesStoreTTL)
if err != nil {
return fmt.Errorf("invalid Open Responses store TTL: %w", err)
}
opts = append(opts, config.WithOpenResponsesStoreTTL(dur))
}
// split ":" to get backend name and the uri
for _, v := range r.ExternalGRPCBackends {
backend := v[:strings.IndexByte(v, ':')]

View File

@@ -86,8 +86,6 @@ type ApplicationConfig struct {
AgentJobRetentionDays int // Default: 30 days
OpenResponsesStoreTTL time.Duration // TTL for Open Responses store (0 = no expiration)
PathWithoutAuth []string
}
@@ -469,12 +467,6 @@ func WithAgentJobRetentionDays(days int) AppOption {
}
}
func WithOpenResponsesStoreTTL(ttl time.Duration) AppOption {
return func(o *ApplicationConfig) {
o.OpenResponsesStoreTTL = ttl
}
}
func WithEnforcedPredownloadScans(enforced bool) AppOption {
return func(o *ApplicationConfig) {
o.EnforcePredownloadScans = enforced
@@ -602,12 +594,6 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
} else {
lruEvictionRetryInterval = "1s" // default
}
var openResponsesStoreTTL string
if o.OpenResponsesStoreTTL > 0 {
openResponsesStoreTTL = o.OpenResponsesStoreTTL.String()
} else {
openResponsesStoreTTL = "0" // default: no expiration
}
return RuntimeSettings{
WatchdogEnabled: &watchdogEnabled,
@@ -642,7 +628,6 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
AutoloadBackendGalleries: &autoloadBackendGalleries,
ApiKeys: &apiKeys,
AgentJobRetentionDays: &agentJobRetentionDays,
OpenResponsesStoreTTL: &openResponsesStoreTTL,
}
}
@@ -784,14 +769,6 @@ func (o *ApplicationConfig) ApplyRuntimeSettings(settings *RuntimeSettings) (req
if settings.AgentJobRetentionDays != nil {
o.AgentJobRetentionDays = *settings.AgentJobRetentionDays
}
if settings.OpenResponsesStoreTTL != nil {
if *settings.OpenResponsesStoreTTL == "0" || *settings.OpenResponsesStoreTTL == "" {
o.OpenResponsesStoreTTL = 0 // No expiration
} else if dur, err := time.ParseDuration(*settings.OpenResponsesStoreTTL); err == nil {
o.OpenResponsesStoreTTL = dur
}
// This setting doesn't require restart, can be updated dynamically
}
// Note: ApiKeys requires special handling (merging with startup keys) - handled in caller
return requireRestart

View File

@@ -10,7 +10,6 @@ import (
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/pkg/downloader"
"github.com/mudler/LocalAI/pkg/functions"
"github.com/mudler/LocalAI/pkg/reasoning"
"github.com/mudler/cogito"
"gopkg.in/yaml.v3"
)
@@ -52,7 +51,6 @@ type ModelConfig struct {
ResponseFormatMap map[string]interface{} `yaml:"-" json:"-"`
FunctionsConfig functions.FunctionsConfig `yaml:"function,omitempty" json:"function,omitempty"`
ReasoningConfig reasoning.ReasoningConfig `yaml:"reasoning,omitempty" json:"reasoning,omitempty"`
FeatureFlag FeatureFlag `yaml:"feature_flags,omitempty" json:"feature_flags,omitempty"` // Feature Flag registry. We move fast, and features may break on a per model/backend basis. Registry for (usually temporary) flags that indicate aborting something early.
// LLM configs (GPT4ALL, Llama.cpp, ...)

View File

@@ -60,7 +60,4 @@ type RuntimeSettings struct {
// Agent settings
AgentJobRetentionDays *int `json:"agent_job_retention_days,omitempty"`
// Open Responses settings
OpenResponsesStoreTTL *string `json:"open_responses_store_ttl,omitempty"` // TTL for stored responses (e.g., "1h", "30m", "0" = no expiration)
}

View File

@@ -63,25 +63,6 @@ func (m *GalleryBackend) IsMeta() bool {
return len(m.CapabilitiesMap) > 0 && m.URI == ""
}
// IsCompatibleWith checks if the backend is compatible with the current system capability.
// For meta backends, it checks if any of the capabilities in the map match the system capability.
// For concrete backends, it delegates to SystemState.IsBackendCompatible.
func (m *GalleryBackend) IsCompatibleWith(systemState *system.SystemState) bool {
if systemState == nil {
return true
}
// Meta backends are compatible if the system capability matches one of the keys
if m.IsMeta() {
capability := systemState.Capability(m.CapabilitiesMap)
_, exists := m.CapabilitiesMap[capability]
return exists
}
// For concrete backends, delegate to the system package
return systemState.IsBackendCompatible(m.Name, m.URI)
}
func (m *GalleryBackend) SetInstalled(installed bool) {
m.Installed = installed
}

View File

@@ -172,252 +172,6 @@ var _ = Describe("Gallery Backends", func() {
Expect(nilMetaBackend.IsMeta()).To(BeFalse())
})
It("should check IsCompatibleWith correctly for meta backends", func() {
metaBackend := &GalleryBackend{
Metadata: Metadata{
Name: "meta-backend",
},
CapabilitiesMap: map[string]string{
"nvidia": "nvidia-backend",
"amd": "amd-backend",
"default": "default-backend",
},
}
// Test with nil state - should be compatible
Expect(metaBackend.IsCompatibleWith(nil)).To(BeTrue())
// Test with NVIDIA system - should be compatible (has nvidia key)
nvidiaState := &system.SystemState{GPUVendor: "nvidia", VRAM: 8 * 1024 * 1024 * 1024}
Expect(metaBackend.IsCompatibleWith(nvidiaState)).To(BeTrue())
// Test with default (no GPU) - should be compatible (has default key)
defaultState := &system.SystemState{}
Expect(metaBackend.IsCompatibleWith(defaultState)).To(BeTrue())
})
Describe("IsCompatibleWith for concrete backends", func() {
Context("CPU backends", func() {
It("should be compatible on all systems", func() {
cpuBackend := &GalleryBackend{
Metadata: Metadata{
Name: "cpu-llama-cpp",
},
URI: "quay.io/go-skynet/local-ai-backends:latest-cpu-llama-cpp",
}
Expect(cpuBackend.IsCompatibleWith(&system.SystemState{})).To(BeTrue())
Expect(cpuBackend.IsCompatibleWith(&system.SystemState{GPUVendor: system.Nvidia, VRAM: 8 * 1024 * 1024 * 1024})).To(BeTrue())
Expect(cpuBackend.IsCompatibleWith(&system.SystemState{GPUVendor: system.AMD, VRAM: 8 * 1024 * 1024 * 1024})).To(BeTrue())
})
})
Context("Darwin/Metal backends", func() {
When("running on darwin", func() {
BeforeEach(func() {
if runtime.GOOS != "darwin" {
Skip("Skipping darwin-specific tests on non-darwin system")
}
})
It("should be compatible for MLX backend", func() {
mlxBackend := &GalleryBackend{
Metadata: Metadata{
Name: "mlx",
},
URI: "quay.io/go-skynet/local-ai-backends:latest-metal-darwin-arm64-mlx",
}
Expect(mlxBackend.IsCompatibleWith(&system.SystemState{})).To(BeTrue())
})
It("should be compatible for metal-llama-cpp backend", func() {
metalBackend := &GalleryBackend{
Metadata: Metadata{
Name: "metal-llama-cpp",
},
URI: "quay.io/go-skynet/local-ai-backends:latest-metal-darwin-arm64-llama-cpp",
}
Expect(metalBackend.IsCompatibleWith(&system.SystemState{})).To(BeTrue())
})
})
When("running on non-darwin", func() {
BeforeEach(func() {
if runtime.GOOS == "darwin" {
Skip("Skipping non-darwin-specific tests on darwin system")
}
})
It("should NOT be compatible for MLX backend", func() {
mlxBackend := &GalleryBackend{
Metadata: Metadata{
Name: "mlx",
},
URI: "quay.io/go-skynet/local-ai-backends:latest-metal-darwin-arm64-mlx",
}
Expect(mlxBackend.IsCompatibleWith(&system.SystemState{})).To(BeFalse())
})
It("should NOT be compatible for metal-llama-cpp backend", func() {
metalBackend := &GalleryBackend{
Metadata: Metadata{
Name: "metal-llama-cpp",
},
URI: "quay.io/go-skynet/local-ai-backends:latest-metal-darwin-arm64-llama-cpp",
}
Expect(metalBackend.IsCompatibleWith(&system.SystemState{})).To(BeFalse())
})
})
})
Context("NVIDIA/CUDA backends", func() {
When("running on non-darwin", func() {
BeforeEach(func() {
if runtime.GOOS == "darwin" {
Skip("Skipping CUDA tests on darwin system")
}
})
It("should NOT be compatible without nvidia GPU", func() {
cudaBackend := &GalleryBackend{
Metadata: Metadata{
Name: "cuda12-llama-cpp",
},
URI: "quay.io/go-skynet/local-ai-backends:latest-gpu-nvidia-cuda-12-llama-cpp",
}
Expect(cudaBackend.IsCompatibleWith(&system.SystemState{})).To(BeFalse())
Expect(cudaBackend.IsCompatibleWith(&system.SystemState{GPUVendor: system.AMD, VRAM: 8 * 1024 * 1024 * 1024})).To(BeFalse())
})
It("should be compatible with nvidia GPU", func() {
cudaBackend := &GalleryBackend{
Metadata: Metadata{
Name: "cuda12-llama-cpp",
},
URI: "quay.io/go-skynet/local-ai-backends:latest-gpu-nvidia-cuda-12-llama-cpp",
}
Expect(cudaBackend.IsCompatibleWith(&system.SystemState{GPUVendor: system.Nvidia, VRAM: 8 * 1024 * 1024 * 1024})).To(BeTrue())
})
It("should be compatible with cuda13 backend on nvidia GPU", func() {
cuda13Backend := &GalleryBackend{
Metadata: Metadata{
Name: "cuda13-llama-cpp",
},
URI: "quay.io/go-skynet/local-ai-backends:latest-gpu-nvidia-cuda-13-llama-cpp",
}
Expect(cuda13Backend.IsCompatibleWith(&system.SystemState{GPUVendor: system.Nvidia, VRAM: 8 * 1024 * 1024 * 1024})).To(BeTrue())
})
})
})
Context("AMD/ROCm backends", func() {
When("running on non-darwin", func() {
BeforeEach(func() {
if runtime.GOOS == "darwin" {
Skip("Skipping AMD/ROCm tests on darwin system")
}
})
It("should NOT be compatible without AMD GPU", func() {
rocmBackend := &GalleryBackend{
Metadata: Metadata{
Name: "rocm-llama-cpp",
},
URI: "quay.io/go-skynet/local-ai-backends:latest-gpu-rocm-hipblas-llama-cpp",
}
Expect(rocmBackend.IsCompatibleWith(&system.SystemState{})).To(BeFalse())
Expect(rocmBackend.IsCompatibleWith(&system.SystemState{GPUVendor: system.Nvidia, VRAM: 8 * 1024 * 1024 * 1024})).To(BeFalse())
})
It("should be compatible with AMD GPU", func() {
rocmBackend := &GalleryBackend{
Metadata: Metadata{
Name: "rocm-llama-cpp",
},
URI: "quay.io/go-skynet/local-ai-backends:latest-gpu-rocm-hipblas-llama-cpp",
}
Expect(rocmBackend.IsCompatibleWith(&system.SystemState{GPUVendor: system.AMD, VRAM: 8 * 1024 * 1024 * 1024})).To(BeTrue())
})
It("should be compatible with hipblas backend on AMD GPU", func() {
hipBackend := &GalleryBackend{
Metadata: Metadata{
Name: "hip-llama-cpp",
},
URI: "quay.io/go-skynet/local-ai-backends:latest-gpu-hip-llama-cpp",
}
Expect(hipBackend.IsCompatibleWith(&system.SystemState{GPUVendor: system.AMD, VRAM: 8 * 1024 * 1024 * 1024})).To(BeTrue())
})
})
})
Context("Intel/SYCL backends", func() {
When("running on non-darwin", func() {
BeforeEach(func() {
if runtime.GOOS == "darwin" {
Skip("Skipping Intel/SYCL tests on darwin system")
}
})
It("should NOT be compatible without Intel GPU", func() {
intelBackend := &GalleryBackend{
Metadata: Metadata{
Name: "intel-sycl-f16-llama-cpp",
},
URI: "quay.io/go-skynet/local-ai-backends:latest-gpu-intel-sycl-f16-llama-cpp",
}
Expect(intelBackend.IsCompatibleWith(&system.SystemState{})).To(BeFalse())
Expect(intelBackend.IsCompatibleWith(&system.SystemState{GPUVendor: system.Nvidia, VRAM: 8 * 1024 * 1024 * 1024})).To(BeFalse())
})
It("should be compatible with Intel GPU", func() {
intelBackend := &GalleryBackend{
Metadata: Metadata{
Name: "intel-sycl-f16-llama-cpp",
},
URI: "quay.io/go-skynet/local-ai-backends:latest-gpu-intel-sycl-f16-llama-cpp",
}
Expect(intelBackend.IsCompatibleWith(&system.SystemState{GPUVendor: system.Intel, VRAM: 8 * 1024 * 1024 * 1024})).To(BeTrue())
})
It("should be compatible with intel-sycl-f32 backend on Intel GPU", func() {
intelF32Backend := &GalleryBackend{
Metadata: Metadata{
Name: "intel-sycl-f32-llama-cpp",
},
URI: "quay.io/go-skynet/local-ai-backends:latest-gpu-intel-sycl-f32-llama-cpp",
}
Expect(intelF32Backend.IsCompatibleWith(&system.SystemState{GPUVendor: system.Intel, VRAM: 8 * 1024 * 1024 * 1024})).To(BeTrue())
})
It("should be compatible with intel-transformers backend on Intel GPU", func() {
intelTransformersBackend := &GalleryBackend{
Metadata: Metadata{
Name: "intel-transformers",
},
URI: "quay.io/go-skynet/local-ai-backends:latest-intel-transformers",
}
Expect(intelTransformersBackend.IsCompatibleWith(&system.SystemState{GPUVendor: system.Intel, VRAM: 8 * 1024 * 1024 * 1024})).To(BeTrue())
})
})
})
Context("Vulkan backends", func() {
It("should be compatible on CPU-only systems", func() {
// Vulkan backends don't have a specific GPU vendor requirement in the current logic
// They are compatible if no other GPU-specific pattern matches
vulkanBackend := &GalleryBackend{
Metadata: Metadata{
Name: "vulkan-llama-cpp",
},
URI: "quay.io/go-skynet/local-ai-backends:latest-gpu-vulkan-llama-cpp",
}
// Vulkan doesn't have vendor-specific filtering in current implementation
Expect(vulkanBackend.IsCompatibleWith(&system.SystemState{})).To(BeTrue())
})
})
})
It("should find best backend from meta based on system capabilities", func() {
metaBackend := &GalleryBackend{

View File

@@ -226,16 +226,6 @@ func AvailableGalleryModels(galleries []config.Gallery, systemState *system.Syst
// List available backends
func AvailableBackends(galleries []config.Gallery, systemState *system.SystemState) (GalleryElements[*GalleryBackend], error) {
return availableBackendsWithFilter(galleries, systemState, true)
}
// AvailableBackendsUnfiltered returns all available backends without filtering by system capability.
func AvailableBackendsUnfiltered(galleries []config.Gallery, systemState *system.SystemState) (GalleryElements[*GalleryBackend], error) {
return availableBackendsWithFilter(galleries, systemState, false)
}
// availableBackendsWithFilter is a helper function that lists available backends with optional filtering.
func availableBackendsWithFilter(galleries []config.Gallery, systemState *system.SystemState, filterByCapability bool) (GalleryElements[*GalleryBackend], error) {
var backends []*GalleryBackend
systemBackends, err := ListSystemBackends(systemState)
@@ -251,17 +241,7 @@ func availableBackendsWithFilter(galleries []config.Gallery, systemState *system
if err != nil {
return nil, err
}
// Filter backends by system capability if requested
if filterByCapability {
for _, backend := range galleryBackends {
if backend.IsCompatibleWith(systemState) {
backends = append(backends, backend)
}
}
} else {
backends = append(backends, galleryBackends...)
}
backends = append(backends, galleryBackends...)
}
return backends, nil

View File

@@ -108,15 +108,7 @@ func API(application *application.Application) (*echo.Echo, error) {
req := c.Request()
res := c.Response()
err := next(c)
// Fix for #7989: Reduce log verbosity of Web UI polling
// If the path is /api/operations and the request was successful (200),
// we log it at DEBUG level (hidden by default) instead of INFO.
if req.URL.Path == "/api/operations" && res.Status == 200 {
xlog.Debug("HTTP request", "method", req.Method, "path", req.URL.Path, "status", res.Status)
} else {
xlog.Info("HTTP request", "method", req.Method, "path", req.URL.Path, "status", res.Status)
}
xlog.Info("HTTP request", "method", req.Method, "path", req.URL.Path, "status", res.Status)
return err
}
})
@@ -193,8 +185,6 @@ func API(application *application.Application) (*echo.Echo, error) {
corsConfig.AllowOrigins = strings.Split(application.ApplicationConfig().CORSAllowOrigins, ",")
}
e.Use(middleware.CORSWithConfig(corsConfig))
} else {
e.Use(middleware.CORS())
}
// CSRF middleware
@@ -215,8 +205,6 @@ func API(application *application.Application) (*echo.Echo, error) {
routes.RegisterLocalAIRoutes(e, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService(), opcache, application.TemplatesEvaluator(), application)
routes.RegisterOpenAIRoutes(e, requestExtractor, application)
routes.RegisterAnthropicRoutes(e, requestExtractor, application)
routes.RegisterOpenResponsesRoutes(e, requestExtractor, application)
if !application.ApplicationConfig().DisableWebUI {
routes.RegisterUIAPIRoutes(e, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService(), opcache, application)
routes.RegisterUIRoutes(e, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService())

View File

@@ -1,537 +0,0 @@
package anthropic
import (
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/templates"
"github.com/mudler/LocalAI/pkg/functions"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/xlog"
)
// MessagesEndpoint is the Anthropic Messages API endpoint
// https://docs.anthropic.com/claude/reference/messages_post
// @Summary Generate a message response for the given messages and model.
// @Param request body schema.AnthropicRequest true "query params"
// @Success 200 {object} schema.AnthropicResponse "Response"
// @Router /v1/messages [post]
func MessagesEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, appConfig *config.ApplicationConfig) echo.HandlerFunc {
return func(c echo.Context) error {
id := uuid.New().String()
input, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.AnthropicRequest)
if !ok || input.Model == "" {
return sendAnthropicError(c, 400, "invalid_request_error", "model is required")
}
cfg, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
if !ok || cfg == nil {
return sendAnthropicError(c, 400, "invalid_request_error", "model configuration not found")
}
if input.MaxTokens <= 0 {
return sendAnthropicError(c, 400, "invalid_request_error", "max_tokens is required and must be greater than 0")
}
xlog.Debug("Anthropic Messages endpoint configuration read", "config", cfg)
// Convert Anthropic messages to OpenAI format for internal processing
openAIMessages := convertAnthropicToOpenAIMessages(input)
// Convert Anthropic tools to internal Functions format
funcs, shouldUseFn := convertAnthropicTools(input, cfg)
// Create an OpenAI-compatible request for internal processing
openAIReq := &schema.OpenAIRequest{
PredictionOptions: schema.PredictionOptions{
BasicModelRequest: schema.BasicModelRequest{Model: input.Model},
Temperature: input.Temperature,
TopK: input.TopK,
TopP: input.TopP,
Maxtokens: &input.MaxTokens,
},
Messages: openAIMessages,
Stream: input.Stream,
Context: input.Context,
Cancel: input.Cancel,
}
// Set stop sequences
if len(input.StopSequences) > 0 {
openAIReq.Stop = input.StopSequences
}
// Merge config settings
if input.Temperature != nil {
cfg.Temperature = input.Temperature
}
if input.TopK != nil {
cfg.TopK = input.TopK
}
if input.TopP != nil {
cfg.TopP = input.TopP
}
cfg.Maxtokens = &input.MaxTokens
if len(input.StopSequences) > 0 {
cfg.StopWords = append(cfg.StopWords, input.StopSequences...)
}
// Template the prompt with tools if available
predInput := evaluator.TemplateMessages(*openAIReq, openAIReq.Messages, cfg, funcs, shouldUseFn)
xlog.Debug("Anthropic Messages - Prompt (after templating)", "prompt", predInput)
if input.Stream {
return handleAnthropicStream(c, id, input, cfg, ml, predInput, openAIReq, funcs, shouldUseFn)
}
return handleAnthropicNonStream(c, id, input, cfg, ml, predInput, openAIReq, funcs, shouldUseFn)
}
}
func handleAnthropicNonStream(c echo.Context, id string, input *schema.AnthropicRequest, cfg *config.ModelConfig, ml *model.ModelLoader, predInput string, openAIReq *schema.OpenAIRequest, funcs functions.Functions, shouldUseFn bool) error {
images := []string{}
for _, m := range openAIReq.Messages {
images = append(images, m.StringImages...)
}
predFunc, err := backend.ModelInference(
input.Context, predInput, openAIReq.Messages, images, nil, nil, ml, cfg, nil, nil, nil, "", "", nil, nil, nil)
if err != nil {
xlog.Error("Anthropic model inference failed", "error", err)
return sendAnthropicError(c, 500, "api_error", fmt.Sprintf("model inference failed: %v", err))
}
prediction, err := predFunc()
if err != nil {
xlog.Error("Anthropic prediction failed", "error", err)
return sendAnthropicError(c, 500, "api_error", fmt.Sprintf("prediction failed: %v", err))
}
result := backend.Finetune(*cfg, predInput, prediction.Response)
// Check if the result contains tool calls
toolCalls := functions.ParseFunctionCall(result, cfg.FunctionsConfig)
var contentBlocks []schema.AnthropicContentBlock
var stopReason string
if shouldUseFn && len(toolCalls) > 0 {
// Model wants to use tools
stopReason = "tool_use"
for _, tc := range toolCalls {
// Parse arguments as JSON
var inputArgs map[string]interface{}
if err := json.Unmarshal([]byte(tc.Arguments), &inputArgs); err != nil {
xlog.Warn("Failed to parse tool call arguments as JSON", "error", err, "args", tc.Arguments)
inputArgs = map[string]interface{}{"raw": tc.Arguments}
}
contentBlocks = append(contentBlocks, schema.AnthropicContentBlock{
Type: "tool_use",
ID: fmt.Sprintf("toolu_%s_%d", id, len(contentBlocks)),
Name: tc.Name,
Input: inputArgs,
})
}
// Add any text content before the tool calls
textContent := functions.ParseTextContent(result, cfg.FunctionsConfig)
if textContent != "" {
// Prepend text block
contentBlocks = append([]schema.AnthropicContentBlock{{Type: "text", Text: textContent}}, contentBlocks...)
}
} else {
// Normal text response
stopReason = "end_turn"
contentBlocks = []schema.AnthropicContentBlock{
{Type: "text", Text: result},
}
}
resp := &schema.AnthropicResponse{
ID: fmt.Sprintf("msg_%s", id),
Type: "message",
Role: "assistant",
Model: input.Model,
StopReason: &stopReason,
Content: contentBlocks,
Usage: schema.AnthropicUsage{
InputTokens: prediction.Usage.Prompt,
OutputTokens: prediction.Usage.Completion,
},
}
if respData, err := json.Marshal(resp); err == nil {
xlog.Debug("Anthropic Response", "response", string(respData))
}
return c.JSON(200, resp)
}
func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicRequest, cfg *config.ModelConfig, ml *model.ModelLoader, predInput string, openAIReq *schema.OpenAIRequest, funcs functions.Functions, shouldUseFn bool) error {
c.Response().Header().Set("Content-Type", "text/event-stream")
c.Response().Header().Set("Cache-Control", "no-cache")
c.Response().Header().Set("Connection", "keep-alive")
// Create OpenAI messages for inference
openAIMessages := openAIReq.Messages
images := []string{}
for _, m := range openAIMessages {
images = append(images, m.StringImages...)
}
// Send message_start event
messageStart := schema.AnthropicStreamEvent{
Type: "message_start",
Message: &schema.AnthropicStreamMessage{
ID: fmt.Sprintf("msg_%s", id),
Type: "message",
Role: "assistant",
Content: []schema.AnthropicContentBlock{},
Model: input.Model,
Usage: schema.AnthropicUsage{InputTokens: 0, OutputTokens: 0},
},
}
sendAnthropicSSE(c, messageStart)
// Track accumulated content for tool call detection
accumulatedContent := ""
currentBlockIndex := 0
inToolCall := false
toolCallsEmitted := 0
// Send initial content_block_start event
contentBlockStart := schema.AnthropicStreamEvent{
Type: "content_block_start",
Index: currentBlockIndex,
ContentBlock: &schema.AnthropicContentBlock{Type: "text", Text: ""},
}
sendAnthropicSSE(c, contentBlockStart)
// Stream content deltas
tokenCallback := func(token string, usage backend.TokenUsage) bool {
accumulatedContent += token
// If we're using functions, try to detect tool calls incrementally
if shouldUseFn {
cleanedResult := functions.CleanupLLMResult(accumulatedContent, cfg.FunctionsConfig)
// Try parsing for tool calls
toolCalls := functions.ParseFunctionCall(cleanedResult, cfg.FunctionsConfig)
// If we detected new tool calls and haven't emitted them yet
if len(toolCalls) > toolCallsEmitted {
// Stop the current text block if we were in one
if !inToolCall && currentBlockIndex == 0 {
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
Type: "content_block_stop",
Index: currentBlockIndex,
})
currentBlockIndex++
inToolCall = true
}
// Emit new tool calls
for i := toolCallsEmitted; i < len(toolCalls); i++ {
tc := toolCalls[i]
// Send content_block_start for tool_use
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
Type: "content_block_start",
Index: currentBlockIndex,
ContentBlock: &schema.AnthropicContentBlock{
Type: "tool_use",
ID: fmt.Sprintf("toolu_%s_%d", id, i),
Name: tc.Name,
},
})
// Send input_json_delta with the arguments
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
Type: "content_block_delta",
Index: currentBlockIndex,
Delta: &schema.AnthropicStreamDelta{
Type: "input_json_delta",
PartialJSON: tc.Arguments,
},
})
// Send content_block_stop
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
Type: "content_block_stop",
Index: currentBlockIndex,
})
currentBlockIndex++
}
toolCallsEmitted = len(toolCalls)
return true
}
}
// Send regular text delta if not in tool call mode
if !inToolCall {
delta := schema.AnthropicStreamEvent{
Type: "content_block_delta",
Index: 0,
Delta: &schema.AnthropicStreamDelta{
Type: "text_delta",
Text: token,
},
}
sendAnthropicSSE(c, delta)
}
return true
}
predFunc, err := backend.ModelInference(
input.Context, predInput, openAIMessages, images, nil, nil, ml, cfg, nil, nil, tokenCallback, "", "", nil, nil, nil)
if err != nil {
xlog.Error("Anthropic stream model inference failed", "error", err)
return sendAnthropicError(c, 500, "api_error", fmt.Sprintf("model inference failed: %v", err))
}
prediction, err := predFunc()
if err != nil {
xlog.Error("Anthropic stream prediction failed", "error", err)
return sendAnthropicError(c, 500, "api_error", fmt.Sprintf("prediction failed: %v", err))
}
// Send content_block_stop event for last block if we didn't close it yet
if !inToolCall {
contentBlockStop := schema.AnthropicStreamEvent{
Type: "content_block_stop",
Index: 0,
}
sendAnthropicSSE(c, contentBlockStop)
}
// Determine stop reason
stopReason := "end_turn"
if toolCallsEmitted > 0 {
stopReason = "tool_use"
}
// Send message_delta event with stop_reason
messageDelta := schema.AnthropicStreamEvent{
Type: "message_delta",
Delta: &schema.AnthropicStreamDelta{
StopReason: &stopReason,
},
Usage: &schema.AnthropicUsage{
OutputTokens: prediction.Usage.Completion,
},
}
sendAnthropicSSE(c, messageDelta)
// Send message_stop event
messageStop := schema.AnthropicStreamEvent{
Type: "message_stop",
}
sendAnthropicSSE(c, messageStop)
return nil
}
func sendAnthropicSSE(c echo.Context, event schema.AnthropicStreamEvent) {
data, err := json.Marshal(event)
if err != nil {
xlog.Error("Failed to marshal SSE event", "error", err)
return
}
fmt.Fprintf(c.Response().Writer, "event: %s\ndata: %s\n\n", event.Type, string(data))
c.Response().Flush()
}
func sendAnthropicError(c echo.Context, statusCode int, errorType, message string) error {
resp := schema.AnthropicErrorResponse{
Type: "error",
Error: schema.AnthropicError{
Type: errorType,
Message: message,
},
}
return c.JSON(statusCode, resp)
}
func convertAnthropicToOpenAIMessages(input *schema.AnthropicRequest) []schema.Message {
var messages []schema.Message
// Add system message if present
if input.System != "" {
messages = append(messages, schema.Message{
Role: "system",
StringContent: input.System,
Content: input.System,
})
}
// Convert Anthropic messages to OpenAI format
for _, msg := range input.Messages {
openAIMsg := schema.Message{
Role: msg.Role,
}
// Handle content (can be string or array of content blocks)
switch content := msg.Content.(type) {
case string:
openAIMsg.StringContent = content
openAIMsg.Content = content
case []interface{}:
// Handle array of content blocks
var textContent string
var stringImages []string
var toolCalls []schema.ToolCall
toolCallIndex := 0
for _, block := range content {
if blockMap, ok := block.(map[string]interface{}); ok {
blockType, _ := blockMap["type"].(string)
switch blockType {
case "text":
if text, ok := blockMap["text"].(string); ok {
textContent += text
}
case "image":
// Handle image content
if source, ok := blockMap["source"].(map[string]interface{}); ok {
if sourceType, ok := source["type"].(string); ok && sourceType == "base64" {
if data, ok := source["data"].(string); ok {
mediaType, _ := source["media_type"].(string)
// Format as data URI
dataURI := fmt.Sprintf("data:%s;base64,%s", mediaType, data)
stringImages = append(stringImages, dataURI)
}
}
}
case "tool_use":
// Convert tool_use to ToolCall format
toolID, _ := blockMap["id"].(string)
toolName, _ := blockMap["name"].(string)
toolInput := blockMap["input"]
// Serialize input to JSON string
inputJSON, err := json.Marshal(toolInput)
if err != nil {
xlog.Warn("Failed to marshal tool input", "error", err)
inputJSON = []byte("{}")
}
toolCalls = append(toolCalls, schema.ToolCall{
Index: toolCallIndex,
ID: toolID,
Type: "function",
FunctionCall: schema.FunctionCall{
Name: toolName,
Arguments: string(inputJSON),
},
})
toolCallIndex++
case "tool_result":
// Convert tool_result to a message with role "tool"
// This is handled by creating a separate message after this block
// For now, we'll add it as text content
toolUseID, _ := blockMap["tool_use_id"].(string)
isError := false
if isErrorPtr, ok := blockMap["is_error"].(*bool); ok && isErrorPtr != nil {
isError = *isErrorPtr
}
var resultText string
if resultContent, ok := blockMap["content"]; ok {
switch rc := resultContent.(type) {
case string:
resultText = rc
case []interface{}:
// Array of content blocks
for _, cb := range rc {
if cbMap, ok := cb.(map[string]interface{}); ok {
if cbMap["type"] == "text" {
if text, ok := cbMap["text"].(string); ok {
resultText += text
}
}
}
}
}
}
// Add tool result as a tool role message
// We need to handle this differently - create a new message
if msg.Role == "user" {
// Store tool result info for creating separate message
prefix := ""
if isError {
prefix = "Error: "
}
textContent += fmt.Sprintf("\n[Tool Result for %s]: %s%s", toolUseID, prefix, resultText)
}
}
}
}
openAIMsg.StringContent = textContent
openAIMsg.Content = textContent
openAIMsg.StringImages = stringImages
// Add tool calls if present
if len(toolCalls) > 0 {
openAIMsg.ToolCalls = toolCalls
}
}
messages = append(messages, openAIMsg)
}
return messages
}
// convertAnthropicTools converts Anthropic tools to internal Functions format
func convertAnthropicTools(input *schema.AnthropicRequest, cfg *config.ModelConfig) (functions.Functions, bool) {
if len(input.Tools) == 0 {
return nil, false
}
var funcs functions.Functions
for _, tool := range input.Tools {
f := functions.Function{
Name: tool.Name,
Description: tool.Description,
Parameters: tool.InputSchema,
}
funcs = append(funcs, f)
}
// Handle tool_choice
if input.ToolChoice != nil {
switch tc := input.ToolChoice.(type) {
case string:
// "auto", "any", or "none"
if tc == "any" {
// Force the model to use one of the tools
cfg.SetFunctionCallString("required")
} else if tc == "none" {
// Don't use tools
return nil, false
}
// "auto" is the default - let model decide
case map[string]interface{}:
// Specific tool selection: {"type": "tool", "name": "tool_name"}
if tcType, ok := tc["type"].(string); ok && tcType == "tool" {
if name, ok := tc["name"].(string); ok {
// Force specific tool
cfg.SetFunctionCallString(name)
}
}
}
}
return funcs, len(funcs) > 0 && cfg.ShouldUseFunctions()
}

View File

@@ -11,7 +11,6 @@ import (
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/endpoints/openresponses"
"github.com/mudler/LocalAI/core/p2p"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/xlog"
@@ -85,16 +84,6 @@ func UpdateSettingsEndpoint(app *application.Application) echo.HandlerFunc {
})
}
}
if settings.OpenResponsesStoreTTL != nil {
if *settings.OpenResponsesStoreTTL != "0" && *settings.OpenResponsesStoreTTL != "" {
if _, err := time.ParseDuration(*settings.OpenResponsesStoreTTL); err != nil {
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
Success: false,
Error: "Invalid open_responses_store_ttl format: " + err.Error(),
})
}
}
}
// Save to file
if appConfig.DynamicConfigsDir == "" {
@@ -155,22 +144,6 @@ func UpdateSettingsEndpoint(app *application.Application) echo.HandlerFunc {
xlog.Info("Updated LRU eviction retry settings", "maxRetries", maxRetries, "retryInterval", retryInterval)
}
// Update Open Responses store TTL dynamically
if settings.OpenResponsesStoreTTL != nil {
ttl := time.Duration(0)
if *settings.OpenResponsesStoreTTL != "0" && *settings.OpenResponsesStoreTTL != "" {
if dur, err := time.ParseDuration(*settings.OpenResponsesStoreTTL); err == nil {
ttl = dur
} else {
xlog.Warn("Invalid Open Responses store TTL format", "ttl", *settings.OpenResponsesStoreTTL, "error", err)
}
}
// Import the store package
store := openresponses.GetGlobalStore()
store.SetTTL(ttl)
xlog.Info("Updated Open Responses store TTL", "ttl", ttl)
}
// Check if agent job retention changed
agentJobChanged := settings.AgentJobRetentionDays != nil

View File

@@ -3,7 +3,6 @@ package openai
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
@@ -13,7 +12,6 @@ import (
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/pkg/functions"
"github.com/mudler/LocalAI/pkg/reasoning"
"github.com/mudler/LocalAI/core/templates"
"github.com/mudler/LocalAI/pkg/model"
@@ -36,63 +34,11 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
Created: created,
Model: req.Model, // we have to return what the user sent here, due to OpenAI spec.
Choices: []schema.Choice{{Delta: &schema.Message{Role: "assistant"}, Index: 0, FinishReason: nil}},
Object: "chat.completion.chunk",
}
responses <- initialMessage
// Track accumulated content for reasoning extraction
accumulatedContent := ""
lastEmittedReasoning := ""
lastEmittedCleanedContent := ""
// Configure reasoning extraction options
// Auto-detect if prompt ends with thinking tag
// or use explicit config setting
thinkingForcedOpen := config.ReasoningConfig.ThinkingForcedOpen || reasoning.DetectThinkingForcedOpen(s)
_, _, err := ComputeChoices(req, s, config, cl, startupOptions, loader, func(s string, c *[]schema.Choice) {}, func(s string, tokenUsage backend.TokenUsage) bool {
accumulatedContent += s
// Extract reasoning from accumulated content
opts := []reasoning.Option{}
if thinkingForcedOpen {
opts = append(opts, reasoning.WithThinkingForcedOpen())
}
currentReasoning, cleanedContent := reasoning.Extract(accumulatedContent, opts...)
// Calculate new reasoning delta (what we haven't emitted yet)
var reasoningDelta *string
if currentReasoning != lastEmittedReasoning {
// Extract only the new part
if len(currentReasoning) > len(lastEmittedReasoning) && strings.HasPrefix(currentReasoning, lastEmittedReasoning) {
newReasoning := currentReasoning[len(lastEmittedReasoning):]
reasoningDelta = &newReasoning
lastEmittedReasoning = currentReasoning
} else if currentReasoning != "" {
// If reasoning changed in a non-append way, emit the full current reasoning
reasoningDelta = &currentReasoning
lastEmittedReasoning = currentReasoning
}
}
// Calculate content delta from cleaned content
var deltaContent string
if len(cleanedContent) > len(lastEmittedCleanedContent) && strings.HasPrefix(cleanedContent, lastEmittedCleanedContent) {
deltaContent = cleanedContent[len(lastEmittedCleanedContent):]
lastEmittedCleanedContent = cleanedContent
} else if cleanedContent != lastEmittedCleanedContent {
// If cleaned content changed but not in a simple append, extract delta from cleaned content
// This handles cases where thinking tags are removed mid-stream
if lastEmittedCleanedContent == "" {
deltaContent = cleanedContent
lastEmittedCleanedContent = cleanedContent
} else {
// Content changed in non-append way, use the new cleaned content
deltaContent = cleanedContent
lastEmittedCleanedContent = cleanedContent
}
}
// Only emit content if there's actual content (not just thinking tags)
// If deltaContent is empty, we still emit the response but with empty content
usage := schema.OpenAIUsage{
PromptTokens: tokenUsage.Prompt,
CompletionTokens: tokenUsage.Completion,
@@ -103,20 +49,11 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
usage.TimingPromptProcessing = tokenUsage.TimingPromptProcessing
}
delta := &schema.Message{}
// Only include content if there's actual content (not just thinking tags)
if deltaContent != "" {
delta.Content = &deltaContent
}
if reasoningDelta != nil && *reasoningDelta != "" {
delta.Reasoning = reasoningDelta
}
resp := schema.OpenAIResponse{
ID: id,
Created: created,
Model: req.Model, // we have to return what the user sent here, due to OpenAI spec.
Choices: []schema.Choice{{Delta: delta, Index: 0, FinishReason: nil}},
Choices: []schema.Choice{{Delta: &schema.Message{Content: &s}, Index: 0, FinishReason: nil}},
Object: "chat.completion.chunk",
Usage: usage,
}
@@ -239,16 +176,6 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
if err != nil {
return err
}
// Extract reasoning before processing tool calls
// Auto-detect if prompt ends with thinking tag or use explicit config
toolsThinkingForcedOpen := config.ReasoningConfig.ThinkingForcedOpen || reasoning.DetectThinkingForcedOpen(prompt)
opts := []reasoning.Option{}
if toolsThinkingForcedOpen {
opts = append(opts, reasoning.WithThinkingForcedOpen())
}
extractedReasoning, cleanedResult := reasoning.Extract(result, opts...)
result = cleanedResult
textContentToReturn = functions.ParseTextContent(result, config.FunctionsConfig)
result = functions.CleanupLLMResult(result, config.FunctionsConfig)
functionResults := functions.ParseFunctionCall(result, config.FunctionsConfig)
@@ -281,20 +208,11 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
usage.TimingPromptProcessing = tokenUsage.TimingPromptProcessing
}
var deltaReasoning *string
if extractedReasoning != "" {
deltaReasoning = &extractedReasoning
}
delta := &schema.Message{Content: &result}
if deltaReasoning != nil {
delta.Reasoning = deltaReasoning
}
resp := schema.OpenAIResponse{
ID: id,
Created: created,
Model: req.Model, // we have to return what the user sent here, due to OpenAI spec.
Choices: []schema.Choice{{Delta: delta, Index: 0, FinishReason: nil}},
Choices: []schema.Choice{{Delta: &schema.Message{Content: &result}, Index: 0, FinishReason: nil}},
Object: "chat.completion.chunk",
Usage: usage,
}
@@ -634,26 +552,11 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
// no streaming mode
default:
// Auto-detect if prompt ends with thinking tag for non-streaming mode
nonStreamThinkingForcedOpen := config.ReasoningConfig.ThinkingForcedOpen || reasoning.DetectThinkingForcedOpen(predInput)
tokenCallback := func(s string, c *[]schema.Choice) {
// Extract reasoning from the response
var extractedReasoning string
opts := []reasoning.Option{}
if nonStreamThinkingForcedOpen {
opts = append(opts, reasoning.WithThinkingForcedOpen())
}
extractedReasoning, s = reasoning.Extract(s, opts...)
if !shouldUseFn {
// no function is called, just reply and use stop as finish reason
stopReason := FinishReasonStop
message := &schema.Message{Role: "assistant", Content: &s}
if extractedReasoning != "" {
message.Reasoning = &extractedReasoning
}
*c = append(*c, schema.Choice{FinishReason: &stopReason, Index: 0, Message: message})
*c = append(*c, schema.Choice{FinishReason: &stopReason, Index: 0, Message: &schema.Message{Role: "assistant", Content: &s}})
return
}
@@ -672,13 +575,9 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
}
stopReason := FinishReasonStop
message := &schema.Message{Role: "assistant", Content: &result}
if extractedReasoning != "" {
message.Reasoning = &extractedReasoning
}
*c = append(*c, schema.Choice{
FinishReason: &stopReason,
Message: message})
Message: &schema.Message{Role: "assistant", Content: &result}})
default:
toolCallsReason := FinishReasonToolCalls
toolChoice := schema.Choice{
@@ -687,9 +586,6 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
Role: "assistant",
},
}
if extractedReasoning != "" {
toolChoice.Message.Reasoning = &extractedReasoning
}
for _, ss := range results {
name, args := ss.Name, ss.Arguments
@@ -710,20 +606,16 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
} else {
// otherwise we return more choices directly (deprecated)
functionCallReason := FinishReasonFunctionCall
message := &schema.Message{
Role: "assistant",
Content: &textContentToReturn,
FunctionCall: map[string]interface{}{
"name": name,
"arguments": args,
},
}
if extractedReasoning != "" {
message.Reasoning = &extractedReasoning
}
*c = append(*c, schema.Choice{
FinishReason: &functionCallReason,
Message: message,
Message: &schema.Message{
Role: "assistant",
Content: &textContentToReturn,
FunctionCall: map[string]interface{}{
"name": name,
"arguments": args,
},
},
})
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,453 +0,0 @@
package openresponses
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/xlog"
)
// ResponseStore provides thread-safe storage for Open Responses API responses
type ResponseStore struct {
mu sync.RWMutex
responses map[string]*StoredResponse
ttl time.Duration // Time-to-live for stored responses (0 = no expiration)
cleanupCtx context.Context
cleanupCancel context.CancelFunc
}
// StreamedEvent represents a buffered SSE event for streaming resume
type StreamedEvent struct {
SequenceNumber int `json:"sequence_number"`
EventType string `json:"event_type"`
Data []byte `json:"data"` // JSON-serialized event
}
// StoredResponse contains a complete response with its input request and output items
type StoredResponse struct {
Request *schema.OpenResponsesRequest
Response *schema.ORResponseResource
Items map[string]*schema.ORItemField // item_id -> item mapping for quick lookup
StoredAt time.Time
ExpiresAt *time.Time // nil if no expiration
// Background execution support
CancelFunc context.CancelFunc // For cancellation of background tasks
StreamEvents []StreamedEvent // Buffered events for streaming resume
StreamEnabled bool // Was created with stream=true
IsBackground bool // Was created with background=true
EventsChan chan struct{} // Signals new events for live subscribers
mu sync.RWMutex // Protect concurrent access to this response
}
var (
globalStore *ResponseStore
storeOnce sync.Once
)
// GetGlobalStore returns the singleton response store instance
func GetGlobalStore() *ResponseStore {
storeOnce.Do(func() {
globalStore = NewResponseStore(0) // Default: no TTL, will be updated from appConfig
})
return globalStore
}
// SetTTL updates the TTL for the store
// This will affect all new responses stored after this call
func (s *ResponseStore) SetTTL(ttl time.Duration) {
s.mu.Lock()
defer s.mu.Unlock()
// Stop existing cleanup loop if running
if s.cleanupCancel != nil {
s.cleanupCancel()
s.cleanupCancel = nil
s.cleanupCtx = nil
}
s.ttl = ttl
// If TTL > 0, start cleanup loop
if ttl > 0 {
s.cleanupCtx, s.cleanupCancel = context.WithCancel(context.Background())
go s.cleanupLoop(s.cleanupCtx)
}
xlog.Debug("Updated Open Responses store TTL", "ttl", ttl, "cleanup_running", ttl > 0)
}
// NewResponseStore creates a new response store with optional TTL
// If ttl is 0, responses are stored indefinitely
func NewResponseStore(ttl time.Duration) *ResponseStore {
store := &ResponseStore{
responses: make(map[string]*StoredResponse),
ttl: ttl,
}
// Start cleanup goroutine if TTL is set
if ttl > 0 {
store.cleanupCtx, store.cleanupCancel = context.WithCancel(context.Background())
go store.cleanupLoop(store.cleanupCtx)
}
return store
}
// Store stores a response with its request and items
func (s *ResponseStore) Store(responseID string, request *schema.OpenResponsesRequest, response *schema.ORResponseResource) {
s.mu.Lock()
defer s.mu.Unlock()
// Build item index for quick lookup
items := make(map[string]*schema.ORItemField)
for i := range response.Output {
item := &response.Output[i]
if item.ID != "" {
items[item.ID] = item
}
}
stored := &StoredResponse{
Request: request,
Response: response,
Items: items,
StoredAt: time.Now(),
ExpiresAt: nil,
}
// Set expiration if TTL is configured
if s.ttl > 0 {
expiresAt := time.Now().Add(s.ttl)
stored.ExpiresAt = &expiresAt
}
s.responses[responseID] = stored
xlog.Debug("Stored Open Responses response", "response_id", responseID, "items_count", len(items))
}
// Get retrieves a stored response by ID
func (s *ResponseStore) Get(responseID string) (*StoredResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
stored, exists := s.responses[responseID]
if !exists {
return nil, fmt.Errorf("response not found: %s", responseID)
}
// Check expiration
if stored.ExpiresAt != nil && time.Now().After(*stored.ExpiresAt) {
// Expired, but we'll return it anyway and let caller handle cleanup
return nil, fmt.Errorf("response expired: %s", responseID)
}
return stored, nil
}
// GetItem retrieves a specific item from a stored response
func (s *ResponseStore) GetItem(responseID, itemID string) (*schema.ORItemField, error) {
stored, err := s.Get(responseID)
if err != nil {
return nil, err
}
item, exists := stored.Items[itemID]
if !exists {
return nil, fmt.Errorf("item not found: %s in response %s", itemID, responseID)
}
return item, nil
}
// FindItem searches for an item across all stored responses
// Returns the item and the response ID it was found in
func (s *ResponseStore) FindItem(itemID string) (*schema.ORItemField, string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
now := time.Now()
for responseID, stored := range s.responses {
// Skip expired responses
if stored.ExpiresAt != nil && now.After(*stored.ExpiresAt) {
continue
}
if item, exists := stored.Items[itemID]; exists {
return item, responseID, nil
}
}
return nil, "", fmt.Errorf("item not found in any stored response: %s", itemID)
}
// Delete removes a response from storage
func (s *ResponseStore) Delete(responseID string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.responses, responseID)
xlog.Debug("Deleted Open Responses response", "response_id", responseID)
}
// Cleanup removes expired responses
func (s *ResponseStore) Cleanup() int {
if s.ttl == 0 {
return 0
}
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
count := 0
for id, stored := range s.responses {
if stored.ExpiresAt != nil && now.After(*stored.ExpiresAt) {
delete(s.responses, id)
count++
}
}
if count > 0 {
xlog.Debug("Cleaned up expired Open Responses", "count", count)
}
return count
}
// cleanupLoop runs periodic cleanup of expired responses
func (s *ResponseStore) cleanupLoop(ctx context.Context) {
if s.ttl == 0 {
return
}
ticker := time.NewTicker(s.ttl / 2) // Cleanup at half TTL interval
defer ticker.Stop()
for {
select {
case <-ctx.Done():
xlog.Debug("Stopped Open Responses store cleanup loop")
return
case <-ticker.C:
s.Cleanup()
}
}
}
// Count returns the number of stored responses
func (s *ResponseStore) Count() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.responses)
}
// StoreBackground stores a background response with cancel function and optional streaming support
func (s *ResponseStore) StoreBackground(responseID string, request *schema.OpenResponsesRequest, response *schema.ORResponseResource, cancelFunc context.CancelFunc, streamEnabled bool) {
s.mu.Lock()
defer s.mu.Unlock()
// Build item index for quick lookup
items := make(map[string]*schema.ORItemField)
for i := range response.Output {
item := &response.Output[i]
if item.ID != "" {
items[item.ID] = item
}
}
stored := &StoredResponse{
Request: request,
Response: response,
Items: items,
StoredAt: time.Now(),
ExpiresAt: nil,
CancelFunc: cancelFunc,
StreamEvents: []StreamedEvent{},
StreamEnabled: streamEnabled,
IsBackground: true,
EventsChan: make(chan struct{}, 100), // Buffered channel for event notifications
}
// Set expiration if TTL is configured
if s.ttl > 0 {
expiresAt := time.Now().Add(s.ttl)
stored.ExpiresAt = &expiresAt
}
s.responses[responseID] = stored
xlog.Debug("Stored background Open Responses response", "response_id", responseID, "stream_enabled", streamEnabled)
}
// UpdateStatus updates the status of a stored response
func (s *ResponseStore) UpdateStatus(responseID string, status string, completedAt *int64) error {
s.mu.RLock()
stored, exists := s.responses[responseID]
s.mu.RUnlock()
if !exists {
return fmt.Errorf("response not found: %s", responseID)
}
stored.mu.Lock()
defer stored.mu.Unlock()
stored.Response.Status = status
stored.Response.CompletedAt = completedAt
xlog.Debug("Updated response status", "response_id", responseID, "status", status)
return nil
}
// UpdateResponse updates the entire response object for a stored response
func (s *ResponseStore) UpdateResponse(responseID string, response *schema.ORResponseResource) error {
s.mu.RLock()
stored, exists := s.responses[responseID]
s.mu.RUnlock()
if !exists {
return fmt.Errorf("response not found: %s", responseID)
}
stored.mu.Lock()
defer stored.mu.Unlock()
// Rebuild item index
items := make(map[string]*schema.ORItemField)
for i := range response.Output {
item := &response.Output[i]
if item.ID != "" {
items[item.ID] = item
}
}
stored.Response = response
stored.Items = items
xlog.Debug("Updated response", "response_id", responseID, "status", response.Status, "items_count", len(items))
return nil
}
// AppendEvent appends a streaming event to the buffer for resume support
func (s *ResponseStore) AppendEvent(responseID string, event *schema.ORStreamEvent) error {
s.mu.RLock()
stored, exists := s.responses[responseID]
s.mu.RUnlock()
if !exists {
return fmt.Errorf("response not found: %s", responseID)
}
// Serialize the event
data, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("failed to marshal event: %w", err)
}
stored.mu.Lock()
stored.StreamEvents = append(stored.StreamEvents, StreamedEvent{
SequenceNumber: event.SequenceNumber,
EventType: event.Type,
Data: data,
})
stored.mu.Unlock()
// Notify any subscribers of new event
select {
case stored.EventsChan <- struct{}{}:
default:
// Channel full, subscribers will catch up
}
return nil
}
// GetEventsAfter returns all events with sequence number greater than startingAfter
func (s *ResponseStore) GetEventsAfter(responseID string, startingAfter int) ([]StreamedEvent, error) {
s.mu.RLock()
stored, exists := s.responses[responseID]
s.mu.RUnlock()
if !exists {
return nil, fmt.Errorf("response not found: %s", responseID)
}
stored.mu.RLock()
defer stored.mu.RUnlock()
var result []StreamedEvent
for _, event := range stored.StreamEvents {
if event.SequenceNumber > startingAfter {
result = append(result, event)
}
}
return result, nil
}
// Cancel cancels a background response if it's still in progress
func (s *ResponseStore) Cancel(responseID string) (*schema.ORResponseResource, error) {
s.mu.RLock()
stored, exists := s.responses[responseID]
s.mu.RUnlock()
if !exists {
return nil, fmt.Errorf("response not found: %s", responseID)
}
stored.mu.Lock()
defer stored.mu.Unlock()
// If already in a terminal state, just return the response (idempotent)
status := stored.Response.Status
if status == schema.ORStatusCompleted || status == schema.ORStatusFailed ||
status == schema.ORStatusIncomplete || status == schema.ORStatusCancelled {
xlog.Debug("Response already in terminal state", "response_id", responseID, "status", status)
return stored.Response, nil
}
// Cancel the context if available
if stored.CancelFunc != nil {
stored.CancelFunc()
xlog.Debug("Cancelled background response", "response_id", responseID)
}
// Update status to cancelled
now := time.Now().Unix()
stored.Response.Status = schema.ORStatusCancelled
stored.Response.CompletedAt = &now
return stored.Response, nil
}
// GetEventsChan returns the events notification channel for a response
func (s *ResponseStore) GetEventsChan(responseID string) (chan struct{}, error) {
s.mu.RLock()
stored, exists := s.responses[responseID]
s.mu.RUnlock()
if !exists {
return nil, fmt.Errorf("response not found: %s", responseID)
}
return stored.EventsChan, nil
}
// IsStreamEnabled checks if a response was created with streaming enabled
func (s *ResponseStore) IsStreamEnabled(responseID string) (bool, error) {
s.mu.RLock()
stored, exists := s.responses[responseID]
s.mu.RUnlock()
if !exists {
return false, fmt.Errorf("response not found: %s", responseID)
}
stored.mu.RLock()
defer stored.mu.RUnlock()
return stored.StreamEnabled, nil
}

View File

@@ -1,13 +0,0 @@
package openresponses
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestStore(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "ResponseStore Suite")
}

View File

@@ -1,626 +0,0 @@
package openresponses
import (
"context"
"fmt"
"time"
"github.com/mudler/LocalAI/core/schema"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("ResponseStore", func() {
var store *ResponseStore
BeforeEach(func() {
store = NewResponseStore(0) // No TTL for most tests
})
AfterEach(func() {
// Clean up
})
Describe("Store and Get", func() {
It("should store and retrieve a response", func() {
responseID := "resp_test123"
request := &schema.OpenResponsesRequest{
Model: "test-model",
Input: "Hello",
}
response := &schema.ORResponseResource{
ID: responseID,
Object: "response",
CreatedAt: time.Now().Unix(),
Status: "completed",
Model: "test-model",
Output: []schema.ORItemField{
{
Type: "message",
ID: "msg_123",
Status: "completed",
Role: "assistant",
Content: []schema.ORContentPart{{
Type: "output_text",
Text: "Hello, world!",
Annotations: []schema.ORAnnotation{},
Logprobs: []schema.ORLogProb{},
}},
},
},
}
store.Store(responseID, request, response)
stored, err := store.Get(responseID)
Expect(err).ToNot(HaveOccurred())
Expect(stored).ToNot(BeNil())
Expect(stored.Response.ID).To(Equal(responseID))
Expect(stored.Request.Model).To(Equal("test-model"))
Expect(len(stored.Items)).To(Equal(1))
Expect(stored.Items["msg_123"]).ToNot(BeNil())
Expect(stored.Items["msg_123"].ID).To(Equal("msg_123"))
})
It("should return error for non-existent response", func() {
_, err := store.Get("nonexistent")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("not found"))
})
It("should index all items by ID", func() {
responseID := "resp_test456"
request := &schema.OpenResponsesRequest{
Model: "test-model",
Input: "Test",
}
response := &schema.ORResponseResource{
ID: responseID,
Object: "response",
Output: []schema.ORItemField{
{
Type: "message",
ID: "msg_1",
Status: "completed",
Role: "assistant",
},
{
Type: "function_call",
ID: "fc_1",
Status: "completed",
CallID: "fc_1",
Name: "test_function",
Arguments: `{"arg": "value"}`,
},
{
Type: "message",
ID: "msg_2",
Status: "completed",
Role: "assistant",
},
},
}
store.Store(responseID, request, response)
stored, err := store.Get(responseID)
Expect(err).ToNot(HaveOccurred())
Expect(len(stored.Items)).To(Equal(3))
Expect(stored.Items["msg_1"]).ToNot(BeNil())
Expect(stored.Items["fc_1"]).ToNot(BeNil())
Expect(stored.Items["msg_2"]).ToNot(BeNil())
})
It("should handle items without IDs", func() {
responseID := "resp_test789"
request := &schema.OpenResponsesRequest{
Model: "test-model",
Input: "Test",
}
response := &schema.ORResponseResource{
ID: responseID,
Object: "response",
Output: []schema.ORItemField{
{
Type: "message",
ID: "", // No ID
Status: "completed",
Role: "assistant",
},
{
Type: "message",
ID: "msg_with_id",
Status: "completed",
Role: "assistant",
},
},
}
store.Store(responseID, request, response)
stored, err := store.Get(responseID)
Expect(err).ToNot(HaveOccurred())
// Only items with IDs are indexed
Expect(len(stored.Items)).To(Equal(1))
Expect(stored.Items["msg_with_id"]).ToNot(BeNil())
})
})
Describe("GetItem", func() {
It("should retrieve a specific item by ID", func() {
responseID := "resp_item_test"
itemID := "msg_specific"
request := &schema.OpenResponsesRequest{
Model: "test-model",
Input: "Test",
}
response := &schema.ORResponseResource{
ID: responseID,
Object: "response",
Output: []schema.ORItemField{
{
Type: "message",
ID: itemID,
Status: "completed",
Role: "assistant",
Content: []schema.ORContentPart{{
Type: "output_text",
Text: "Specific message",
Annotations: []schema.ORAnnotation{},
Logprobs: []schema.ORLogProb{},
}},
},
},
}
store.Store(responseID, request, response)
item, err := store.GetItem(responseID, itemID)
Expect(err).ToNot(HaveOccurred())
Expect(item).ToNot(BeNil())
Expect(item.ID).To(Equal(itemID))
Expect(item.Type).To(Equal("message"))
})
It("should return error for non-existent item", func() {
responseID := "resp_item_test2"
request := &schema.OpenResponsesRequest{
Model: "test-model",
Input: "Test",
}
response := &schema.ORResponseResource{
ID: responseID,
Object: "response",
Output: []schema.ORItemField{
{
Type: "message",
ID: "msg_existing",
Status: "completed",
},
},
}
store.Store(responseID, request, response)
_, err := store.GetItem(responseID, "nonexistent_item")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("item not found"))
})
It("should return error for non-existent response when getting item", func() {
_, err := store.GetItem("nonexistent_response", "any_item")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("response not found"))
})
})
Describe("FindItem", func() {
It("should find an item across all stored responses", func() {
// Store first response
responseID1 := "resp_find_1"
itemID1 := "msg_find_1"
store.Store(responseID1, &schema.OpenResponsesRequest{Model: "test"}, &schema.ORResponseResource{
ID: responseID1,
Object: "response",
Output: []schema.ORItemField{
{Type: "message", ID: itemID1, Status: "completed"},
},
})
// Store second response
responseID2 := "resp_find_2"
itemID2 := "msg_find_2"
store.Store(responseID2, &schema.OpenResponsesRequest{Model: "test"}, &schema.ORResponseResource{
ID: responseID2,
Object: "response",
Output: []schema.ORItemField{
{Type: "message", ID: itemID2, Status: "completed"},
},
})
// Find item from first response
item, foundResponseID, err := store.FindItem(itemID1)
Expect(err).ToNot(HaveOccurred())
Expect(item).ToNot(BeNil())
Expect(item.ID).To(Equal(itemID1))
Expect(foundResponseID).To(Equal(responseID1))
// Find item from second response
item, foundResponseID, err = store.FindItem(itemID2)
Expect(err).ToNot(HaveOccurred())
Expect(item).ToNot(BeNil())
Expect(item.ID).To(Equal(itemID2))
Expect(foundResponseID).To(Equal(responseID2))
})
It("should return error when item not found in any response", func() {
_, _, err := store.FindItem("nonexistent_item")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("item not found in any stored response"))
})
})
Describe("Delete", func() {
It("should delete a stored response", func() {
responseID := "resp_delete_test"
request := &schema.OpenResponsesRequest{Model: "test"}
response := &schema.ORResponseResource{
ID: responseID,
Object: "response",
}
store.Store(responseID, request, response)
Expect(store.Count()).To(Equal(1))
store.Delete(responseID)
Expect(store.Count()).To(Equal(0))
_, err := store.Get(responseID)
Expect(err).To(HaveOccurred())
})
It("should handle deleting non-existent response gracefully", func() {
// Should not panic
store.Delete("nonexistent")
Expect(store.Count()).To(Equal(0))
})
})
Describe("Count", func() {
It("should return correct count of stored responses", func() {
Expect(store.Count()).To(Equal(0))
store.Store("resp_1", &schema.OpenResponsesRequest{Model: "test"}, &schema.ORResponseResource{ID: "resp_1", Object: "response"})
Expect(store.Count()).To(Equal(1))
store.Store("resp_2", &schema.OpenResponsesRequest{Model: "test"}, &schema.ORResponseResource{ID: "resp_2", Object: "response"})
Expect(store.Count()).To(Equal(2))
store.Delete("resp_1")
Expect(store.Count()).To(Equal(1))
})
})
Describe("TTL and Expiration", func() {
It("should set expiration when TTL is configured", func() {
ttlStore := NewResponseStore(100 * time.Millisecond)
responseID := "resp_ttl_test"
request := &schema.OpenResponsesRequest{Model: "test"}
response := &schema.ORResponseResource{ID: responseID, Object: "response"}
ttlStore.Store(responseID, request, response)
stored, err := ttlStore.Get(responseID)
Expect(err).ToNot(HaveOccurred())
Expect(stored.ExpiresAt).ToNot(BeNil())
Expect(stored.ExpiresAt.After(time.Now())).To(BeTrue())
})
It("should not set expiration when TTL is 0", func() {
responseID := "resp_no_ttl"
request := &schema.OpenResponsesRequest{Model: "test"}
response := &schema.ORResponseResource{ID: responseID, Object: "response"}
store.Store(responseID, request, response)
stored, err := store.Get(responseID)
Expect(err).ToNot(HaveOccurred())
Expect(stored.ExpiresAt).To(BeNil())
})
It("should clean up expired responses", func() {
ttlStore := NewResponseStore(50 * time.Millisecond)
responseID := "resp_expire_test"
request := &schema.OpenResponsesRequest{Model: "test"}
response := &schema.ORResponseResource{ID: responseID, Object: "response"}
ttlStore.Store(responseID, request, response)
Expect(ttlStore.Count()).To(Equal(1))
// Wait for expiration (longer than TTL and cleanup interval)
time.Sleep(150 * time.Millisecond)
// Cleanup should remove expired response (may have already been cleaned by goroutine)
count := ttlStore.Cleanup()
// Count might be 0 if cleanup goroutine already ran, or 1 if we're first
Expect(count).To(BeNumerically(">=", 0))
Expect(ttlStore.Count()).To(Equal(0))
_, err := ttlStore.Get(responseID)
Expect(err).To(HaveOccurred())
})
It("should return error for expired response", func() {
ttlStore := NewResponseStore(50 * time.Millisecond)
responseID := "resp_expire_error"
request := &schema.OpenResponsesRequest{Model: "test"}
response := &schema.ORResponseResource{ID: responseID, Object: "response"}
ttlStore.Store(responseID, request, response)
// Wait for expiration (but not long enough for cleanup goroutine to remove it)
time.Sleep(75 * time.Millisecond)
// Try to get before cleanup goroutine removes it
_, err := ttlStore.Get(responseID)
// Error could be "expired" or "not found" (if cleanup already ran)
Expect(err).To(HaveOccurred())
// Either error message is acceptable
errMsg := err.Error()
Expect(errMsg).To(Or(ContainSubstring("expired"), ContainSubstring("not found")))
})
})
Describe("Thread Safety", func() {
It("should handle concurrent stores and gets", func() {
// This is a basic concurrency test
done := make(chan bool, 10)
for i := 0; i < 10; i++ {
go func(id int) {
responseID := fmt.Sprintf("resp_concurrent_%d", id)
request := &schema.OpenResponsesRequest{Model: "test"}
response := &schema.ORResponseResource{
ID: responseID,
Object: "response",
Output: []schema.ORItemField{
{Type: "message", ID: fmt.Sprintf("msg_%d", id), Status: "completed"},
},
}
store.Store(responseID, request, response)
// Retrieve immediately
stored, err := store.Get(responseID)
Expect(err).ToNot(HaveOccurred())
Expect(stored).ToNot(BeNil())
done <- true
}(i)
}
// Wait for all goroutines
for i := 0; i < 10; i++ {
<-done
}
Expect(store.Count()).To(Equal(10))
})
})
Describe("GetGlobalStore", func() {
It("should return singleton instance", func() {
store1 := GetGlobalStore()
store2 := GetGlobalStore()
Expect(store1).To(Equal(store2))
})
It("should persist data across GetGlobalStore calls", func() {
globalStore := GetGlobalStore()
responseID := "resp_global_test"
request := &schema.OpenResponsesRequest{Model: "test"}
response := &schema.ORResponseResource{ID: responseID, Object: "response"}
globalStore.Store(responseID, request, response)
// Get store again
globalStore2 := GetGlobalStore()
stored, err := globalStore2.Get(responseID)
Expect(err).ToNot(HaveOccurred())
Expect(stored).ToNot(BeNil())
})
})
Describe("Background Mode Support", func() {
It("should store background response with cancel function", func() {
responseID := "resp_bg_test"
request := &schema.OpenResponsesRequest{Model: "test"}
response := &schema.ORResponseResource{
ID: responseID,
Object: "response",
Status: schema.ORStatusQueued,
}
_, cancel := context.WithCancel(context.Background())
defer cancel()
store.StoreBackground(responseID, request, response, cancel, true)
stored, err := store.Get(responseID)
Expect(err).ToNot(HaveOccurred())
Expect(stored).ToNot(BeNil())
Expect(stored.IsBackground).To(BeTrue())
Expect(stored.StreamEnabled).To(BeTrue())
Expect(stored.CancelFunc).ToNot(BeNil())
})
It("should update status of stored response", func() {
responseID := "resp_status_test"
request := &schema.OpenResponsesRequest{Model: "test"}
response := &schema.ORResponseResource{
ID: responseID,
Object: "response",
Status: schema.ORStatusQueued,
}
store.Store(responseID, request, response)
err := store.UpdateStatus(responseID, schema.ORStatusInProgress, nil)
Expect(err).ToNot(HaveOccurred())
stored, err := store.Get(responseID)
Expect(err).ToNot(HaveOccurred())
Expect(stored.Response.Status).To(Equal(schema.ORStatusInProgress))
})
It("should append and retrieve streaming events", func() {
responseID := "resp_events_test"
request := &schema.OpenResponsesRequest{Model: "test"}
response := &schema.ORResponseResource{
ID: responseID,
Object: "response",
Status: schema.ORStatusInProgress,
}
_, cancel := context.WithCancel(context.Background())
defer cancel()
store.StoreBackground(responseID, request, response, cancel, true)
// Append events
event1 := &schema.ORStreamEvent{
Type: "response.created",
SequenceNumber: 0,
}
event2 := &schema.ORStreamEvent{
Type: "response.in_progress",
SequenceNumber: 1,
}
event3 := &schema.ORStreamEvent{
Type: "response.output_text.delta",
SequenceNumber: 2,
}
err := store.AppendEvent(responseID, event1)
Expect(err).ToNot(HaveOccurred())
err = store.AppendEvent(responseID, event2)
Expect(err).ToNot(HaveOccurred())
err = store.AppendEvent(responseID, event3)
Expect(err).ToNot(HaveOccurred())
// Get all events after -1 (all events)
events, err := store.GetEventsAfter(responseID, -1)
Expect(err).ToNot(HaveOccurred())
Expect(events).To(HaveLen(3))
// Get events after sequence 1
events, err = store.GetEventsAfter(responseID, 1)
Expect(err).ToNot(HaveOccurred())
Expect(events).To(HaveLen(1))
Expect(events[0].SequenceNumber).To(Equal(2))
})
It("should cancel an in-progress response", func() {
responseID := "resp_cancel_test"
request := &schema.OpenResponsesRequest{Model: "test"}
response := &schema.ORResponseResource{
ID: responseID,
Object: "response",
Status: schema.ORStatusInProgress,
}
_, cancel := context.WithCancel(context.Background())
defer cancel()
store.StoreBackground(responseID, request, response, cancel, false)
// Cancel the response
cancelledResponse, err := store.Cancel(responseID)
Expect(err).ToNot(HaveOccurred())
Expect(cancelledResponse.Status).To(Equal(schema.ORStatusCancelled))
Expect(cancelledResponse.CompletedAt).ToNot(BeNil())
})
It("should be idempotent when cancelling already completed response", func() {
responseID := "resp_idempotent_cancel"
request := &schema.OpenResponsesRequest{Model: "test"}
completedAt := time.Now().Unix()
response := &schema.ORResponseResource{
ID: responseID,
Object: "response",
Status: schema.ORStatusCompleted,
CompletedAt: &completedAt,
}
store.Store(responseID, request, response)
// Try to cancel a completed response
cancelledResponse, err := store.Cancel(responseID)
Expect(err).ToNot(HaveOccurred())
// Status should remain completed (not changed to cancelled)
Expect(cancelledResponse.Status).To(Equal(schema.ORStatusCompleted))
})
It("should check if streaming is enabled", func() {
responseID := "resp_stream_check"
request := &schema.OpenResponsesRequest{Model: "test"}
response := &schema.ORResponseResource{
ID: responseID,
Object: "response",
Status: schema.ORStatusQueued,
}
_, cancel := context.WithCancel(context.Background())
defer cancel()
store.StoreBackground(responseID, request, response, cancel, true)
enabled, err := store.IsStreamEnabled(responseID)
Expect(err).ToNot(HaveOccurred())
Expect(enabled).To(BeTrue())
// Store another without streaming
responseID2 := "resp_no_stream"
store.StoreBackground(responseID2, request, response, cancel, false)
enabled2, err := store.IsStreamEnabled(responseID2)
Expect(err).ToNot(HaveOccurred())
Expect(enabled2).To(BeFalse())
})
It("should notify subscribers of new events", func() {
responseID := "resp_events_chan"
request := &schema.OpenResponsesRequest{Model: "test"}
response := &schema.ORResponseResource{
ID: responseID,
Object: "response",
Status: schema.ORStatusInProgress,
}
_, cancel := context.WithCancel(context.Background())
defer cancel()
store.StoreBackground(responseID, request, response, cancel, true)
eventsChan, err := store.GetEventsChan(responseID)
Expect(err).ToNot(HaveOccurred())
Expect(eventsChan).ToNot(BeNil())
// Append an event
event := &schema.ORStreamEvent{
Type: "response.output_text.delta",
SequenceNumber: 0,
}
go func() {
time.Sleep(10 * time.Millisecond)
store.AppendEvent(responseID, event)
}()
// Wait for notification
select {
case <-eventsChan:
// Event received
case <-time.After(1 * time.Second):
Fail("Timeout waiting for event notification")
}
})
})
})

View File

@@ -1,33 +1,13 @@
package http_test
import (
"os"
"path/filepath"
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var (
tmpdir string
modelDir string
)
func TestLocalAI(t *testing.T) {
RegisterFailHandler(Fail)
var err error
tmpdir, err = os.MkdirTemp("", "")
Expect(err).ToNot(HaveOccurred())
modelDir = filepath.Join(tmpdir, "models")
err = os.Mkdir(modelDir, 0750)
Expect(err).ToNot(HaveOccurred())
AfterSuite(func() {
err := os.RemoveAll(tmpdir)
Expect(err).ToNot(HaveOccurred())
})
RunSpecs(t, "LocalAI HTTP test suite")
}

View File

@@ -484,103 +484,3 @@ func mergeOpenAIRequestAndModelConfig(config *config.ModelConfig, input *schema.
}
return fmt.Errorf("unable to validate configuration after merging")
}
func (re *RequestExtractor) SetOpenResponsesRequest(c echo.Context) error {
input, ok := c.Get(CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.OpenResponsesRequest)
if !ok || input.Model == "" {
return echo.ErrBadRequest
}
cfg, ok := c.Get(CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
if !ok || cfg == nil {
return echo.ErrBadRequest
}
// Extract or generate the correlation ID (Open Responses uses x-request-id)
correlationID := c.Request().Header.Get("x-request-id")
if correlationID == "" {
correlationID = uuid.New().String()
}
c.Response().Header().Set("x-request-id", correlationID)
// Use the request context directly - Echo properly supports context cancellation!
reqCtx := c.Request().Context()
c1, cancel := context.WithCancel(re.applicationConfig.Context)
// Cancel when request context is cancelled (client disconnects)
go func() {
select {
case <-reqCtx.Done():
cancel()
case <-c1.Done():
// Already cancelled
}
}()
// Add the correlation ID to the new context
ctxWithCorrelationID := context.WithValue(c1, CorrelationIDKey, correlationID)
input.Context = ctxWithCorrelationID
input.Cancel = cancel
err := mergeOpenResponsesRequestAndModelConfig(cfg, input)
if err != nil {
return err
}
if cfg.Model == "" {
xlog.Debug("replacing empty cfg.Model with input value", "input.Model", input.Model)
cfg.Model = input.Model
}
c.Set(CONTEXT_LOCALS_KEY_LOCALAI_REQUEST, input)
c.Set(CONTEXT_LOCALS_KEY_MODEL_CONFIG, cfg)
return nil
}
func mergeOpenResponsesRequestAndModelConfig(config *config.ModelConfig, input *schema.OpenResponsesRequest) error {
// Temperature
if input.Temperature != nil {
config.Temperature = input.Temperature
}
// TopP
if input.TopP != nil {
config.TopP = input.TopP
}
// MaxOutputTokens -> Maxtokens
if input.MaxOutputTokens != nil {
config.Maxtokens = input.MaxOutputTokens
}
// Convert tools to functions - this will be handled in the endpoint handler
// We just validate that tools are present if needed
// Handle tool_choice
if input.ToolChoice != nil {
switch tc := input.ToolChoice.(type) {
case string:
// "auto", "required", or "none"
if tc == "required" {
config.SetFunctionCallString("required")
} else if tc == "none" {
// Don't use tools - handled in endpoint
}
// "auto" is default - let model decide
case map[string]interface{}:
// Specific tool: {type:"function", name:"..."}
if tcType, ok := tc["type"].(string); ok && tcType == "function" {
if name, ok := tc["name"].(string); ok {
config.SetFunctionCallString(name)
}
}
}
}
if valid, _ := config.Validate(); valid {
return nil
}
return fmt.Errorf("unable to validate configuration after merging")
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,108 +0,0 @@
package routes
import (
"context"
"fmt"
"net/http"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/endpoints/anthropic"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/xlog"
)
func RegisterAnthropicRoutes(app *echo.Echo,
re *middleware.RequestExtractor,
application *application.Application) {
// Anthropic Messages API endpoint
messagesHandler := anthropic.MessagesEndpoint(
application.ModelConfigLoader(),
application.ModelLoader(),
application.TemplatesEvaluator(),
application.ApplicationConfig(),
)
messagesMiddleware := []echo.MiddlewareFunc{
middleware.TraceMiddleware(application),
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_CHAT)),
re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.AnthropicRequest) }),
setAnthropicRequestContext(application.ApplicationConfig()),
}
// Main Anthropic endpoint
app.POST("/v1/messages", messagesHandler, messagesMiddleware...)
// Also support without version prefix for compatibility
app.POST("/messages", messagesHandler, messagesMiddleware...)
}
// setAnthropicRequestContext sets up the context and cancel function for Anthropic requests
func setAnthropicRequestContext(appConfig *config.ApplicationConfig) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
input, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.AnthropicRequest)
if !ok || input.Model == "" {
return echo.NewHTTPError(http.StatusBadRequest, "model is required")
}
cfg, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
if !ok || cfg == nil {
return echo.NewHTTPError(http.StatusBadRequest, "model configuration not found")
}
// Extract or generate the correlation ID
// Anthropic uses x-request-id header
correlationID := c.Request().Header.Get("x-request-id")
if correlationID == "" {
correlationID = uuid.New().String()
}
c.Response().Header().Set("x-request-id", correlationID)
// Set up context with cancellation
reqCtx := c.Request().Context()
c1, cancel := context.WithCancel(appConfig.Context)
// Cancel when request context is cancelled (client disconnects)
go func() {
select {
case <-reqCtx.Done():
cancel()
case <-c1.Done():
// Already cancelled
}
}()
// Add the correlation ID to the new context
ctxWithCorrelationID := context.WithValue(c1, middleware.CorrelationIDKey, correlationID)
input.Context = ctxWithCorrelationID
input.Cancel = cancel
if cfg.Model == "" {
xlog.Debug("replacing empty cfg.Model with input value", "input.Model", input.Model)
cfg.Model = input.Model
}
c.Set(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST, input)
c.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, cfg)
// Log the Anthropic API version if provided
anthropicVersion := c.Request().Header.Get("anthropic-version")
if anthropicVersion != "" {
xlog.Debug("Anthropic API version", "version", anthropicVersion)
}
// Validate max_tokens is provided
if input.MaxTokens <= 0 {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("max_tokens is required and must be greater than 0"))
}
return next(c)
}
}
}

View File

@@ -1,58 +0,0 @@
package routes
import (
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/endpoints/openresponses"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
)
func RegisterOpenResponsesRoutes(app *echo.Echo,
re *middleware.RequestExtractor,
application *application.Application) {
// Open Responses API endpoint
responsesHandler := openresponses.ResponsesEndpoint(
application.ModelConfigLoader(),
application.ModelLoader(),
application.TemplatesEvaluator(),
application.ApplicationConfig(),
)
responsesMiddleware := []echo.MiddlewareFunc{
middleware.TraceMiddleware(application),
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_CHAT)),
re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.OpenResponsesRequest) }),
setOpenResponsesRequestContext(re),
}
// Main Open Responses endpoint
app.POST("/v1/responses", responsesHandler, responsesMiddleware...)
// Also support without version prefix for compatibility
app.POST("/responses", responsesHandler, responsesMiddleware...)
// GET /responses/:id - Retrieve a response (for polling background requests)
getResponseHandler := openresponses.GetResponseEndpoint()
app.GET("/v1/responses/:id", getResponseHandler, middleware.TraceMiddleware(application))
app.GET("/responses/:id", getResponseHandler, middleware.TraceMiddleware(application))
// POST /responses/:id/cancel - Cancel a background response
cancelResponseHandler := openresponses.CancelResponseEndpoint()
app.POST("/v1/responses/:id/cancel", cancelResponseHandler, middleware.TraceMiddleware(application))
app.POST("/responses/:id/cancel", cancelResponseHandler, middleware.TraceMiddleware(application))
}
// setOpenResponsesRequestContext sets up the context and cancel function for Open Responses requests
func setOpenResponsesRequestContext(re *middleware.RequestExtractor) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if err := re.SetOpenResponsesRequest(c); err != nil {
return err
}
return next(c)
}
}
}

View File

@@ -219,7 +219,7 @@ func RegisterUIRoutes(app *echo.Echo,
return c.Render(200, "views/chat", summary)
})
app.GET("/image/:model", func(c echo.Context) error {
app.GET("/text2image/:model", func(c echo.Context) error {
modelConfigs := cl.GetAllModelsConfigs()
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
@@ -233,10 +233,10 @@ func RegisterUIRoutes(app *echo.Echo,
}
// Render index
return c.Render(200, "views/image", summary)
return c.Render(200, "views/text2image", summary)
})
app.GET("/image", func(c echo.Context) error {
app.GET("/text2image", func(c echo.Context) error {
modelConfigs := cl.GetAllModelsConfigs()
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
@@ -266,7 +266,7 @@ func RegisterUIRoutes(app *echo.Echo,
}
// Render index
return c.Render(200, "views/image", summary)
return c.Render(200, "views/text2image", summary)
})
app.GET("/tts/:model", func(c echo.Context) error {
@@ -318,56 +318,6 @@ func RegisterUIRoutes(app *echo.Echo,
return c.Render(200, "views/tts", summary)
})
app.GET("/video/:model", func(c echo.Context) error {
modelConfigs := cl.GetAllModelsConfigs()
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
summary := map[string]interface{}{
"Title": "LocalAI - Generate videos with " + c.Param("model"),
"BaseURL": middleware.BaseURL(c),
"ModelsConfig": modelConfigs,
"ModelsWithoutConfig": modelsWithoutConfig,
"Model": c.Param("model"),
"Version": internal.PrintableVersion(),
}
// Render index
return c.Render(200, "views/video", summary)
})
app.GET("/video", func(c echo.Context) error {
modelConfigs := cl.GetAllModelsConfigs()
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
if len(modelConfigs)+len(modelsWithoutConfig) == 0 {
// If no model is available redirect to the index which suggests how to install models
return c.Redirect(302, middleware.BaseURL(c))
}
modelThatCanBeUsed := ""
title := "LocalAI - Generate videos"
for _, b := range modelConfigs {
if b.HasUsecases(config.FLAG_VIDEO) {
modelThatCanBeUsed = b.Name
title = "LocalAI - Generate videos with " + modelThatCanBeUsed
break
}
}
summary := map[string]interface{}{
"Title": title,
"BaseURL": middleware.BaseURL(c),
"ModelsConfig": modelConfigs,
"ModelsWithoutConfig": modelsWithoutConfig,
"Model": modelThatCanBeUsed,
"Version": internal.PrintableVersion(),
}
// Render index
return c.Render(200, "views/video", summary)
})
// Traces UI
app.GET("/traces", func(c echo.Context) error {
summary := map[string]interface{}{

View File

@@ -617,12 +617,6 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
installedBackendsCount = len(installedBackends)
}
// Get the detected system capability
detectedCapability := ""
if appConfig.SystemState != nil {
detectedCapability = appConfig.SystemState.DetectedCapability()
}
return c.JSON(200, map[string]interface{}{
"backends": backendsJSON,
"repositories": appConfig.BackendGalleries,
@@ -635,7 +629,6 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
"totalPages": totalPages,
"prevPage": prevPage,
"nextPage": nextPage,
"systemCapability": detectedCapability,
})
})

View File

@@ -1368,7 +1368,6 @@ async function promptGPT(systemPrompt, input) {
let lastAssistantMessageIndex = -1;
let lastThinkingMessageIndex = -1;
let lastThinkingScrollTime = 0;
let hasReasoningFromAPI = false; // Track if we're receiving reasoning from API (skip tag-based detection)
const THINKING_SCROLL_THROTTLE = 200; // Throttle scrolling to every 200ms
try {
@@ -1402,24 +1401,19 @@ async function promptGPT(systemPrompt, input) {
// Handle different event types
switch (eventData.type) {
case "reasoning":
hasReasoningFromAPI = true; // Mark that we're receiving reasoning from API
if (eventData.content) {
const currentChat = chatStore.getChat(chatId);
if (!currentChat) break; // Chat was deleted
const isMCPMode = currentChat.mcpMode || false;
const shouldExpand = !isMCPMode; // Expanded in non-MCP mode, collapsed in MCP mode
// Insert thinking before assistant message if it exists (always use "thinking" role)
// Insert reasoning before assistant message if it exists
if (lastAssistantMessageIndex >= 0 && targetHistory[lastAssistantMessageIndex]?.role === "assistant") {
targetHistory.splice(lastAssistantMessageIndex, 0, {
role: "thinking",
role: "reasoning",
content: eventData.content,
html: DOMPurify.sanitize(marked.parse(eventData.content)),
image: [],
audio: [],
expanded: shouldExpand
expanded: false // Reasoning is always collapsed
});
lastAssistantMessageIndex++; // Adjust index since we inserted
// Scroll smoothly after adding thinking
// Scroll smoothly after adding reasoning
setTimeout(() => {
const chatContainer = document.getElementById('chat');
if (chatContainer) {
@@ -1431,7 +1425,7 @@ async function promptGPT(systemPrompt, input) {
}, 100);
} else {
// No assistant message yet, just add normally
chatStore.add("thinking", eventData.content, null, null, chatId);
chatStore.add("reasoning", eventData.content, null, null, chatId);
}
}
break;
@@ -1497,17 +1491,14 @@ async function promptGPT(systemPrompt, input) {
// Only update display if this is the active chat (interval will handle it)
// Don't call updateTokensPerSecond here to avoid unnecessary updates
// Only check for thinking tags if we're NOT receiving reasoning from API
// This prevents duplicate thinking/reasoning messages
if (!hasReasoningFromAPI) {
// Check for thinking tags in the chunk (incremental detection)
if (contentChunk.includes("<thinking>") || contentChunk.includes("<think>")) {
isThinking = true;
thinkingContent = "";
lastThinkingMessageIndex = -1;
}
if (contentChunk.includes("</thinking>") || contentChunk.includes("</think>")) {
// Check for thinking tags in the chunk (incremental detection)
if (contentChunk.includes("<thinking>") || contentChunk.includes("<think>")) {
isThinking = true;
thinkingContent = "";
lastThinkingMessageIndex = -1;
}
if (contentChunk.includes("</thinking>") || contentChunk.includes("</think>")) {
isThinking = false;
// When closing tag is detected, process the accumulated thinking content
if (thinkingContent.trim()) {
@@ -1561,11 +1552,10 @@ async function promptGPT(systemPrompt, input) {
}
thinkingContent = "";
}
}
}
// Handle content based on thinking state (only if not receiving reasoning from API)
if (!hasReasoningFromAPI && isThinking) {
// Handle content based on thinking state
if (isThinking) {
thinkingContent += contentChunk;
const currentChat = chatStore.getChat(chatId);
if (!currentChat) break; // Chat was deleted
@@ -1647,10 +1637,7 @@ async function promptGPT(systemPrompt, input) {
// Process any thinking tags that might be in the accumulated content
// This handles cases where tags are split across chunks
// Only process if we're NOT receiving reasoning from API (to avoid duplicates)
const { regularContent: processedRegular, thinkingContent: processedThinking } = hasReasoningFromAPI
? { regularContent: regularContent, thinkingContent: "" }
: processThinkingTags(regularContent);
const { regularContent: processedRegular, thinkingContent: processedThinking } = processThinkingTags(regularContent);
// Update or create assistant message with processed regular content
const currentChat = chatStore.getChat(chatId);
@@ -1658,10 +1645,10 @@ async function promptGPT(systemPrompt, input) {
const request = activeRequests.get(chatId);
const requestModel = request?.model || null;
if (lastAssistantMessageIndex === -1) {
// Create assistant message if we have any content (even if empty string after processing)
// This ensures the message is created and can be updated with more content later
chatStore.add("assistant", processedRegular || "", null, null, chatId, requestModel);
lastAssistantMessageIndex = targetHistory.length - 1;
if (processedRegular && processedRegular.trim()) {
chatStore.add("assistant", processedRegular, null, null, chatId, requestModel);
lastAssistantMessageIndex = targetHistory.length - 1;
}
} else {
const lastMessage = targetHistory[lastAssistantMessageIndex];
if (lastMessage && lastMessage.role === "assistant") {
@@ -1699,10 +1686,7 @@ async function promptGPT(systemPrompt, input) {
if (assistantContentBuffer.length > 0) {
const regularContent = assistantContentBuffer.join("");
// Process any remaining thinking tags that might be in the buffer
// Only process if we're NOT receiving reasoning from API (to avoid duplicates)
const { regularContent: processedRegular, thinkingContent: processedThinking } = hasReasoningFromAPI
? { regularContent: regularContent, thinkingContent: "" }
: processThinkingTags(regularContent);
const { regularContent: processedRegular, thinkingContent: processedThinking } = processThinkingTags(regularContent);
const currentChat = chatStore.getChat(chatId);
if (!currentChat) {
@@ -1735,26 +1719,23 @@ async function promptGPT(systemPrompt, input) {
}
// Then update or create assistant message
// Always create/update assistant message if we have any content
if (lastAssistantMessageIndex !== -1) {
const lastMessage = targetHistory[lastAssistantMessageIndex];
if (lastMessage && lastMessage.role === "assistant") {
lastMessage.content = (lastMessage.content || "") + (processedRegular || "");
lastMessage.html = DOMPurify.sanitize(marked.parse(lastMessage.content));
}
} else {
// Create assistant message (even if empty, so it can be updated with more content)
} else if (processedRegular && processedRegular.trim()) {
const request = activeRequests.get(chatId);
const requestModel = request?.model || null;
chatStore.add("assistant", processedRegular || "", null, null, chatId, requestModel);
chatStore.add("assistant", processedRegular, null, null, chatId, requestModel);
lastAssistantMessageIndex = targetHistory.length - 1;
}
}
// Final thinking content flush if any data remains (from incremental detection)
// Only process if we're NOT receiving reasoning from API (to avoid duplicates)
const finalChat = chatStore.getChat(chatId);
if (finalChat && !hasReasoningFromAPI && thinkingContent.trim() && lastThinkingMessageIndex === -1) {
if (finalChat && thinkingContent.trim() && lastThinkingMessageIndex === -1) {
const finalHistory = finalChat.history;
// Extract thinking content if tags are present
const thinkingMatch = thinkingContent.match(/<(?:thinking|redacted_reasoning)>(.*?)<\/(?:thinking|redacted_reasoning)>/s);
@@ -1910,13 +1891,9 @@ async function promptGPT(systemPrompt, input) {
let buffer = "";
let contentBuffer = [];
let thinkingContent = "";
let reasoningContent = ""; // Track reasoning from API reasoning field
let isThinking = false;
let lastThinkingMessageIndex = -1;
let lastReasoningMessageIndex = -1; // Track reasoning message separately
let lastAssistantMessageIndex = -1; // Track assistant message for reasoning placement
let lastThinkingScrollTime = 0;
let hasReasoningFromAPI = false; // Track if we're receiving reasoning from API (skip tag-based detection)
const THINKING_SCROLL_THROTTLE = 200; // Throttle scrolling to every 200ms
try {
@@ -1952,100 +1929,30 @@ async function promptGPT(systemPrompt, input) {
chatStore.updateTokenUsage(jsonData.usage, chatId);
}
const token = jsonData.choices?.[0]?.delta?.content;
const reasoningDelta = jsonData.choices?.[0]?.delta?.reasoning;
const token = jsonData.choices[0].delta.content;
// Handle reasoning from API reasoning field - always use "thinking" role
if (reasoningDelta && reasoningDelta.trim() !== "") {
hasReasoningFromAPI = true; // Mark that we're receiving reasoning from API
reasoningContent += reasoningDelta;
const currentChat = chatStore.getChat(chatId);
if (!currentChat) {
// Chat was deleted, skip this line
if (token) {
// Check for thinking tags
if (token.includes("<thinking>") || token.includes("<think>")) {
isThinking = true;
thinkingContent = "";
lastThinkingMessageIndex = -1;
return;
}
const isMCPMode = currentChat.mcpMode || false;
const shouldExpand = !isMCPMode; // Expanded in non-MCP mode, collapsed in MCP mode
// Only create/update thinking message if we have actual content
if (reasoningContent.trim() !== "") {
// Update or create thinking message (always use "thinking" role, not "reasoning")
if (lastReasoningMessageIndex === -1) {
// Find the last assistant message index to insert thinking before it
const targetHistory = currentChat.history;
const assistantIndex = targetHistory.length - 1;
if (assistantIndex >= 0 && targetHistory[assistantIndex]?.role === "assistant") {
// Insert thinking before assistant message
targetHistory.splice(assistantIndex, 0, {
role: "thinking",
content: reasoningContent,
html: DOMPurify.sanitize(marked.parse(reasoningContent)),
image: [],
audio: [],
expanded: shouldExpand
});
lastReasoningMessageIndex = assistantIndex;
lastAssistantMessageIndex = assistantIndex + 1; // Adjust for inserted thinking
} else {
// No assistant message yet, just add normally
chatStore.add("thinking", reasoningContent, null, null, chatId);
lastReasoningMessageIndex = currentChat.history.length - 1;
}
} else {
// Update existing thinking message
const targetHistory = currentChat.history;
if (lastReasoningMessageIndex >= 0 && lastReasoningMessageIndex < targetHistory.length) {
const thinkingMessage = targetHistory[lastReasoningMessageIndex];
if (thinkingMessage && thinkingMessage.role === "thinking") {
thinkingMessage.content = reasoningContent;
thinkingMessage.html = DOMPurify.sanitize(marked.parse(reasoningContent));
}
if (token.includes("</thinking>") || token.includes("</think>")) {
isThinking = false;
if (thinkingContent.trim()) {
// Only add the final thinking message if we don't already have one
if (lastThinkingMessageIndex === -1) {
chatStore.add("thinking", thinkingContent, null, null, chatId);
}
}
return;
}
// Scroll when reasoning is updated (throttled)
const now = Date.now();
if (now - lastThinkingScrollTime > THINKING_SCROLL_THROTTLE) {
lastThinkingScrollTime = now;
setTimeout(() => {
const chatContainer = document.getElementById('chat');
if (chatContainer) {
chatContainer.scrollTo({
top: chatContainer.scrollHeight,
behavior: 'smooth'
});
}
scrollThinkingBoxToBottom();
}, 100);
}
}
if (token && token.trim() !== "") {
// Only check for thinking tags if we're NOT receiving reasoning from API
// This prevents duplicate thinking/reasoning messages
if (!hasReasoningFromAPI) {
// Check for thinking tags (legacy support - models that output tags directly)
if (token.includes("<thinking>") || token.includes("<think>")) {
isThinking = true;
thinkingContent = "";
lastThinkingMessageIndex = -1;
return;
}
if (token.includes("</thinking>") || token.includes("</think>")) {
isThinking = false;
if (thinkingContent.trim()) {
// Only add the final thinking message if we don't already have one
if (lastThinkingMessageIndex === -1) {
chatStore.add("thinking", thinkingContent, null, null, chatId);
}
}
return;
}
// Handle content based on thinking state
if (isThinking) {
thinkingContent += token;
// Handle content based on thinking state
if (isThinking) {
thinkingContent += token;
// Count tokens for rate calculation (per chat)
const request = activeRequests.get(chatId);
if (request) {
@@ -2088,42 +1995,7 @@ async function promptGPT(systemPrompt, input) {
}, 100);
}
} else {
// Not in thinking state, add to content buffer
contentBuffer.push(token);
// Track assistant message index for reasoning placement
if (lastAssistantMessageIndex === -1) {
const currentChat = chatStore.getChat(chatId);
if (currentChat) {
const targetHistory = currentChat.history;
// Find or create assistant message index
for (let i = targetHistory.length - 1; i >= 0; i--) {
if (targetHistory[i].role === "assistant") {
lastAssistantMessageIndex = i;
break;
}
}
// If no assistant message yet, it will be created when we flush contentBuffer
}
}
}
} else {
// Receiving reasoning from API, just add token to content buffer
contentBuffer.push(token);
// Track assistant message index for reasoning placement
if (lastAssistantMessageIndex === -1) {
const currentChat = chatStore.getChat(chatId);
if (currentChat) {
const targetHistory = currentChat.history;
// Find or create assistant message index
for (let i = targetHistory.length - 1; i >= 0; i--) {
if (targetHistory[i].role === "assistant") {
lastAssistantMessageIndex = i;
break;
}
}
// If no assistant message yet, it will be created when we flush contentBuffer
}
}
}
}
} catch (error) {
@@ -2135,17 +2007,6 @@ async function promptGPT(systemPrompt, input) {
// Efficiently update the chat in batch
if (contentBuffer.length > 0) {
addToChat(contentBuffer.join(""));
// Update assistant message index after adding content
const currentChat = chatStore.getChat(chatId);
if (currentChat) {
const targetHistory = currentChat.history;
for (let i = targetHistory.length - 1; i >= 0; i--) {
if (targetHistory[i].role === "assistant") {
lastAssistantMessageIndex = i;
break;
}
}
}
contentBuffer = [];
// Scroll when assistant content is updated (this will also show thinking messages above)
setTimeout(() => {
@@ -2164,30 +2025,7 @@ async function promptGPT(systemPrompt, input) {
if (contentBuffer.length > 0) {
addToChat(contentBuffer.join(""));
}
// Final reasoning flush if any data remains - always use "thinking" role
const finalChat = chatStore.getChat(chatId);
if (finalChat && reasoningContent.trim() && lastReasoningMessageIndex === -1) {
const isMCPMode = finalChat.mcpMode || false;
const shouldExpand = !isMCPMode;
const targetHistory = finalChat.history;
// Find assistant message to insert before
const assistantIndex = targetHistory.length - 1;
if (assistantIndex >= 0 && targetHistory[assistantIndex]?.role === "assistant") {
targetHistory.splice(assistantIndex, 0, {
role: "thinking",
content: reasoningContent,
html: DOMPurify.sanitize(marked.parse(reasoningContent)),
image: [],
audio: [],
expanded: shouldExpand
});
} else {
chatStore.add("thinking", reasoningContent, null, null, chatId);
}
}
// Final thinking content flush (legacy tag-based thinking)
if (finalChat && thinkingContent.trim() && lastThinkingMessageIndex === -1) {
chatStore.add("thinking", thinkingContent, null, null, chatId);
}

View File

@@ -1,300 +0,0 @@
// Helper function to convert file to base64
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
// Remove data:image/...;base64, prefix if present
const base64 = reader.result.split(',')[1] || reader.result;
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function genVideo(event) {
event.preventDefault();
promptVideo();
}
async function promptVideo() {
const loader = document.getElementById("loader");
const input = document.getElementById("input");
const generateBtn = document.getElementById("generate-btn");
const resultDiv = document.getElementById("result");
const resultPlaceholder = document.getElementById("result-placeholder");
// Show loader and disable form
loader.classList.remove("hidden");
if (resultPlaceholder) {
resultPlaceholder.style.display = "none";
}
input.disabled = true;
generateBtn.disabled = true;
// Store the prompt for later restoration
const prompt = input.value.trim();
if (!prompt) {
alert("Please enter a prompt");
loader.classList.add("hidden");
if (resultPlaceholder) {
resultPlaceholder.style.display = "flex";
}
input.disabled = false;
generateBtn.disabled = false;
return;
}
// Collect all form values
const model = document.getElementById("video-model").value;
const size = document.getElementById("video-size").value;
const negativePrompt = document.getElementById("negative-prompt").value.trim();
// Parse size into width and height
const sizeParts = size.split("x");
let width = 512;
let height = 512;
if (sizeParts.length === 2) {
width = parseInt(sizeParts[0]) || 512;
height = parseInt(sizeParts[1]) || 512;
}
// Video-specific parameters
const secondsInput = document.getElementById("video-seconds").value.trim();
const seconds = secondsInput ? secondsInput : undefined;
const fpsInput = document.getElementById("video-fps").value.trim();
const fps = fpsInput ? parseInt(fpsInput) : 16;
const framesInput = document.getElementById("video-frames").value.trim();
const numFrames = framesInput ? parseInt(framesInput) : undefined;
// Advanced parameters
const stepInput = document.getElementById("video-steps").value.trim();
const step = stepInput ? parseInt(stepInput) : undefined;
const seedInput = document.getElementById("video-seed").value.trim();
const seed = seedInput ? parseInt(seedInput) : undefined;
const cfgScaleInput = document.getElementById("video-cfg-scale").value.trim();
const cfgScale = cfgScaleInput ? parseFloat(cfgScaleInput) : undefined;
// Prepare request body
const requestBody = {
model: model,
prompt: prompt,
width: width,
height: height,
fps: fps,
};
if (negativePrompt) {
requestBody.negative_prompt = negativePrompt;
}
if (seconds !== undefined) {
requestBody.seconds = seconds;
}
if (numFrames !== undefined) {
requestBody.num_frames = numFrames;
}
if (step !== undefined) {
requestBody.step = step;
}
if (seed !== undefined) {
requestBody.seed = seed;
}
if (cfgScale !== undefined) {
requestBody.cfg_scale = cfgScale;
}
// Handle file inputs
try {
// Start image (for img2video)
const startImageInput = document.getElementById("start-image");
if (startImageInput.files.length > 0) {
const base64 = await fileToBase64(startImageInput.files[0]);
requestBody.start_image = base64;
}
// End image
const endImageInput = document.getElementById("end-image");
if (endImageInput.files.length > 0) {
const base64 = await fileToBase64(endImageInput.files[0]);
requestBody.end_image = base64;
}
} catch (error) {
console.error("Error processing image files:", error);
resultDiv.innerHTML = '<p class="text-xs text-red-500 p-2">Error processing image files: ' + error.message + '</p>';
loader.classList.add("hidden");
if (resultPlaceholder) {
resultPlaceholder.style.display = "none";
}
input.disabled = false;
generateBtn.disabled = false;
return;
}
// Make API request
try {
const response = await fetch("v1/videos/generations", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
const json = await response.json();
if (json.error) {
// Display error
resultDiv.innerHTML = '<p class="text-xs text-red-500 p-2">Error: ' + json.error.message + '</p>';
loader.classList.add("hidden");
if (resultPlaceholder) {
resultPlaceholder.style.display = "none";
}
input.disabled = false;
generateBtn.disabled = false;
return;
}
// Clear result div and hide placeholder
resultDiv.innerHTML = '';
if (resultPlaceholder) {
resultPlaceholder.style.display = "none";
}
// Display generated video
if (json.data && json.data.length > 0) {
json.data.forEach((item, index) => {
const videoContainer = document.createElement("div");
videoContainer.className = "flex flex-col";
// Create video element
const video = document.createElement("video");
video.controls = true;
video.className = "w-full h-auto rounded-lg";
video.preload = "metadata";
if (item.url) {
video.src = item.url;
} else if (item.b64_json) {
video.src = "data:video/mp4;base64," + item.b64_json;
} else {
return; // Skip invalid items
}
videoContainer.appendChild(video);
// Create caption container
const captionDiv = document.createElement("div");
captionDiv.className = "mt-2 p-2 bg-[var(--color-bg-secondary)] rounded-lg text-xs";
// Prompt caption
const promptCaption = document.createElement("p");
promptCaption.className = "text-[var(--color-text-primary)] mb-1.5 break-words";
promptCaption.innerHTML = '<strong>Prompt:</strong> ' + escapeHtml(prompt);
captionDiv.appendChild(promptCaption);
// Negative prompt if provided
if (negativePrompt) {
const negativeCaption = document.createElement("p");
negativeCaption.className = "text-[var(--color-text-secondary)] mb-1.5 break-words";
negativeCaption.innerHTML = '<strong>Negative Prompt:</strong> ' + escapeHtml(negativePrompt);
captionDiv.appendChild(negativeCaption);
}
// Generation details
const detailsDiv = document.createElement("div");
detailsDiv.className = "flex flex-wrap gap-3 text-[10px] text-[var(--color-text-secondary)] mt-1.5";
detailsDiv.innerHTML = `
<span><strong>Size:</strong> ${width}x${height}</span>
${fps ? `<span><strong>FPS:</strong> ${fps}</span>` : ''}
${numFrames !== undefined ? `<span><strong>Frames:</strong> ${numFrames}</span>` : ''}
${seconds !== undefined ? `<span><strong>Duration:</strong> ${seconds}s</span>` : ''}
${step !== undefined ? `<span><strong>Steps:</strong> ${step}</span>` : ''}
${seed !== undefined ? `<span><strong>Seed:</strong> ${seed}</span>` : ''}
${cfgScale !== undefined ? `<span><strong>CFG Scale:</strong> ${cfgScale}</span>` : ''}
`;
captionDiv.appendChild(detailsDiv);
// Copy prompt button
const copyBtn = document.createElement("button");
copyBtn.className = "mt-1.5 px-2 py-0.5 text-[10px] bg-[var(--color-primary)] text-white rounded hover:opacity-80";
copyBtn.innerHTML = '<i class="fas fa-copy mr-1"></i>Copy Prompt';
copyBtn.onclick = () => {
navigator.clipboard.writeText(prompt).then(() => {
copyBtn.innerHTML = '<i class="fas fa-check mr-1"></i>Copied!';
setTimeout(() => {
copyBtn.innerHTML = '<i class="fas fa-copy mr-1"></i>Copy Prompt';
}, 2000);
});
};
captionDiv.appendChild(copyBtn);
videoContainer.appendChild(captionDiv);
resultDiv.appendChild(videoContainer);
});
// Hide placeholder when videos are displayed
if (resultPlaceholder) {
resultPlaceholder.style.display = "none";
}
} else {
resultDiv.innerHTML = '<p class="text-xs text-[var(--color-text-secondary)] p-2">No videos were generated.</p>';
if (resultPlaceholder) {
resultPlaceholder.style.display = "none";
}
}
} catch (error) {
console.error("Error generating video:", error);
resultDiv.innerHTML = '<p class="text-xs text-red-500 p-2">Error: ' + error.message + '</p>';
if (resultPlaceholder) {
resultPlaceholder.style.display = "none";
}
} finally {
// Hide loader and re-enable form
loader.classList.add("hidden");
input.disabled = false;
generateBtn.disabled = false;
input.focus();
}
}
// Helper function to escape HTML
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
// Initialize
document.addEventListener("DOMContentLoaded", function() {
const input = document.getElementById("input");
const form = document.getElementById("genvideo");
if (input) {
input.focus();
}
if (form) {
form.addEventListener("submit", genVideo);
}
// Handle Enter key press in the prompt input (but allow Shift+Enter for new lines)
if (input) {
input.addEventListener("keydown", function(event) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
genVideo(event);
}
});
}
// Hide loader initially
const loader = document.getElementById("loader");
if (loader) {
loader.classList.add("hidden");
}
});

View File

@@ -54,11 +54,6 @@
<span class="font-semibold text-cyan-300" x-text="installedBackends"></span>
<span class="text-[#94A3B8] ml-1">installed</span>
</a>
<div class="flex items-center bg-[#101827] rounded-lg px-4 py-2 border border-[#38BDF8]/30">
<i class="fas fa-microchip text-[#38BDF8] mr-2"></i>
<span class="text-[#94A3B8] mr-1">Capability:</span>
<span class="font-semibold text-[#38BDF8]" x-text="systemCapability"></span>
</div>
<a href="https://localai.io/backends/" target="_blank" class="btn-primary">
<i class="fas fa-info-circle mr-2"></i>
<span>Documentation</span>
@@ -593,7 +588,6 @@ function backendsGallery() {
totalPages: 1,
availableBackends: 0,
installedBackends: 0,
systemCapability: '',
selectedBackend: null,
jobProgress: {},
notifications: [],
@@ -689,7 +683,6 @@ function backendsGallery() {
this.totalPages = data.totalPages || 1;
this.availableBackends = data.availableBackends || 0;
this.installedBackends = data.installedBackends || 0;
this.systemCapability = data.systemCapability || 'default';
} catch (error) {
console.error('Error fetching backends:', error);
} finally {

View File

@@ -41,7 +41,7 @@ SOFTWARE.
__chatContextSize = {{ .ContextSize }};
{{ end }}
// Store gallery configs for header icon display and model info modal
// Store gallery configs for header icon display
window.__galleryConfigs = {};
{{ $allGalleryConfigs:=.GalleryConfig }}
{{ range $modelName, $galleryConfig := $allGalleryConfigs }}
@@ -49,16 +49,6 @@ SOFTWARE.
{{ if $galleryConfig.Icon }}
window.__galleryConfigs["{{$modelName}}"].Icon = "{{$galleryConfig.Icon}}";
{{ end }}
{{ if $galleryConfig.Description }}
window.__galleryConfigs["{{$modelName}}"].Description = {{ printf "%q" $galleryConfig.Description }};
{{ end }}
{{ if $galleryConfig.URLs }}
window.__galleryConfigs["{{$modelName}}"].URLs = [
{{ range $idx, $url := $galleryConfig.URLs }}
{{ if $idx }},{{ end }}{{ printf "%q" $url }}
{{ end }}
];
{{ end }}
{{ end }}
// Function to initialize store
@@ -336,10 +326,10 @@ SOFTWARE.
c += DOMPurify.sanitize(marked.parse(line));
});
}
// Set expanded state: thinking and reasoning are expanded by default in non-MCP mode, collapsed in MCP mode
// tool_call and tool_result are always collapsed by default
// Set expanded state: thinking is expanded by default in non-MCP mode, collapsed in MCP mode
// Reasoning, tool_call, and tool_result are always collapsed by default
const isMCPMode = chat.mcpMode || false;
const shouldExpand = ((role === "thinking" || role === "reasoning") && !isMCPMode) || false;
const shouldExpand = (role === "thinking" && !isMCPMode) || false;
chat.history.push({ role, content, html: c, image, audio, expanded: shouldExpand, model: messageModel });
// Auto-name chat from first user message
@@ -507,11 +497,6 @@ SOFTWARE.
activeChat.model = modelName;
activeChat.updatedAt = Date.now();
// Update model info modal with new model
if (window.updateModelInfoModal) {
window.updateModelInfoModal(modelName);
}
// Get context size from data attribute
let contextSize = null;
if (selectedOption.dataset.contextSize) {
@@ -551,23 +536,18 @@ SOFTWARE.
}
// Update model selector to reflect the change (ensure it stays in sync)
// Note: We don't dispatch a change event here to avoid infinite loop
// The selector is already updated via user interaction or programmatic change
const modelSelector = document.getElementById('modelSelector');
if (modelSelector) {
// Find and select the option matching the model
const optionValue = 'chat/' + modelName;
for (let i = 0; i < modelSelector.options.length; i++) {
if (modelSelector.options[i].value === optionValue) {
// Only update if it's different to avoid unnecessary updates
if (modelSelector.selectedIndex !== i) {
modelSelector.selectedIndex = i;
}
modelSelector.selectedIndex = i;
break;
}
}
// Don't dispatch change event here - it would cause infinite recursion
// The selector is already in sync with the model
// Trigger Alpine reactivity by dispatching change event
modelSelector.dispatchEvent(new Event('change', { bubbles: true }));
}
// Trigger MCP availability check in Alpine component
@@ -623,52 +603,27 @@ SOFTWARE.
<div class="flex items-center justify-between gap-2">
<label class="text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wide flex-shrink-0">Model</label>
<div class="flex items-center gap-1 flex-shrink-0">
<!-- Info button - reactive to active chat model -->
<template x-if="$store.chat.activeChat() && $store.chat.activeChat().model && window.__galleryConfigs && window.__galleryConfigs[$store.chat.activeChat().model]">
<button
data-twe-ripple-init
data-twe-ripple-color="light"
class="text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors text-xs p-1 rounded hover:bg-[var(--color-bg-primary)]"
data-modal-target="model-info-modal"
data-modal-toggle="model-info-modal"
:data-model-name="$store.chat.activeChat().model"
@click="if (window.updateModelInfoModal) { window.updateModelInfoModal($store.chat.activeChat().model, true); }"
title="Model Information">
<i class="fas fa-info-circle"></i>
</button>
</template>
<!-- Fallback info button for initial model from server -->
<template x-if="(!$store.chat.activeChat() || !$store.chat.activeChat().model) && window.__galleryConfigs && window.__galleryConfigs['{{$model}}']">
<button
data-twe-ripple-init
data-twe-ripple-color="light"
class="text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors text-xs p-1 rounded hover:bg-[var(--color-bg-primary)]"
data-modal-target="model-info-modal"
data-modal-toggle="model-info-modal"
data-model-name="{{$model}}"
@click="if (window.updateModelInfoModal) { window.updateModelInfoModal('{{$model}}', true); }"
title="Model Information">
<i class="fas fa-info-circle"></i>
</button>
</template>
<!-- Edit button - reactive to active chat model -->
<template x-if="$store.chat.activeChat() && $store.chat.activeChat().model">
<a :href="'/models/edit/' + $store.chat.activeChat().model"
class="text-[var(--color-text-secondary)] hover:text-[var(--color-warning)] transition-colors text-xs p-1 rounded hover:bg-[var(--color-bg-primary)]"
title="Edit Model Configuration">
<i class="fas fa-edit"></i>
</a>
</template>
<!-- Fallback edit button for initial model from server -->
<template x-if="!$store.chat.activeChat() || !$store.chat.activeChat().model">
{{ if $model }}
<a href="/models/edit/{{$model}}"
class="text-[var(--color-text-secondary)] hover:text-[var(--color-warning)] transition-colors text-xs p-1 rounded hover:bg-[var(--color-bg-primary)]"
title="Edit Model Configuration">
<i class="fas fa-edit"></i>
</a>
{{ end }}
</template>
{{ if $model }}
{{ $galleryConfig:= index $allGalleryConfigs $model}}
{{ if $galleryConfig }}
<button
data-twe-ripple-init
data-twe-ripple-color="light"
class="text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors text-xs p-1 rounded hover:bg-[var(--color-bg-primary)]"
data-modal-target="model-info-modal"
data-modal-toggle="model-info-modal"
title="Model Information">
<i class="fas fa-info-circle"></i>
</button>
{{ end }}
{{ end }}
{{ if $model }}
<a href="/models/edit/{{$model}}"
class="text-[var(--color-text-secondary)] hover:text-[var(--color-warning)] transition-colors text-xs p-1 rounded hover:bg-[var(--color-bg-primary)]"
title="Edit Model Configuration">
<i class="fas fa-edit"></i>
</a>
{{ end }}
</div>
</div>
<select
@@ -1533,14 +1488,17 @@ SOFTWARE.
</div>
</div>
<!-- Modal moved outside of sidebar to appear in center of page - Always available, content updated dynamically -->
<div id="model-info-modal" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 flex justify-center items-center w-full h-full md:inset-0 max-h-full" style="padding: 1rem;">
<!-- Modal moved outside of sidebar to appear in center of page -->
{{ if $model }}
{{ $galleryConfig:= index $allGalleryConfigs $model}}
{{ if $galleryConfig }}
<div id="model-info-modal" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 flex justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
<div class="relative p-4 w-full max-w-2xl max-h-full">
<div class="relative p-4 w-full max-w-2xl max-h-full bg-white rounded-lg shadow dark:bg-gray-700">
<!-- Header -->
<div class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600">
<h3 id="model-info-modal-title" class="text-xl font-semibold text-gray-900 dark:text-white">{{ if $model }}{{ $model }}{{ end }}</h3>
<button class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white" data-modal-hide="model-info-modal" @click="if (window.closeModelInfoModal) { window.closeModelInfoModal(); }">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">{{ $model }}</h3>
<button class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white" data-modal-hide="model-info-modal">
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
</svg>
@@ -1551,24 +1509,29 @@ SOFTWARE.
<!-- Body -->
<div class="p-4 md:p-5 space-y-4">
<div class="flex justify-center items-center">
<img id="model-info-modal-icon" class="lazy rounded-t-lg max-h-48 max-w-96 object-cover mt-3 entered loaded" style="display: none;" loading="lazy"/>
{{ if $galleryConfig.Icon }}<img class="lazy rounded-t-lg max-h-48 max-w-96 object-cover mt-3 entered loaded" src="{{$galleryConfig.Icon}}" loading="lazy"/>{{end}}
</div>
<div id="model-info-description" class="text-base leading-relaxed text-gray-500 dark:text-gray-400 break-words max-w-full"></div>
<div id="model-info-description" class="text-base leading-relaxed text-gray-500 dark:text-gray-400 break-words max-w-full">{{ $galleryConfig.Description }}</div>
<hr>
<p class="text-sm font-semibold text-gray-900 dark:text-white">Links</p>
<ul id="model-info-links">
<ul>
{{range $galleryConfig.URLs}}
<li><a href="{{ . }}" target="_blank">{{ . }}</a></li>
{{end}}
</ul>
</div>
<!-- Footer -->
<div class="flex items-center p-4 md:p-5 border-t border-gray-200 rounded-b dark:border-gray-600">
<button data-modal-hide="model-info-modal" class="py-2.5 px-5 ms-3 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700" @click="if (window.closeModelInfoModal) { window.closeModelInfoModal(); }">
<button data-modal-hide="model-info-modal" class="py-2.5 px-5 ms-3 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
Close
</button>
</div>
</div>
</div>
</div>
{{ end }}
{{ end }}
<!-- Alpine store initialization and utilities -->
<script>
@@ -1779,20 +1742,10 @@ SOFTWARE.
});
// Also listen for click events on modal toggle buttons
// Use event delegation to handle dynamically created buttons
document.addEventListener('click', (e) => {
const button = e.target.closest('[data-modal-toggle="model-info-modal"]');
if (button) {
// Update modal with current model before showing
if (window.Alpine && window.Alpine.store("chat")) {
const activeChat = window.Alpine.store("chat").activeChat();
const modelName = activeChat ? activeChat.model : (button.dataset.modelName || (document.getElementById("chat-model") ? document.getElementById("chat-model").value : null));
if (modelName && window.updateModelInfoModal) {
window.updateModelInfoModal(modelName, true);
}
}
document.querySelectorAll('[data-modal-toggle="model-info-modal"]').forEach(button => {
button.addEventListener('click', () => {
setTimeout(processMarkdown, 300);
}
});
});
// Process on initial load if libraries are ready
@@ -1833,176 +1786,12 @@ SOFTWARE.
syncModelSelectorOnLoad();
}
// Function to update model info modal with current model
// Set openModal to true to actually open the modal, false to just update content
window.updateModelInfoModal = function(modelName, openModal = false) {
if (!modelName) {
return;
}
if (!window.__galleryConfigs) {
return;
}
const galleryConfig = window.__galleryConfigs[modelName];
// Check if galleryConfig exists and has at least one property
if (!galleryConfig || Object.keys(galleryConfig).length === 0) {
// Still update the modal title even if no config, so user can see which model they clicked
const titleEl = document.getElementById('model-info-modal-title');
if (titleEl) {
titleEl.textContent = modelName;
}
// Show message that no info is available
const descEl = document.getElementById('model-info-description');
if (descEl) {
descEl.textContent = 'No additional information available for this model.';
}
const linksEl = document.getElementById('model-info-links');
if (linksEl) {
linksEl.innerHTML = '';
}
const iconEl = document.getElementById('model-info-modal-icon');
if (iconEl) {
iconEl.style.display = 'none';
}
// Only open the modal if explicitly requested
if (openModal) {
const modalElement = document.getElementById('model-info-modal');
if (modalElement) {
modalElement.classList.remove('hidden');
modalElement.setAttribute('aria-hidden', 'false');
// Add backdrop
let backdrop = document.querySelector('.modal-backdrop');
if (!backdrop) {
backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop fixed inset-0 bg-gray-900 bg-opacity-50 dark:bg-opacity-80 z-40';
document.body.appendChild(backdrop);
backdrop.addEventListener('click', () => {
closeModelInfoModal();
});
}
}
}
return;
}
// Update modal title
const titleEl = document.getElementById('model-info-modal-title');
if (titleEl) {
titleEl.textContent = modelName;
}
// Update icon
const iconEl = document.getElementById('model-info-modal-icon');
if (iconEl) {
if (galleryConfig.Icon) {
iconEl.src = galleryConfig.Icon;
iconEl.style.display = 'block';
} else {
iconEl.style.display = 'none';
}
}
// Update description
const descEl = document.getElementById('model-info-description');
if (descEl) {
descEl.textContent = galleryConfig.Description || 'No description available.';
}
// Update links
const linksEl = document.getElementById('model-info-links');
if (linksEl && galleryConfig.URLs && Array.isArray(galleryConfig.URLs) && galleryConfig.URLs.length > 0) {
linksEl.innerHTML = '';
galleryConfig.URLs.forEach(url => {
const li = document.createElement('li');
const a = document.createElement('a');
a.href = url;
a.target = '_blank';
a.textContent = url;
li.appendChild(a);
linksEl.appendChild(li);
});
} else if (linksEl) {
linksEl.innerHTML = '<li>No links available</li>';
}
// Only open the modal if explicitly requested
if (openModal) {
const modalElement = document.getElementById('model-info-modal');
if (modalElement) {
// Ensure positioning classes are present (they might have been removed)
if (!modalElement.classList.contains('flex')) {
modalElement.classList.add('flex');
}
if (!modalElement.classList.contains('justify-center')) {
modalElement.classList.add('justify-center');
}
if (!modalElement.classList.contains('items-center')) {
modalElement.classList.add('items-center');
}
// Ensure fixed positioning
if (!modalElement.classList.contains('fixed')) {
modalElement.classList.add('fixed');
}
// Ensure full width and height
if (!modalElement.classList.contains('w-full')) {
modalElement.classList.add('w-full');
}
if (!modalElement.classList.contains('h-full')) {
modalElement.classList.add('h-full');
}
// Ensure padding is set
if (!modalElement.style.padding) {
modalElement.style.padding = '1rem';
}
// Remove hidden class if present
modalElement.classList.remove('hidden');
// Set aria-hidden to false
modalElement.setAttribute('aria-hidden', 'false');
// Add backdrop if needed
let backdrop = document.querySelector('.modal-backdrop');
if (!backdrop) {
backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop fixed inset-0 bg-gray-900 bg-opacity-50 dark:bg-opacity-80 z-40';
document.body.appendChild(backdrop);
backdrop.addEventListener('click', () => {
window.closeModelInfoModal();
});
}
}
}
};
// Function to close the model info modal
window.closeModelInfoModal = function() {
const modalElement = document.getElementById('model-info-modal');
if (modalElement) {
modalElement.classList.add('hidden');
modalElement.setAttribute('aria-hidden', 'true');
}
const backdrop = document.querySelector('.modal-backdrop');
if (backdrop) {
backdrop.remove();
}
};
// Also sync after Alpine initializes (in case it runs after DOMContentLoaded)
function initializeModelInfo() {
syncModelSelectorOnLoad();
// Initialize model info modal content with current model (but don't open it)
if (window.updateModelInfoModal && window.Alpine && window.Alpine.store("chat")) {
const activeChat = window.Alpine.store("chat").activeChat();
const modelName = activeChat ? activeChat.model : (document.getElementById("chat-model") ? document.getElementById("chat-model").value : null);
if (modelName) {
window.updateModelInfoModal(modelName, false); // false = don't open, just update content
}
}
}
if (window.Alpine) {
Alpine.nextTick(initializeModelInfo);
Alpine.nextTick(syncModelSelectorOnLoad);
} else {
document.addEventListener('alpine:init', () => {
Alpine.nextTick(initializeModelInfo);
Alpine.nextTick(syncModelSelectorOnLoad);
});
}
</script>

View File

@@ -315,7 +315,7 @@
</a>
{{ end }}
{{ if eq . "FLAG_IMAGE" }}
<a href="image/{{$backendCfg.Name}}" class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[var(--color-success)]/10 text-green-300 hover:bg-[var(--color-success)]/20 transition-colors" title="Image">
<a href="text2image/{{$backendCfg.Name}}" class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[var(--color-success)]/10 text-green-300 hover:bg-[var(--color-success)]/20 transition-colors" title="Image">
<i class="fas fa-image text-[8px] mr-1"></i>Image
</a>
{{ end }}

View File

@@ -25,12 +25,9 @@
<a href="chat/" class="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] px-2 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)] flex items-center group text-sm">
<i class="fa-solid fa-comments text-[var(--color-primary)] mr-1.5 text-sm group-hover:scale-110 transition-transform"></i>Chat
</a>
<a href="image/" class="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] px-2 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)] flex items-center group text-sm">
<a href="text2image/" class="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] px-2 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)] flex items-center group text-sm">
<i class="fas fa-image text-[var(--color-primary)] mr-1.5 text-sm group-hover:scale-110 transition-transform"></i>Images
</a>
<a href="video/" class="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] px-2 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)] flex items-center group text-sm">
<i class="fas fa-video text-[var(--color-primary)] mr-1.5 text-sm group-hover:scale-110 transition-transform"></i>Video
</a>
<a href="tts/" class="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] px-2 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)] flex items-center group text-sm">
<i class="fa-solid fa-music text-[var(--color-primary)] mr-1.5 text-sm group-hover:scale-110 transition-transform"></i>TTS
</a>
@@ -88,12 +85,9 @@
<a href="chat/" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
<i class="fa-solid fa-comments text-[var(--color-primary)] mr-3 w-5 text-center text-sm"></i>Chat
</a>
<a href="image/" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
<a href="text2image/" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
<i class="fas fa-image text-[var(--color-primary)] mr-3 w-5 text-center text-sm"></i>Images
</a>
<a href="video/" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
<i class="fas fa-video text-[var(--color-primary)] mr-3 w-5 text-center text-sm"></i>Video
</a>
<a href="tts/" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
<i class="fa-solid fa-music text-[var(--color-primary)] mr-3 w-5 text-center text-sm"></i>TTS
</a>

View File

@@ -485,28 +485,6 @@
</div>
</div>
<!-- Open Responses Settings Section -->
<div class="bg-[var(--color-bg-secondary)] border border-[var(--color-accent)]/20 rounded-lg p-6">
<h2 class="text-xl font-semibold text-[var(--color-text-primary)] mb-4 flex items-center">
<i class="fas fa-database mr-2 text-[var(--color-accent)] text-sm"></i>
Open Responses Settings
</h2>
<p class="text-xs text-[var(--color-text-secondary)] mb-4">
Configure Open Responses API response storage
</p>
<div class="space-y-4">
<!-- Store TTL -->
<div>
<label class="block text-sm font-medium text-[var(--color-text-primary)] mb-2">Response Store TTL</label>
<p class="text-xs text-[var(--color-text-secondary)] mb-2">Time-to-live for stored responses (e.g., 1h, 30m, 0 = no expiration)</p>
<input type="text" x-model="settings.open_responses_store_ttl"
placeholder="0"
class="w-full px-3 py-2 bg-[var(--color-bg-primary)] border border-[var(--color-accent)]/20 rounded text-sm text-[var(--color-text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)]/50">
</div>
</div>
</div>
<!-- API Keys Settings Section -->
<div class="bg-[var(--color-bg-secondary)] border border-[var(--color-error-light)] rounded-lg p-6">
<h2 class="text-xl font-semibold text-[var(--color-text-primary)] mb-4 flex items-center">
@@ -655,8 +633,7 @@ function settingsDashboard() {
galleries_json: '[]',
backend_galleries_json: '[]',
api_keys_text: '',
agent_job_retention_days: 30,
open_responses_store_ttl: '0'
agent_job_retention_days: 30
},
sourceInfo: '',
saving: false,
@@ -703,8 +680,7 @@ function settingsDashboard() {
galleries_json: JSON.stringify(data.galleries || [], null, 2),
backend_galleries_json: JSON.stringify(data.backend_galleries || [], null, 2),
api_keys_text: (data.api_keys || []).join('\n'),
agent_job_retention_days: data.agent_job_retention_days || 30,
open_responses_store_ttl: data.open_responses_store_ttl || '0'
agent_job_retention_days: data.agent_job_retention_days || 30
};
this.sourceInfo = data.source || 'default';
} else {
@@ -862,9 +838,6 @@ function settingsDashboard() {
if (this.settings.agent_job_retention_days !== undefined) {
payload.agent_job_retention_days = parseInt(this.settings.agent_job_retention_days) || 30;
}
if (this.settings.open_responses_store_ttl !== undefined) {
payload.open_responses_store_ttl = this.settings.open_responses_store_ttl;
}
const response = await fetch('/api/settings', {
method: 'POST',

View File

@@ -28,19 +28,19 @@
{{ $cfg := . }}
{{ range .KnownUsecaseStrings }}
{{ if eq . "FLAG_IMAGE" }}
<option value="image/{{$cfg.Name}}" {{ if eq $cfg.Name $model }} selected {{end}} class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">{{$cfg.Name}}</option>
<option value="text2image/{{$cfg.Name}}" {{ if eq $cfg.Name $model }} selected {{end}} class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">{{$cfg.Name}}</option>
{{ end }}
{{ end }}
{{ end }}
{{ range .ModelsWithoutConfig }}
<option value="image/{{.}}" {{ if eq . $model }} selected {{ end }} class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">{{.}}</option>
<option value="text2image/{{.}}" {{ if eq . $model }} selected {{ end }} class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">{{.}}</option>
{{end}}
</select>
</div>
<div class="relative">
<input id="image-model" type="hidden" value="{{.Model}}">
<form id="genimage" action="image/{{.Model}}" method="get">
<form id="genimage" action="text2image/{{.Model}}" method="get">
<!-- Basic Settings -->
<div class="space-y-2">
<!-- Prompt -->
@@ -326,4 +326,4 @@
</script>
</body>
</html>
</html>

View File

@@ -1,315 +0,0 @@
<!DOCTYPE html>
<html lang="en">
{{template "views/partials/head" .}}
<script defer src="static/video.js"></script>
<body class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] flex flex-col h-screen">
<div class="flex flex-col flex-1 overflow-hidden">
{{template "views/partials/navbar" .}}
<div class="flex flex-1 overflow-hidden">
<!-- Two Column Layout: Settings on Left, Preview on Right -->
<div class="flex flex-col lg:flex-row flex-1 gap-4 p-4 overflow-hidden">
<!-- Left Column: Generation Settings -->
<div class="flex-shrink-0 lg:w-1/4 flex flex-col min-h-0">
<div class="card p-3 space-y-3 overflow-y-auto flex-1">
<!-- Model Selection - Compact -->
<div class="space-y-1.5">
<div class="flex items-center justify-between gap-2">
<label class="text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wide flex-shrink-0">Model</label>
</div>
<select x-data="{ link : '' }" x-model="link" x-init="$watch('link', value => window.location = link)"
id="model-select"
class="input w-full p-1.5 text-xs"
>
<option value="" disabled class="text-[var(--color-text-secondary)]">Select a model</option>
{{ $model:=.Model}}
{{ range .ModelsConfig }}
{{ $cfg := . }}
{{ range .KnownUsecaseStrings }}
{{ if eq . "FLAG_VIDEO" }}
<option value="video/{{$cfg.Name}}" {{ if eq $cfg.Name $model }} selected {{end}} class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">{{$cfg.Name}}</option>
{{ end }}
{{ end }}
{{ end }}
{{ range .ModelsWithoutConfig }}
<option value="video/{{.}}" {{ if eq . $model }} selected {{ end }} class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">{{.}}</option>
{{end}}
</select>
</div>
<div class="relative">
<input id="video-model" type="hidden" value="{{.Model}}">
<form id="genvideo" action="video/{{.Model}}" method="get">
<!-- Basic Settings -->
<div class="space-y-2">
<!-- Prompt -->
<div class="space-y-1">
<label for="input" class="block text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wide">
<i class="fas fa-magic mr-1.5 text-[var(--color-primary)]"></i>Prompt
</label>
<textarea
id="input"
name="input"
placeholder="Describe the video you want to generate..."
autocomplete="off"
rows="3"
class="input w-full p-1.5 text-xs resize-y"
required
></textarea>
</div>
<!-- Negative Prompt -->
<div class="space-y-1">
<label for="negative-prompt" class="block text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wide">
<i class="fas fa-ban mr-1.5 text-[var(--color-primary)]"></i>Negative Prompt
</label>
<textarea
id="negative-prompt"
name="negative-prompt"
placeholder="Things to avoid in the video..."
rows="2"
class="input w-full p-1.5 text-xs resize-y"
></textarea>
</div>
<!-- Size Selection with Presets -->
<div class="space-y-1">
<label for="video-size" class="block text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wide">
<i class="fas fa-expand-arrows-alt mr-1.5 text-[var(--color-primary)]"></i>Video Size
</label>
<div class="flex flex-wrap gap-1.5 mb-1.5">
<button type="button" class="size-preset px-2 py-0.5 text-[10px] rounded border border-[var(--color-border)] hover:bg-[var(--color-bg-secondary)]" data-size="256x256">256×256</button>
<button type="button" class="size-preset px-2 py-0.5 text-[10px] rounded border border-[var(--color-border)] hover:bg-[var(--color-bg-secondary)]" data-size="512x512">512×512</button>
<button type="button" class="size-preset px-2 py-0.5 text-[10px] rounded border border-[var(--color-border)] hover:bg-[var(--color-bg-secondary)]" data-size="768x768">768×768</button>
<button type="button" class="size-preset px-2 py-0.5 text-[10px] rounded border border-[var(--color-border)] hover:bg-[var(--color-bg-secondary)]" data-size="1024x1024">1024×1024</button>
</div>
<input
type="text"
id="video-size"
value="512x512"
placeholder="e.g., 256x256, 512x512, 1024x1024"
class="input p-1.5 text-xs w-full"
/>
</div>
<!-- Video Duration / FPS / Frames -->
<div class="space-y-1">
<label for="video-seconds" class="block text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wide">
<i class="fas fa-clock mr-1.5 text-[var(--color-primary)]"></i>Duration (seconds)
</label>
<input
type="number"
id="video-seconds"
name="seconds"
min="1"
max="60"
placeholder="Leave empty for default"
class="input p-1.5 text-xs w-full"
/>
</div>
<div class="space-y-1">
<label for="video-fps" class="block text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wide">
<i class="fas fa-film mr-1.5 text-[var(--color-primary)]"></i>FPS
</label>
<input
type="number"
id="video-fps"
name="fps"
min="1"
max="60"
value="16"
placeholder="Frames per second"
class="input p-1.5 text-xs w-full"
/>
</div>
<div class="space-y-1">
<label for="video-frames" class="block text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wide">
<i class="fas fa-images mr-1.5 text-[var(--color-primary)]"></i>Number of Frames
</label>
<input
type="number"
id="video-frames"
name="num_frames"
min="1"
max="500"
placeholder="Leave empty for default"
class="input p-1.5 text-xs w-full"
/>
</div>
</div>
<!-- Advanced Settings (Collapsible) -->
<div class="space-y-2">
<button type="button" id="advanced-toggle" class="w-full flex items-center justify-between px-2 py-1.5 text-xs rounded text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] transition-colors">
<span><i class="fa-solid fa-sliders mr-1.5 text-[var(--color-primary)]"></i> Advanced Settings</span>
<i class="fas fa-chevron-down text-[10px]" id="advanced-chevron"></i>
</button>
<div id="advanced-settings" class="hidden p-2 bg-[var(--color-bg-secondary)] border border-[var(--color-primary-border)]/20 rounded pl-4 border-l-2 border-[var(--color-bg-secondary)] space-y-2">
<!-- Steps -->
<div class="space-y-1">
<label for="video-steps" class="block text-xs text-[var(--color-text-secondary)]">
<i class="fas fa-step-forward mr-1.5 text-[var(--color-primary)]"></i>Steps
</label>
<input
type="number"
id="video-steps"
name="step"
min="1"
max="100"
placeholder="Leave empty for default"
class="input p-1.5 text-xs w-full"
/>
</div>
<!-- Seed -->
<div class="space-y-1">
<label for="video-seed" class="block text-xs text-[var(--color-text-secondary)]">
<i class="fas fa-seedling mr-1.5 text-[var(--color-primary)]"></i>Seed
</label>
<input
type="number"
id="video-seed"
name="seed"
min="0"
placeholder="Leave empty for random"
class="input p-1.5 text-xs w-full"
/>
</div>
<!-- CFG Scale -->
<div class="space-y-1">
<label for="video-cfg-scale" class="block text-xs text-[var(--color-text-secondary)]">
<i class="fas fa-sliders-h mr-1.5 text-[var(--color-primary)]"></i>CFG Scale
</label>
<input
type="number"
id="video-cfg-scale"
name="cfg_scale"
min="0"
max="20"
step="0.1"
placeholder="Leave empty for default"
class="input p-1.5 text-xs w-full"
/>
</div>
</div>
</div>
<!-- Image Inputs (Collapsible) -->
<div class="space-y-2">
<button type="button" id="image-inputs-toggle" class="w-full flex items-center justify-between px-2 py-1.5 text-xs rounded text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] transition-colors">
<span><i class="fa-solid fa-image mr-1.5 text-[var(--color-primary)]"></i> Image Inputs</span>
<i class="fas fa-chevron-down text-[10px]" id="image-inputs-chevron"></i>
</button>
<div id="image-inputs-settings" class="hidden p-2 bg-[var(--color-bg-secondary)] border border-[var(--color-primary-border)]/20 rounded pl-4 border-l-2 border-[var(--color-bg-secondary)] space-y-2">
<!-- Start Image (img2video) -->
<div class="space-y-1">
<label for="start-image" class="block text-xs text-[var(--color-text-secondary)]">
<i class="fas fa-play-circle mr-1.5 text-[var(--color-primary)]"></i>Start Image (img2video)
</label>
<input
type="file"
id="start-image"
name="start_image"
accept="image/*"
class="input p-1.5 text-xs w-full"
/>
</div>
<!-- End Image -->
<div class="space-y-1">
<label for="end-image" class="block text-xs text-[var(--color-text-secondary)]">
<i class="fas fa-stop-circle mr-1.5 text-[var(--color-primary)]"></i>End Image
</label>
<input
type="file"
id="end-image"
name="end_image"
accept="image/*"
class="input p-1.5 text-xs w-full"
/>
</div>
</div>
</div>
<!-- Submit Button -->
<div>
<button
type="submit"
id="generate-btn"
class="w-full px-2 py-1.5 text-xs rounded text-[var(--color-bg-primary)] bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90 transition-colors font-medium"
>
<i class="fas fa-video mr-1.5"></i>Generate Video
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Right Column: Video Preview -->
<div class="flex-grow lg:w-3/4 flex flex-col min-h-0">
<div class="relative flex-1 min-h-0 overflow-y-auto">
<!-- Loading Animation -->
<div id="loader" class="hidden absolute inset-0 flex items-center justify-center bg-[var(--color-bg-primary)]/80 rounded-xl z-10">
<div class="text-center">
<svg class="animate-spin h-10 w-10 text-[var(--color-primary)] mx-auto mb-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-xs text-[var(--color-text-secondary)]">Generating video...</p>
</div>
</div>
<!-- Placeholder when no videos -->
<div id="result-placeholder" class="min-h-[400px] flex items-center justify-center flex-shrink-0">
<p class="text-xs text-[var(--color-text-secondary)] italic text-center">Your generated videos will appear here</p>
</div>
<!-- Results container -->
<div id="result" class="grid grid-cols-1 gap-4 pb-4"></div>
</div>
</div>
</div>
</div>
</div>
<script>
// Collapsible sections
document.getElementById('advanced-toggle').addEventListener('click', function() {
const settings = document.getElementById('advanced-settings');
const chevron = document.getElementById('advanced-chevron');
settings.classList.toggle('hidden');
chevron.classList.toggle('fa-chevron-down');
chevron.classList.toggle('fa-chevron-up');
});
document.getElementById('image-inputs-toggle').addEventListener('click', function() {
const settings = document.getElementById('image-inputs-settings');
const chevron = document.getElementById('image-inputs-chevron');
settings.classList.toggle('hidden');
chevron.classList.toggle('fa-chevron-down');
chevron.classList.toggle('fa-chevron-up');
});
// Size preset buttons
document.querySelectorAll('.size-preset').forEach(button => {
button.addEventListener('click', function() {
const size = this.getAttribute('data-size');
document.getElementById('video-size').value = size;
// Update active state
document.querySelectorAll('.size-preset').forEach(btn => {
btn.classList.remove('bg-[var(--color-primary)]', 'text-white');
});
this.classList.add('bg-[var(--color-primary)]', 'text-white');
});
});
// Set initial active size preset
document.querySelector('.size-preset[data-size="512x512"]').classList.add('bg-[var(--color-primary)]', 'text-white');
</script>
</body>
</html>

View File

@@ -1,176 +0,0 @@
package schema
import (
"context"
"encoding/json"
)
// AnthropicRequest represents a request to the Anthropic Messages API
// https://docs.anthropic.com/claude/reference/messages_post
type AnthropicRequest struct {
Model string `json:"model"`
Messages []AnthropicMessage `json:"messages"`
MaxTokens int `json:"max_tokens"`
Metadata map[string]string `json:"metadata,omitempty"`
StopSequences []string `json:"stop_sequences,omitempty"`
Stream bool `json:"stream,omitempty"`
System string `json:"system,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopK *int `json:"top_k,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
Tools []AnthropicTool `json:"tools,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
// Internal fields for request handling
Context context.Context `json:"-"`
Cancel context.CancelFunc `json:"-"`
}
// ModelName implements the LocalAIRequest interface
func (ar *AnthropicRequest) ModelName(s *string) string {
if s != nil {
ar.Model = *s
}
return ar.Model
}
// AnthropicTool represents a tool definition in the Anthropic format
type AnthropicTool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema map[string]interface{} `json:"input_schema"`
}
// AnthropicMessage represents a message in the Anthropic format
type AnthropicMessage struct {
Role string `json:"role"`
Content interface{} `json:"content"`
}
// AnthropicContentBlock represents a content block in an Anthropic message
type AnthropicContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Source *AnthropicImageSource `json:"source,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input map[string]interface{} `json:"input,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"`
Content interface{} `json:"content,omitempty"`
IsError *bool `json:"is_error,omitempty"`
}
// AnthropicImageSource represents an image source in Anthropic format
type AnthropicImageSource struct {
Type string `json:"type"`
MediaType string `json:"media_type"`
Data string `json:"data"`
}
// AnthropicResponse represents a response from the Anthropic Messages API
type AnthropicResponse struct {
ID string `json:"id"`
Type string `json:"type"`
Role string `json:"role"`
Content []AnthropicContentBlock `json:"content"`
Model string `json:"model"`
StopReason *string `json:"stop_reason"`
StopSequence *string `json:"stop_sequence,omitempty"`
Usage AnthropicUsage `json:"usage"`
}
// AnthropicUsage represents token usage in Anthropic format
type AnthropicUsage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
}
// AnthropicStreamEvent represents a streaming event from the Anthropic API
type AnthropicStreamEvent struct {
Type string `json:"type"`
Index int `json:"index,omitempty"`
ContentBlock *AnthropicContentBlock `json:"content_block,omitempty"`
Delta *AnthropicStreamDelta `json:"delta,omitempty"`
Message *AnthropicStreamMessage `json:"message,omitempty"`
Usage *AnthropicUsage `json:"usage,omitempty"`
}
// AnthropicStreamDelta represents the delta in a streaming response
type AnthropicStreamDelta struct {
Type string `json:"type,omitempty"`
Text string `json:"text,omitempty"`
PartialJSON string `json:"partial_json,omitempty"`
StopReason *string `json:"stop_reason,omitempty"`
StopSequence *string `json:"stop_sequence,omitempty"`
}
// AnthropicStreamMessage represents the message object in streaming events
type AnthropicStreamMessage struct {
ID string `json:"id"`
Type string `json:"type"`
Role string `json:"role"`
Content []AnthropicContentBlock `json:"content"`
Model string `json:"model"`
StopReason *string `json:"stop_reason"`
StopSequence *string `json:"stop_sequence,omitempty"`
Usage AnthropicUsage `json:"usage"`
}
// AnthropicErrorResponse represents an error response from the Anthropic API
type AnthropicErrorResponse struct {
Type string `json:"type"`
Error AnthropicError `json:"error"`
}
// AnthropicError represents an error in the Anthropic format
type AnthropicError struct {
Type string `json:"type"`
Message string `json:"message"`
}
// GetStringContent extracts the string content from an AnthropicMessage
// Content can be either a string or an array of content blocks
func (m *AnthropicMessage) GetStringContent() string {
switch content := m.Content.(type) {
case string:
return content
case []interface{}:
var result string
for _, block := range content {
if blockMap, ok := block.(map[string]interface{}); ok {
if blockMap["type"] == "text" {
if text, ok := blockMap["text"].(string); ok {
result += text
}
}
}
}
return result
}
return ""
}
// GetContentBlocks extracts content blocks from an AnthropicMessage
func (m *AnthropicMessage) GetContentBlocks() []AnthropicContentBlock {
switch content := m.Content.(type) {
case string:
return []AnthropicContentBlock{{Type: "text", Text: content}}
case []interface{}:
var blocks []AnthropicContentBlock
for _, block := range content {
if blockMap, ok := block.(map[string]interface{}); ok {
cb := AnthropicContentBlock{}
data, err := json.Marshal(blockMap)
if err != nil {
continue
}
if err := json.Unmarshal(data, &cb); err != nil {
continue
}
blocks = append(blocks, cb)
}
}
return blocks
}
return nil
}

View File

@@ -1,216 +0,0 @@
package schema_test
import (
"encoding/json"
"github.com/mudler/LocalAI/core/schema"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Anthropic Schema", func() {
Describe("AnthropicRequest", func() {
It("should unmarshal a valid request", func() {
jsonData := `{
"model": "claude-3-sonnet-20240229",
"max_tokens": 1024,
"messages": [
{"role": "user", "content": "Hello, world!"}
],
"system": "You are a helpful assistant.",
"temperature": 0.7
}`
var req schema.AnthropicRequest
err := json.Unmarshal([]byte(jsonData), &req)
Expect(err).ToNot(HaveOccurred())
Expect(req.Model).To(Equal("claude-3-sonnet-20240229"))
Expect(req.MaxTokens).To(Equal(1024))
Expect(len(req.Messages)).To(Equal(1))
Expect(req.System).To(Equal("You are a helpful assistant."))
Expect(*req.Temperature).To(Equal(0.7))
})
It("should unmarshal a request with tools", func() {
jsonData := `{
"model": "claude-3-sonnet-20240229",
"max_tokens": 1024,
"messages": [
{"role": "user", "content": "What's the weather?"}
],
"tools": [
{
"name": "get_weather",
"description": "Get the current weather",
"input_schema": {
"type": "object",
"properties": {
"location": {"type": "string"}
}
}
}
],
"tool_choice": {"type": "tool", "name": "get_weather"}
}`
var req schema.AnthropicRequest
err := json.Unmarshal([]byte(jsonData), &req)
Expect(err).ToNot(HaveOccurred())
Expect(len(req.Tools)).To(Equal(1))
Expect(req.Tools[0].Name).To(Equal("get_weather"))
Expect(req.Tools[0].Description).To(Equal("Get the current weather"))
Expect(req.ToolChoice).ToNot(BeNil())
})
It("should implement LocalAIRequest interface", func() {
req := &schema.AnthropicRequest{Model: "test-model"}
Expect(req.ModelName(nil)).To(Equal("test-model"))
newModel := "new-model"
Expect(req.ModelName(&newModel)).To(Equal("new-model"))
Expect(req.Model).To(Equal("new-model"))
})
})
Describe("AnthropicMessage", func() {
It("should get string content from string content", func() {
msg := schema.AnthropicMessage{
Role: "user",
Content: "Hello, world!",
}
Expect(msg.GetStringContent()).To(Equal("Hello, world!"))
})
It("should get string content from array content", func() {
msg := schema.AnthropicMessage{
Role: "user",
Content: []interface{}{
map[string]interface{}{"type": "text", "text": "Hello, "},
map[string]interface{}{"type": "text", "text": "world!"},
},
}
Expect(msg.GetStringContent()).To(Equal("Hello, world!"))
})
It("should get content blocks from string content", func() {
msg := schema.AnthropicMessage{
Role: "user",
Content: "Hello, world!",
}
blocks := msg.GetContentBlocks()
Expect(len(blocks)).To(Equal(1))
Expect(blocks[0].Type).To(Equal("text"))
Expect(blocks[0].Text).To(Equal("Hello, world!"))
})
It("should get content blocks from array content", func() {
msg := schema.AnthropicMessage{
Role: "user",
Content: []interface{}{
map[string]interface{}{"type": "text", "text": "Hello"},
map[string]interface{}{"type": "image", "source": map[string]interface{}{"type": "base64", "data": "abc123"}},
},
}
blocks := msg.GetContentBlocks()
Expect(len(blocks)).To(Equal(2))
Expect(blocks[0].Type).To(Equal("text"))
Expect(blocks[0].Text).To(Equal("Hello"))
})
})
Describe("AnthropicResponse", func() {
It("should marshal a valid response", func() {
stopReason := "end_turn"
resp := schema.AnthropicResponse{
ID: "msg_123",
Type: "message",
Role: "assistant",
Model: "claude-3-sonnet-20240229",
StopReason: &stopReason,
Content: []schema.AnthropicContentBlock{
{Type: "text", Text: "Hello!"},
},
Usage: schema.AnthropicUsage{
InputTokens: 10,
OutputTokens: 5,
},
}
data, err := json.Marshal(resp)
Expect(err).ToNot(HaveOccurred())
var result map[string]interface{}
err = json.Unmarshal(data, &result)
Expect(err).ToNot(HaveOccurred())
Expect(result["id"]).To(Equal("msg_123"))
Expect(result["type"]).To(Equal("message"))
Expect(result["role"]).To(Equal("assistant"))
Expect(result["stop_reason"]).To(Equal("end_turn"))
})
It("should marshal a response with tool use", func() {
stopReason := "tool_use"
resp := schema.AnthropicResponse{
ID: "msg_123",
Type: "message",
Role: "assistant",
Model: "claude-3-sonnet-20240229",
StopReason: &stopReason,
Content: []schema.AnthropicContentBlock{
{
Type: "tool_use",
ID: "toolu_123",
Name: "get_weather",
Input: map[string]interface{}{
"location": "San Francisco",
},
},
},
Usage: schema.AnthropicUsage{
InputTokens: 10,
OutputTokens: 5,
},
}
data, err := json.Marshal(resp)
Expect(err).ToNot(HaveOccurred())
var result map[string]interface{}
err = json.Unmarshal(data, &result)
Expect(err).ToNot(HaveOccurred())
Expect(result["stop_reason"]).To(Equal("tool_use"))
content := result["content"].([]interface{})
Expect(len(content)).To(Equal(1))
toolUse := content[0].(map[string]interface{})
Expect(toolUse["type"]).To(Equal("tool_use"))
Expect(toolUse["id"]).To(Equal("toolu_123"))
Expect(toolUse["name"]).To(Equal("get_weather"))
})
})
Describe("AnthropicErrorResponse", func() {
It("should marshal an error response", func() {
resp := schema.AnthropicErrorResponse{
Type: "error",
Error: schema.AnthropicError{
Type: "invalid_request_error",
Message: "max_tokens is required",
},
}
data, err := json.Marshal(resp)
Expect(err).ToNot(HaveOccurred())
var result map[string]interface{}
err = json.Unmarshal(data, &result)
Expect(err).ToNot(HaveOccurred())
Expect(result["type"]).To(Equal("error"))
errorObj := result["error"].(map[string]interface{})
Expect(errorObj["type"]).To(Equal("invalid_request_error"))
Expect(errorObj["message"]).To(Equal("max_tokens is required"))
})
})
})

View File

@@ -27,9 +27,6 @@ type Message struct {
FunctionCall interface{} `json:"function_call,omitempty" yaml:"function_call,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty" yaml:"tool_call,omitempty"`
// Reasoning content extracted from <thinking>...</thinking> tags
Reasoning *string `json:"reasoning,omitempty" yaml:"reasoning,omitempty"`
}
type ToolCall struct {
@@ -81,8 +78,8 @@ func (messages Messages) ToProto() []*proto.Message {
}
}
// Note: tool_call_id is not in schema.Message yet
// Reasoning field is now available in schema.Message but not yet in proto.Message
// Note: tool_call_id and reasoning_content are not in schema.Message yet
// They may need to be added to schema.Message if needed in the future
}
return protoMessages
}

View File

@@ -1,306 +0,0 @@
package schema
import (
"context"
)
// Open Responses status constants
const (
ORStatusQueued = "queued"
ORStatusInProgress = "in_progress"
ORStatusCompleted = "completed"
ORStatusFailed = "failed"
ORStatusIncomplete = "incomplete"
ORStatusCancelled = "cancelled"
)
// OpenResponsesRequest represents a request to the Open Responses API
// https://www.openresponses.org/specification
type OpenResponsesRequest struct {
Model string `json:"model"`
Input interface{} `json:"input"` // string or []ORItemParam
Tools []ORFunctionTool `json:"tools,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"` // "auto"|"required"|"none"|{type:"function",name:"..."}
Stream bool `json:"stream,omitempty"`
MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
Truncation string `json:"truncation,omitempty"` // "auto"|"disabled"
Instructions string `json:"instructions,omitempty"`
Reasoning *ORReasoningParam `json:"reasoning,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
PreviousResponseID string `json:"previous_response_id,omitempty"`
// Additional parameters from spec
TextFormat interface{} `json:"text_format,omitempty"` // TextResponseFormat or JsonSchemaResponseFormatParam
ServiceTier string `json:"service_tier,omitempty"` // "auto"|"default"|priority hint
AllowedTools []string `json:"allowed_tools,omitempty"` // Restrict which tools can be invoked
Store *bool `json:"store,omitempty"` // Whether to store the response
Include []string `json:"include,omitempty"` // What to include in response
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` // Allow parallel tool calls
PresencePenalty *float64 `json:"presence_penalty,omitempty"` // Presence penalty (-2.0 to 2.0)
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` // Frequency penalty (-2.0 to 2.0)
TopLogprobs *int `json:"top_logprobs,omitempty"` // Number of top logprobs to return
Background *bool `json:"background,omitempty"` // Run request in background
MaxToolCalls *int `json:"max_tool_calls,omitempty"` // Maximum number of tool calls
// OpenAI-compatible extensions (not in Open Responses spec)
LogitBias map[string]float64 `json:"logit_bias,omitempty"` // Map of token IDs to bias values (-100 to 100)
// Internal fields (like OpenAIRequest)
Context context.Context `json:"-"`
Cancel context.CancelFunc `json:"-"`
}
// ModelName implements the LocalAIRequest interface
func (r *OpenResponsesRequest) ModelName(s *string) string {
if s != nil {
r.Model = *s
}
return r.Model
}
// ORFunctionTool represents a function tool definition
type ORFunctionTool struct {
Type string `json:"type"` // always "function"
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters map[string]interface{} `json:"parameters,omitempty"`
Strict bool `json:"strict"` // Always include in response
}
// ORReasoningParam represents reasoning configuration
type ORReasoningParam struct {
Effort string `json:"effort,omitempty"` // "none"|"low"|"medium"|"high"|"xhigh"
Summary string `json:"summary,omitempty"` // "auto"|"concise"|"detailed"
}
// ORItemParam represents an input/output item (discriminated union by type)
type ORItemParam struct {
Type string `json:"type"` // message|function_call|function_call_output|reasoning|item_reference
ID string `json:"id,omitempty"` // Present for all output items
Status string `json:"status,omitempty"` // in_progress|completed|incomplete
// Message fields
Role string `json:"role,omitempty"` // user|assistant|system|developer
Content interface{} `json:"content,omitempty"` // string or []ORContentPart for messages
// Function call fields
CallID string `json:"call_id,omitempty"`
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
// Function call output fields
Output interface{} `json:"output,omitempty"` // string or []ORContentPart
// Note: For item_reference type, use the ID field above to reference the item
}
// ORContentPart represents a content block (discriminated union by type)
// For output_text: type, text, annotations, logprobs are ALL REQUIRED per Open Responses spec
type ORContentPart struct {
Type string `json:"type"` // input_text|input_image|input_file|output_text|refusal
Text string `json:"text"` // REQUIRED for output_text - must always be present (even if empty)
Annotations []ORAnnotation `json:"annotations"` // REQUIRED for output_text - must always be present (use [])
Logprobs []ORLogProb `json:"logprobs"` // REQUIRED for output_text - must always be present (use [])
ImageURL string `json:"image_url,omitempty"`
FileURL string `json:"file_url,omitempty"`
Filename string `json:"filename,omitempty"`
FileData string `json:"file_data,omitempty"`
Refusal string `json:"refusal,omitempty"`
Detail string `json:"detail,omitempty"` // low|high|auto for images
}
// OROutputTextContentPart is an alias for ORContentPart used specifically for output_text
type OROutputTextContentPart = ORContentPart
// ORItemField represents an output item (same structure as ORItemParam)
type ORItemField = ORItemParam
// ORResponseResource represents the main response object
type ORResponseResource struct {
ID string `json:"id"`
Object string `json:"object"` // always "response"
CreatedAt int64 `json:"created_at"`
CompletedAt *int64 `json:"completed_at"` // Required: present as number or null
Status string `json:"status"` // in_progress|completed|failed|incomplete
Model string `json:"model"`
Output []ORItemField `json:"output"`
Error *ORError `json:"error"` // Always present, null if no error
IncompleteDetails *ORIncompleteDetails `json:"incomplete_details"` // Always present, null if complete
PreviousResponseID *string `json:"previous_response_id"`
Instructions *string `json:"instructions"`
// Tool-related fields
Tools []ORFunctionTool `json:"tools"` // Always present, empty array if no tools
ToolChoice interface{} `json:"tool_choice"`
ParallelToolCalls bool `json:"parallel_tool_calls"`
MaxToolCalls *int `json:"max_tool_calls"` // nullable
// Sampling parameters (always required)
Temperature float64 `json:"temperature"`
TopP float64 `json:"top_p"`
PresencePenalty float64 `json:"presence_penalty"`
FrequencyPenalty float64 `json:"frequency_penalty"`
TopLogprobs int `json:"top_logprobs"` // Default to 0
MaxOutputTokens *int `json:"max_output_tokens"`
// Text format configuration
Text *ORTextConfig `json:"text"`
// Truncation and reasoning
Truncation string `json:"truncation"`
Reasoning *ORReasoning `json:"reasoning"` // nullable
// Usage statistics
Usage *ORUsage `json:"usage"` // nullable
// Metadata and operational flags
Metadata map[string]string `json:"metadata"`
Store bool `json:"store"`
Background bool `json:"background"`
ServiceTier string `json:"service_tier"`
// Safety and caching
SafetyIdentifier *string `json:"safety_identifier"` // nullable
PromptCacheKey *string `json:"prompt_cache_key"` // nullable
}
// ORTextConfig represents text format configuration
type ORTextConfig struct {
Format *ORTextFormat `json:"format,omitempty"`
}
// ORTextFormat represents the text format type
type ORTextFormat struct {
Type string `json:"type"` // "text" or "json_schema"
}
// ORError represents an error in the response
type ORError struct {
Type string `json:"type"` // invalid_request|not_found|server_error|model_error|too_many_requests
Code string `json:"code,omitempty"`
Message string `json:"message"`
Param string `json:"param,omitempty"`
}
// ORUsage represents token usage statistics
type ORUsage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
TotalTokens int `json:"total_tokens"`
InputTokensDetails *ORInputTokensDetails `json:"input_tokens_details"` // Always present
OutputTokensDetails *OROutputTokensDetails `json:"output_tokens_details"` // Always present
}
// ORInputTokensDetails represents input token breakdown
type ORInputTokensDetails struct {
CachedTokens int `json:"cached_tokens"` // Always include, even if 0
}
// OROutputTokensDetails represents output token breakdown
type OROutputTokensDetails struct {
ReasoningTokens int `json:"reasoning_tokens"` // Always include, even if 0
}
// ORReasoning represents reasoning configuration and metadata
type ORReasoning struct {
Effort string `json:"effort,omitempty"`
Summary string `json:"summary,omitempty"`
}
// ORIncompleteDetails represents details about why a response was incomplete
type ORIncompleteDetails struct {
Reason string `json:"reason"`
}
// ORStreamEvent represents a streaming event
// Note: Fields like delta, text, logprobs should be set explicitly for events that require them
// The sendSSEEvent function uses a custom serializer to handle conditional field inclusion
type ORStreamEvent struct {
Type string `json:"type"`
SequenceNumber int `json:"sequence_number"`
Response *ORResponseResource `json:"response,omitempty"`
OutputIndex *int `json:"output_index,omitempty"`
ContentIndex *int `json:"content_index,omitempty"`
SummaryIndex *int `json:"summary_index,omitempty"`
ItemID string `json:"item_id,omitempty"`
Item *ORItemField `json:"item,omitempty"`
Part *ORContentPart `json:"part,omitempty"`
Delta *string `json:"delta,omitempty"` // Pointer to distinguish unset from empty
Text *string `json:"text,omitempty"` // Pointer to distinguish unset from empty
Arguments *string `json:"arguments,omitempty"` // Pointer to distinguish unset from empty
Refusal string `json:"refusal,omitempty"`
Error *ORErrorPayload `json:"error,omitempty"`
Logprobs *[]ORLogProb `json:"logprobs,omitempty"` // Pointer to distinguish unset from empty
Obfuscation string `json:"obfuscation,omitempty"`
Annotation *ORAnnotation `json:"annotation,omitempty"`
AnnotationIndex *int `json:"annotation_index,omitempty"`
}
// ORErrorPayload represents an error payload in streaming events
type ORErrorPayload struct {
Type string `json:"type"`
Code string `json:"code,omitempty"`
Message string `json:"message"`
Param string `json:"param,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
}
// ORLogProb represents log probability information
type ORLogProb struct {
Token string `json:"token"`
Logprob float64 `json:"logprob"`
Bytes []int `json:"bytes"`
TopLogprobs []ORTopLogProb `json:"top_logprobs,omitempty"`
}
// ORTopLogProb represents a top log probability
type ORTopLogProb struct {
Token string `json:"token"`
Logprob float64 `json:"logprob"`
Bytes []int `json:"bytes"`
}
// ORAnnotation represents an annotation (e.g., URL citation)
type ORAnnotation struct {
Type string `json:"type"` // url_citation
StartIndex int `json:"start_index"`
EndIndex int `json:"end_index"`
URL string `json:"url"`
Title string `json:"title"`
}
// ORContentPartWithLogprobs creates an output_text content part with logprobs converted from OpenAI format
func ORContentPartWithLogprobs(text string, logprobs *Logprobs) ORContentPart {
orLogprobs := []ORLogProb{}
// Convert OpenAI-style logprobs to Open Responses format
if logprobs != nil && len(logprobs.Content) > 0 {
for _, lp := range logprobs.Content {
// Convert top logprobs
topLPs := []ORTopLogProb{}
for _, tlp := range lp.TopLogprobs {
topLPs = append(topLPs, ORTopLogProb{
Token: tlp.Token,
Logprob: tlp.Logprob,
Bytes: tlp.Bytes,
})
}
orLogprobs = append(orLogprobs, ORLogProb{
Token: lp.Token,
Logprob: lp.Logprob,
Bytes: lp.Bytes,
TopLogprobs: topLPs,
})
}
}
return ORContentPart{
Type: "output_text",
Text: text,
Annotations: []ORAnnotation{}, // REQUIRED - must always be present as array (empty if none)
Logprobs: orLogprobs, // REQUIRED - must always be present as array (empty if none)
}
}

View File

@@ -72,359 +72,6 @@ You can list all the models available with:
curl http://localhost:8080/v1/models
```
### Anthropic Messages API
LocalAI supports the Anthropic Messages API, which is compatible with Claude clients. This endpoint provides a structured way to send messages and receive responses, with support for tools, streaming, and multimodal content.
**Endpoint:** `POST /v1/messages` or `POST /messages`
**Reference:** https://docs.anthropic.com/claude/reference/messages_post
#### Basic Usage
```bash
curl http://localhost:8080/v1/messages \
-H "Content-Type: application/json" \
-H "anthropic-version: 2023-06-01" \
-d '{
"model": "ggml-koala-7b-model-q4_0-r2.bin",
"max_tokens": 1024,
"messages": [
{"role": "user", "content": "Say this is a test!"}
]
}'
```
#### Request Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `model` | string | Yes | The model identifier |
| `messages` | array | Yes | Array of message objects with `role` and `content` |
| `max_tokens` | integer | Yes | Maximum number of tokens to generate (must be > 0) |
| `system` | string | No | System message to set the assistant's behavior |
| `temperature` | float | No | Sampling temperature (0.0 to 1.0) |
| `top_p` | float | No | Nucleus sampling parameter |
| `top_k` | integer | No | Top-k sampling parameter |
| `stop_sequences` | array | No | Array of strings that will stop generation |
| `stream` | boolean | No | Enable streaming responses |
| `tools` | array | No | Array of tool definitions for function calling |
| `tool_choice` | string/object | No | Tool choice strategy: "auto", "any", "none", or specific tool |
| `metadata` | object | No | Custom metadata to attach to the request |
#### Message Format
Messages can contain text or structured content blocks:
```bash
curl http://localhost:8080/v1/messages \
-H "Content-Type: application/json" \
-d '{
"model": "ggml-koala-7b-model-q4_0-r2.bin",
"max_tokens": 1024,
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "What is in this image?"
},
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/jpeg",
"data": "base64_encoded_image_data"
}
}
]
}
]
}'
```
#### Tool Calling
The Anthropic API supports function calling through tools:
```bash
curl http://localhost:8080/v1/messages \
-H "Content-Type: application/json" \
-d '{
"model": "ggml-koala-7b-model-q4_0-r2.bin",
"max_tokens": 1024,
"tools": [
{
"name": "get_weather",
"description": "Get the current weather",
"input_schema": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and state"
}
},
"required": ["location"]
}
}
],
"tool_choice": "auto",
"messages": [
{"role": "user", "content": "What is the weather in San Francisco?"}
]
}'
```
#### Streaming
Enable streaming responses by setting `stream: true`:
```bash
curl http://localhost:8080/v1/messages \
-H "Content-Type: application/json" \
-d '{
"model": "ggml-koala-7b-model-q4_0-r2.bin",
"max_tokens": 1024,
"stream": true,
"messages": [
{"role": "user", "content": "Tell me a story"}
]
}'
```
Streaming responses use Server-Sent Events (SSE) format with event types: `message_start`, `content_block_start`, `content_block_delta`, `content_block_stop`, `message_delta`, and `message_stop`.
#### Response Format
```json
{
"id": "msg_abc123",
"type": "message",
"role": "assistant",
"content": [
{
"type": "text",
"text": "This is a test!"
}
],
"model": "ggml-koala-7b-model-q4_0-r2.bin",
"stop_reason": "end_turn",
"usage": {
"input_tokens": 10,
"output_tokens": 5
}
}
```
### Open Responses API
LocalAI supports the Open Responses API specification, which provides a standardized interface for AI model interactions with support for background processing, streaming, tool calling, and advanced features like reasoning.
**Endpoint:** `POST /v1/responses` or `POST /responses`
**Reference:** https://www.openresponses.org/specification
#### Basic Usage
```bash
curl http://localhost:8080/v1/responses \
-H "Content-Type: application/json" \
-d '{
"model": "ggml-koala-7b-model-q4_0-r2.bin",
"input": "Say this is a test!",
"max_output_tokens": 1024
}'
```
#### Request Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `model` | string | Yes | The model identifier |
| `input` | string/array | Yes | Input text or array of input items |
| `max_output_tokens` | integer | No | Maximum number of tokens to generate |
| `temperature` | float | No | Sampling temperature |
| `top_p` | float | No | Nucleus sampling parameter |
| `instructions` | string | No | System instructions |
| `tools` | array | No | Array of tool definitions |
| `tool_choice` | string/object | No | Tool choice: "auto", "required", "none", or specific tool |
| `stream` | boolean | No | Enable streaming responses |
| `background` | boolean | No | Run request in background (returns immediately) |
| `store` | boolean | No | Whether to store the response |
| `reasoning` | object | No | Reasoning configuration with `effort` and `summary` |
| `parallel_tool_calls` | boolean | No | Allow parallel tool calls |
| `max_tool_calls` | integer | No | Maximum number of tool calls |
| `presence_penalty` | float | No | Presence penalty (-2.0 to 2.0) |
| `frequency_penalty` | float | No | Frequency penalty (-2.0 to 2.0) |
| `top_logprobs` | integer | No | Number of top logprobs to return |
| `truncation` | string | No | Truncation mode: "auto" or "disabled" |
| `text_format` | object | No | Text format configuration |
| `metadata` | object | No | Custom metadata |
#### Input Format
Input can be a simple string or an array of structured items:
```bash
curl http://localhost:8080/v1/responses \
-H "Content-Type: application/json" \
-d '{
"model": "ggml-koala-7b-model-q4_0-r2.bin",
"input": [
{
"type": "message",
"role": "user",
"content": "What is the weather?"
}
],
"max_output_tokens": 1024
}'
```
#### Background Processing
Run requests in the background for long-running tasks:
```bash
curl http://localhost:8080/v1/responses \
-H "Content-Type: application/json" \
-d '{
"model": "ggml-koala-7b-model-q4_0-r2.bin",
"input": "Generate a long story",
"max_output_tokens": 4096,
"background": true
}'
```
The response will include a response ID that can be used to poll for completion:
```json
{
"id": "resp_abc123",
"object": "response",
"status": "in_progress",
"created_at": 1234567890
}
```
#### Retrieving Background Responses
Use the GET endpoint to retrieve background responses:
```bash
# Get response by ID
curl http://localhost:8080/v1/responses/resp_abc123
# Resume streaming with query parameters
curl "http://localhost:8080/v1/responses/resp_abc123?stream=true&starting_after=10"
```
#### Canceling Background Responses
Cancel a background response that's still in progress:
```bash
curl -X POST http://localhost:8080/v1/responses/resp_abc123/cancel
```
#### Tool Calling
Open Responses API supports function calling with tools:
```bash
curl http://localhost:8080/v1/responses \
-H "Content-Type: application/json" \
-d '{
"model": "ggml-koala-7b-model-q4_0-r2.bin",
"input": "What is the weather in San Francisco?",
"tools": [
{
"type": "function",
"name": "get_weather",
"description": "Get the current weather",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and state"
}
},
"required": ["location"]
}
}
],
"tool_choice": "auto",
"max_output_tokens": 1024
}'
```
#### Reasoning Configuration
Configure reasoning effort and summary style:
```bash
curl http://localhost:8080/v1/responses \
-H "Content-Type: application/json" \
-d '{
"model": "ggml-koala-7b-model-q4_0-r2.bin",
"input": "Solve this complex problem step by step",
"reasoning": {
"effort": "high",
"summary": "detailed"
},
"max_output_tokens": 2048
}'
```
#### Response Format
```json
{
"id": "resp_abc123",
"object": "response",
"created_at": 1234567890,
"completed_at": 1234567895,
"status": "completed",
"model": "ggml-koala-7b-model-q4_0-r2.bin",
"output": [
{
"type": "message",
"id": "msg_001",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "This is a test!",
"annotations": [],
"logprobs": []
}
],
"status": "completed"
}
],
"error": null,
"incomplete_details": null,
"temperature": 0.7,
"top_p": 1.0,
"presence_penalty": 0.0,
"frequency_penalty": 0.0,
"usage": {
"input_tokens": 10,
"output_tokens": 5,
"total_tokens": 15,
"input_tokens_details": {
"cached_tokens": 0
},
"output_tokens_details": {
"reasoning_tokens": 0
}
}
}
```
## Backends
### RWKV

View File

@@ -164,57 +164,6 @@ curl http://localhost:8080/tts -H "Content-Type: application/json" -d '{
}' | aplay
```
### Pocket TTS
[Pocket TTS](https://github.com/kyutai-labs/pocket-tts) is a lightweight text-to-speech model designed to run efficiently on CPUs. It supports voice cloning through HuggingFace voice URLs or local audio files.
#### Setup
Install the `pocket-tts` model in the Model gallery or run `local-ai run models install pocket-tts`.
#### Usage
Use the tts endpoint by specifying the pocket-tts backend:
```
curl http://localhost:8080/tts -H "Content-Type: application/json" -d '{
"model": "pocket-tts",
"input":"Hello world, this is a test."
}' | aplay
```
#### Voice cloning
Pocket TTS supports voice cloning through built-in voice names, HuggingFace URLs, or local audio files. You can configure a model with a specific voice:
```yaml
name: pocket-tts
backend: pocket-tts
tts:
voice: "azelma" # Built-in voice name
# Or use HuggingFace URL: "hf://kyutai/tts-voices/alba-mackenna/casual.wav"
# Or use local file path: "path/to/voice.wav"
# Available built-in voices: alba, marius, javert, jean, fantine, cosette, eponine, azelma
```
You can also pre-load a default voice for faster first generation:
```yaml
name: pocket-tts
backend: pocket-tts
options:
- "default_voice:azelma" # Pre-load this voice when model loads
```
Then you can use the model:
```
curl http://localhost:8080/tts -H "Content-Type: application/json" -d '{
"model": "pocket-tts",
"input":"Hello world, this is a test."
}' | aplay
```
### Vall-E-X
[VALL-E-X](https://github.com/Plachtaa/VALL-E-X) is an open source implementation of Microsoft's VALL-E X zero-shot TTS model.

View File

@@ -112,66 +112,6 @@ curl http://localhost:8080/v1/chat/completions \
</details>
### Anthropic Messages API
LocalAI supports the Anthropic Messages API for Claude-compatible models. [Anthropic documentation](https://docs.anthropic.com/claude/reference/messages_post).
<details>
```bash
curl http://localhost:8080/v1/messages \
-H "Content-Type: application/json" \
-H "anthropic-version: 2023-06-01" \
-d '{
"model": "gpt-4",
"max_tokens": 1024,
"messages": [
{"role": "user", "content": "How are you doing?"}
],
"temperature": 0.7
}'
```
</details>
### Open Responses API
LocalAI supports the Open Responses API specification with support for background processing, streaming, and advanced features. [Open Responses documentation](https://www.openresponses.org/specification).
<details>
```bash
curl http://localhost:8080/v1/responses \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4",
"input": "Say this is a test!",
"max_output_tokens": 1024,
"temperature": 0.7
}'
```
For background processing:
```bash
curl http://localhost:8080/v1/responses \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4",
"input": "Generate a long story",
"max_output_tokens": 4096,
"background": true
}'
```
Then retrieve the response:
```bash
curl http://localhost:8080/v1/responses/<response_id>
```
</details>
### Image Generation
Creates an image given a prompt. [OpenAI documentation](https://platform.openai.com/docs/api-reference/images/create).

View File

@@ -20,7 +20,7 @@ Choose the installation method that best suits your needs:
1. **[Docker](docker/)** ⭐ **Recommended** - Works on all platforms, easiest setup
2. **[macOS](macos/)** - Download and install the DMG application
3. **[Linux](linux/)** - Install on Linux using binaries (install.sh script currently has issues - see [issue #8032](https://github.com/mudler/LocalAI/issues/8032))
3. **[Linux](linux/)** - Install on Linux using the one-liner script or binaries
4. **[Kubernetes](kubernetes/)** - Deploy LocalAI on Kubernetes clusters
5. **[Build from Source](build/)** - Build LocalAI from source code
@@ -36,6 +36,6 @@ This will start LocalAI. The API will be available at `http://localhost:8080`. F
For other platforms:
- **macOS**: Download the [DMG](macos/)
- **Linux**: See the [Linux installation guide](linux/) for installation options. **Note:** The `install.sh` script is currently experiencing issues - see [issue #8032](https://github.com/mudler/LocalAI/issues/8032) for details.
- **Linux**: Use the `curl https://localai.io/install.sh | sh` [one-liner](linux/)
For detailed instructions, see the [Docker installation guide](docker/).

View File

@@ -6,11 +6,7 @@ url: '/installation/linux/'
---
## One-Line Installer
{{% notice warning %}}
**The `install.sh` script is currently experiencing issues and may produce broken or misconfigured installations. Please use alternative installation methods (Docker or manual binary installation) until [issue #8032](https://github.com/mudler/LocalAI/issues/8032) is resolved.**
{{% /notice %}}
## One-Line Installer (Recommended)
The fastest way to install LocalAI on Linux is with the installation script:

View File

@@ -97,7 +97,7 @@ For more information on VRAM management, see [VRAM and Memory Management]({{%rel
| `--opaque-errors` | `false` | If true, all error responses are replaced with blank 500 errors. This is intended only for hardening against information leaks and is normally not recommended | `$LOCALAI_OPAQUE_ERRORS` |
| `--use-subtle-key-comparison` | `false` | If true, API Key validation comparisons will be performed using constant-time comparisons rather than simple equality. This trades off performance on each request for resilience against timing attacks | `$LOCALAI_SUBTLE_KEY_COMPARISON` |
| `--disable-api-key-requirement-for-http-get` | `false` | If true, a valid API key is not required to issue GET requests to portions of the web UI. This should only be enabled in secure testing environments | `$LOCALAI_DISABLE_API_KEY_REQUIREMENT_FOR_HTTP_GET` |
| `--http-get-exempted-endpoints` | `^/$,^/browse/?$,^/talk/?$,^/p2p/?$,^/chat/?$,^/image/?$,^/text2image/?$,^/tts/?$,^/static/.*$,^/swagger.*$` | If `--disable-api-key-requirement-for-http-get` is overridden to true, this is the list of endpoints to exempt. Only adjust this in case of a security incident or as a result of a personal security posture review | `$LOCALAI_HTTP_GET_EXEMPTED_ENDPOINTS` |
| `--http-get-exempted-endpoints` | `^/$,^/browse/?$,^/talk/?$,^/p2p/?$,^/chat/?$,^/text2image/?$,^/tts/?$,^/static/.*$,^/swagger.*$` | If `--disable-api-key-requirement-for-http-get` is overridden to true, this is the list of endpoints to exempt. Only adjust this in case of a security incident or as a result of a personal security posture review | `$LOCALAI_HTTP_GET_EXEMPTED_ENDPOINTS` |
## P2P Flags

View File

@@ -42,7 +42,6 @@ LocalAI will attempt to automatically load models which are not explicitly confi
| [silero-vad](https://github.com/snakers4/silero-vad) with [Golang bindings](https://github.com/streamer45/silero-vad-go) | Silero VAD | no | Voice Activity Detection | no | no | CPU |
| [neutts](https://github.com/neuphonic/neuttsair) | NeuTTSAir | no | Text-to-speech with voice cloning | no | no | CUDA 12/13, ROCm, CPU |
| [vibevoice](https://github.com/microsoft/VibeVoice) | VibeVoice-Realtime | no | Real-time text-to-speech with voice cloning | no | no | CUDA 12/13, ROCm, Intel, CPU |
| [pocket-tts](https://github.com/kyutai-labs/pocket-tts) | Pocket TTS | no | Lightweight CPU-based text-to-speech with voice cloning | no | no | CUDA 12/13, ROCm, Intel, CPU |
| [mlx-audio](https://github.com/Blaizzy/mlx-audio) | MLX | no | Text-tospeech | no | no | Metal (Apple Silicon) |
## Image & Video Generation

View File

@@ -1,3 +1,3 @@
{
"version": "v3.10.0"
"version": "v3.9.0"
}

View File

@@ -1,57 +1,4 @@
---
- name: "qwen3-vl-reranker-8b"
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
urls:
- https://huggingface.co/mradermacher/Qwen3-VL-Reranker-8B-GGUF
description: |
**Model Name:** Qwen3-VL-Reranker-8B
**Base Model:** Qwen/Qwen3-VL-Reranker-8B
**Description:**
A high-performance multimodal reranking model for state-of-the-art cross-modal search. It supports 30+ languages and handles text, images, screenshots, videos, and mixed modalities. With 8B parameters and a 32K context length, it refines retrieval results by combining embedding vectors with precise relevance scores. Optimized for efficiency, it supports quantized versions (e.g., Q8_0, Q4_K_M) and is ideal for applications requiring accurate multimodal content matching.
**Key Features:**
- **Multimodal**: Text, images, videos, and mixed content.
- **Language Support**: 30+ languages.
- **Quantization**: Available in Q8_0 (best quality), Q4_K_M (fast, recommended), and lower-precision options.
- **Performance**: Outperforms base models in retrieval tasks (e.g., JinaVDR, ViDoRe v3).
- **Use Case**: Enhances search pipelines by refining embeddings with precise relevance scores.
**Downloads:**
- [GGUF Files](https://huggingface.co/mradermacher/Qwen3-VL-Reranker-8B-GGUF) (e.g., `Qwen3-VL-Reranker-8B.Q8_0.gguf`).
**Usage:**
- Requires `transformers`, `qwen-vl-utils`, and `torch`.
- Example: `from scripts.qwen3_vl_reranker import Qwen3VLReranker; model = Qwen3VLReranker(...)`
**Citation:**
@article{qwen3vlembedding, ...}
This description emphasizes its capabilities, efficiency, and versatility for multimodal search tasks.
overrides:
reranking: true
parameters:
model: llama-cpp/models/Qwen3-VL-Reranker-8B.Q4_K_M.gguf
name: Qwen3-VL-Reranker-8B-GGUF
backend: llama-cpp
template:
use_tokenizer_template: true
known_usecases:
- chat
function:
grammar:
disable: true
mmproj: llama-cpp/mmproj/Qwen3-VL-Reranker-8B.mmproj-f16.gguf
description: Imported from https://huggingface.co/mradermacher/Qwen3-VL-Reranker-8B-GGUF
options:
- use_jinja:true
files:
- filename: llama-cpp/models/Qwen3-VL-Reranker-8B.Q4_K_M.gguf
sha256: f73e62ea68abf741c3e713af823cfb4d2fd2ca35c8b68277b87b4b3d8570b66d
uri: https://huggingface.co/mradermacher/Qwen3-VL-Reranker-8B-GGUF/resolve/main/Qwen3-VL-Reranker-8B.Q4_K_M.gguf
- filename: llama-cpp/mmproj/Qwen3-VL-Reranker-8B.mmproj-f16.gguf
sha256: 15cd9bd4882dae771344f0ac204fce07de91b47c1438ada3861dfc817403c31e
uri: https://huggingface.co/mradermacher/Qwen3-VL-Reranker-8B-GGUF/resolve/main/Qwen3-VL-Reranker-8B.mmproj-f16.gguf
- name: "liquidai.lfm2-2.6b-transcript"
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
urls:
@@ -479,16 +426,6 @@
- filename: voices/streaming_model/en-Davis_man.pt
uri: https://raw.githubusercontent.com/microsoft/VibeVoice/main/demo/voices/streaming_model/en-Davis_man.pt
sha256: 67561d63bfa2153616e4c02fd967007c182593fc53738a6ad94bf5f84e8832ac
- &pocket-tts
url: "github:mudler/LocalAI/gallery/pocket-tts.yaml@master"
icon: https://avatars.githubusercontent.com/u/6154722?s=200&v=4
license: mit
tags:
- text-to-speech
- TTS
name: "pocket-tts"
urls:
- https://github.com/kyutai-labs/pocket-tts
- &qwen3vl
url: "github:mudler/LocalAI/gallery/qwen3.yaml@master"
icon: https://cdn-avatars.huggingface.co/v1/production/uploads/620760a26e3b7210c2ff1943/-s1gyJfvbE1RgO5iBeNOi.png
@@ -1248,63 +1185,6 @@
cuda: true
pipeline_type: QwenImageEditPipeline
enable_parameters: num_inference_steps,image
- &ltx2
name: "ltx-2"
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
urls:
- https://huggingface.co/Lightricks/LTX-2
license: ltx-2-community-license-agreement
tags:
- diffusers
- gpu
- image-to-video
- video-generation
- audio-video
description: |
**LTX-2** is a DiT-based audio-video foundation model designed to generate synchronized video and audio within a single model. It brings together the core building blocks of modern video generation, with open weights and a focus on practical, local execution.
**Key Features:**
- **Joint Audio-Video Generation**: Generates synchronized video and audio in a single model
- **Image-to-Video**: Converts static images into dynamic videos with matching audio
- **High Quality**: Produces realistic video with natural motion and synchronized audio
- **Open Weights**: Available under the LTX-2 Community License Agreement
**Model Details:**
- **Model Type**: Diffusion-based audio-video foundation model
- **Architecture**: DiT (Diffusion Transformer) based
- **Developed by**: Lightricks
- **Paper**: [LTX-2: Efficient Joint Audio-Visual Foundation Model](https://arxiv.org/abs/2601.03233)
**Usage Tips:**
- Width & height settings must be divisible by 32
- Frame count must be divisible by 8 + 1 (e.g., 9, 17, 25, 33, 41, 49, 57, 65, 73, 81, 89, 97, 105, 113, 121)
- Recommended settings: width=768, height=512, num_frames=121, frame_rate=24.0
- For best results, use detailed prompts describing motion and scene dynamics
**Limitations:**
- This model is not intended or able to provide factual information
- Prompt following is heavily influenced by the prompting-style
- When generating audio without speech, the audio may be of lower quality
**Citation:**
```bibtex
@article{hacohen2025ltx2,
title={LTX-2: Efficient Joint Audio-Visual Foundation Model},
author={HaCohen, Yoav and Brazowski, Benny and Chiprut, Nisan and others},
journal={arXiv preprint arXiv:2601.03233},
year={2025}
}
```
overrides:
backend: diffusers
low_vram: true
parameters:
model: Lightricks/LTX-2
diffusers:
cuda: true
pipeline_type: LTX2ImageToVideoPipeline
options:
- torch_dtype:bf16
- &gptoss
name: "gpt-oss-20b"
url: "github:mudler/LocalAI/gallery/harmony.yaml@master"
@@ -3823,41 +3703,6 @@
- filename: boomerang-qwen3-4.9B.Q4_K_M.gguf
sha256: 11e6c068351d104dee31dd63550e5e2fc9be70467c1cfc07a6f84030cb701537
uri: huggingface://mradermacher/boomerang-qwen3-4.9B-GGUF/boomerang-qwen3-4.9B.Q4_K_M.gguf
- !!merge <<: *qwen3
name: "qwen3-coder-30b-a3b-instruct"
icon: https://cdn-avatars.huggingface.co/v1/production/uploads/620760a26e3b7210c2ff1943/-s1gyJfvbE1RgO5iBeNOi.png
url: "github:mudler/LocalAI/gallery/qwen3.yaml@master"
urls:
- https://huggingface.co/Qwen/Qwen3-Coder-30B-A3B-Instruct
- https://huggingface.co/unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF
description: |
Qwen3-Coder is available in multiple sizes. Today, we're excited to introduce Qwen3-Coder-30B-A3B-Instruct. This streamlined model maintains impressive performance and efficiency, featuring the following key enhancements:
- Significant Performance among open models on Agentic Coding, Agentic Browser-Use, and other foundational coding tasks.
- Long-context Capabilities with native support for 256K tokens, extendable up to 1M tokens using Yarn, optimized for repository-scale understanding.
- Agentic Coding supporting for most platform such as Qwen Code, CLINE, featuring a specially designed function call format.
Model Overview:
Qwen3-Coder-30B-A3B-Instruct has the following features:
- Type: Causal Language Models
- Training Stage: Pretraining & Post-training
- Number of Parameters: 30.5B in total and 3.3B activated
- Number of Layers: 48
- Number of Attention Heads (GQA): 32 for Q and 4 for KV
- Number of Experts: 128
- Number of Activated Experts: 8
- Context Length: 262,144 natively.
NOTE: This model supports only non-thinking mode and does not generate <think></think> blocks in its output. Meanwhile, specifying enable_thinking=False is no longer required.
overrides:
parameters:
model: Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf
files:
- filename: Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf
sha256: fadc3e5f8d42bf7e894a785b05082e47daee4df26680389817e2093056f088ad
uri: huggingface://unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF/Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf
- &gemma3
url: "github:mudler/LocalAI/gallery/gemma.yaml@master"
name: "gemma-3-27b-it"

View File

@@ -1,34 +0,0 @@
---
name: localai
config_file: |-
name: pocket-tts
backend: pocket-tts
description: |
Pocket TTS is a lightweight text-to-speech model designed to run efficiently on CPUs.
This model supports voice cloning through HuggingFace voice URLs or local audio files.
parameters:
model: ""
# TTS configuration
tts:
# Voice selection - can be:
# 1. Built-in voice name (e.g., "alba", "marius", "javert", "jean", "fantine", "cosette", "eponine", "azelma")
# 2. HuggingFace URL (e.g., "hf://kyutai/tts-voices/alba-mackenna/casual.wav")
# 3. Local file path (relative to model directory or absolute)
# voice: "azelma"
# Alternative: use audio_path to specify a voice file directly
# audio_path: "hf://kyutai/tts-voices/alba-mackenna/casual.wav"
known_usecases:
- tts
# Backend-specific options
# These are passed as "key:value" strings to the backend
options:
# Default voice to pre-load (optional)
# Can be a voice name or HuggingFace URL
# If set, this voice will be loaded when the model loads for faster first generation
- "default_voice:azelma"
# - "default_voice:hf://kyutai/tts-voices/alba-mackenna/casual.wav"

21
go.mod
View File

@@ -6,10 +6,9 @@ toolchain go1.24.5
require (
dario.cat/mergo v1.0.2
fyne.io/fyne/v2 v2.7.2
fyne.io/fyne/v2 v2.7.1
github.com/Masterminds/sprig/v3 v3.3.0
github.com/alecthomas/kong v1.13.0
github.com/anthropics/anthropic-sdk-go v1.19.0
github.com/charmbracelet/glamour v0.10.0
github.com/containerd/containerd v1.7.30
github.com/ebitengine/purego v0.9.1
@@ -20,7 +19,7 @@ require (
github.com/gofrs/flock v0.13.0
github.com/google/go-containerregistry v0.20.7
github.com/google/uuid v1.6.0
github.com/gpustack/gguf-parser-go v0.23.1
github.com/gpustack/gguf-parser-go v0.22.1
github.com/hpcloud/tail v1.0.0
github.com/ipfs/go-log v1.0.5
github.com/jaypipes/ghw v0.21.2
@@ -32,13 +31,13 @@ require (
github.com/mholt/archiver/v3 v3.5.1
github.com/microcosm-cc/bluemonday v1.0.27
github.com/modelcontextprotocol/go-sdk v1.2.0
github.com/mudler/cogito v0.8.1
github.com/mudler/cogito v0.7.2
github.com/mudler/edgevpn v0.31.1
github.com/mudler/go-processmanager v0.1.0
github.com/mudler/go-processmanager v0.0.0-20240820160718-8b802d3ecf82
github.com/mudler/memory v0.0.0-20251216220809-d1256471a6c2
github.com/mudler/xlog v0.0.5
github.com/onsi/ginkgo/v2 v2.27.5
github.com/onsi/gomega v1.39.0
github.com/onsi/ginkgo/v2 v2.27.3
github.com/onsi/gomega v1.38.3
github.com/otiai10/copy v1.14.1
github.com/otiai10/openaigo v1.7.0
github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5
@@ -68,16 +67,12 @@ require (
github.com/ghodss/yaml v1.0.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/swaggo/files/v2 v2.0.2 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)
require (
fyne.io/systray v1.12.0 // indirect
fyne.io/systray v1.11.1-0.20250603113521-ca66a66d8b58 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
@@ -324,7 +319,7 @@ require (
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.39.0 // indirect

36
go.sum
View File

@@ -8,10 +8,10 @@ dmitri.shuralyov.com/app/changes v0.0.0-20180602232624-0a106ad413e3/go.mod h1:Yl
dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBrvjyP0v+ecvNYvCpyZgu5/xkfAUhi6wJj28eUfSU=
dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4=
dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU=
fyne.io/fyne/v2 v2.7.2 h1:XiNpWkn0PzX43ZCjbb0QYGg1RCxVbugwfVgikWZBCMw=
fyne.io/fyne/v2 v2.7.2/go.mod h1:PXbqY3mQmJV3J1NRUR2VbVgUUx3vgvhuFJxyjRK/4Ug=
fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM=
fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
fyne.io/fyne/v2 v2.7.1 h1:ja7rNHWWEooha4XBIZNnPP8tVFwmTfwMJdpZmLxm2Zc=
fyne.io/fyne/v2 v2.7.1/go.mod h1:xClVlrhxl7D+LT+BWYmcrW4Nf+dJTvkhnPgji7spAwE=
fyne.io/systray v1.11.1-0.20250603113521-ca66a66d8b58 h1:eA5/u2XRd8OUkoMqEv3IBlFYSruNlXD8bRHDiqm0VNI=
fyne.io/systray v1.11.1-0.20250603113521-ca66a66d8b58/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
@@ -44,8 +44,6 @@ github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
github.com/anthropics/anthropic-sdk-go v1.19.0 h1:mO6E+ffSzLRvR/YUH9KJC0uGw0uV8GjISIuzem//3KE=
github.com/anthropics/anthropic-sdk-go v1.19.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
@@ -292,8 +290,8 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gpustack/gguf-parser-go v0.23.1 h1:0U7DOrsi7ryx2L/dlMy+BSQ5bJV4AuMEIgGBs4RK46A=
github.com/gpustack/gguf-parser-go v0.23.1/go.mod h1:y4TwTtDqFWTK+xvprOjRUh+dowgU2TKCX37vRKvGiZ0=
github.com/gpustack/gguf-parser-go v0.22.1 h1:FRnEDWqT0Rcplr/R9ctCRSN2+3DhVsf6dnR5/i9JA4E=
github.com/gpustack/gguf-parser-go v0.22.1/go.mod h1:y4TwTtDqFWTK+xvprOjRUh+dowgU2TKCX37vRKvGiZ0=
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw=
github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo=
@@ -507,14 +505,14 @@ github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7P
github.com/mr-tron/base58 v1.1.2/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/mudler/cogito v0.8.1 h1:66qPJkAMrq/Vo8AC/PvXWuVxYPhi7X2DQuJIilL8+3I=
github.com/mudler/cogito v0.8.1/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
github.com/mudler/cogito v0.7.2 h1:J5eHZPsxpoKcnYUfogje5u0nnzGww7ytv7nSn1DMpms=
github.com/mudler/cogito v0.7.2/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
github.com/mudler/edgevpn v0.31.1 h1:7qegiDWd0kAg6ljhNHxqvp8hbo/6BbzSdbb7/2WZfiY=
github.com/mudler/edgevpn v0.31.1/go.mod h1:ftV5B0nKFzm4R8vR80UYnCb2nf7lxCRgAALxUEEgCf8=
github.com/mudler/go-piper v0.0.0-20241023091659-2494246fd9fc h1:RxwneJl1VgvikiX28EkpdAyL4yQVnJMrbquKospjHyA=
github.com/mudler/go-piper v0.0.0-20241023091659-2494246fd9fc/go.mod h1:O7SwdSWMilAWhBZMK9N9Y/oBDyMMzshE3ju8Xkexwig=
github.com/mudler/go-processmanager v0.1.0 h1:fcSKgF9U/a1Z7KofAFeZnke5YseadCI5GqL9oT0LS3E=
github.com/mudler/go-processmanager v0.1.0/go.mod h1:h6kmHUZeafr+k5hRYpGLMzJFH4hItHffgpRo2QIkP+o=
github.com/mudler/go-processmanager v0.0.0-20240820160718-8b802d3ecf82 h1:FVT07EI8njvsD4tC2Hw8Xhactp5AWhsQWD4oTeQuSAU=
github.com/mudler/go-processmanager v0.0.0-20240820160718-8b802d3ecf82/go.mod h1:Urp7LG5jylKoDq0663qeBh0pINGcRl35nXdKx82PSoU=
github.com/mudler/memory v0.0.0-20251216220809-d1256471a6c2 h1:+WHsL/j6EWOMUiMVIOJNKOwSKiQt/qDPc9fePCf87fA=
github.com/mudler/memory v0.0.0-20251216220809-d1256471a6c2/go.mod h1:EA8Ashhd56o32qN7ouPKFSRUs/Z+LrRCF4v6R2Oarm8=
github.com/mudler/water v0.0.0-20250808092830-dd90dcf09025 h1:WFLP5FHInarYGXi6B/Ze204x7Xy6q/I4nCZnWEyPHK0=
@@ -561,10 +559,10 @@ github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE=
github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q=
github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8=
github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@@ -764,12 +762,10 @@ github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
@@ -978,8 +974,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 h1:E2/AqCUMZGgd73TQkxUMcMla25GB9i/5HOdLr+uH7Vo=
golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=

View File

@@ -1492,7 +1492,7 @@ func ParseFunctionCall(llmresult string, functionConfig FunctionsConfig) []FuncC
results := []FuncCallResults{}
llmResults := []string{}
extractJSON := func(results []string) (result []FuncCallResults, e error) {
returnResult := func(results []string) (result []FuncCallResults, e error) {
// As we have to change the result before processing, we can't stream the answer token-by-token (yet?)
result = make([]FuncCallResults, 0)
@@ -1593,7 +1593,7 @@ func ParseFunctionCall(llmresult string, functionConfig FunctionsConfig) []FuncC
if len(llmResults) == 0 {
llmResults = append(llmResults, llmresult)
}
results, _ = extractJSON(llmResults)
results, _ = returnResult(llmResults)
}
// Determine which XML format to use (if any)
@@ -1632,16 +1632,8 @@ func ParseFunctionCall(llmresult string, functionConfig FunctionsConfig) []FuncC
// But skip if JSONRegexMatch or ResponseRegex was used (they already extracted the content)
xmlResults, err := ParseXML(llmresult, xmlFormat)
if err == nil && len(xmlResults) > 0 {
// Check if JSON is inside XML tags, if so, skip it
for _, result := range xmlResults {
jsonResults, _ := extractJSON([]string{result.Name})
if len(jsonResults) > 0 {
xlog.Debug("Found valid JSON inside XML tags, skipping XML parsing", "json_count", len(jsonResults))
} else {
xlog.Debug("Found additional XML tool calls alongside JSON", "xml_count", len(xmlResults))
results = append(results, xmlResults...)
}
}
xlog.Debug("Found additional XML tool calls alongside JSON", "xml_count", len(xmlResults))
results = append(results, xmlResults...)
}
}

View File

@@ -820,23 +820,6 @@ Final text`
Expect(results[0].Name).To(Equal("first"))
Expect(results[1].Name).To(Equal("second"))
})
It("should not duplicate parse JSON inside tool_call tags", func() {
// This test reproduces a bug where JSON inside <tool_call> tags
// gets parsed twice: once as JSON (correctly) and once as XML (incorrectly)
// The XML parser should not run when JSON parsing already found valid results
input := `<tool_call>
{"name": "get_current_weather", "arguments": {"location": "Beijing", "unit": "celsius"}}
</tool_call>`
results := ParseFunctionCall(input, functionConfig)
// Should only have 1 result, not 2 (one correct + one malformed)
Expect(results).To(HaveLen(1), "Should not create duplicate entries when JSON is inside XML tags")
Expect(results[0].Name).To(Equal("get_current_weather"))
Expect(results[0].Arguments).To(Equal(`{"location":"Beijing","unit":"celsius"}`))
// Verify the name is not the entire JSON object (which would indicate malformed XML parsing)
Expect(results[0].Name).NotTo(ContainSubstring(`{"name"`), "Function name should not contain JSON object")
})
})
Context("Iterative Parser (ChatMsgParser)", func() {

View File

@@ -24,6 +24,8 @@ func (ml *ModelLoader) deleteProcess(s string) error {
return fmt.Errorf("model %s not found", s)
}
defer delete(ml.models, s)
retries := 1
for model.GRPC(false, ml.wd).IsBusy() {
xlog.Debug("Model busy. Waiting.", "model", s)
@@ -46,7 +48,6 @@ func (ml *ModelLoader) deleteProcess(s string) error {
if process == nil {
xlog.Error("No process", "model", s)
// Nothing to do as there is no process
delete(ml.models, s)
return nil
}
@@ -55,10 +56,6 @@ func (ml *ModelLoader) deleteProcess(s string) error {
xlog.Error("(deleteProcess) error while deleting process", "error", err, "model", s)
}
if err == nil {
delete(ml.models, s)
}
return err
}

View File

@@ -1,8 +0,0 @@
package reasoning
type ReasoningConfig struct {
// ThinkingForcedOpen indicates that the model outputs reasoning without an opening tag.
// When true, all content from the start is treated as reasoning until a closing tag is found.
// This is useful for models like GLM-4 that output reasoning without <think> but end with </think>.
ThinkingForcedOpen bool `yaml:"thinking_forced_open,omitempty" json:"thinking_forced_open,omitempty"`
}

View File

@@ -1,18 +0,0 @@
package reasoning
// options holds the configuration for reasoning extraction
type options struct {
thinkingForcedOpen bool
}
// Option is a functional option for configuring reasoning extraction
type Option func(*options)
// WithThinkingForcedOpen configures the extractor to treat all content from the start
// as reasoning until a closing tag is found. This is useful for models like GLM-4
// that output reasoning without <think> but end with </think>.
func WithThinkingForcedOpen() Option {
return func(o *options) {
o.thinkingForcedOpen = true
}
}

View File

@@ -1,256 +0,0 @@
package reasoning
import (
"strings"
)
// Common thinking/reasoning opening tags used by various models.
// These match the tags detected by llama.cpp in common/chat.cpp
var thinkingOpenTags = []string{
// DeepSeek R1, V3.1, Nemotron V2, MiniMax M2, Hermes 2 Pro, Granite, Exaone MOE
"<think>\n",
"<think>",
// Generic thinking tags
"<thinking>\n",
"<thinking>",
// Apertus
"<|inner_prefix|>",
// Command R7B
"<|START_THINKING|>",
// Seed
"<seed:think>",
// Magistral (not in llama.cpp but common)
"[THINK]\n",
"[THINK]",
}
// DetectThinkingForcedOpen checks if a prompt ends with a thinking opening tag.
// This is used to automatically detect when the model template has already added
// the opening thinking tag, meaning the model will output reasoning content directly.
// Returns true if the prompt ends with a known thinking opening tag.
func DetectThinkingForcedOpen(prompt string) bool {
for _, tag := range thinkingOpenTags {
if strings.HasSuffix(prompt, tag) {
return true
}
}
return false
}
// Extract extracts reasoning content from thinking tags and returns
// both the extracted reasoning and the cleaned content (with tags removed).
// It handles <thinking>...</thinking> and <think>...</think> tags.
// Multiple reasoning blocks are concatenated with newlines.
// It also handles the case where only a closing tag is present (no opening tag),
// in which case everything before the closing tag is treated as reasoning.
//
// Use WithThinkingForcedOpen() option when all content from the start should be
// treated as reasoning until a closing tag is found.
func Extract(content string, opts ...Option) (reasoning string, cleanedContent string) {
if content == "" {
return "", content
}
cfg := &options{}
for _, opt := range opts {
opt(cfg)
}
if cfg.thinkingForcedOpen {
return extractForcedOpen(content)
}
return extractFromTags(content)
}
// extractForcedOpen handles the case where reasoning starts without an opening tag.
// All content from the start is treated as reasoning until a closing tag is found.
func extractForcedOpen(content string) (reasoning string, cleanedContent string) {
// Look for the earliest closing tag
// These match the closing tags used by llama.cpp for various models
closingTags := []string{
"</thinking>",
"</think>",
"<|END_THINKING|>", // Command R7B
"<|inner_suffix|>", // Apertus
"</seed:think>", // Seed
"[/THINK]", // Magistral
}
earliestCloseIdx := -1
var matchedCloseTag string
for _, closeTag := range closingTags {
idx := strings.Index(content, closeTag)
if idx != -1 && (earliestCloseIdx == -1 || idx < earliestCloseIdx) {
earliestCloseIdx = idx
matchedCloseTag = closeTag
}
}
if earliestCloseIdx == -1 {
// No closing tag found - all content is reasoning (still streaming)
return strings.TrimSpace(content), ""
}
// Found closing tag - everything before is reasoning, everything after is content
reasoning = strings.TrimSpace(content[:earliestCloseIdx])
cleanedContent = content[earliestCloseIdx+len(matchedCloseTag):]
// Continue processing the rest for any additional reasoning blocks
if cleanedContent != "" {
additionalReasoning, finalContent := extractFromTags(cleanedContent)
if additionalReasoning != "" {
if reasoning != "" {
reasoning = reasoning + "\n\n" + additionalReasoning
} else {
reasoning = additionalReasoning
}
}
cleanedContent = finalContent
}
return reasoning, cleanedContent
}
// extractFromTags extracts reasoning content from thinking tags.
// This is the core implementation that handles standard tag-based extraction.
func extractFromTags(content string) (reasoning string, cleanedContent string) {
if content == "" {
return "", content
}
var reasoningParts []string
var cleanedParts []string
remaining := content
// Define tag pairs to look for
// These match the tags used by llama.cpp for various models
tagPairs := []struct {
start string
end string
}{
{"<thinking>", "</thinking>"},
{"<think>", "</think>"},
{"<|START_THINKING|>", "<|END_THINKING|>"}, // Command R7B
{"<|inner_prefix|>", "<|inner_suffix|>"}, // Apertus
{"<seed:think>", "</seed:think>"}, // Seed
{"[THINK]", "[/THINK]"}, // Magistral
}
// Track the last position we've processed
lastPos := 0
for {
// Find the earliest tag start
earliestStart := -1
earliestEnd := -1
isUnclosed := false
isClosingOnly := false
var matchedTag struct {
start string
end string
}
for _, tagPair := range tagPairs {
startIdx := strings.Index(remaining[lastPos:], tagPair.start)
endIdx := strings.Index(remaining[lastPos:], tagPair.end)
// Check for closing-only tag (closing tag appears before or without opening tag)
if endIdx != -1 && (startIdx == -1 || endIdx < startIdx) {
// Found a closing tag without a preceding opening tag
closingTagPos := endIdx + lastPos
if earliestStart == -1 || closingTagPos < earliestStart || (isClosingOnly && closingTagPos < earliestEnd) {
earliestStart = lastPos
earliestEnd = closingTagPos + len(tagPair.end)
isClosingOnly = true
isUnclosed = false
matchedTag = tagPair
}
continue
}
if startIdx == -1 {
continue
}
startIdx += lastPos
// Find the corresponding end tag after the start tag
endIdxAfterStart := strings.Index(remaining[startIdx+len(tagPair.start):], tagPair.end)
if endIdxAfterStart == -1 {
// Unclosed tag - extract what we have
if earliestStart == -1 || startIdx < earliestStart {
earliestStart = startIdx
earliestEnd = len(remaining)
isUnclosed = true
isClosingOnly = false
matchedTag = tagPair
}
continue
}
endIdxAfterStart += startIdx + len(tagPair.start)
// Found a complete tag pair
if earliestStart == -1 || startIdx < earliestStart {
earliestStart = startIdx
earliestEnd = endIdxAfterStart + len(tagPair.end)
isUnclosed = false
isClosingOnly = false
matchedTag = tagPair
}
}
if earliestStart == -1 {
// No more tags found, add remaining content
if lastPos < len(remaining) {
cleanedParts = append(cleanedParts, remaining[lastPos:])
}
break
}
if isClosingOnly {
// Closing tag without opening tag - content before closing tag is reasoning
reasoningContent := strings.TrimSpace(remaining[lastPos : earliestEnd-len(matchedTag.end)])
if reasoningContent != "" {
reasoningParts = append(reasoningParts, reasoningContent)
}
// Move past the closing tag
lastPos = earliestEnd
continue
}
// Add content before the tag
if earliestStart > lastPos {
cleanedParts = append(cleanedParts, remaining[lastPos:earliestStart])
}
// Extract reasoning content
reasoningStart := earliestStart + len(matchedTag.start)
// For unclosed tags, earliestEnd is already at the end of the string
// For closed tags, earliestEnd points to after the closing tag, so we subtract the end tag length
var reasoningEnd int
if isUnclosed {
// Unclosed tag - extract everything to the end
reasoningEnd = len(remaining)
} else {
// Closed tag - exclude the end tag
reasoningEnd = earliestEnd - len(matchedTag.end)
}
if reasoningEnd > reasoningStart {
reasoningContent := strings.TrimSpace(remaining[reasoningStart:reasoningEnd])
if reasoningContent != "" {
reasoningParts = append(reasoningParts, reasoningContent)
}
}
// Move past this tag
lastPos = earliestEnd
}
// Combine reasoning parts
reasoning = strings.Join(reasoningParts, "\n\n")
// Combine cleaned content parts
cleanedContent = strings.Join(cleanedParts, "")
return reasoning, cleanedContent
}

View File

@@ -1,13 +0,0 @@
package reasoning_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestReasoning(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Reasoning Suite")
}

View File

@@ -1,499 +0,0 @@
package reasoning_test
import (
"strings"
. "github.com/mudler/LocalAI/pkg/reasoning"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("DetectThinkingForcedOpen", func() {
It("should detect <think> at end of prompt", func() {
Expect(DetectThinkingForcedOpen("Some prompt<think>")).To(BeTrue())
Expect(DetectThinkingForcedOpen("Some prompt<think>\n")).To(BeTrue())
})
It("should detect <thinking> at end of prompt", func() {
Expect(DetectThinkingForcedOpen("Some prompt<thinking>")).To(BeTrue())
Expect(DetectThinkingForcedOpen("Some prompt<thinking>\n")).To(BeTrue())
})
It("should detect model-specific tags", func() {
Expect(DetectThinkingForcedOpen("Some prompt<|inner_prefix|>")).To(BeTrue())
Expect(DetectThinkingForcedOpen("Some prompt<|START_THINKING|>")).To(BeTrue())
Expect(DetectThinkingForcedOpen("Some prompt<seed:think>")).To(BeTrue())
Expect(DetectThinkingForcedOpen("Some prompt[THINK]")).To(BeTrue())
Expect(DetectThinkingForcedOpen("Some prompt[THINK]\n")).To(BeTrue())
})
It("should not detect if tag is in the middle", func() {
Expect(DetectThinkingForcedOpen("Some <think> prompt")).To(BeFalse())
Expect(DetectThinkingForcedOpen("<think>reasoning</think>")).To(BeFalse())
})
It("should not detect if no thinking tag", func() {
Expect(DetectThinkingForcedOpen("Some regular prompt")).To(BeFalse())
Expect(DetectThinkingForcedOpen("")).To(BeFalse())
})
})
var _ = Describe("Extract", func() {
Context("when content has no reasoning tags", func() {
It("should return empty reasoning and original content", func() {
content := "This is regular content without any tags."
reasoning, cleaned := Extract(content)
Expect(reasoning).To(BeEmpty())
Expect(cleaned).To(Equal(content))
})
It("should handle empty string", func() {
content := ""
reasoning, cleaned := Extract(content)
Expect(reasoning).To(BeEmpty())
Expect(cleaned).To(BeEmpty())
})
It("should handle content with only whitespace", func() {
content := " \n\t "
reasoning, cleaned := Extract(content)
Expect(reasoning).To(BeEmpty())
Expect(cleaned).To(Equal(content))
})
})
Context("when content has <thinking> tags", func() {
It("should extract reasoning from single thinking block", func() {
content := "Some text <thinking>This is my reasoning</thinking> More text"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("This is my reasoning"))
Expect(cleaned).To(Equal("Some text More text"))
})
It("should extract reasoning and preserve surrounding content", func() {
content := "Before <thinking>Reasoning here</thinking> After"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("Reasoning here"))
Expect(cleaned).To(Equal("Before After"))
})
It("should handle thinking block at the start", func() {
content := "<thinking>Start reasoning</thinking> Regular content"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("Start reasoning"))
Expect(cleaned).To(Equal(" Regular content"))
})
It("should handle thinking block at the end", func() {
content := "Regular content <thinking>End reasoning</thinking>"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("End reasoning"))
Expect(cleaned).To(Equal("Regular content "))
})
It("should handle only thinking block", func() {
content := "<thinking>Only reasoning</thinking>"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("Only reasoning"))
Expect(cleaned).To(BeEmpty())
})
It("should trim whitespace from reasoning content", func() {
content := "Text <thinking> \n Reasoning with spaces \n </thinking> More"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("Reasoning with spaces"))
Expect(cleaned).To(Equal("Text More"))
})
})
Context("when content has <think> tags", func() {
It("should extract reasoning from redacted_reasoning block", func() {
content := "Text <think>Redacted reasoning</think> More"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("Redacted reasoning"))
Expect(cleaned).To(Equal("Text More"))
})
It("should handle redacted_reasoning with multiline content", func() {
content := "Before <think>Line 1\nLine 2\nLine 3</think> After"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("Line 1\nLine 2\nLine 3"))
Expect(cleaned).To(Equal("Before After"))
})
It("should handle redacted_reasoning with complex content", func() {
content := "Start <think>Complex reasoning\nwith\nmultiple\nlines</think> End"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("Complex reasoning\nwith\nmultiple\nlines"))
Expect(cleaned).To(Equal("Start End"))
})
})
Context("when content has multiple reasoning blocks", func() {
It("should concatenate multiple thinking blocks with newlines", func() {
content := "Text <thinking>First</thinking> Middle <thinking>Second</thinking> End"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("First\n\nSecond"))
Expect(cleaned).To(Equal("Text Middle End"))
})
It("should handle multiple different tag types", func() {
content := "A <thinking>One</thinking> B <think>Two</think> C <think>Three</think> D"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(ContainSubstring("One"))
Expect(reasoning).To(ContainSubstring("Two"))
Expect(reasoning).To(ContainSubstring("Three"))
Expect(cleaned).To(Equal("A B C D"))
})
It("should handle nested tags correctly (extracts first match)", func() {
content := "Text <thinking>Outer <think>Inner</think></thinking> More"
reasoning, cleaned := Extract(content)
// Should extract the outer thinking block
Expect(reasoning).To(ContainSubstring("Outer"))
Expect(reasoning).To(ContainSubstring("Inner"))
Expect(cleaned).To(Equal("Text More"))
})
})
Context("when content has unclosed reasoning tags", func() {
It("should extract unclosed thinking block", func() {
content := "Text <thinking>Unclosed reasoning"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("Unclosed reasoning"))
Expect(cleaned).To(Equal("Text "))
})
It("should extract unclosed think block", func() {
content := "Before <think>Incomplete"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("Incomplete"))
Expect(cleaned).To(Equal("Before "))
})
It("should extract unclosed redacted_reasoning block", func() {
content := "Start <think>Partial reasoning content"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("Partial reasoning content"))
Expect(cleaned).To(Equal("Start "))
})
It("should handle unclosed tag at the end", func() {
content := "Regular content <thinking>Unclosed at end"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("Unclosed at end"))
Expect(cleaned).To(Equal("Regular content "))
})
})
Context("when content has empty reasoning blocks", func() {
It("should ignore empty thinking block", func() {
content := "Text <thinking></thinking> More"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(BeEmpty())
Expect(cleaned).To(Equal("Text More"))
})
It("should ignore thinking block with only whitespace", func() {
content := "Text <thinking> \n\t </thinking> More"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(BeEmpty())
Expect(cleaned).To(Equal("Text More"))
})
})
Context("when content has reasoning tags with special characters", func() {
It("should handle reasoning with newlines", func() {
content := "Before <thinking>Line 1\nLine 2\nLine 3</thinking> After"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("Line 1\nLine 2\nLine 3"))
Expect(cleaned).To(Equal("Before After"))
})
It("should handle reasoning with code blocks", func() {
content := "Text <thinking>Reasoning with ```code``` blocks</thinking> More"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("Reasoning with ```code``` blocks"))
Expect(cleaned).To(Equal("Text More"))
})
It("should handle reasoning with JSON", func() {
content := "Before <think>{\"key\": \"value\"}</think> After"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("{\"key\": \"value\"}"))
Expect(cleaned).To(Equal("Before After"))
})
It("should handle reasoning with HTML-like content", func() {
content := "Text <thinking>Reasoning with <tags> inside</thinking> More"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("Reasoning with <tags> inside"))
Expect(cleaned).To(Equal("Text More"))
})
})
Context("when content has reasoning mixed with regular content", func() {
It("should preserve content order correctly", func() {
content := "Start <thinking>Reasoning</thinking> Middle <think>More reasoning</think> End"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(ContainSubstring("Reasoning"))
Expect(reasoning).To(ContainSubstring("More reasoning"))
Expect(cleaned).To(Equal("Start Middle End"))
})
It("should handle reasoning in the middle of a sentence", func() {
content := "This is a <thinking>reasoning</thinking> sentence."
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("reasoning"))
Expect(cleaned).To(Equal("This is a sentence."))
})
})
Context("edge cases without WithThinkingForcedOpen", func() {
It("should handle content with only opening tag", func() {
content := "<thinking>"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(BeEmpty())
Expect(cleaned).To(Equal(""))
})
It("should handle content with only closing tag (no content before)", func() {
content := "</thinking>"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(BeEmpty())
Expect(cleaned).To(BeEmpty())
})
It("should extract reasoning when only closing tag is present", func() {
// GLM-4 style: reasoning content followed by closing tag without opening tag
content := "This is reasoning content</think>this is the actual response"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("This is reasoning content"))
Expect(cleaned).To(Equal("this is the actual response"))
})
It("should handle closing-only tag with multiline reasoning", func() {
content := "1. First point\n2. Second point\n3. Third point</think>Final answer"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("1. First point\n2. Second point\n3. Third point"))
Expect(cleaned).To(Equal("Final answer"))
})
It("should handle closing-only tag with complex reasoning (GLM-4 example)", func() {
content := "**Analyze the user's input:** The user says something.\n\n**Final Decision:** Output the text.</think>this is a test"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("**Analyze the user's input:** The user says something.\n\n**Final Decision:** Output the text."))
Expect(cleaned).To(Equal("this is a test"))
})
It("should handle closing-only thinking tag", func() {
content := "Some reasoning here</thinking>actual content"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("Some reasoning here"))
Expect(cleaned).To(Equal("actual content"))
})
It("should handle mismatched tags", func() {
content := "<thinking>Content</think>"
reasoning, cleaned := Extract(content)
// Should extract unclosed thinking block
Expect(reasoning).To(ContainSubstring("Content"))
Expect(cleaned).To(Equal(""))
})
It("should handle very long reasoning content", func() {
longReasoning := strings.Repeat("This is reasoning content. ", 100)
content := "Text <thinking>" + longReasoning + "</thinking> More"
reasoning, cleaned := Extract(content)
// TrimSpace is applied, so we need to account for that
Expect(reasoning).To(Equal(strings.TrimSpace(longReasoning)))
Expect(cleaned).To(Equal("Text More"))
})
It("should handle reasoning with unicode characters", func() {
content := "Text <thinking>Reasoning with 中文 and emoji 🧠</thinking> More"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("Reasoning with 中文 and emoji 🧠"))
Expect(cleaned).To(Equal("Text More"))
})
})
Context("with WithThinkingForcedOpen option", func() {
It("should treat all content as reasoning until closing tag", func() {
content := "This is reasoning</think>this is content"
reasoning, cleaned := Extract(content, WithThinkingForcedOpen())
Expect(reasoning).To(Equal("This is reasoning"))
Expect(cleaned).To(Equal("this is content"))
})
It("should treat all content as reasoning when no closing tag (streaming)", func() {
content := "This is reasoning content still streaming"
reasoning, cleaned := Extract(content, WithThinkingForcedOpen())
Expect(reasoning).To(Equal("This is reasoning content still streaming"))
Expect(cleaned).To(BeEmpty())
})
It("should handle GLM-4 style output", func() {
content := "**Analyze:** The user says something.\n\n**Final Decision:** Output the text.</think>this is a test"
reasoning, cleaned := Extract(content, WithThinkingForcedOpen())
Expect(reasoning).To(Equal("**Analyze:** The user says something.\n\n**Final Decision:** Output the text."))
Expect(cleaned).To(Equal("this is a test"))
})
It("should handle multiline reasoning with closing tag", func() {
content := "1. First point\n2. Second point\n3. Third point</think>Final answer"
reasoning, cleaned := Extract(content, WithThinkingForcedOpen())
Expect(reasoning).To(Equal("1. First point\n2. Second point\n3. Third point"))
Expect(cleaned).To(Equal("Final answer"))
})
It("should handle </thinking> closing tag", func() {
content := "Some reasoning here</thinking>actual content"
reasoning, cleaned := Extract(content, WithThinkingForcedOpen())
Expect(reasoning).To(Equal("Some reasoning here"))
Expect(cleaned).To(Equal("actual content"))
})
It("should handle additional reasoning blocks after initial forced open", func() {
content := "Initial reasoning</think>content<think>more reasoning</think>final content"
reasoning, cleaned := Extract(content, WithThinkingForcedOpen())
Expect(reasoning).To(Equal("Initial reasoning\n\nmore reasoning"))
Expect(cleaned).To(Equal("contentfinal content"))
})
It("should handle empty content", func() {
reasoning, cleaned := Extract("", WithThinkingForcedOpen())
Expect(reasoning).To(BeEmpty())
Expect(cleaned).To(BeEmpty())
})
It("should handle only closing tag", func() {
content := "</think>only content"
reasoning, cleaned := Extract(content, WithThinkingForcedOpen())
Expect(reasoning).To(BeEmpty())
Expect(cleaned).To(Equal("only content"))
})
It("should find earliest closing tag", func() {
// </think> comes before </thinking>
content := "Reasoning</think>content</thinking>more"
reasoning, cleaned := Extract(content, WithThinkingForcedOpen())
Expect(reasoning).To(Equal("Reasoning"))
Expect(cleaned).To(Equal("content</thinking>more"))
})
It("should handle Command R7B closing tag", func() {
content := "Reasoning content<|END_THINKING|>actual response"
reasoning, cleaned := Extract(content, WithThinkingForcedOpen())
Expect(reasoning).To(Equal("Reasoning content"))
Expect(cleaned).To(Equal("actual response"))
})
It("should handle Apertus closing tag", func() {
content := "Reasoning content<|inner_suffix|>actual response"
reasoning, cleaned := Extract(content, WithThinkingForcedOpen())
Expect(reasoning).To(Equal("Reasoning content"))
Expect(cleaned).To(Equal("actual response"))
})
It("should handle Seed closing tag", func() {
content := "Reasoning content</seed:think>actual response"
reasoning, cleaned := Extract(content, WithThinkingForcedOpen())
Expect(reasoning).To(Equal("Reasoning content"))
Expect(cleaned).To(Equal("actual response"))
})
It("should handle Magistral closing tag", func() {
content := "Reasoning content[/THINK]actual response"
reasoning, cleaned := Extract(content, WithThinkingForcedOpen())
Expect(reasoning).To(Equal("Reasoning content"))
Expect(cleaned).To(Equal("actual response"))
})
})
Context("with model-specific tag pairs", func() {
It("should extract Command R7B reasoning tags", func() {
content := "Before <|START_THINKING|>reasoning here<|END_THINKING|> After"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("reasoning here"))
Expect(cleaned).To(Equal("Before After"))
})
It("should extract Apertus reasoning tags", func() {
content := "Before <|inner_prefix|>reasoning here<|inner_suffix|> After"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("reasoning here"))
Expect(cleaned).To(Equal("Before After"))
})
It("should extract Seed reasoning tags", func() {
content := "Before <seed:think>reasoning here</seed:think> After"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("reasoning here"))
Expect(cleaned).To(Equal("Before After"))
})
It("should extract Magistral reasoning tags", func() {
content := "Before [THINK]reasoning here[/THINK] After"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("reasoning here"))
Expect(cleaned).To(Equal("Before After"))
})
It("should handle unclosed Command R7B tag", func() {
content := "Before <|START_THINKING|>reasoning still streaming"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("reasoning still streaming"))
Expect(cleaned).To(Equal("Before "))
})
It("should handle unclosed Apertus tag", func() {
content := "Before <|inner_prefix|>reasoning still streaming"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("reasoning still streaming"))
Expect(cleaned).To(Equal("Before "))
})
It("should handle unclosed Seed tag", func() {
content := "Before <seed:think>reasoning still streaming"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("reasoning still streaming"))
Expect(cleaned).To(Equal("Before "))
})
It("should handle unclosed Magistral tag", func() {
content := "Before [THINK]reasoning still streaming"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("reasoning still streaming"))
Expect(cleaned).To(Equal("Before "))
})
It("should handle closing-only Command R7B tag", func() {
content := "Reasoning content<|END_THINKING|>actual response"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("Reasoning content"))
Expect(cleaned).To(Equal("actual response"))
})
It("should handle closing-only Apertus tag", func() {
content := "Reasoning content<|inner_suffix|>actual response"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("Reasoning content"))
Expect(cleaned).To(Equal("actual response"))
})
It("should handle closing-only Seed tag", func() {
content := "Reasoning content</seed:think>actual response"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("Reasoning content"))
Expect(cleaned).To(Equal("actual response"))
})
It("should handle closing-only Magistral tag", func() {
content := "Reasoning content[/THINK]actual response"
reasoning, cleaned := Extract(content)
Expect(reasoning).To(Equal("Reasoning content"))
Expect(cleaned).To(Equal("actual response"))
})
})
})

View File

@@ -12,17 +12,15 @@ import (
)
const (
// Public constants - used by tests and external packages
Nvidia = "nvidia"
AMD = "amd"
Intel = "intel"
// Private constants - only used within this package
defaultCapability = "default"
nvidiaL4T = "nvidia-l4t"
darwinX86 = "darwin-x86"
metal = "metal"
vulkan = "vulkan"
nvidia = "nvidia"
amd = "amd"
intel = "intel"
vulkan = "vulkan"
nvidiaCuda13 = "nvidia-cuda-13"
nvidiaCuda12 = "nvidia-cuda-12"
@@ -32,16 +30,6 @@ const (
capabilityEnv = "LOCALAI_FORCE_META_BACKEND_CAPABILITY"
capabilityRunFileEnv = "LOCALAI_FORCE_META_BACKEND_CAPABILITY_RUN_FILE"
defaultRunFile = "/run/localai/capability"
// Backend detection tokens (private)
backendTokenDarwin = "darwin"
backendTokenMLX = "mlx"
backendTokenMetal = "metal"
backendTokenL4T = "l4t"
backendTokenCUDA = "cuda"
backendTokenROCM = "rocm"
backendTokenHIP = "hip"
backendTokenSYCL = "sycl"
)
var (
@@ -108,7 +96,7 @@ func (s *SystemState) getSystemCapabilities() string {
// If arm64 on linux and a nvidia gpu is detected, we will return nvidia-l4t
if runtime.GOOS == "linux" && runtime.GOARCH == "arm64" {
if s.GPUVendor == Nvidia {
if s.GPUVendor == nvidia {
xlog.Info("Using nvidia-l4t capability (arm64 on linux)", "env", capabilityEnv)
if cuda13DirExists {
return nvidiaL4TCuda13
@@ -143,6 +131,7 @@ func (s *SystemState) getSystemCapabilities() string {
return s.GPUVendor
}
// BackendPreferenceTokens returns a list of substrings that represent the preferred
// backend implementation order for the current system capability. Callers can use
// these tokens to select the most appropriate concrete backend among multiple
@@ -150,76 +139,19 @@ func (s *SystemState) getSystemCapabilities() string {
func (s *SystemState) BackendPreferenceTokens() []string {
capStr := strings.ToLower(s.getSystemCapabilities())
switch {
case strings.HasPrefix(capStr, Nvidia):
return []string{backendTokenCUDA, vulkan, "cpu"}
case strings.HasPrefix(capStr, AMD):
return []string{backendTokenROCM, backendTokenHIP, vulkan, "cpu"}
case strings.HasPrefix(capStr, Intel):
return []string{backendTokenSYCL, Intel, "cpu"}
case strings.HasPrefix(capStr, nvidia):
return []string{"cuda", "vulkan", "cpu"}
case strings.HasPrefix(capStr, amd):
return []string{"rocm", "hip", "vulkan", "cpu"}
case strings.HasPrefix(capStr, intel):
return []string{"sycl", intel, "cpu"}
case strings.HasPrefix(capStr, metal):
return []string{backendTokenMetal, "cpu"}
return []string{"metal", "cpu"}
case strings.HasPrefix(capStr, darwinX86):
return []string{"darwin-x86", "cpu"}
case strings.HasPrefix(capStr, vulkan):
return []string{vulkan, "cpu"}
return []string{"vulkan", "cpu"}
default:
return []string{"cpu"}
}
}
// DetectedCapability returns the detected system capability string.
// This can be used by the UI to display what capability was detected.
func (s *SystemState) DetectedCapability() string {
return s.getSystemCapabilities()
}
// IsBackendCompatible checks if a backend (identified by name and URI) is compatible
// with the current system capability. This function uses getSystemCapabilities to ensure
// consistency with capability detection (including VRAM checks, environment overrides, etc.).
func (s *SystemState) IsBackendCompatible(name, uri string) bool {
combined := strings.ToLower(name + " " + uri)
capability := s.getSystemCapabilities()
// Check for darwin/macOS-specific backends (mlx, metal, darwin)
isDarwinBackend := strings.Contains(combined, backendTokenDarwin) ||
strings.Contains(combined, backendTokenMLX) ||
strings.Contains(combined, backendTokenMetal)
if isDarwinBackend {
// Darwin backends require the system to be running on darwin with metal or darwin-x86 capability
return capability == metal || capability == darwinX86
}
// Check for NVIDIA L4T-specific backends (arm64 Linux with NVIDIA GPU)
// This must be checked before the general NVIDIA check as L4T backends
// may also contain "cuda" or "nvidia" in their names
isL4TBackend := strings.Contains(combined, backendTokenL4T)
if isL4TBackend {
return strings.HasPrefix(capability, nvidiaL4T)
}
// Check for NVIDIA/CUDA-specific backends (non-L4T)
isNvidiaBackend := strings.Contains(combined, backendTokenCUDA) ||
strings.Contains(combined, Nvidia)
if isNvidiaBackend {
// NVIDIA backends are compatible with nvidia, nvidia-cuda-12, nvidia-cuda-13, and l4t capabilities
return strings.HasPrefix(capability, Nvidia)
}
// Check for AMD/ROCm-specific backends
isAMDBackend := strings.Contains(combined, backendTokenROCM) ||
strings.Contains(combined, backendTokenHIP) ||
strings.Contains(combined, AMD)
if isAMDBackend {
return capability == AMD
}
// Check for Intel/SYCL-specific backends
isIntelBackend := strings.Contains(combined, backendTokenSYCL) ||
strings.Contains(combined, Intel)
if isIntelBackend {
return capability == Intel
}
// CPU backends are always compatible
return true
}

View File

@@ -148,10 +148,10 @@ package_cuda_libs() {
done
# Copy CUDA target directory for runtime compilation support
# if [ -d "/usr/local/cuda/targets" ]; then
# mkdir -p "$TARGET_LIB_DIR/../cuda"
# cp -arfL /usr/local/cuda/targets "$TARGET_LIB_DIR/../cuda/" 2>/dev/null || true
# fi
if [ -d "/usr/local/cuda/targets" ]; then
mkdir -p "$TARGET_LIB_DIR/../cuda"
cp -arfL /usr/local/cuda/targets "$TARGET_LIB_DIR/../cuda/" 2>/dev/null || true
fi
echo "CUDA libraries packaged successfully"
}

View File

@@ -1198,30 +1198,6 @@ const docTemplate = `{
}
}
},
"/v1/messages": {
"post": {
"summary": "Generate a message response for the given messages and model.",
"parameters": [
{
"description": "query params",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.AnthropicRequest"
}
}
],
"responses": {
"200": {
"description": "Response",
"schema": {
"$ref": "#/definitions/schema.AnthropicResponse"
}
}
}
}
},
"/v1/models": {
"get": {
"summary": "List and describe the various models available in the API.",
@@ -1259,116 +1235,6 @@ const docTemplate = `{
}
}
},
"/v1/responses": {
"post": {
"summary": "Create a response using the Open Responses API",
"parameters": [
{
"description": "Request body",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.OpenResponsesRequest"
}
}
],
"responses": {
"200": {
"description": "Response",
"schema": {
"$ref": "#/definitions/schema.ORResponseResource"
}
}
}
}
},
"/v1/responses/{id}": {
"get": {
"description": "Retrieve a response by ID. Can be used for polling background responses or resuming streaming responses.",
"summary": "Get a response by ID",
"parameters": [
{
"type": "string",
"description": "Response ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Set to 'true' to resume streaming",
"name": "stream",
"in": "query"
},
{
"type": "integer",
"description": "Sequence number to resume from (for streaming)",
"name": "starting_after",
"in": "query"
}
],
"responses": {
"200": {
"description": "Response",
"schema": {
"$ref": "#/definitions/schema.ORResponseResource"
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/v1/responses/{id}/cancel": {
"post": {
"description": "Cancel a background response if it's still in progress",
"summary": "Cancel a response",
"parameters": [
{
"type": "string",
"description": "Response ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Response",
"schema": {
"$ref": "#/definitions/schema.ORResponseResource"
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/v1/sound-generation": {
"post": {
"summary": "Generates audio from the input text.",
@@ -1873,169 +1739,6 @@ const docTemplate = `{
}
}
},
"schema.AnthropicContentBlock": {
"type": "object",
"properties": {
"content": {},
"id": {
"type": "string"
},
"input": {
"type": "object",
"additionalProperties": true
},
"is_error": {
"type": "boolean"
},
"name": {
"type": "string"
},
"source": {
"$ref": "#/definitions/schema.AnthropicImageSource"
},
"text": {
"type": "string"
},
"tool_use_id": {
"type": "string"
},
"type": {
"type": "string"
}
}
},
"schema.AnthropicImageSource": {
"type": "object",
"properties": {
"data": {
"type": "string"
},
"media_type": {
"type": "string"
},
"type": {
"type": "string"
}
}
},
"schema.AnthropicMessage": {
"type": "object",
"properties": {
"content": {},
"role": {
"type": "string"
}
}
},
"schema.AnthropicRequest": {
"type": "object",
"properties": {
"max_tokens": {
"type": "integer"
},
"messages": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.AnthropicMessage"
}
},
"metadata": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"model": {
"type": "string"
},
"stop_sequences": {
"type": "array",
"items": {
"type": "string"
}
},
"stream": {
"type": "boolean"
},
"system": {
"type": "string"
},
"temperature": {
"type": "number"
},
"tool_choice": {},
"tools": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.AnthropicTool"
}
},
"top_k": {
"type": "integer"
},
"top_p": {
"type": "number"
}
}
},
"schema.AnthropicResponse": {
"type": "object",
"properties": {
"content": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.AnthropicContentBlock"
}
},
"id": {
"type": "string"
},
"model": {
"type": "string"
},
"role": {
"type": "string"
},
"stop_reason": {
"type": "string"
},
"stop_sequence": {
"type": "string"
},
"type": {
"type": "string"
},
"usage": {
"$ref": "#/definitions/schema.AnthropicUsage"
}
}
},
"schema.AnthropicTool": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"input_schema": {
"type": "object",
"additionalProperties": true
},
"name": {
"type": "string"
}
}
},
"schema.AnthropicUsage": {
"type": "object",
"properties": {
"input_tokens": {
"type": "integer"
},
"output_tokens": {
"type": "integer"
}
}
},
"schema.BackendMonitorRequest": {
"type": "object",
"properties": {
@@ -2526,10 +2229,6 @@ const docTemplate = `{
"description": "The message name (used for tools calls)",
"type": "string"
},
"reasoning": {
"description": "Reasoning content extracted from \u003cthinking\u003e...\u003c/thinking\u003e tags",
"type": "string"
},
"role": {
"description": "The message role",
"type": "string"
@@ -2617,322 +2316,6 @@ const docTemplate = `{
}
}
},
"schema.ORError": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"param": {
"type": "string"
},
"type": {
"description": "invalid_request|not_found|server_error|model_error|too_many_requests",
"type": "string"
}
}
},
"schema.ORFunctionTool": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"name": {
"type": "string"
},
"parameters": {
"type": "object",
"additionalProperties": true
},
"strict": {
"description": "Always include in response",
"type": "boolean"
},
"type": {
"description": "always \"function\"",
"type": "string"
}
}
},
"schema.ORIncompleteDetails": {
"type": "object",
"properties": {
"reason": {
"type": "string"
}
}
},
"schema.ORInputTokensDetails": {
"type": "object",
"properties": {
"cached_tokens": {
"description": "Always include, even if 0",
"type": "integer"
}
}
},
"schema.ORItemField": {
"type": "object",
"properties": {
"arguments": {
"type": "string"
},
"call_id": {
"description": "Function call fields",
"type": "string"
},
"content": {
"description": "string or []ORContentPart for messages"
},
"id": {
"description": "Present for all output items",
"type": "string"
},
"name": {
"type": "string"
},
"output": {
"description": "Function call output fields"
},
"role": {
"description": "Message fields",
"type": "string"
},
"status": {
"description": "in_progress|completed|incomplete",
"type": "string"
},
"type": {
"description": "message|function_call|function_call_output|reasoning|item_reference",
"type": "string"
}
}
},
"schema.OROutputTokensDetails": {
"type": "object",
"properties": {
"reasoning_tokens": {
"description": "Always include, even if 0",
"type": "integer"
}
}
},
"schema.ORReasoning": {
"type": "object",
"properties": {
"effort": {
"type": "string"
},
"summary": {
"type": "string"
}
}
},
"schema.ORReasoningParam": {
"type": "object",
"properties": {
"effort": {
"description": "\"none\"|\"low\"|\"medium\"|\"high\"|\"xhigh\"",
"type": "string"
},
"summary": {
"description": "\"auto\"|\"concise\"|\"detailed\"",
"type": "string"
}
}
},
"schema.ORResponseResource": {
"type": "object",
"properties": {
"background": {
"type": "boolean"
},
"completed_at": {
"description": "Required: present as number or null",
"type": "integer"
},
"created_at": {
"type": "integer"
},
"error": {
"description": "Always present, null if no error",
"allOf": [
{
"$ref": "#/definitions/schema.ORError"
}
]
},
"frequency_penalty": {
"type": "number"
},
"id": {
"type": "string"
},
"incomplete_details": {
"description": "Always present, null if complete",
"allOf": [
{
"$ref": "#/definitions/schema.ORIncompleteDetails"
}
]
},
"instructions": {
"type": "string"
},
"max_output_tokens": {
"type": "integer"
},
"max_tool_calls": {
"description": "nullable",
"type": "integer"
},
"metadata": {
"description": "Metadata and operational flags",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"model": {
"type": "string"
},
"object": {
"description": "always \"response\"",
"type": "string"
},
"output": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.ORItemField"
}
},
"parallel_tool_calls": {
"type": "boolean"
},
"presence_penalty": {
"type": "number"
},
"previous_response_id": {
"type": "string"
},
"prompt_cache_key": {
"description": "nullable",
"type": "string"
},
"reasoning": {
"description": "nullable",
"allOf": [
{
"$ref": "#/definitions/schema.ORReasoning"
}
]
},
"safety_identifier": {
"description": "Safety and caching",
"type": "string"
},
"service_tier": {
"type": "string"
},
"status": {
"description": "in_progress|completed|failed|incomplete",
"type": "string"
},
"store": {
"type": "boolean"
},
"temperature": {
"description": "Sampling parameters (always required)",
"type": "number"
},
"text": {
"description": "Text format configuration",
"allOf": [
{
"$ref": "#/definitions/schema.ORTextConfig"
}
]
},
"tool_choice": {},
"tools": {
"description": "Tool-related fields",
"type": "array",
"items": {
"$ref": "#/definitions/schema.ORFunctionTool"
}
},
"top_logprobs": {
"description": "Default to 0",
"type": "integer"
},
"top_p": {
"type": "number"
},
"truncation": {
"description": "Truncation and reasoning",
"type": "string"
},
"usage": {
"description": "Usage statistics",
"allOf": [
{
"$ref": "#/definitions/schema.ORUsage"
}
]
}
}
},
"schema.ORTextConfig": {
"type": "object",
"properties": {
"format": {
"$ref": "#/definitions/schema.ORTextFormat"
}
}
},
"schema.ORTextFormat": {
"type": "object",
"properties": {
"type": {
"description": "\"text\" or \"json_schema\"",
"type": "string"
}
}
},
"schema.ORUsage": {
"type": "object",
"properties": {
"input_tokens": {
"type": "integer"
},
"input_tokens_details": {
"description": "Always present",
"allOf": [
{
"$ref": "#/definitions/schema.ORInputTokensDetails"
}
]
},
"output_tokens": {
"type": "integer"
},
"output_tokens_details": {
"description": "Always present",
"allOf": [
{
"$ref": "#/definitions/schema.OROutputTokensDetails"
}
]
},
"total_tokens": {
"type": "integer"
}
}
},
"schema.OpenAIModel": {
"type": "object",
"properties": {
@@ -3207,114 +2590,6 @@ const docTemplate = `{
}
}
},
"schema.OpenResponsesRequest": {
"type": "object",
"properties": {
"allowed_tools": {
"description": "Restrict which tools can be invoked",
"type": "array",
"items": {
"type": "string"
}
},
"background": {
"description": "Run request in background",
"type": "boolean"
},
"frequency_penalty": {
"description": "Frequency penalty (-2.0 to 2.0)",
"type": "number"
},
"include": {
"description": "What to include in response",
"type": "array",
"items": {
"type": "string"
}
},
"input": {
"description": "string or []ORItemParam"
},
"instructions": {
"type": "string"
},
"logit_bias": {
"description": "OpenAI-compatible extensions (not in Open Responses spec)",
"type": "object",
"additionalProperties": {
"type": "number",
"format": "float64"
}
},
"max_output_tokens": {
"type": "integer"
},
"max_tool_calls": {
"description": "Maximum number of tool calls",
"type": "integer"
},
"metadata": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"model": {
"type": "string"
},
"parallel_tool_calls": {
"description": "Allow parallel tool calls",
"type": "boolean"
},
"presence_penalty": {
"description": "Presence penalty (-2.0 to 2.0)",
"type": "number"
},
"previous_response_id": {
"type": "string"
},
"reasoning": {
"$ref": "#/definitions/schema.ORReasoningParam"
},
"service_tier": {
"description": "\"auto\"|\"default\"|priority hint",
"type": "string"
},
"store": {
"description": "Whether to store the response",
"type": "boolean"
},
"stream": {
"type": "boolean"
},
"temperature": {
"type": "number"
},
"text_format": {
"description": "Additional parameters from spec"
},
"tool_choice": {
"description": "\"auto\"|\"required\"|\"none\"|{type:\"function\",name:\"...\"}"
},
"tools": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.ORFunctionTool"
}
},
"top_logprobs": {
"description": "Number of top logprobs to return",
"type": "integer"
},
"top_p": {
"type": "number"
},
"truncation": {
"description": "\"auto\"|\"disabled\"",
"type": "string"
}
}
},
"schema.P2PNodesResponse": {
"type": "object",
"properties": {

View File

@@ -1191,30 +1191,6 @@
}
}
},
"/v1/messages": {
"post": {
"summary": "Generate a message response for the given messages and model.",
"parameters": [
{
"description": "query params",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.AnthropicRequest"
}
}
],
"responses": {
"200": {
"description": "Response",
"schema": {
"$ref": "#/definitions/schema.AnthropicResponse"
}
}
}
}
},
"/v1/models": {
"get": {
"summary": "List and describe the various models available in the API.",
@@ -1252,116 +1228,6 @@
}
}
},
"/v1/responses": {
"post": {
"summary": "Create a response using the Open Responses API",
"parameters": [
{
"description": "Request body",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.OpenResponsesRequest"
}
}
],
"responses": {
"200": {
"description": "Response",
"schema": {
"$ref": "#/definitions/schema.ORResponseResource"
}
}
}
}
},
"/v1/responses/{id}": {
"get": {
"description": "Retrieve a response by ID. Can be used for polling background responses or resuming streaming responses.",
"summary": "Get a response by ID",
"parameters": [
{
"type": "string",
"description": "Response ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Set to 'true' to resume streaming",
"name": "stream",
"in": "query"
},
{
"type": "integer",
"description": "Sequence number to resume from (for streaming)",
"name": "starting_after",
"in": "query"
}
],
"responses": {
"200": {
"description": "Response",
"schema": {
"$ref": "#/definitions/schema.ORResponseResource"
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/v1/responses/{id}/cancel": {
"post": {
"description": "Cancel a background response if it's still in progress",
"summary": "Cancel a response",
"parameters": [
{
"type": "string",
"description": "Response ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Response",
"schema": {
"$ref": "#/definitions/schema.ORResponseResource"
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/v1/sound-generation": {
"post": {
"summary": "Generates audio from the input text.",
@@ -1866,169 +1732,6 @@
}
}
},
"schema.AnthropicContentBlock": {
"type": "object",
"properties": {
"content": {},
"id": {
"type": "string"
},
"input": {
"type": "object",
"additionalProperties": true
},
"is_error": {
"type": "boolean"
},
"name": {
"type": "string"
},
"source": {
"$ref": "#/definitions/schema.AnthropicImageSource"
},
"text": {
"type": "string"
},
"tool_use_id": {
"type": "string"
},
"type": {
"type": "string"
}
}
},
"schema.AnthropicImageSource": {
"type": "object",
"properties": {
"data": {
"type": "string"
},
"media_type": {
"type": "string"
},
"type": {
"type": "string"
}
}
},
"schema.AnthropicMessage": {
"type": "object",
"properties": {
"content": {},
"role": {
"type": "string"
}
}
},
"schema.AnthropicRequest": {
"type": "object",
"properties": {
"max_tokens": {
"type": "integer"
},
"messages": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.AnthropicMessage"
}
},
"metadata": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"model": {
"type": "string"
},
"stop_sequences": {
"type": "array",
"items": {
"type": "string"
}
},
"stream": {
"type": "boolean"
},
"system": {
"type": "string"
},
"temperature": {
"type": "number"
},
"tool_choice": {},
"tools": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.AnthropicTool"
}
},
"top_k": {
"type": "integer"
},
"top_p": {
"type": "number"
}
}
},
"schema.AnthropicResponse": {
"type": "object",
"properties": {
"content": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.AnthropicContentBlock"
}
},
"id": {
"type": "string"
},
"model": {
"type": "string"
},
"role": {
"type": "string"
},
"stop_reason": {
"type": "string"
},
"stop_sequence": {
"type": "string"
},
"type": {
"type": "string"
},
"usage": {
"$ref": "#/definitions/schema.AnthropicUsage"
}
}
},
"schema.AnthropicTool": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"input_schema": {
"type": "object",
"additionalProperties": true
},
"name": {
"type": "string"
}
}
},
"schema.AnthropicUsage": {
"type": "object",
"properties": {
"input_tokens": {
"type": "integer"
},
"output_tokens": {
"type": "integer"
}
}
},
"schema.BackendMonitorRequest": {
"type": "object",
"properties": {
@@ -2519,10 +2222,6 @@
"description": "The message name (used for tools calls)",
"type": "string"
},
"reasoning": {
"description": "Reasoning content extracted from \u003cthinking\u003e...\u003c/thinking\u003e tags",
"type": "string"
},
"role": {
"description": "The message role",
"type": "string"
@@ -2610,322 +2309,6 @@
}
}
},
"schema.ORError": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"param": {
"type": "string"
},
"type": {
"description": "invalid_request|not_found|server_error|model_error|too_many_requests",
"type": "string"
}
}
},
"schema.ORFunctionTool": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"name": {
"type": "string"
},
"parameters": {
"type": "object",
"additionalProperties": true
},
"strict": {
"description": "Always include in response",
"type": "boolean"
},
"type": {
"description": "always \"function\"",
"type": "string"
}
}
},
"schema.ORIncompleteDetails": {
"type": "object",
"properties": {
"reason": {
"type": "string"
}
}
},
"schema.ORInputTokensDetails": {
"type": "object",
"properties": {
"cached_tokens": {
"description": "Always include, even if 0",
"type": "integer"
}
}
},
"schema.ORItemField": {
"type": "object",
"properties": {
"arguments": {
"type": "string"
},
"call_id": {
"description": "Function call fields",
"type": "string"
},
"content": {
"description": "string or []ORContentPart for messages"
},
"id": {
"description": "Present for all output items",
"type": "string"
},
"name": {
"type": "string"
},
"output": {
"description": "Function call output fields"
},
"role": {
"description": "Message fields",
"type": "string"
},
"status": {
"description": "in_progress|completed|incomplete",
"type": "string"
},
"type": {
"description": "message|function_call|function_call_output|reasoning|item_reference",
"type": "string"
}
}
},
"schema.OROutputTokensDetails": {
"type": "object",
"properties": {
"reasoning_tokens": {
"description": "Always include, even if 0",
"type": "integer"
}
}
},
"schema.ORReasoning": {
"type": "object",
"properties": {
"effort": {
"type": "string"
},
"summary": {
"type": "string"
}
}
},
"schema.ORReasoningParam": {
"type": "object",
"properties": {
"effort": {
"description": "\"none\"|\"low\"|\"medium\"|\"high\"|\"xhigh\"",
"type": "string"
},
"summary": {
"description": "\"auto\"|\"concise\"|\"detailed\"",
"type": "string"
}
}
},
"schema.ORResponseResource": {
"type": "object",
"properties": {
"background": {
"type": "boolean"
},
"completed_at": {
"description": "Required: present as number or null",
"type": "integer"
},
"created_at": {
"type": "integer"
},
"error": {
"description": "Always present, null if no error",
"allOf": [
{
"$ref": "#/definitions/schema.ORError"
}
]
},
"frequency_penalty": {
"type": "number"
},
"id": {
"type": "string"
},
"incomplete_details": {
"description": "Always present, null if complete",
"allOf": [
{
"$ref": "#/definitions/schema.ORIncompleteDetails"
}
]
},
"instructions": {
"type": "string"
},
"max_output_tokens": {
"type": "integer"
},
"max_tool_calls": {
"description": "nullable",
"type": "integer"
},
"metadata": {
"description": "Metadata and operational flags",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"model": {
"type": "string"
},
"object": {
"description": "always \"response\"",
"type": "string"
},
"output": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.ORItemField"
}
},
"parallel_tool_calls": {
"type": "boolean"
},
"presence_penalty": {
"type": "number"
},
"previous_response_id": {
"type": "string"
},
"prompt_cache_key": {
"description": "nullable",
"type": "string"
},
"reasoning": {
"description": "nullable",
"allOf": [
{
"$ref": "#/definitions/schema.ORReasoning"
}
]
},
"safety_identifier": {
"description": "Safety and caching",
"type": "string"
},
"service_tier": {
"type": "string"
},
"status": {
"description": "in_progress|completed|failed|incomplete",
"type": "string"
},
"store": {
"type": "boolean"
},
"temperature": {
"description": "Sampling parameters (always required)",
"type": "number"
},
"text": {
"description": "Text format configuration",
"allOf": [
{
"$ref": "#/definitions/schema.ORTextConfig"
}
]
},
"tool_choice": {},
"tools": {
"description": "Tool-related fields",
"type": "array",
"items": {
"$ref": "#/definitions/schema.ORFunctionTool"
}
},
"top_logprobs": {
"description": "Default to 0",
"type": "integer"
},
"top_p": {
"type": "number"
},
"truncation": {
"description": "Truncation and reasoning",
"type": "string"
},
"usage": {
"description": "Usage statistics",
"allOf": [
{
"$ref": "#/definitions/schema.ORUsage"
}
]
}
}
},
"schema.ORTextConfig": {
"type": "object",
"properties": {
"format": {
"$ref": "#/definitions/schema.ORTextFormat"
}
}
},
"schema.ORTextFormat": {
"type": "object",
"properties": {
"type": {
"description": "\"text\" or \"json_schema\"",
"type": "string"
}
}
},
"schema.ORUsage": {
"type": "object",
"properties": {
"input_tokens": {
"type": "integer"
},
"input_tokens_details": {
"description": "Always present",
"allOf": [
{
"$ref": "#/definitions/schema.ORInputTokensDetails"
}
]
},
"output_tokens": {
"type": "integer"
},
"output_tokens_details": {
"description": "Always present",
"allOf": [
{
"$ref": "#/definitions/schema.OROutputTokensDetails"
}
]
},
"total_tokens": {
"type": "integer"
}
}
},
"schema.OpenAIModel": {
"type": "object",
"properties": {
@@ -3200,114 +2583,6 @@
}
}
},
"schema.OpenResponsesRequest": {
"type": "object",
"properties": {
"allowed_tools": {
"description": "Restrict which tools can be invoked",
"type": "array",
"items": {
"type": "string"
}
},
"background": {
"description": "Run request in background",
"type": "boolean"
},
"frequency_penalty": {
"description": "Frequency penalty (-2.0 to 2.0)",
"type": "number"
},
"include": {
"description": "What to include in response",
"type": "array",
"items": {
"type": "string"
}
},
"input": {
"description": "string or []ORItemParam"
},
"instructions": {
"type": "string"
},
"logit_bias": {
"description": "OpenAI-compatible extensions (not in Open Responses spec)",
"type": "object",
"additionalProperties": {
"type": "number",
"format": "float64"
}
},
"max_output_tokens": {
"type": "integer"
},
"max_tool_calls": {
"description": "Maximum number of tool calls",
"type": "integer"
},
"metadata": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"model": {
"type": "string"
},
"parallel_tool_calls": {
"description": "Allow parallel tool calls",
"type": "boolean"
},
"presence_penalty": {
"description": "Presence penalty (-2.0 to 2.0)",
"type": "number"
},
"previous_response_id": {
"type": "string"
},
"reasoning": {
"$ref": "#/definitions/schema.ORReasoningParam"
},
"service_tier": {
"description": "\"auto\"|\"default\"|priority hint",
"type": "string"
},
"store": {
"description": "Whether to store the response",
"type": "boolean"
},
"stream": {
"type": "boolean"
},
"temperature": {
"type": "number"
},
"text_format": {
"description": "Additional parameters from spec"
},
"tool_choice": {
"description": "\"auto\"|\"required\"|\"none\"|{type:\"function\",name:\"...\"}"
},
"tools": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.ORFunctionTool"
}
},
"top_logprobs": {
"description": "Number of top logprobs to return",
"type": "integer"
},
"top_p": {
"type": "number"
},
"truncation": {
"description": "\"auto\"|\"disabled\"",
"type": "string"
}
}
},
"schema.P2PNodesResponse": {
"type": "object",
"properties": {

Some files were not shown because too many files have changed in this diff Show More