Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a84098d3b4 | |||
| 4d02ccd26a | |||
| dfd47eeac4 | |||
| 1ac6499c08 | |||
| 25f3dc25e7 | |||
| 8422e4e6a1 | |||
| 02ee29d881 | |||
| b2a891f8f4 | |||
| 8d2b568897 | |||
| fb44cf4e08 | |||
| 02aee4e86d | |||
| f45896d395 | |||
| f7e46a359f | |||
| c260907415 | |||
| b83a5fa291 | |||
| 6e2ff28d59 | |||
| a8b81f2799 | |||
| f9ee7156dc |
@@ -15,7 +15,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
platform: [intel, cuda, vulkan, cpu, musa]
|
#platform: [intel, cuda, vulkan, cpu, musa]
|
||||||
|
platform: [cuda, vulkan, cpu, musa]
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
llama-swap is a light weight, transparent proxy server that provides automatic model swapping to llama.cpp's server.
|
llama-swap is a light weight, transparent proxy server that provides automatic model swapping to llama.cpp's server.
|
||||||
|
|
||||||
Written in golang, it is very easy to install (single binary with no dependancies) and configure (single yaml file). To get started, download a pre-built binary or use the provided docker images.
|
Written in golang, it is very easy to install (single binary with no dependencies) and configure (single yaml file). To get started, download a pre-built binary or use the provided docker images.
|
||||||
|
|
||||||
## Features:
|
## Features:
|
||||||
|
|
||||||
@@ -40,166 +40,45 @@ In the most basic configuration llama-swap handles one model at a time. For more
|
|||||||
|
|
||||||
## config.yaml
|
## config.yaml
|
||||||
|
|
||||||
llama-swap's configuration is purposefully simple.
|
llama-swap's configuration is purposefully simple:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
models:
|
models:
|
||||||
"qwen2.5":
|
"qwen2.5":
|
||||||
proxy: "http://127.0.0.1:9999"
|
cmd: |
|
||||||
cmd: >
|
|
||||||
/app/llama-server
|
/app/llama-server
|
||||||
-hf bartowski/Qwen2.5-0.5B-Instruct-GGUF:Q4_K_M
|
-hf bartowski/Qwen2.5-0.5B-Instruct-GGUF:Q4_K_M
|
||||||
--port 9999
|
--port ${PORT}
|
||||||
|
|
||||||
"smollm2":
|
"smollm2":
|
||||||
proxy: "http://127.0.0.1:9999"
|
cmd: |
|
||||||
cmd: >
|
|
||||||
/app/llama-server
|
/app/llama-server
|
||||||
-hf bartowski/SmolLM2-135M-Instruct-GGUF:Q4_K_M
|
-hf bartowski/SmolLM2-135M-Instruct-GGUF:Q4_K_M
|
||||||
--port 9999
|
--port ${PORT}
|
||||||
```
|
```
|
||||||
|
|
||||||
<details>
|
.. but also supports many advanced features:
|
||||||
<summary>But also very powerful ...</summary>
|
|
||||||
|
|
||||||
```yaml
|
- `groups` to run multiple models at once
|
||||||
# Seconds to wait for llama.cpp to load and be ready to serve requests
|
- `macros` for reusable snippets
|
||||||
# Default (and minimum) is 15 seconds
|
- `ttl` to automatically unload models
|
||||||
healthCheckTimeout: 60
|
- `aliases` to use familiar model names (e.g., "gpt-4o-mini")
|
||||||
|
- `env` variables to pass custom environment to inference servers
|
||||||
|
- `useModelName` to override model names sent to upstream servers
|
||||||
|
- `healthCheckTimeout` to control model startup wait times
|
||||||
|
- `${PORT}` automatic port variables for dynamic port assignment
|
||||||
|
- `cmdStop` for to gracefully stop Docker/Podman containers
|
||||||
|
|
||||||
# Valid log levels: debug, info (default), warn, error
|
Check the [configuration documentation](https://github.com/mostlygeek/llama-swap/wiki/Configuration) in the wiki for all options.
|
||||||
logLevel: info
|
|
||||||
|
|
||||||
# Automatic Port Values
|
|
||||||
# use ${PORT} in model.cmd and model.proxy to use an automatic port number
|
|
||||||
# when you use ${PORT} you can omit a custom model.proxy value, as it will
|
|
||||||
# default to http://localhost:${PORT}
|
|
||||||
|
|
||||||
# override the default port (5800) for automatic port values
|
|
||||||
startPort: 10001
|
|
||||||
|
|
||||||
# define valid model values and the upstream server start
|
|
||||||
models:
|
|
||||||
"llama":
|
|
||||||
# multiline for readability
|
|
||||||
cmd: >
|
|
||||||
llama-server --port 8999
|
|
||||||
--model path/to/Qwen2.5-1.5B-Instruct-Q4_K_M.gguf
|
|
||||||
|
|
||||||
# environment variables to pass to the command
|
|
||||||
env:
|
|
||||||
- "CUDA_VISIBLE_DEVICES=0"
|
|
||||||
|
|
||||||
# where to reach the server started by cmd, make sure the ports match
|
|
||||||
# can be omitted if you use an automatic ${PORT} in cmd
|
|
||||||
proxy: http://127.0.0.1:8999
|
|
||||||
|
|
||||||
# aliases names to use this model for
|
|
||||||
aliases:
|
|
||||||
- "gpt-4o-mini"
|
|
||||||
- "gpt-3.5-turbo"
|
|
||||||
|
|
||||||
# check this path for an HTTP 200 OK before serving requests
|
|
||||||
# default: /health to match llama.cpp
|
|
||||||
# use "none" to skip endpoint checking, but may cause HTTP errors
|
|
||||||
# until the model is ready
|
|
||||||
checkEndpoint: /custom-endpoint
|
|
||||||
|
|
||||||
# automatically unload the model after this many seconds
|
|
||||||
# ttl values must be a value greater than 0
|
|
||||||
# default: 0 = never unload model
|
|
||||||
ttl: 60
|
|
||||||
|
|
||||||
# `useModelName` overrides the model name in the request
|
|
||||||
# and sends a specific name to the upstream server
|
|
||||||
useModelName: "qwen:qwq"
|
|
||||||
|
|
||||||
# unlisted models do not show up in /v1/models or /upstream lists
|
|
||||||
# but they can still be requested as normal
|
|
||||||
"qwen-unlisted":
|
|
||||||
unlisted: true
|
|
||||||
cmd: llama-server --port ${PORT} -m Llama-3.2-1B-Instruct-Q4_K_M.gguf -ngl 0
|
|
||||||
|
|
||||||
# Docker Support (v26.1.4+ required!)
|
|
||||||
"docker-llama":
|
|
||||||
proxy: "http://127.0.0.1:${PORT}"
|
|
||||||
cmd: >
|
|
||||||
docker run --name dockertest
|
|
||||||
--init --rm -p ${PORT}:8080 -v /mnt/nvme/models:/models
|
|
||||||
ghcr.io/ggerganov/llama.cpp:server
|
|
||||||
--model '/models/Qwen2.5-Coder-0.5B-Instruct-Q4_K_M.gguf'
|
|
||||||
|
|
||||||
# Groups provide advanced controls over model swapping behaviour. Using groups
|
|
||||||
# some models can be kept loaded indefinitely, while others are swapped out.
|
|
||||||
#
|
|
||||||
# Tips:
|
|
||||||
#
|
|
||||||
# - models must be defined above in the Models section
|
|
||||||
# - a model can only be a member of one group
|
|
||||||
# - group behaviour is controlled via the `swap`, `exclusive` and `persistent` fields
|
|
||||||
# - see issue #109 for details
|
|
||||||
#
|
|
||||||
# NOTE: the example below uses model names that are not defined above for demonstration purposes
|
|
||||||
groups:
|
|
||||||
# group1 is the default behaviour of llama-swap where only one model is allowed
|
|
||||||
# to run a time across the whole llama-swap instance
|
|
||||||
"group1":
|
|
||||||
# swap controls the model swapping behaviour in within the group
|
|
||||||
# - true : only one model is allowed to run at a time
|
|
||||||
# - false: all models can run together, no swapping
|
|
||||||
swap: true
|
|
||||||
|
|
||||||
# exclusive controls how the group affects other groups
|
|
||||||
# - true: causes all other groups to unload their models when this group runs a model
|
|
||||||
# - false: does not affect other groups
|
|
||||||
exclusive: true
|
|
||||||
|
|
||||||
# members references the models defined above
|
|
||||||
members:
|
|
||||||
- "llama"
|
|
||||||
- "qwen-unlisted"
|
|
||||||
|
|
||||||
# models in this group are never unloaded
|
|
||||||
"group2":
|
|
||||||
swap: false
|
|
||||||
exclusive: false
|
|
||||||
members:
|
|
||||||
- "docker-llama"
|
|
||||||
# (not defined above, here for example)
|
|
||||||
- "modelA"
|
|
||||||
- "modelB"
|
|
||||||
|
|
||||||
"forever":
|
|
||||||
# setting persistent to true causes the group to never be affected by the swapping behaviour of
|
|
||||||
# other groups. It is a shortcut to keeping some models always loaded.
|
|
||||||
persistent: true
|
|
||||||
|
|
||||||
# set swap/exclusive to false to prevent swapping inside the group and effect on other groups
|
|
||||||
swap: false
|
|
||||||
exclusive: false
|
|
||||||
members:
|
|
||||||
- "forever-modelA"
|
|
||||||
- "forever-modelB"
|
|
||||||
- "forever-modelc"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Use Case Examples
|
|
||||||
|
|
||||||
- [config.example.yaml](config.example.yaml) includes example for supporting `v1/embeddings` and `v1/rerank` endpoints
|
|
||||||
- [Speculative Decoding](examples/speculative-decoding/README.md) - using a small draft model can increase inference speeds from 20% to 40%. This example includes a configurations Qwen2.5-Coder-32B (2.5x increase) and Llama-3.1-70B (1.4x increase) in the best cases.
|
|
||||||
- [Optimizing Code Generation](examples/benchmark-snakegame/README.md) - find the optimal settings for your machine. This example demonstrates defining multiple configurations and testing which one is fastest.
|
|
||||||
- [Restart on Config Change](examples/restart-on-config-change/README.md) - automatically restart llama-swap when trying out different configurations.
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## Docker Install ([download images](https://github.com/mostlygeek/llama-swap/pkgs/container/llama-swap))
|
## Docker Install ([download images](https://github.com/mostlygeek/llama-swap/pkgs/container/llama-swap))
|
||||||
|
|
||||||
Docker is the quickest way to try out llama-swap:
|
Docker is the quickest way to try out llama-swap:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
# use CPU inference
|
# use CPU inference comes with the example config above
|
||||||
$ docker run -it --rm -p 9292:8080 ghcr.io/mostlygeek/llama-swap:cpu
|
$ docker run -it --rm -p 9292:8080 ghcr.io/mostlygeek/llama-swap:cpu
|
||||||
|
|
||||||
|
|
||||||
# qwen2.5 0.5B
|
# qwen2.5 0.5B
|
||||||
$ curl -s http://localhost:9292/v1/chat/completions \
|
$ curl -s http://localhost:9292/v1/chat/completions \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
@@ -207,7 +86,6 @@ $ curl -s http://localhost:9292/v1/chat/completions \
|
|||||||
-d '{"model":"qwen2.5","messages": [{"role": "user","content": "tell me a joke"}]}' | \
|
-d '{"model":"qwen2.5","messages": [{"role": "user","content": "tell me a joke"}]}' | \
|
||||||
jq -r '.choices[0].message.content'
|
jq -r '.choices[0].message.content'
|
||||||
|
|
||||||
|
|
||||||
# SmolLM2 135M
|
# SmolLM2 135M
|
||||||
$ curl -s http://localhost:9292/v1/chat/completions \
|
$ curl -s http://localhost:9292/v1/chat/completions \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
@@ -217,7 +95,7 @@ $ curl -s http://localhost:9292/v1/chat/completions \
|
|||||||
```
|
```
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Docker images are nightly ...</summary>
|
<summary>Docker images are built nightly for cuda, intel, vulcan, etc ...</summary>
|
||||||
|
|
||||||
They include:
|
They include:
|
||||||
|
|
||||||
@@ -247,11 +125,11 @@ Pre-built binaries are available for Linux, FreeBSD and Darwin (OSX). These are
|
|||||||
1. Create a configuration file, see [config.example.yaml](config.example.yaml)
|
1. Create a configuration file, see [config.example.yaml](config.example.yaml)
|
||||||
1. Download a [release](https://github.com/mostlygeek/llama-swap/releases) appropriate for your OS and architecture.
|
1. Download a [release](https://github.com/mostlygeek/llama-swap/releases) appropriate for your OS and architecture.
|
||||||
1. Run the binary with `llama-swap --config path/to/config.yaml`.
|
1. Run the binary with `llama-swap --config path/to/config.yaml`.
|
||||||
Available flags:
|
Available flags:
|
||||||
- `--config`: Path to the configuration file (default: `config.yaml`).
|
- `--config`: Path to the configuration file (default: `config.yaml`).
|
||||||
- `--listen`: Address and port to listen on (default: `:8080`).
|
- `--listen`: Address and port to listen on (default: `:8080`).
|
||||||
- `--version`: Show version information and exit.
|
- `--version`: Show version information and exit.
|
||||||
- `--watch-config`: Automatically reload the configuration file when it changes. This will wait for in-flight requests to complete then stop all running models (default: `false`).
|
- `--watch-config`: Automatically reload the configuration file when it changes. This will wait for in-flight requests to complete then stop all running models (default: `false`).
|
||||||
|
|
||||||
### Building from source
|
### Building from source
|
||||||
|
|
||||||
@@ -292,32 +170,6 @@ Any OpenAI compatible server would work. llama-swap was originally designed for
|
|||||||
|
|
||||||
For Python based inference servers like vllm or tabbyAPI it is recommended to run them via podman or docker. This provides clean environment isolation as well as responding correctly to `SIGTERM` signals to shutdown.
|
For Python based inference servers like vllm or tabbyAPI it is recommended to run them via podman or docker. This provides clean environment isolation as well as responding correctly to `SIGTERM` signals to shutdown.
|
||||||
|
|
||||||
## Systemd Unit Files
|
|
||||||
|
|
||||||
Use this unit file to start llama-swap on boot. This is only tested on Ubuntu.
|
|
||||||
|
|
||||||
`/etc/systemd/system/llama-swap.service`
|
|
||||||
|
|
||||||
```
|
|
||||||
[Unit]
|
|
||||||
Description=llama-swap
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
User=nobody
|
|
||||||
|
|
||||||
# set this to match your environment
|
|
||||||
ExecStart=/path/to/llama-swap --config /path/to/llama-swap.config.yml
|
|
||||||
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=3
|
|
||||||
StartLimitBurst=3
|
|
||||||
StartLimitInterval=30
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://www.star-history.com/#mostlygeek/llama-swap&Date)
|
[](https://www.star-history.com/#mostlygeek/llama-swap&Date)
|
||||||
|
|||||||
+4
-4
@@ -15,7 +15,7 @@ groups:
|
|||||||
|
|
||||||
models:
|
models:
|
||||||
"llama":
|
"llama":
|
||||||
cmd: >
|
cmd: |
|
||||||
models/llama-server-osx
|
models/llama-server-osx
|
||||||
--port ${PORT}
|
--port ${PORT}
|
||||||
-m models/Llama-3.2-1B-Instruct-Q4_0.gguf
|
-m models/Llama-3.2-1B-Instruct-Q4_0.gguf
|
||||||
@@ -38,7 +38,7 @@ models:
|
|||||||
# Embedding example with Nomic
|
# Embedding example with Nomic
|
||||||
# https://huggingface.co/nomic-ai/nomic-embed-text-v1.5-GGUF
|
# https://huggingface.co/nomic-ai/nomic-embed-text-v1.5-GGUF
|
||||||
"nomic":
|
"nomic":
|
||||||
cmd: >
|
cmd: |
|
||||||
models/llama-server-osx --port ${PORT}
|
models/llama-server-osx --port ${PORT}
|
||||||
-m models/nomic-embed-text-v1.5.Q8_0.gguf
|
-m models/nomic-embed-text-v1.5.Q8_0.gguf
|
||||||
--ctx-size 8192
|
--ctx-size 8192
|
||||||
@@ -51,7 +51,7 @@ models:
|
|||||||
# Reranking example with bge-reranker
|
# Reranking example with bge-reranker
|
||||||
# https://huggingface.co/gpustack/bge-reranker-v2-m3-GGUF
|
# https://huggingface.co/gpustack/bge-reranker-v2-m3-GGUF
|
||||||
"bge-reranker":
|
"bge-reranker":
|
||||||
cmd: >
|
cmd: |
|
||||||
models/llama-server-osx --port ${PORT}
|
models/llama-server-osx --port ${PORT}
|
||||||
-m models/bge-reranker-v2-m3-Q4_K_M.gguf
|
-m models/bge-reranker-v2-m3-Q4_K_M.gguf
|
||||||
--ctx-size 8192
|
--ctx-size 8192
|
||||||
@@ -59,7 +59,7 @@ models:
|
|||||||
|
|
||||||
# Docker Support (v26.1.4+ required!)
|
# Docker Support (v26.1.4+ required!)
|
||||||
"dockertest":
|
"dockertest":
|
||||||
cmd: >
|
cmd: |
|
||||||
docker run --name dockertest
|
docker run --name dockertest
|
||||||
--init --rm -p ${PORT}:8080 -v /mnt/nvme/models:/models
|
--init --rm -p ${PORT}:8080 -v /mnt/nvme/models:/models
|
||||||
ghcr.io/ggerganov/llama.cpp:server
|
ghcr.io/ggerganov/llama.cpp:server
|
||||||
|
|||||||
@@ -223,13 +223,13 @@ runloop:
|
|||||||
if countSigInt > 1 {
|
if countSigInt > 1 {
|
||||||
break runloop
|
break runloop
|
||||||
} else {
|
} else {
|
||||||
log.Println("Recieved SIGINT, send another SIGINT to shutdown")
|
log.Println("Received SIGINT, send another SIGINT to shutdown")
|
||||||
}
|
}
|
||||||
case syscall.SIGTERM:
|
case syscall.SIGTERM:
|
||||||
if *ignoreSigTerm {
|
if *ignoreSigTerm {
|
||||||
log.Println("Ignoring SIGTERM")
|
log.Println("Ignoring SIGTERM")
|
||||||
} else {
|
} else {
|
||||||
log.Println("Recieved SIGTERM, shutting down")
|
log.Println("Received SIGTERM, shutting down")
|
||||||
break runloop
|
break runloop
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|||||||
+75
-9
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -17,6 +18,7 @@ const DEFAULT_GROUP_ID = "(default)"
|
|||||||
|
|
||||||
type ModelConfig struct {
|
type ModelConfig struct {
|
||||||
Cmd string `yaml:"cmd"`
|
Cmd string `yaml:"cmd"`
|
||||||
|
CmdStop string `yaml:"cmdStop"`
|
||||||
Proxy string `yaml:"proxy"`
|
Proxy string `yaml:"proxy"`
|
||||||
Aliases []string `yaml:"aliases"`
|
Aliases []string `yaml:"aliases"`
|
||||||
Env []string `yaml:"env"`
|
Env []string `yaml:"env"`
|
||||||
@@ -66,6 +68,9 @@ type Config struct {
|
|||||||
Profiles map[string][]string `yaml:"profiles"`
|
Profiles map[string][]string `yaml:"profiles"`
|
||||||
Groups map[string]GroupConfig `yaml:"groups"` /* key is group ID */
|
Groups map[string]GroupConfig `yaml:"groups"` /* key is group ID */
|
||||||
|
|
||||||
|
// for key/value replacements in model's cmd, cmdStop, proxy, checkEndPoint
|
||||||
|
Macros map[string]string `yaml:"macros"`
|
||||||
|
|
||||||
// map aliases to actual model IDs
|
// map aliases to actual model IDs
|
||||||
aliases map[string]string
|
aliases map[string]string
|
||||||
|
|
||||||
@@ -112,7 +117,12 @@ func LoadConfigFromReader(r io.Reader) (Config, error) {
|
|||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.HealthCheckTimeout < 15 {
|
if config.HealthCheckTimeout == 0 {
|
||||||
|
// this high default timeout helps avoid failing health checks
|
||||||
|
// for configurations that wait for docker or have slower startup
|
||||||
|
config.HealthCheckTimeout = 120
|
||||||
|
} else if config.HealthCheckTimeout < 15 {
|
||||||
|
// set a minimum of 15 seconds
|
||||||
config.HealthCheckTimeout = 15
|
config.HealthCheckTimeout = 15
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +145,30 @@ func LoadConfigFromReader(r io.Reader) (Config, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// iterate over the models and replace any ${PORT} with the next available port
|
/* check macro constraint rules:
|
||||||
|
|
||||||
|
- name must fit the regex ^[a-zA-Z0-9_-]+$
|
||||||
|
- names must be less than 64 characters (no reason, just cause)
|
||||||
|
- name can not be any reserved macros: PORT
|
||||||
|
- macro values must be less than 1024 characters
|
||||||
|
*/
|
||||||
|
macroNameRegex := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
||||||
|
for macroName, macroValue := range config.Macros {
|
||||||
|
if len(macroName) >= 64 {
|
||||||
|
return Config{}, fmt.Errorf("macro name '%s' exceeds maximum length of 63 characters", macroName)
|
||||||
|
}
|
||||||
|
if !macroNameRegex.MatchString(macroName) {
|
||||||
|
return Config{}, fmt.Errorf("macro name '%s' contains invalid characters, must match pattern ^[a-zA-Z0-9_-]+$", macroName)
|
||||||
|
}
|
||||||
|
if len(macroValue) >= 1024 {
|
||||||
|
return Config{}, fmt.Errorf("macro value for '%s' exceeds maximum length of 1024 characters", macroName)
|
||||||
|
}
|
||||||
|
switch macroName {
|
||||||
|
case "PORT":
|
||||||
|
return Config{}, fmt.Errorf("macro name '%s' is reserved and cannot be used", macroName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get and sort all model IDs first, makes testing more consistent
|
// Get and sort all model IDs first, makes testing more consistent
|
||||||
modelIds := make([]string, 0, len(config.Models))
|
modelIds := make([]string, 0, len(config.Models))
|
||||||
for modelId := range config.Models {
|
for modelId := range config.Models {
|
||||||
@@ -143,23 +176,56 @@ func LoadConfigFromReader(r io.Reader) (Config, error) {
|
|||||||
}
|
}
|
||||||
sort.Strings(modelIds) // This guarantees stable iteration order
|
sort.Strings(modelIds) // This guarantees stable iteration order
|
||||||
|
|
||||||
// iterate over the sorted models
|
|
||||||
nextPort := config.StartPort
|
nextPort := config.StartPort
|
||||||
for _, modelId := range modelIds {
|
for _, modelId := range modelIds {
|
||||||
modelConfig := config.Models[modelId]
|
modelConfig := config.Models[modelId]
|
||||||
if strings.Contains(modelConfig.Cmd, "${PORT}") {
|
|
||||||
modelConfig.Cmd = strings.ReplaceAll(modelConfig.Cmd, "${PORT}", strconv.Itoa(nextPort))
|
// go through model config fields: cmd, cmdStop, proxy, checkEndPoint and replace macros with macro values
|
||||||
|
for macroName, macroValue := range config.Macros {
|
||||||
|
macroSlug := fmt.Sprintf("${%s}", macroName)
|
||||||
|
modelConfig.Cmd = strings.ReplaceAll(modelConfig.Cmd, macroSlug, macroValue)
|
||||||
|
modelConfig.CmdStop = strings.ReplaceAll(modelConfig.CmdStop, macroSlug, macroValue)
|
||||||
|
modelConfig.Proxy = strings.ReplaceAll(modelConfig.Proxy, macroSlug, macroValue)
|
||||||
|
modelConfig.CheckEndpoint = strings.ReplaceAll(modelConfig.CheckEndpoint, macroSlug, macroValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// only iterate over models that use ${PORT} to keep port numbers from increasing unnecessarily
|
||||||
|
if strings.Contains(modelConfig.Cmd, "${PORT}") || strings.Contains(modelConfig.Proxy, "${PORT}") || strings.Contains(modelConfig.CmdStop, "${PORT}") {
|
||||||
if modelConfig.Proxy == "" {
|
if modelConfig.Proxy == "" {
|
||||||
modelConfig.Proxy = fmt.Sprintf("http://localhost:%d", nextPort)
|
modelConfig.Proxy = "http://localhost:${PORT}"
|
||||||
} else {
|
|
||||||
modelConfig.Proxy = strings.ReplaceAll(modelConfig.Proxy, "${PORT}", strconv.Itoa(nextPort))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nextPortStr := strconv.Itoa(nextPort)
|
||||||
|
modelConfig.Cmd = strings.ReplaceAll(modelConfig.Cmd, "${PORT}", nextPortStr)
|
||||||
|
modelConfig.CmdStop = strings.ReplaceAll(modelConfig.CmdStop, "${PORT}", nextPortStr)
|
||||||
|
modelConfig.Proxy = strings.ReplaceAll(modelConfig.Proxy, "${PORT}", nextPortStr)
|
||||||
nextPort++
|
nextPort++
|
||||||
config.Models[modelId] = modelConfig
|
|
||||||
} else if modelConfig.Proxy == "" {
|
} else if modelConfig.Proxy == "" {
|
||||||
return Config{}, fmt.Errorf("model %s requires a proxy value when not using automatic ${PORT}", modelId)
|
return Config{}, fmt.Errorf("model %s requires a proxy value when not using automatic ${PORT}", modelId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// make sure there are no unknown macros that have not been replaced
|
||||||
|
macroPattern := regexp.MustCompile(`\$\{([a-zA-Z0-9_-]+)\}`)
|
||||||
|
fieldMap := map[string]string{
|
||||||
|
"cmd": modelConfig.Cmd,
|
||||||
|
"cmdStop": modelConfig.CmdStop,
|
||||||
|
"proxy": modelConfig.Proxy,
|
||||||
|
"checkEndpoint": modelConfig.CheckEndpoint,
|
||||||
|
}
|
||||||
|
|
||||||
|
for fieldName, fieldValue := range fieldMap {
|
||||||
|
matches := macroPattern.FindAllStringSubmatch(fieldValue, -1)
|
||||||
|
for _, match := range matches {
|
||||||
|
macroName := match[1]
|
||||||
|
if _, exists := config.Macros[macroName]; !exists {
|
||||||
|
return Config{}, fmt.Errorf("unknown macro '${%s}' found in %s.%s", macroName, modelId, fieldName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.Models[modelId] = modelConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
config = AddDefaultGroupToConfig(config)
|
config = AddDefaultGroupToConfig(config)
|
||||||
// check that members are all unique in the groups
|
// check that members are all unique in the groups
|
||||||
memberUsage := make(map[string]string) // maps member to group it appears in
|
memberUsage := make(map[string]string) // maps member to group it appears in
|
||||||
|
|||||||
+110
-2
@@ -19,6 +19,8 @@ func TestConfig_Load(t *testing.T) {
|
|||||||
|
|
||||||
tempFile := filepath.Join(tempDir, "config.yaml")
|
tempFile := filepath.Join(tempDir, "config.yaml")
|
||||||
content := `
|
content := `
|
||||||
|
macros:
|
||||||
|
svr-path: "path/to/server"
|
||||||
models:
|
models:
|
||||||
model1:
|
model1:
|
||||||
cmd: path/to/cmd --arg1 one
|
cmd: path/to/cmd --arg1 one
|
||||||
@@ -31,7 +33,7 @@ models:
|
|||||||
- "VAR2=value2"
|
- "VAR2=value2"
|
||||||
checkEndpoint: "/health"
|
checkEndpoint: "/health"
|
||||||
model2:
|
model2:
|
||||||
cmd: path/to/cmd --arg1 one
|
cmd: ${svr-path} --arg1 one
|
||||||
proxy: "http://localhost:8081"
|
proxy: "http://localhost:8081"
|
||||||
aliases:
|
aliases:
|
||||||
- "m2"
|
- "m2"
|
||||||
@@ -76,6 +78,9 @@ groups:
|
|||||||
|
|
||||||
expected := Config{
|
expected := Config{
|
||||||
StartPort: 5800,
|
StartPort: 5800,
|
||||||
|
Macros: map[string]string{
|
||||||
|
"svr-path": "path/to/server",
|
||||||
|
},
|
||||||
Models: map[string]ModelConfig{
|
Models: map[string]ModelConfig{
|
||||||
"model1": {
|
"model1": {
|
||||||
Cmd: "path/to/cmd --arg1 one",
|
Cmd: "path/to/cmd --arg1 one",
|
||||||
@@ -85,7 +90,7 @@ groups:
|
|||||||
CheckEndpoint: "/health",
|
CheckEndpoint: "/health",
|
||||||
},
|
},
|
||||||
"model2": {
|
"model2": {
|
||||||
Cmd: "path/to/cmd --arg1 one",
|
Cmd: "path/to/server --arg1 one",
|
||||||
Proxy: "http://localhost:8081",
|
Proxy: "http://localhost:8081",
|
||||||
Aliases: []string{"m2"},
|
Aliases: []string{"m2"},
|
||||||
Env: nil,
|
Env: nil,
|
||||||
@@ -331,3 +336,106 @@ models:
|
|||||||
assert.Equal(t, "model model1 requires a proxy value when not using automatic ${PORT}", err.Error())
|
assert.Equal(t, "model model1 requires a proxy value when not using automatic ${PORT}", err.Error())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfig_MacroReplacement(t *testing.T) {
|
||||||
|
content := `
|
||||||
|
startPort: 9990
|
||||||
|
macros:
|
||||||
|
svr-path: "path/to/server"
|
||||||
|
argOne: "--arg1"
|
||||||
|
argTwo: "--arg2"
|
||||||
|
autoPort: "--port ${PORT}"
|
||||||
|
|
||||||
|
models:
|
||||||
|
model1:
|
||||||
|
cmd: |
|
||||||
|
${svr-path} ${argTwo}
|
||||||
|
# the automatic ${PORT} is replaced
|
||||||
|
${autoPort}
|
||||||
|
${argOne}
|
||||||
|
--arg3 three
|
||||||
|
cmdStop: |
|
||||||
|
/path/to/stop.sh --port ${PORT} ${argTwo}
|
||||||
|
`
|
||||||
|
|
||||||
|
config, err := LoadConfigFromReader(strings.NewReader(content))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
sanitizedCmd, err := SanitizeCommand(config.Models["model1"].Cmd)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "path/to/server --arg2 --port 9990 --arg1 --arg3 three", strings.Join(sanitizedCmd, " "))
|
||||||
|
|
||||||
|
sanitizedCmdStop, err := SanitizeCommand(config.Models["model1"].CmdStop)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "/path/to/stop.sh --port 9990 --arg2", strings.Join(sanitizedCmdStop, " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_MacroErrorOnUnknownMacros(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
field string
|
||||||
|
content string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "unknown macro in cmd",
|
||||||
|
field: "cmd",
|
||||||
|
content: `
|
||||||
|
startPort: 9990
|
||||||
|
macros:
|
||||||
|
svr-path: "path/to/server"
|
||||||
|
models:
|
||||||
|
model1:
|
||||||
|
cmd: |
|
||||||
|
${svr-path} --port ${PORT}
|
||||||
|
${unknownMacro}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown macro in cmdStop",
|
||||||
|
field: "cmdStop",
|
||||||
|
content: `
|
||||||
|
startPort: 9990
|
||||||
|
macros:
|
||||||
|
svr-path: "path/to/server"
|
||||||
|
models:
|
||||||
|
model1:
|
||||||
|
cmd: "${svr-path} --port ${PORT}"
|
||||||
|
cmdStop: "kill ${unknownMacro}"
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown macro in proxy",
|
||||||
|
field: "proxy",
|
||||||
|
content: `
|
||||||
|
startPort: 9990
|
||||||
|
macros:
|
||||||
|
svr-path: "path/to/server"
|
||||||
|
models:
|
||||||
|
model1:
|
||||||
|
cmd: "${svr-path} --port ${PORT}"
|
||||||
|
proxy: "http://localhost:${unknownMacro}"
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown macro in checkEndpoint",
|
||||||
|
field: "checkEndpoint",
|
||||||
|
content: `
|
||||||
|
startPort: 9990
|
||||||
|
macros:
|
||||||
|
svr-path: "path/to/server"
|
||||||
|
models:
|
||||||
|
model1:
|
||||||
|
cmd: "${svr-path} --port ${PORT}"
|
||||||
|
checkEndpoint: "http://localhost:${unknownMacro}/health"
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := LoadConfigFromReader(strings.NewReader(tt.content))
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "unknown macro '${unknownMacro}' found in model1."+tt.field)
|
||||||
|
//t.Log(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,5 +38,4 @@ func TestConfig_SanitizeCommand(t *testing.T) {
|
|||||||
args, err = SanitizeCommand("")
|
args, err = SanitizeCommand("")
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Nil(t, args)
|
assert.Nil(t, args)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-11
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -80,9 +81,8 @@ func NewProcess(ID string, healthCheckTimeout int, config ModelConfig, processLo
|
|||||||
concurrentLimit := 10
|
concurrentLimit := 10
|
||||||
if config.ConcurrencyLimit > 0 {
|
if config.ConcurrencyLimit > 0 {
|
||||||
concurrentLimit = config.ConcurrencyLimit
|
concurrentLimit = config.ConcurrencyLimit
|
||||||
} else {
|
|
||||||
proxyLogger.Debugf("Concurrency limit for model %s not set, defaulting to 10", ID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Process{
|
return &Process{
|
||||||
ID: ID,
|
ID: ID,
|
||||||
config: config,
|
config: config,
|
||||||
@@ -100,7 +100,7 @@ func NewProcess(ID string, healthCheckTimeout int, config ModelConfig, processLo
|
|||||||
concurrencyLimitSemaphore: make(chan struct{}, concurrentLimit),
|
concurrencyLimitSemaphore: make(chan struct{}, concurrentLimit),
|
||||||
|
|
||||||
// stop timeout
|
// stop timeout
|
||||||
gracefulStopTimeout: 5 * time.Second,
|
gracefulStopTimeout: 10 * time.Second,
|
||||||
upstreamWasStoppedWithKill: false,
|
upstreamWasStoppedWithKill: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,7 +148,9 @@ func isValidTransition(from, to ProcessState) bool {
|
|||||||
return to == StateStopping
|
return to == StateStopping
|
||||||
case StateStopping:
|
case StateStopping:
|
||||||
return to == StateStopped || to == StateShutdown
|
return to == StateStopped || to == StateShutdown
|
||||||
case StateFailed, StateShutdown:
|
case StateFailed:
|
||||||
|
return to == StateStopping
|
||||||
|
case StateShutdown:
|
||||||
return false // No transitions allowed from these states
|
return false // No transitions allowed from these states
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -358,12 +360,19 @@ func (p *Process) StopImmediately() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
p.proxyLogger.Debugf("<%s> Stopping process", p.ID)
|
p.proxyLogger.Debugf("<%s> Stopping process, current state: %s", p.ID, p.CurrentState())
|
||||||
|
currentState := p.CurrentState()
|
||||||
|
|
||||||
// calling Stop() when state is invalid is a no-op
|
if currentState == StateFailed {
|
||||||
if curState, err := p.swapState(StateReady, StateStopping); err != nil {
|
if curState, err := p.swapState(StateFailed, StateStopping); err != nil {
|
||||||
p.proxyLogger.Infof("<%s> Stop() Ready -> StateStopping err: %v, current state: %v", p.ID, err, curState)
|
p.proxyLogger.Infof("<%s> Stop() Failed -> StateStopping err: %v, current state: %v", p.ID, err, curState)
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if curState, err := p.swapState(StateReady, StateStopping); err != nil {
|
||||||
|
p.proxyLogger.Infof("<%s> Stop() Ready -> StateStopping err: %v, current state: %v", p.ID, err, curState)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// stop the process with a graceful exit timeout
|
// stop the process with a graceful exit timeout
|
||||||
@@ -379,8 +388,14 @@ func (p *Process) StopImmediately() {
|
|||||||
// is in the state of starting, it will cancel it and shut it down. Once a process is in
|
// is in the state of starting, it will cancel it and shut it down. Once a process is in
|
||||||
// the StateShutdown state, it can not be started again.
|
// the StateShutdown state, it can not be started again.
|
||||||
func (p *Process) Shutdown() {
|
func (p *Process) Shutdown() {
|
||||||
|
if !isValidTransition(p.CurrentState(), StateStopping) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
p.shutdownCancel()
|
p.shutdownCancel()
|
||||||
p.stopCommand(p.gracefulStopTimeout)
|
p.stopCommand(p.gracefulStopTimeout)
|
||||||
|
|
||||||
|
// just force it to this state since there is no recovery from shutdown
|
||||||
p.state = StateShutdown
|
p.state = StateShutdown
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,8 +415,38 @@ func (p *Process) stopCommand(sigtermTTL time.Duration) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := p.terminateProcess(); err != nil {
|
// if err := p.terminateProcess(); err != nil {
|
||||||
p.proxyLogger.Debugf("<%s> Process already terminated: %v (normal during shutdown)", p.ID, err)
|
// p.proxyLogger.Debugf("<%s> Process already terminated: %v (normal during shutdown)", p.ID, err)
|
||||||
|
// }
|
||||||
|
// the default cmdStop to taskkill /f /t /pid ${PID}
|
||||||
|
if runtime.GOOS == "windows" && strings.TrimSpace(p.config.CmdStop) == "" {
|
||||||
|
p.config.CmdStop = "taskkill /f /t /pid ${PID}"
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.config.CmdStop != "" {
|
||||||
|
// replace ${PID} with the pid of the process
|
||||||
|
stopArgs, err := SanitizeCommand(strings.ReplaceAll(p.config.CmdStop, "${PID}", fmt.Sprintf("%d", p.cmd.Process.Pid)))
|
||||||
|
if err != nil {
|
||||||
|
p.proxyLogger.Errorf("<%s> Failed to sanitize stop command: %v", p.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p.proxyLogger.Debugf("<%s> Executing stop command: %s", p.ID, strings.Join(stopArgs, " "))
|
||||||
|
|
||||||
|
stopCmd := exec.Command(stopArgs[0], stopArgs[1:]...)
|
||||||
|
stopCmd.Stdout = p.processLogger
|
||||||
|
stopCmd.Stderr = p.processLogger
|
||||||
|
stopCmd.Env = p.config.Env
|
||||||
|
|
||||||
|
if err := stopCmd.Run(); err != nil {
|
||||||
|
p.proxyLogger.Errorf("<%s> Failed to exec stop command: %v", p.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||||
|
p.proxyLogger.Errorf("<%s> Failed to send SIGTERM to process: %v", p.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
//go:build !windows
|
|
||||||
|
|
||||||
package proxy
|
|
||||||
|
|
||||||
import "syscall"
|
|
||||||
|
|
||||||
func (p *Process) terminateProcess() error {
|
|
||||||
return p.cmd.Process.Signal(syscall.SIGTERM)
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (p *Process) terminateProcess() error {
|
|
||||||
pid := fmt.Sprintf("%d", p.cmd.Process.Pid)
|
|
||||||
cmd := exec.Command("taskkill", "/f", "/t", "/pid", pid)
|
|
||||||
return cmd.Run()
|
|
||||||
}
|
|
||||||
@@ -449,3 +449,22 @@ func TestProcess_ForceStopWithKill(t *testing.T) {
|
|||||||
// the request should have been interrupted by SIGKILL
|
// the request should have been interrupted by SIGKILL
|
||||||
<-waitChan
|
<-waitChan
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProcess_StopCmd(t *testing.T) {
|
||||||
|
config := getTestSimpleResponderConfig("test_stop_cmd")
|
||||||
|
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
config.CmdStop = "taskkill /f /t /pid ${PID}"
|
||||||
|
} else {
|
||||||
|
config.CmdStop = "kill -TERM ${PID}"
|
||||||
|
}
|
||||||
|
|
||||||
|
process := NewProcess("testStopCmd", 2, config, debugLogger, debugLogger)
|
||||||
|
defer process.Stop()
|
||||||
|
|
||||||
|
err := process.start()
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, process.CurrentState(), StateReady)
|
||||||
|
process.StopImmediately()
|
||||||
|
assert.Equal(t, process.CurrentState(), StateStopped)
|
||||||
|
}
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ func (pm *ProxyManager) listModelsHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Encode the data as JSON and write it to the response writer
|
// Encode the data as JSON and write it to the response writer
|
||||||
if err := json.NewEncoder(c.Writer).Encode(map[string]interface{}{"data": data}); err != nil {
|
if err := json.NewEncoder(c.Writer).Encode(map[string]interface{}{"object": "list", "data": data}); err != nil {
|
||||||
pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error encoding JSON %s", err.Error()))
|
pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error encoding JSON %s", err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -319,7 +319,7 @@ func (pm *ProxyManager) proxyToUpstream(c *gin.Context) {
|
|||||||
func (pm *ProxyManager) upstreamIndex(c *gin.Context) {
|
func (pm *ProxyManager) upstreamIndex(c *gin.Context) {
|
||||||
var html strings.Builder
|
var html strings.Builder
|
||||||
|
|
||||||
html.WriteString("<!doctype HTML>\n<html><body><h1>Available Models</h1><ul>")
|
html.WriteString("<!doctype HTML>\n<html><body><h1>Available Models</h1><a href=\"/unload\">Unload all models</a><ul>")
|
||||||
|
|
||||||
// Extract keys and sort them
|
// Extract keys and sort them
|
||||||
var modelIDs []string
|
var modelIDs []string
|
||||||
@@ -352,6 +352,8 @@ func (pm *ProxyManager) upstreamIndex(c *gin.Context) {
|
|||||||
stateStr = "Failed"
|
stateStr = "Failed"
|
||||||
case StateShutdown:
|
case StateShutdown:
|
||||||
stateStr = "Shutdown"
|
stateStr = "Shutdown"
|
||||||
|
case StateStopped:
|
||||||
|
stateStr = "Stopped"
|
||||||
default:
|
default:
|
||||||
stateStr = "Unknown"
|
stateStr = "Unknown"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# This script installs llama-swap on Linux.
|
||||||
|
# It detects the current operating system architecture and installs the appropriate version of llama-swap.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
LLAMA_SWAP_DEFAULT_ADDRESS=${LLAMA_SWAP_DEFAULT_ADDRESS:-"127.0.0.1:8080"}
|
||||||
|
|
||||||
|
red="$( (/usr/bin/tput bold || :; /usr/bin/tput setaf 1 || :) 2>&-)"
|
||||||
|
plain="$( (/usr/bin/tput sgr0 || :) 2>&-)"
|
||||||
|
|
||||||
|
status() { echo ">>> $*" >&2; }
|
||||||
|
error() { echo "${red}ERROR:${plain} $*"; exit 1; }
|
||||||
|
warning() { echo "${red}WARNING:${plain} $*"; }
|
||||||
|
|
||||||
|
available() { command -v "$1" >/dev/null; }
|
||||||
|
require() {
|
||||||
|
_MISSING=''
|
||||||
|
for TOOL in "$@"; do
|
||||||
|
if ! available "$TOOL"; then
|
||||||
|
_MISSING="$_MISSING $TOOL"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "$_MISSING"
|
||||||
|
}
|
||||||
|
|
||||||
|
SUDO=
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
if ! available sudo; then
|
||||||
|
error "This script requires superuser permissions. Please re-run as root."
|
||||||
|
fi
|
||||||
|
|
||||||
|
SUDO="sudo"
|
||||||
|
fi
|
||||||
|
|
||||||
|
NEEDS=$(require tee tar python3 mktemp)
|
||||||
|
if [ -n "$NEEDS" ]; then
|
||||||
|
status "ERROR: The following tools are required but missing:"
|
||||||
|
for NEED in $NEEDS; do
|
||||||
|
echo " - $NEED"
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ "$(uname -s)" = "Linux" ] || error 'This script is intended to run on Linux only.'
|
||||||
|
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
case "$ARCH" in
|
||||||
|
x86_64) ARCH="amd64" ;;
|
||||||
|
aarch64|arm64) ARCH="arm64" ;;
|
||||||
|
*) error "Unsupported architecture: $ARCH" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
IS_WSL2=false
|
||||||
|
|
||||||
|
KERN=$(uname -r)
|
||||||
|
case "$KERN" in
|
||||||
|
*icrosoft*WSL2 | *icrosoft*wsl2) IS_WSL2=true;;
|
||||||
|
*icrosoft) error "Microsoft WSL1 is not currently supported. Please use WSL2 with 'wsl --set-version <distro> 2'" ;;
|
||||||
|
*) ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
download_binary() {
|
||||||
|
ASSET_NAME="linux_$ARCH"
|
||||||
|
|
||||||
|
TMPDIR=$(mktemp -d)
|
||||||
|
trap 'rm -rf "${TMPDIR}"' EXIT INT TERM HUP
|
||||||
|
PYTHON_SCRIPT=$(cat <<EOF
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ASSET_NAME = "${ASSET_NAME}"
|
||||||
|
|
||||||
|
with urllib.request.urlopen("https://api.github.com/repos/mostlygeek/llama-swap/releases/latest") as resp:
|
||||||
|
data = json.load(resp)
|
||||||
|
for asset in data.get("assets", []):
|
||||||
|
if ASSET_NAME in asset.get("name", ""):
|
||||||
|
url = asset["browser_download_url"]
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
print("ERROR: Matching asset not found.", file=sys.stderr)
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
print("Downloading:", url, file=sys.stderr)
|
||||||
|
output_path = os.path.join("${TMPDIR}", "llama-swap.tar.gz")
|
||||||
|
urllib.request.urlretrieve(url, output_path)
|
||||||
|
print(output_path)
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
TARFILE=$(python3 -c "$PYTHON_SCRIPT")
|
||||||
|
if [ ! -f "$TARFILE" ]; then
|
||||||
|
error "Failed to download binary."
|
||||||
|
fi
|
||||||
|
|
||||||
|
status "Extracting to /usr/local/bin"
|
||||||
|
$SUDO tar -xzf "$TARFILE" -C /usr/local/bin llama-swap
|
||||||
|
}
|
||||||
|
download_binary
|
||||||
|
|
||||||
|
configure_systemd() {
|
||||||
|
if ! id llama-swap >/dev/null 2>&1; then
|
||||||
|
status "Creating llama-swap user..."
|
||||||
|
$SUDO useradd -r -s /bin/false -U -m -d /usr/share/llama-swap llama-swap
|
||||||
|
fi
|
||||||
|
if getent group render >/dev/null 2>&1; then
|
||||||
|
status "Adding llama-swap user to render group..."
|
||||||
|
$SUDO usermod -a -G render llama-swap
|
||||||
|
fi
|
||||||
|
if getent group video >/dev/null 2>&1; then
|
||||||
|
status "Adding llama-swap user to video group..."
|
||||||
|
$SUDO usermod -a -G video llama-swap
|
||||||
|
fi
|
||||||
|
if getent group docker >/dev/null 2>&1; then
|
||||||
|
status "Adding llama-swap user to docker group..."
|
||||||
|
$SUDO usermod -a -G docker llama-swap
|
||||||
|
fi
|
||||||
|
|
||||||
|
status "Adding current user to llama-swap group..."
|
||||||
|
$SUDO usermod -a -G llama-swap "$(whoami)"
|
||||||
|
|
||||||
|
if [ ! -f "/usr/share/llama-swap/config.yaml" ]; then
|
||||||
|
status "Creating default config.yaml..."
|
||||||
|
cat <<EOF | $SUDO -u llama-swap tee /usr/share/llama-swap/config.yaml >/dev/null
|
||||||
|
# default 15s likely to fail for default models due to downloading models
|
||||||
|
healthCheckTimeout: 60
|
||||||
|
|
||||||
|
models:
|
||||||
|
"qwen2.5":
|
||||||
|
cmd: |
|
||||||
|
docker run
|
||||||
|
--rm
|
||||||
|
-p \${PORT}:8080
|
||||||
|
--name qwen2.5
|
||||||
|
ghcr.io/ggml-org/llama.cpp:server
|
||||||
|
-hf bartowski/Qwen2.5-0.5B-Instruct-GGUF:Q4_K_M
|
||||||
|
cmdStop: docker stop qwen2.5
|
||||||
|
|
||||||
|
"smollm2":
|
||||||
|
cmd: |
|
||||||
|
docker run
|
||||||
|
--rm
|
||||||
|
-p \${PORT}:8080
|
||||||
|
--name smollm2
|
||||||
|
ghcr.io/ggml-org/llama.cpp:server
|
||||||
|
-hf bartowski/SmolLM2-135M-Instruct-GGUF:Q4_K_M
|
||||||
|
cmdStop: docker stop smollm2
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
status "Creating llama-swap systemd service..."
|
||||||
|
cat <<EOF | $SUDO tee /etc/systemd/system/llama-swap.service >/dev/null
|
||||||
|
[Unit]
|
||||||
|
Description=llama-swap
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=llama-swap
|
||||||
|
Group=llama-swap
|
||||||
|
|
||||||
|
# set this to match your environment
|
||||||
|
ExecStart=/usr/local/bin/llama-swap --config /usr/share/llama-swap/config.yaml --watch-config -listen ${LLAMA_SWAP_DEFAULT_ADDRESS}
|
||||||
|
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
StartLimitBurst=3
|
||||||
|
StartLimitInterval=30
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
SYSTEMCTL_RUNNING="$(systemctl is-system-running || true)"
|
||||||
|
case $SYSTEMCTL_RUNNING in
|
||||||
|
running|degraded)
|
||||||
|
status "Enabling and starting llama-swap service..."
|
||||||
|
$SUDO systemctl daemon-reload
|
||||||
|
$SUDO systemctl enable llama-swap
|
||||||
|
|
||||||
|
start_service() { $SUDO systemctl restart llama-swap; }
|
||||||
|
trap start_service EXIT
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
warning "systemd is not running"
|
||||||
|
if [ "$IS_WSL2" = true ]; then
|
||||||
|
warning "see https://learn.microsoft.com/en-us/windows/wsl/systemd#how-to-enable-systemd to enable it"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
if available systemctl; then
|
||||||
|
configure_systemd
|
||||||
|
fi
|
||||||
|
|
||||||
|
install_success() {
|
||||||
|
status "The llama-swap API is now available at http://${LLAMA_SWAP_DEFAULT_ADDRESS}"
|
||||||
|
status 'Customize the config file at /usr/share/llama-swap/config.yaml.'
|
||||||
|
status 'Install complete.'
|
||||||
|
}
|
||||||
|
|
||||||
|
# WSL2 only supports GPUs via nvidia passthrough
|
||||||
|
# so check for nvidia-smi to determine if GPU is available
|
||||||
|
if [ "$IS_WSL2" = true ]; then
|
||||||
|
if available nvidia-smi && [ -n "$(nvidia-smi | grep -o "CUDA Version: [0-9]*\.[0-9]*")" ]; then
|
||||||
|
status "Nvidia GPU detected."
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
install_success
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# This script uninstalls llama-swap on Linux.
|
||||||
|
# It removes the binary, systemd service, config.yaml (optional), and llama-swap user and group.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
red="$( (/usr/bin/tput bold || :; /usr/bin/tput setaf 1 || :) 2>&-)"
|
||||||
|
plain="$( (/usr/bin/tput sgr0 || :) 2>&-)"
|
||||||
|
|
||||||
|
status() { echo ">>> $*" >&2; }
|
||||||
|
error() { echo "${red}ERROR:${plain} $*"; exit 1; }
|
||||||
|
warning() { echo "${red}WARNING:${plain} $*"; }
|
||||||
|
|
||||||
|
available() { command -v $1 >/dev/null; }
|
||||||
|
|
||||||
|
SUDO=
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
if ! available sudo; then
|
||||||
|
error "This script requires superuser permissions. Please re-run as root."
|
||||||
|
fi
|
||||||
|
|
||||||
|
SUDO="sudo"
|
||||||
|
fi
|
||||||
|
|
||||||
|
configure_systemd() {
|
||||||
|
status "Stopping llama-swap service..."
|
||||||
|
$SUDO systemctl stop llama-swap
|
||||||
|
|
||||||
|
status "Disabling llama-swap service..."
|
||||||
|
$SUDO systemctl disable llama-swap
|
||||||
|
}
|
||||||
|
if available systemctl; then
|
||||||
|
configure_systemd
|
||||||
|
fi
|
||||||
|
|
||||||
|
if available llama-swap; then
|
||||||
|
status "Removing llama-swap binary..."
|
||||||
|
$SUDO rm $(which llama-swap)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "/usr/share/llama-swap/config.yaml" ]; then
|
||||||
|
while true; do
|
||||||
|
printf "Delete config.yaml (/usr/share/llama-swap/config.yaml)? [y/N] " >&2
|
||||||
|
read answer
|
||||||
|
case "$answer" in
|
||||||
|
[Yy]* )
|
||||||
|
$SUDO rm -r /usr/share/llama-swap
|
||||||
|
break
|
||||||
|
;;
|
||||||
|
[Nn]* | "" )
|
||||||
|
break
|
||||||
|
;;
|
||||||
|
* )
|
||||||
|
echo "Invalid input. Please enter y or n."
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if id llama-swap >/dev/null 2>&1; then
|
||||||
|
status "Removing llama-swap user..."
|
||||||
|
$SUDO userdel llama-swap
|
||||||
|
fi
|
||||||
|
|
||||||
|
if getent group llama-swap >/dev/null 2>&1; then
|
||||||
|
status "Removing llama-swap group..."
|
||||||
|
$SUDO groupdel llama-swap
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user