mirror of
https://github.com/lordmathis/llamactl.git
synced 2025-12-23 01:24:24 +00:00
Compare commits
73 Commits
v0.11.2
...
22a747c318
| Author | SHA1 | Date | |
|---|---|---|---|
| 22a747c318 | |||
| ceef48a125 | |||
| db1347a709 | |||
|
|
e4027722d7 | ||
|
|
8218c042c8 | ||
| efed0f543b | |||
|
|
aa0508eb9b | ||
| 514b1b0e76 | |||
| 6565be3676 | |||
| ad772a05ce | |||
| b594ade8f9 | |||
| 2ceeddbce5 | |||
| 6ed99fccf9 | |||
| 4f8f4b96cd | |||
| c04c952293 | |||
| 7544fbb1ce | |||
| 4f4feacaa8 | |||
| 2c28971e15 | |||
| e77ed0cdef | |||
| 09605d07ab | |||
| 623e258a2a | |||
| 91d8a9008f | |||
| 511889e56d | |||
| 19eb552dc7 | |||
| 4bddea2831 | |||
| b878dfe8da | |||
| d600212cd0 | |||
|
|
5837521821 | ||
| 7e71ada904 | |||
| 5335634879 | |||
| 15d1e17454 | |||
| 72b70918fa | |||
| 11bfe75a3c | |||
| ae5358ff65 | |||
| bff8e7d914 | |||
| 5ad076919e | |||
| c022746cd8 | |||
| 8d92f9b371 | |||
| 15180a227b | |||
|
|
0708327a16 | ||
| a2740055c2 | |||
| 0ddffaa2e6 | |||
| 9a160a5312 | |||
| 8861057f11 | |||
| 34edb8a2e5 | |||
| 560850f86d | |||
| c340439306 | |||
| 77c0e22fd0 | |||
| d65c5ab717 | |||
| 2b94244c8a | |||
| 2e5644db53 | |||
| 7ee22fee51 | |||
| e5baedb776 | |||
| e6205b930e | |||
| f9eb424690 | |||
| 5b84b64623 | |||
| 7813a5f2be | |||
| a00c9b82a6 | |||
| cbfa6bd48f | |||
| bee0f72c10 | |||
| a5d8f541f0 | |||
| dfcc16083c | |||
| 6ec2919049 | |||
| d6a6f377fc | |||
| cd9a71d9fc | |||
| 2c4cc5a69a | |||
| b1fc1d2dc8 | |||
| 08c47a16a0 | |||
| 219db7abce | |||
| 14131a6274 | |||
| e65f4f1641 | |||
| 5ef0654cdd | |||
| 1814772fa2 |
40
.github/dependabot.yml
vendored
Normal file
40
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Go modules
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-patch"]
|
||||
groups:
|
||||
go-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
labels:
|
||||
- "dependencies"
|
||||
|
||||
# npm dependencies for webui
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/webui"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-patch"]
|
||||
groups:
|
||||
npm-production:
|
||||
dependency-type: "production"
|
||||
npm-development:
|
||||
dependency-type: "development"
|
||||
labels:
|
||||
- "dependencies"
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -42,4 +42,7 @@ site/
|
||||
llamactl.dev.yaml
|
||||
|
||||
# Debug files
|
||||
__debug*
|
||||
__debug*
|
||||
|
||||
# Binary
|
||||
llamactl-*
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"llamactl/pkg/config"
|
||||
"llamactl/pkg/manager"
|
||||
"llamactl/pkg/server"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -38,8 +39,7 @@ func main() {
|
||||
configPath := os.Getenv("LLAMACTL_CONFIG_PATH")
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading config: %v\n", err)
|
||||
fmt.Println("Using default configuration.")
|
||||
log.Printf("Error loading config: %v\nUsing default configuration.", err)
|
||||
}
|
||||
|
||||
// Set version information
|
||||
@@ -50,13 +50,11 @@ func main() {
|
||||
// Create the data directory if it doesn't exist
|
||||
if cfg.Instances.AutoCreateDirs {
|
||||
if err := os.MkdirAll(cfg.Instances.InstancesDir, 0755); err != nil {
|
||||
fmt.Printf("Error creating config directory %s: %v\n", cfg.Instances.InstancesDir, err)
|
||||
fmt.Println("Persistence will not be available.")
|
||||
log.Printf("Error creating config directory %s: %v\nPersistence will not be available.", cfg.Instances.InstancesDir, err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(cfg.Instances.LogsDir, 0755); err != nil {
|
||||
fmt.Printf("Error creating log directory %s: %v\n", cfg.Instances.LogsDir, err)
|
||||
fmt.Println("Instance logs will not be available.")
|
||||
log.Printf("Error creating log directory %s: %v\nInstance logs will not be available.", cfg.Instances.LogsDir, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +79,7 @@ func main() {
|
||||
go func() {
|
||||
fmt.Printf("Llamactl server listening on %s:%d\n", cfg.Server.Host, cfg.Server.Port)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
fmt.Printf("Error starting server: %v\n", err)
|
||||
log.Printf("Error starting server: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -90,7 +88,7 @@ func main() {
|
||||
fmt.Println("Shutting down server...")
|
||||
|
||||
if err := server.Close(); err != nil {
|
||||
fmt.Printf("Error shutting down server: %v\n", err)
|
||||
log.Printf("Error shutting down server: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("Server shut down gracefully.")
|
||||
}
|
||||
|
||||
276
docs/docs.go
276
docs/docs.go
@@ -256,6 +256,34 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/config": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Returns the current server configuration (sanitized)",
|
||||
"tags": [
|
||||
"System"
|
||||
],
|
||||
"summary": "Get server configuration",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Sanitized configuration",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/config.AppConfig"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/instances": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -1475,6 +1503,247 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"config.AppConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"auth": {
|
||||
"$ref": "#/definitions/config.AuthConfig"
|
||||
},
|
||||
"backends": {
|
||||
"$ref": "#/definitions/config.BackendConfig"
|
||||
},
|
||||
"build_time": {
|
||||
"type": "string"
|
||||
},
|
||||
"commit_hash": {
|
||||
"type": "string"
|
||||
},
|
||||
"instances": {
|
||||
"$ref": "#/definitions/config.InstancesConfig"
|
||||
},
|
||||
"local_node": {
|
||||
"type": "string"
|
||||
},
|
||||
"nodes": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/config.NodeConfig"
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"$ref": "#/definitions/config.ServerConfig"
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.AuthConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"inference_keys": {
|
||||
"description": "List of keys for OpenAI compatible inference endpoints",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"management_keys": {
|
||||
"description": "List of keys for management endpoints",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"require_inference_auth": {
|
||||
"description": "Require authentication for OpenAI compatible inference endpoints",
|
||||
"type": "boolean"
|
||||
},
|
||||
"require_management_auth": {
|
||||
"description": "Require authentication for management endpoints",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.BackendConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"llama-cpp": {
|
||||
"$ref": "#/definitions/config.BackendSettings"
|
||||
},
|
||||
"mlx": {
|
||||
"$ref": "#/definitions/config.BackendSettings"
|
||||
},
|
||||
"vllm": {
|
||||
"$ref": "#/definitions/config.BackendSettings"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.BackendSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"args": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"command": {
|
||||
"type": "string"
|
||||
},
|
||||
"docker": {
|
||||
"$ref": "#/definitions/config.DockerSettings"
|
||||
},
|
||||
"environment": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"response_headers": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.DockerSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"args": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"environment": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.InstancesConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"auto_create_dirs": {
|
||||
"description": "Automatically create the data directory if it doesn't exist",
|
||||
"type": "boolean"
|
||||
},
|
||||
"configs_dir": {
|
||||
"description": "Instance config directory override",
|
||||
"type": "string"
|
||||
},
|
||||
"data_dir": {
|
||||
"description": "Directory where all llamactl data will be stored (instances.json, logs, etc.)",
|
||||
"type": "string"
|
||||
},
|
||||
"default_auto_restart": {
|
||||
"description": "Default auto-restart setting for new instances",
|
||||
"type": "boolean"
|
||||
},
|
||||
"default_max_restarts": {
|
||||
"description": "Default max restarts for new instances",
|
||||
"type": "integer"
|
||||
},
|
||||
"default_on_demand_start": {
|
||||
"description": "Default on-demand start setting for new instances",
|
||||
"type": "boolean"
|
||||
},
|
||||
"default_restart_delay": {
|
||||
"description": "Default restart delay for new instances (in seconds)",
|
||||
"type": "integer"
|
||||
},
|
||||
"enable_lru_eviction": {
|
||||
"description": "Enable LRU eviction for instance logs",
|
||||
"type": "boolean"
|
||||
},
|
||||
"logs_dir": {
|
||||
"description": "Logs directory override",
|
||||
"type": "string"
|
||||
},
|
||||
"max_instances": {
|
||||
"description": "Maximum number of instances that can be created",
|
||||
"type": "integer"
|
||||
},
|
||||
"max_running_instances": {
|
||||
"description": "Maximum number of instances that can be running at the same time",
|
||||
"type": "integer"
|
||||
},
|
||||
"on_demand_start_timeout": {
|
||||
"description": "How long to wait for an instance to start on demand (in seconds)",
|
||||
"type": "integer"
|
||||
},
|
||||
"port_range": {
|
||||
"description": "Port range for instances (e.g., 8000,9000)",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"timeout_check_interval": {
|
||||
"description": "Interval for checking instance timeouts (in minutes)",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.NodeConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"address": {
|
||||
"type": "string"
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.ServerConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"allowed_headers": {
|
||||
"description": "Allowed headers for CORS (e.g., \"Accept\", \"Authorization\", \"Content-Type\", \"X-CSRF-Token\")",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"allowed_origins": {
|
||||
"description": "Allowed origins for CORS (e.g., \"http://localhost:3000\")",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"enable_swagger": {
|
||||
"description": "Enable Swagger UI for API documentation",
|
||||
"type": "boolean"
|
||||
},
|
||||
"host": {
|
||||
"description": "Server host to bind to",
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"description": "Server port to bind to",
|
||||
"type": "integer"
|
||||
},
|
||||
"response_headers": {
|
||||
"description": "Response headers to send with responses",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"instance.Instance": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1494,6 +1763,13 @@ const docTemplate = `{
|
||||
"description": "Auto restart",
|
||||
"type": "boolean"
|
||||
},
|
||||
"command_override": {
|
||||
"type": "string"
|
||||
},
|
||||
"docker_enabled": {
|
||||
"description": "Execution context overrides",
|
||||
"type": "boolean"
|
||||
},
|
||||
"environment": {
|
||||
"description": "Environment variables",
|
||||
"type": "object",
|
||||
|
||||
@@ -42,32 +42,41 @@ Each instance is displayed as a card showing:
|
||||

|
||||
|
||||
1. Click the **"Create Instance"** button on the dashboard
|
||||
2. Enter a unique **Name** for your instance (only required field)
|
||||
3. **Select Target Node**: Choose which node to deploy the instance to from the dropdown
|
||||
4. **Choose Backend Type**:
|
||||
- **llama.cpp**: For GGUF models using llama-server
|
||||
- **MLX**: For MLX-optimized models (macOS only)
|
||||
2. *Optional*: Click **"Import"** to load a previously exported configuration
|
||||
|
||||
**Instance Settings:**
|
||||
|
||||
3. Enter a unique **Instance Name** (required)
|
||||
4. **Select Node**: Choose which node to deploy the instance to
|
||||
5. Configure **Auto Restart** settings:
|
||||
- Enable automatic restart on failure
|
||||
- Set max restarts and delay between attempts
|
||||
6. Configure basic instance options:
|
||||
- **Idle Timeout**: Minutes before stopping idle instance
|
||||
- **On Demand Start**: Start instance only when needed
|
||||
|
||||
**Backend Configuration:**
|
||||
|
||||
7. **Select Backend Type**:
|
||||
- **Llama Server**: For GGUF models using llama-server
|
||||
- **MLX LM**: For MLX-optimized models (macOS only)
|
||||
- **vLLM**: For distributed serving and high-throughput inference
|
||||
5. Configure model source:
|
||||
- **For llama.cpp**: GGUF model path or HuggingFace repo
|
||||
- **For MLX**: MLX model path or identifier (e.g., `mlx-community/Mistral-7B-Instruct-v0.3-4bit`)
|
||||
- **For vLLM**: HuggingFace model identifier (e.g., `microsoft/DialoGPT-medium`)
|
||||
6. Configure optional instance management settings:
|
||||
- **Auto Restart**: Automatically restart instance on failure
|
||||
- **Max Restarts**: Maximum number of restart attempts
|
||||
- **Restart Delay**: Delay in seconds between restart attempts
|
||||
- **On Demand Start**: Start instance when receiving a request to the OpenAI compatible endpoint
|
||||
- **Idle Timeout**: Minutes before stopping idle instance (set to 0 to disable)
|
||||
- **Environment Variables**: Set custom environment variables for the instance process
|
||||
7. Configure backend-specific options:
|
||||
- **llama.cpp**: Threads, context size, GPU layers, port, etc.
|
||||
- **MLX**: Temperature, top-p, adapter path, Python environment, etc.
|
||||
- **vLLM**: Tensor parallel size, GPU memory utilization, quantization, etc.
|
||||
8. *Optional*: Click **"Parse Command"** to import settings from an existing backend command
|
||||
9. Configure **Execution Context**:
|
||||
- **Enable Docker**: Run backend in Docker container
|
||||
- **Command Override**: Custom path to backend executable
|
||||
- **Environment Variables**: Custom environment variables
|
||||
|
||||
!!! tip "Auto-Assignment"
|
||||
Llamactl automatically assigns ports from the configured port range (default: 8000-9000) and generates API keys if authentication is enabled. You typically don't need to manually specify these values.
|
||||
|
||||
8. Click **"Create"** to save the instance
|
||||
|
||||
10. Configure **Basic Backend Options** (varies by backend):
|
||||
- **llama.cpp**: Model path, threads, context size, GPU layers, etc.
|
||||
- **MLX**: Model identifier, temperature, max tokens, etc.
|
||||
- **vLLM**: Model identifier, tensor parallel size, GPU memory utilization, etc.
|
||||
11. *Optional*: Expand **Advanced Backend Options** for additional settings
|
||||
12. *Optional*: Add **Extra Args** as key-value pairs for custom command-line arguments
|
||||
13. Click **"Create"** to save the instance
|
||||
|
||||
**Via API**
|
||||
|
||||
@@ -82,11 +91,34 @@ curl -X POST http://localhost:8080/api/v1/instances/my-llama-instance \
|
||||
"model": "/path/to/model.gguf",
|
||||
"threads": 8,
|
||||
"ctx_size": 4096,
|
||||
"gpu_layers": 32
|
||||
"gpu_layers": 32,
|
||||
"flash_attn": "on"
|
||||
},
|
||||
"auto_restart": true,
|
||||
"max_restarts": 3,
|
||||
"docker_enabled": false,
|
||||
"command_override": "/opt/llama-server-dev",
|
||||
"nodes": ["main"]
|
||||
}'
|
||||
|
||||
# Create vLLM instance with environment variables
|
||||
curl -X POST http://localhost:8080/api/v1/instances/my-vllm-instance \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-d '{
|
||||
"backend_type": "vllm",
|
||||
"backend_options": {
|
||||
"model": "microsoft/DialoGPT-medium",
|
||||
"tensor_parallel_size": 2,
|
||||
"gpu_memory_utilization": 0.9
|
||||
},
|
||||
"on_demand_start": true,
|
||||
"environment": {
|
||||
"CUDA_VISIBLE_DEVICES": "0,1"
|
||||
},
|
||||
"nodes": ["worker1", "worker2"]
|
||||
}'
|
||||
|
||||
# Create MLX instance (macOS only)
|
||||
curl -X POST http://localhost:8080/api/v1/instances/my-mlx-instance \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -96,74 +128,10 @@ curl -X POST http://localhost:8080/api/v1/instances/my-mlx-instance \
|
||||
"backend_options": {
|
||||
"model": "mlx-community/Mistral-7B-Instruct-v0.3-4bit",
|
||||
"temp": 0.7,
|
||||
"top_p": 0.9,
|
||||
"max_tokens": 2048
|
||||
},
|
||||
"auto_restart": true,
|
||||
"max_restarts": 3,
|
||||
"nodes": ["main"]
|
||||
}'
|
||||
|
||||
# Create vLLM instance
|
||||
curl -X POST http://localhost:8080/api/v1/instances/my-vllm-instance \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-d '{
|
||||
"backend_type": "vllm",
|
||||
"backend_options": {
|
||||
"model": "microsoft/DialoGPT-medium",
|
||||
"tensor_parallel_size": 2,
|
||||
"gpu_memory_utilization": 0.9
|
||||
},
|
||||
"auto_restart": true,
|
||||
"on_demand_start": true,
|
||||
"environment": {
|
||||
"CUDA_VISIBLE_DEVICES": "0,1",
|
||||
"NCCL_DEBUG": "INFO",
|
||||
"PYTHONPATH": "/custom/path"
|
||||
},
|
||||
"nodes": ["main"]
|
||||
}'
|
||||
|
||||
# Create llama.cpp instance with HuggingFace model
|
||||
curl -X POST http://localhost:8080/api/v1/instances/gemma-3-27b \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-d '{
|
||||
"backend_type": "llama_cpp",
|
||||
"backend_options": {
|
||||
"hf_repo": "unsloth/gemma-3-27b-it-GGUF",
|
||||
"hf_file": "gemma-3-27b-it-GGUF.gguf",
|
||||
"gpu_layers": 32
|
||||
},
|
||||
"nodes": ["main"]
|
||||
}'
|
||||
|
||||
# Create instance on specific remote node
|
||||
curl -X POST http://localhost:8080/api/v1/instances/remote-llama \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-d '{
|
||||
"backend_type": "llama_cpp",
|
||||
"backend_options": {
|
||||
"model": "/models/llama-7b.gguf",
|
||||
"gpu_layers": 32
|
||||
},
|
||||
"nodes": ["worker1"]
|
||||
}'
|
||||
|
||||
# Create instance on multiple nodes for high availability
|
||||
curl -X POST http://localhost:8080/api/v1/instances/multi-node-llama \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-d '{
|
||||
"backend_type": "llama_cpp",
|
||||
"backend_options": {
|
||||
"model": "/models/llama-7b.gguf",
|
||||
"gpu_layers": 32
|
||||
},
|
||||
"nodes": ["worker1", "worker2", "worker3"]
|
||||
}'
|
||||
```
|
||||
|
||||
## Start Instance
|
||||
@@ -219,6 +187,12 @@ curl -X PUT http://localhost:8080/api/v1/instances/{name} \
|
||||
Configuration changes require restarting the instance to take effect.
|
||||
|
||||
|
||||
## Export Instance
|
||||
|
||||
**Via Web UI**
|
||||
1. Click the **"More actions"** button (three dots) on an instance card
|
||||
2. Click **"Export"** to download the instance configuration as a JSON file
|
||||
|
||||
## View Logs
|
||||
|
||||
**Via Web UI**
|
||||
|
||||
@@ -249,6 +249,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/config": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Returns the current server configuration (sanitized)",
|
||||
"tags": [
|
||||
"System"
|
||||
],
|
||||
"summary": "Get server configuration",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Sanitized configuration",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/config.AppConfig"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/instances": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -1468,6 +1496,247 @@
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"config.AppConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"auth": {
|
||||
"$ref": "#/definitions/config.AuthConfig"
|
||||
},
|
||||
"backends": {
|
||||
"$ref": "#/definitions/config.BackendConfig"
|
||||
},
|
||||
"build_time": {
|
||||
"type": "string"
|
||||
},
|
||||
"commit_hash": {
|
||||
"type": "string"
|
||||
},
|
||||
"instances": {
|
||||
"$ref": "#/definitions/config.InstancesConfig"
|
||||
},
|
||||
"local_node": {
|
||||
"type": "string"
|
||||
},
|
||||
"nodes": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/config.NodeConfig"
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"$ref": "#/definitions/config.ServerConfig"
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.AuthConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"inference_keys": {
|
||||
"description": "List of keys for OpenAI compatible inference endpoints",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"management_keys": {
|
||||
"description": "List of keys for management endpoints",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"require_inference_auth": {
|
||||
"description": "Require authentication for OpenAI compatible inference endpoints",
|
||||
"type": "boolean"
|
||||
},
|
||||
"require_management_auth": {
|
||||
"description": "Require authentication for management endpoints",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.BackendConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"llama-cpp": {
|
||||
"$ref": "#/definitions/config.BackendSettings"
|
||||
},
|
||||
"mlx": {
|
||||
"$ref": "#/definitions/config.BackendSettings"
|
||||
},
|
||||
"vllm": {
|
||||
"$ref": "#/definitions/config.BackendSettings"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.BackendSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"args": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"command": {
|
||||
"type": "string"
|
||||
},
|
||||
"docker": {
|
||||
"$ref": "#/definitions/config.DockerSettings"
|
||||
},
|
||||
"environment": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"response_headers": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.DockerSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"args": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"environment": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.InstancesConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"auto_create_dirs": {
|
||||
"description": "Automatically create the data directory if it doesn't exist",
|
||||
"type": "boolean"
|
||||
},
|
||||
"configs_dir": {
|
||||
"description": "Instance config directory override",
|
||||
"type": "string"
|
||||
},
|
||||
"data_dir": {
|
||||
"description": "Directory where all llamactl data will be stored (instances.json, logs, etc.)",
|
||||
"type": "string"
|
||||
},
|
||||
"default_auto_restart": {
|
||||
"description": "Default auto-restart setting for new instances",
|
||||
"type": "boolean"
|
||||
},
|
||||
"default_max_restarts": {
|
||||
"description": "Default max restarts for new instances",
|
||||
"type": "integer"
|
||||
},
|
||||
"default_on_demand_start": {
|
||||
"description": "Default on-demand start setting for new instances",
|
||||
"type": "boolean"
|
||||
},
|
||||
"default_restart_delay": {
|
||||
"description": "Default restart delay for new instances (in seconds)",
|
||||
"type": "integer"
|
||||
},
|
||||
"enable_lru_eviction": {
|
||||
"description": "Enable LRU eviction for instance logs",
|
||||
"type": "boolean"
|
||||
},
|
||||
"logs_dir": {
|
||||
"description": "Logs directory override",
|
||||
"type": "string"
|
||||
},
|
||||
"max_instances": {
|
||||
"description": "Maximum number of instances that can be created",
|
||||
"type": "integer"
|
||||
},
|
||||
"max_running_instances": {
|
||||
"description": "Maximum number of instances that can be running at the same time",
|
||||
"type": "integer"
|
||||
},
|
||||
"on_demand_start_timeout": {
|
||||
"description": "How long to wait for an instance to start on demand (in seconds)",
|
||||
"type": "integer"
|
||||
},
|
||||
"port_range": {
|
||||
"description": "Port range for instances (e.g., 8000,9000)",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"timeout_check_interval": {
|
||||
"description": "Interval for checking instance timeouts (in minutes)",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.NodeConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"address": {
|
||||
"type": "string"
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.ServerConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"allowed_headers": {
|
||||
"description": "Allowed headers for CORS (e.g., \"Accept\", \"Authorization\", \"Content-Type\", \"X-CSRF-Token\")",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"allowed_origins": {
|
||||
"description": "Allowed origins for CORS (e.g., \"http://localhost:3000\")",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"enable_swagger": {
|
||||
"description": "Enable Swagger UI for API documentation",
|
||||
"type": "boolean"
|
||||
},
|
||||
"host": {
|
||||
"description": "Server host to bind to",
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"description": "Server port to bind to",
|
||||
"type": "integer"
|
||||
},
|
||||
"response_headers": {
|
||||
"description": "Response headers to send with responses",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"instance.Instance": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1487,6 +1756,13 @@
|
||||
"description": "Auto restart",
|
||||
"type": "boolean"
|
||||
},
|
||||
"command_override": {
|
||||
"type": "string"
|
||||
},
|
||||
"docker_enabled": {
|
||||
"description": "Execution context overrides",
|
||||
"type": "boolean"
|
||||
},
|
||||
"environment": {
|
||||
"description": "Environment variables",
|
||||
"type": "object",
|
||||
|
||||
@@ -1,5 +1,173 @@
|
||||
basePath: /api/v1
|
||||
definitions:
|
||||
config.AppConfig:
|
||||
properties:
|
||||
auth:
|
||||
$ref: '#/definitions/config.AuthConfig'
|
||||
backends:
|
||||
$ref: '#/definitions/config.BackendConfig'
|
||||
build_time:
|
||||
type: string
|
||||
commit_hash:
|
||||
type: string
|
||||
instances:
|
||||
$ref: '#/definitions/config.InstancesConfig'
|
||||
local_node:
|
||||
type: string
|
||||
nodes:
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/config.NodeConfig'
|
||||
type: object
|
||||
server:
|
||||
$ref: '#/definitions/config.ServerConfig'
|
||||
version:
|
||||
type: string
|
||||
type: object
|
||||
config.AuthConfig:
|
||||
properties:
|
||||
inference_keys:
|
||||
description: List of keys for OpenAI compatible inference endpoints
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
management_keys:
|
||||
description: List of keys for management endpoints
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
require_inference_auth:
|
||||
description: Require authentication for OpenAI compatible inference endpoints
|
||||
type: boolean
|
||||
require_management_auth:
|
||||
description: Require authentication for management endpoints
|
||||
type: boolean
|
||||
type: object
|
||||
config.BackendConfig:
|
||||
properties:
|
||||
llama-cpp:
|
||||
$ref: '#/definitions/config.BackendSettings'
|
||||
mlx:
|
||||
$ref: '#/definitions/config.BackendSettings'
|
||||
vllm:
|
||||
$ref: '#/definitions/config.BackendSettings'
|
||||
type: object
|
||||
config.BackendSettings:
|
||||
properties:
|
||||
args:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
command:
|
||||
type: string
|
||||
docker:
|
||||
$ref: '#/definitions/config.DockerSettings'
|
||||
environment:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
response_headers:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
type: object
|
||||
config.DockerSettings:
|
||||
properties:
|
||||
args:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
enabled:
|
||||
type: boolean
|
||||
environment:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
image:
|
||||
type: string
|
||||
type: object
|
||||
config.InstancesConfig:
|
||||
properties:
|
||||
auto_create_dirs:
|
||||
description: Automatically create the data directory if it doesn't exist
|
||||
type: boolean
|
||||
configs_dir:
|
||||
description: Instance config directory override
|
||||
type: string
|
||||
data_dir:
|
||||
description: Directory where all llamactl data will be stored (instances.json,
|
||||
logs, etc.)
|
||||
type: string
|
||||
default_auto_restart:
|
||||
description: Default auto-restart setting for new instances
|
||||
type: boolean
|
||||
default_max_restarts:
|
||||
description: Default max restarts for new instances
|
||||
type: integer
|
||||
default_on_demand_start:
|
||||
description: Default on-demand start setting for new instances
|
||||
type: boolean
|
||||
default_restart_delay:
|
||||
description: Default restart delay for new instances (in seconds)
|
||||
type: integer
|
||||
enable_lru_eviction:
|
||||
description: Enable LRU eviction for instance logs
|
||||
type: boolean
|
||||
logs_dir:
|
||||
description: Logs directory override
|
||||
type: string
|
||||
max_instances:
|
||||
description: Maximum number of instances that can be created
|
||||
type: integer
|
||||
max_running_instances:
|
||||
description: Maximum number of instances that can be running at the same time
|
||||
type: integer
|
||||
on_demand_start_timeout:
|
||||
description: How long to wait for an instance to start on demand (in seconds)
|
||||
type: integer
|
||||
port_range:
|
||||
description: Port range for instances (e.g., 8000,9000)
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
timeout_check_interval:
|
||||
description: Interval for checking instance timeouts (in minutes)
|
||||
type: integer
|
||||
type: object
|
||||
config.NodeConfig:
|
||||
properties:
|
||||
address:
|
||||
type: string
|
||||
api_key:
|
||||
type: string
|
||||
type: object
|
||||
config.ServerConfig:
|
||||
properties:
|
||||
allowed_headers:
|
||||
description: Allowed headers for CORS (e.g., "Accept", "Authorization", "Content-Type",
|
||||
"X-CSRF-Token")
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
allowed_origins:
|
||||
description: Allowed origins for CORS (e.g., "http://localhost:3000")
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
enable_swagger:
|
||||
description: Enable Swagger UI for API documentation
|
||||
type: boolean
|
||||
host:
|
||||
description: Server host to bind to
|
||||
type: string
|
||||
port:
|
||||
description: Server port to bind to
|
||||
type: integer
|
||||
response_headers:
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Response headers to send with responses
|
||||
type: object
|
||||
type: object
|
||||
instance.Instance:
|
||||
properties:
|
||||
created:
|
||||
@@ -13,6 +181,11 @@ definitions:
|
||||
auto_restart:
|
||||
description: Auto restart
|
||||
type: boolean
|
||||
command_override:
|
||||
type: string
|
||||
docker_enabled:
|
||||
description: Execution context overrides
|
||||
type: boolean
|
||||
environment:
|
||||
additionalProperties:
|
||||
type: string
|
||||
@@ -216,6 +389,23 @@ paths:
|
||||
summary: Parse vllm serve command
|
||||
tags:
|
||||
- Backends
|
||||
/api/v1/config:
|
||||
get:
|
||||
description: Returns the current server configuration (sanitized)
|
||||
responses:
|
||||
"200":
|
||||
description: Sanitized configuration
|
||||
schema:
|
||||
$ref: '#/definitions/config.AppConfig'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
type: string
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Get server configuration
|
||||
tags:
|
||||
- System
|
||||
/api/v1/instances:
|
||||
get:
|
||||
description: Returns a list of all instances managed by the server
|
||||
|
||||
@@ -79,26 +79,28 @@ func (o *Options) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
func (o *Options) MarshalJSON() ([]byte, error) {
|
||||
type Alias Options
|
||||
aux := &struct {
|
||||
*Alias
|
||||
}{
|
||||
Alias: (*Alias)(o),
|
||||
}
|
||||
|
||||
// Get backend and marshal it
|
||||
var backendOptions map[string]any
|
||||
backend := o.getBackend()
|
||||
if backend != nil {
|
||||
optionsData, err := json.Marshal(backend)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal backend options: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(optionsData, &aux.BackendOptions); err != nil {
|
||||
// Create a new map to avoid concurrent map writes
|
||||
backendOptions = make(map[string]any)
|
||||
if err := json.Unmarshal(optionsData, &backendOptions); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal backend options to map: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return json.Marshal(aux)
|
||||
return json.Marshal(&struct {
|
||||
BackendType BackendType `json:"backend_type"`
|
||||
BackendOptions map[string]any `json:"backend_options,omitempty"`
|
||||
}{
|
||||
BackendType: o.BackendType,
|
||||
BackendOptions: backendOptions,
|
||||
})
|
||||
}
|
||||
|
||||
// setBackendOptions stores the backend in the appropriate typed field
|
||||
@@ -140,32 +142,54 @@ func (o *Options) getBackend() backend {
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Options) isDockerEnabled(backend *config.BackendSettings) bool {
|
||||
if backend.Docker != nil && backend.Docker.Enabled && o.BackendType != BackendTypeMlxLm {
|
||||
return true
|
||||
// isDockerEnabled checks if Docker is enabled with an optional override
|
||||
func (o *Options) isDockerEnabled(backend *config.BackendSettings, dockerEnabledOverride *bool) bool {
|
||||
// Check if backend supports Docker
|
||||
if backend.Docker == nil {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
|
||||
// MLX doesn't support Docker
|
||||
if o.BackendType == BackendTypeMlxLm {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for instance-level override
|
||||
if dockerEnabledOverride != nil {
|
||||
return *dockerEnabledOverride
|
||||
}
|
||||
|
||||
// Fall back to config value
|
||||
return backend.Docker.Enabled
|
||||
}
|
||||
|
||||
func (o *Options) IsDockerEnabled(backendConfig *config.BackendConfig) bool {
|
||||
func (o *Options) IsDockerEnabled(backendConfig *config.BackendConfig, dockerEnabled *bool) bool {
|
||||
backendSettings := o.getBackendSettings(backendConfig)
|
||||
return o.isDockerEnabled(backendSettings)
|
||||
return o.isDockerEnabled(backendSettings, dockerEnabled)
|
||||
}
|
||||
|
||||
// GetCommand builds the command to run the backend
|
||||
func (o *Options) GetCommand(backendConfig *config.BackendConfig) string {
|
||||
|
||||
func (o *Options) GetCommand(backendConfig *config.BackendConfig, dockerEnabled *bool, commandOverride string) string {
|
||||
backendSettings := o.getBackendSettings(backendConfig)
|
||||
|
||||
if o.isDockerEnabled(backendSettings) {
|
||||
// Determine if Docker is enabled
|
||||
useDocker := o.isDockerEnabled(backendSettings, dockerEnabled)
|
||||
|
||||
if useDocker {
|
||||
return "docker"
|
||||
}
|
||||
|
||||
// Check for command override (only applies when not in Docker mode)
|
||||
if commandOverride != "" {
|
||||
return commandOverride
|
||||
}
|
||||
|
||||
// Fall back to config command
|
||||
return backendSettings.Command
|
||||
}
|
||||
|
||||
// buildCommandArgs builds command line arguments for the backend
|
||||
func (o *Options) BuildCommandArgs(backendConfig *config.BackendConfig) []string {
|
||||
func (o *Options) BuildCommandArgs(backendConfig *config.BackendConfig, dockerEnabled *bool) []string {
|
||||
|
||||
var args []string
|
||||
|
||||
@@ -175,7 +199,7 @@ func (o *Options) BuildCommandArgs(backendConfig *config.BackendConfig) []string
|
||||
return args
|
||||
}
|
||||
|
||||
if o.isDockerEnabled(backendSettings) {
|
||||
if o.isDockerEnabled(backendSettings, dockerEnabled) {
|
||||
// For Docker, start with Docker args
|
||||
args = append(args, backendSettings.Docker.Args...)
|
||||
args = append(args, backendSettings.Docker.Image)
|
||||
@@ -191,7 +215,7 @@ func (o *Options) BuildCommandArgs(backendConfig *config.BackendConfig) []string
|
||||
}
|
||||
|
||||
// BuildEnvironment builds the environment variables for the backend process
|
||||
func (o *Options) BuildEnvironment(backendConfig *config.BackendConfig, environment map[string]string) map[string]string {
|
||||
func (o *Options) BuildEnvironment(backendConfig *config.BackendConfig, dockerEnabled *bool, environment map[string]string) map[string]string {
|
||||
|
||||
backendSettings := o.getBackendSettings(backendConfig)
|
||||
env := map[string]string{}
|
||||
@@ -200,7 +224,7 @@ func (o *Options) BuildEnvironment(backendConfig *config.BackendConfig, environm
|
||||
maps.Copy(env, backendSettings.Environment)
|
||||
}
|
||||
|
||||
if o.isDockerEnabled(backendSettings) {
|
||||
if o.isDockerEnabled(backendSettings, dockerEnabled) {
|
||||
if backendSettings.Docker.Environment != nil {
|
||||
maps.Copy(env, backendSettings.Docker.Environment)
|
||||
}
|
||||
|
||||
@@ -93,3 +93,22 @@ func BuildDockerCommand(backendConfig *config.BackendSettings, instanceArgs []st
|
||||
|
||||
return "docker", dockerArgs, nil
|
||||
}
|
||||
|
||||
// convertExtraArgsToFlags converts map[string]string to command flags
|
||||
// Empty values become boolean flags: {"flag": ""} → ["--flag"]
|
||||
// Non-empty values: {"flag": "value"} → ["--flag", "value"]
|
||||
func convertExtraArgsToFlags(extraArgs map[string]string) []string {
|
||||
var args []string
|
||||
|
||||
for key, value := range extraArgs {
|
||||
if value == "" {
|
||||
// Boolean flag
|
||||
args = append(args, "--"+key)
|
||||
} else {
|
||||
// Value flag
|
||||
args = append(args, "--"+key, value)
|
||||
}
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"llamactl/pkg/validation"
|
||||
"reflect"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// llamaMultiValuedFlags defines flags that should be repeated for each value rather than comma-separated
|
||||
@@ -41,7 +40,7 @@ type LlamaServerOptions struct {
|
||||
BatchSize int `json:"batch_size,omitempty"`
|
||||
UBatchSize int `json:"ubatch_size,omitempty"`
|
||||
Keep int `json:"keep,omitempty"`
|
||||
FlashAttn bool `json:"flash_attn,omitempty"`
|
||||
FlashAttn string `json:"flash_attn,omitempty"`
|
||||
NoPerf bool `json:"no_perf,omitempty"`
|
||||
Escape bool `json:"escape,omitempty"`
|
||||
NoEscape bool `json:"no_escape,omitempty"`
|
||||
@@ -187,6 +186,10 @@ type LlamaServerOptions struct {
|
||||
FIMQwen7BDefault bool `json:"fim_qwen_7b_default,omitempty"`
|
||||
FIMQwen7BSpec bool `json:"fim_qwen_7b_spec,omitempty"`
|
||||
FIMQwen14BSpec bool `json:"fim_qwen_14b_spec,omitempty"`
|
||||
|
||||
// ExtraArgs are additional command line arguments.
|
||||
// Example: {"verbose": "", "log-file": "/logs/llama.log"}
|
||||
ExtraArgs map[string]string `json:"extra_args,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom JSON unmarshaling to support multiple field names
|
||||
@@ -209,6 +212,15 @@ func (o *LlamaServerOptions) UnmarshalJSON(data []byte) error {
|
||||
// Copy to our struct
|
||||
*o = LlamaServerOptions(temp)
|
||||
|
||||
// Track which fields we've processed
|
||||
processedFields := make(map[string]bool)
|
||||
|
||||
// Get all known canonical field names from struct tags
|
||||
knownFields := getKnownFieldNames(o)
|
||||
for field := range knownFields {
|
||||
processedFields[field] = true
|
||||
}
|
||||
|
||||
// Handle alternative field names
|
||||
fieldMappings := map[string]string{
|
||||
// Common params
|
||||
@@ -220,7 +232,7 @@ func (o *LlamaServerOptions) UnmarshalJSON(data []byte) error {
|
||||
"Crb": "cpu_range_batch", // -Crb, --cpu-range-batch lo-hi
|
||||
"c": "ctx_size", // -c, --ctx-size N
|
||||
"n": "predict", // -n, --predict N
|
||||
"n-predict": "predict", // --n-predict N
|
||||
"n_predict": "predict", // -n-predict N
|
||||
"b": "batch_size", // -b, --batch-size N
|
||||
"ub": "ubatch_size", // -ub, --ubatch-size N
|
||||
"fa": "flash_attn", // -fa, --flash-attn
|
||||
@@ -234,7 +246,7 @@ func (o *LlamaServerOptions) UnmarshalJSON(data []byte) error {
|
||||
"dev": "device", // -dev, --device <dev1,dev2,..>
|
||||
"ot": "override_tensor", // --override-tensor, -ot
|
||||
"ngl": "gpu_layers", // -ngl, --gpu-layers, --n-gpu-layers N
|
||||
"n-gpu-layers": "gpu_layers", // --n-gpu-layers N
|
||||
"n_gpu_layers": "gpu_layers", // --n-gpu-layers N
|
||||
"sm": "split_mode", // -sm, --split-mode
|
||||
"ts": "tensor_split", // -ts, --tensor-split N0,N1,N2,...
|
||||
"mg": "main_gpu", // -mg, --main-gpu INDEX
|
||||
@@ -250,9 +262,9 @@ func (o *LlamaServerOptions) UnmarshalJSON(data []byte) error {
|
||||
"hffv": "hf_file_v", // -hffv, --hf-file-v FILE
|
||||
"hft": "hf_token", // -hft, --hf-token TOKEN
|
||||
"v": "verbose", // -v, --verbose, --log-verbose
|
||||
"log-verbose": "verbose", // --log-verbose
|
||||
"log_verbose": "verbose", // --log-verbose
|
||||
"lv": "verbosity", // -lv, --verbosity, --log-verbosity N
|
||||
"log-verbosity": "verbosity", // --log-verbosity N
|
||||
"log_verbosity": "verbosity", // --log-verbosity N
|
||||
|
||||
// Sampling params
|
||||
"s": "seed", // -s, --seed SEED
|
||||
@@ -269,21 +281,23 @@ func (o *LlamaServerOptions) UnmarshalJSON(data []byte) error {
|
||||
"rerank": "reranking", // --reranking
|
||||
"to": "timeout", // -to, --timeout N
|
||||
"sps": "slot_prompt_similarity", // -sps, --slot-prompt-similarity
|
||||
"draft": "draft-max", // -draft, --draft-max N
|
||||
"draft-n": "draft-max", // --draft-n-max N
|
||||
"draft-n-min": "draft_min", // --draft-n-min N
|
||||
"draft": "draft_max", // -draft, --draft-max N
|
||||
"draft_n": "draft_max", // --draft-n-max N
|
||||
"draft_n_min": "draft_min", // --draft-n-min N
|
||||
"cd": "ctx_size_draft", // -cd, --ctx-size-draft N
|
||||
"devd": "device_draft", // -devd, --device-draft
|
||||
"ngld": "gpu_layers_draft", // -ngld, --gpu-layers-draft
|
||||
"n-gpu-layers-draft": "gpu_layers_draft", // --n-gpu-layers-draft N
|
||||
"n_gpu_layers_draft": "gpu_layers_draft", // --n-gpu-layers-draft N
|
||||
"md": "model_draft", // -md, --model-draft FNAME
|
||||
"ctkd": "cache_type_k_draft", // -ctkd, --cache-type-k-draft TYPE
|
||||
"ctvd": "cache_type_v_draft", // -ctvd, --cache-type-v-draft TYPE
|
||||
"mv": "model_vocoder", // -mv, --model-vocoder FNAME
|
||||
}
|
||||
|
||||
// Process alternative field names
|
||||
// Process alternative field names and mark them as processed
|
||||
for altName, canonicalName := range fieldMappings {
|
||||
processedFields[altName] = true // Mark alternatives as known
|
||||
|
||||
if value, exists := raw[altName]; exists {
|
||||
// Use reflection to set the field value
|
||||
v := reflect.ValueOf(o).Elem()
|
||||
@@ -294,36 +308,21 @@ func (o *LlamaServerOptions) UnmarshalJSON(data []byte) error {
|
||||
})
|
||||
|
||||
if field.IsValid() && field.CanSet() {
|
||||
switch field.Kind() {
|
||||
case reflect.Int:
|
||||
if intVal, ok := value.(float64); ok {
|
||||
field.SetInt(int64(intVal))
|
||||
} else if strVal, ok := value.(string); ok {
|
||||
if intVal, err := strconv.Atoi(strVal); err == nil {
|
||||
field.SetInt(int64(intVal))
|
||||
}
|
||||
}
|
||||
case reflect.Float64:
|
||||
if floatVal, ok := value.(float64); ok {
|
||||
field.SetFloat(floatVal)
|
||||
} else if strVal, ok := value.(string); ok {
|
||||
if floatVal, err := strconv.ParseFloat(strVal, 64); err == nil {
|
||||
field.SetFloat(floatVal)
|
||||
}
|
||||
}
|
||||
case reflect.String:
|
||||
if strVal, ok := value.(string); ok {
|
||||
field.SetString(strVal)
|
||||
}
|
||||
case reflect.Bool:
|
||||
if boolVal, ok := value.(bool); ok {
|
||||
field.SetBool(boolVal)
|
||||
}
|
||||
}
|
||||
setFieldValue(field, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect unknown fields into ExtraArgs
|
||||
if o.ExtraArgs == nil {
|
||||
o.ExtraArgs = make(map[string]string)
|
||||
}
|
||||
for key, value := range raw {
|
||||
if !processedFields[key] {
|
||||
o.ExtraArgs[key] = fmt.Sprintf("%v", value)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -354,6 +353,18 @@ func (o *LlamaServerOptions) Validate() error {
|
||||
return validation.ValidationError(fmt.Errorf("invalid port range: %d", o.Port))
|
||||
}
|
||||
|
||||
// Validate extra_args keys and values
|
||||
for key, value := range o.ExtraArgs {
|
||||
if err := validation.ValidateStringForInjection(key); err != nil {
|
||||
return validation.ValidationError(fmt.Errorf("extra_args key %q: %w", key, err))
|
||||
}
|
||||
if value != "" {
|
||||
if err := validation.ValidateStringForInjection(value); err != nil {
|
||||
return validation.ValidationError(fmt.Errorf("extra_args value for %q: %w", key, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -361,7 +372,12 @@ func (o *LlamaServerOptions) Validate() error {
|
||||
func (o *LlamaServerOptions) BuildCommandArgs() []string {
|
||||
// Llama uses multiple flags for arrays by default (not comma-separated)
|
||||
// Use package-level llamaMultiValuedFlags variable
|
||||
return BuildCommandArgs(o, llamaMultiValuedFlags)
|
||||
args := BuildCommandArgs(o, llamaMultiValuedFlags)
|
||||
|
||||
// Append extra args at the end
|
||||
args = append(args, convertExtraArgsToFlags(o.ExtraArgs)...)
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
func (o *LlamaServerOptions) BuildDockerArgs() []string {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"llamactl/pkg/backends"
|
||||
"llamactl/pkg/config"
|
||||
"llamactl/pkg/testutil"
|
||||
"reflect"
|
||||
"testing"
|
||||
@@ -33,12 +34,11 @@ func TestLlamaCppBuildCommandArgs_BooleanFields(t *testing.T) {
|
||||
{
|
||||
name: "multiple booleans",
|
||||
options: backends.LlamaServerOptions{
|
||||
Verbose: true,
|
||||
FlashAttn: true,
|
||||
Mlock: false,
|
||||
NoMmap: true,
|
||||
Verbose: true,
|
||||
Mlock: false,
|
||||
NoMmap: true,
|
||||
},
|
||||
expected: []string{"--verbose", "--flash-attn", "--no-mmap"},
|
||||
expected: []string{"--verbose", "--no-mmap"},
|
||||
excluded: []string{"--mlock"},
|
||||
},
|
||||
}
|
||||
@@ -346,7 +346,7 @@ func TestParseLlamaCommand(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "multiple value types",
|
||||
command: "llama-server --model /test/model.gguf --gpu-layers 32 --temp 0.7 --verbose --no-mmap",
|
||||
command: "llama-server --model /test/model.gguf --n-gpu-layers 32 --temp 0.7 --verbose --no-mmap",
|
||||
expectErr: false,
|
||||
validate: func(t *testing.T, opts *backends.LlamaServerOptions) {
|
||||
if opts.Model != "/test/model.gguf" {
|
||||
@@ -434,3 +434,195 @@ func TestParseLlamaCommandArrays(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLlamaCppBuildCommandArgs_ExtraArgs(t *testing.T) {
|
||||
options := backends.LlamaServerOptions{
|
||||
Model: "/models/test.gguf",
|
||||
ExtraArgs: map[string]string{
|
||||
"flash-attn": "", // boolean flag
|
||||
"log-file": "/logs/test.log", // value flag
|
||||
},
|
||||
}
|
||||
|
||||
args := options.BuildCommandArgs()
|
||||
|
||||
// Check that extra args are present
|
||||
if !testutil.Contains(args, "--flash-attn") {
|
||||
t.Error("Expected --flash-attn flag not found")
|
||||
}
|
||||
if !testutil.Contains(args, "--log-file") || !testutil.Contains(args, "/logs/test.log") {
|
||||
t.Error("Expected --log-file flag or value not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLlamaCommand_ExtraArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
command string
|
||||
expectErr bool
|
||||
validate func(*testing.T, *backends.LlamaServerOptions)
|
||||
}{
|
||||
{
|
||||
name: "extra args with known fields",
|
||||
command: "llama-server --model /path/to/model.gguf --gpu-layers 32 --unknown-flag value --another-bool-flag",
|
||||
expectErr: false,
|
||||
validate: func(t *testing.T, opts *backends.LlamaServerOptions) {
|
||||
if opts.Model != "/path/to/model.gguf" {
|
||||
t.Errorf("expected model '/path/to/model.gguf', got '%s'", opts.Model)
|
||||
}
|
||||
if opts.GPULayers != 32 {
|
||||
t.Errorf("expected gpu_layers 32, got %d", opts.GPULayers)
|
||||
}
|
||||
if opts.ExtraArgs == nil {
|
||||
t.Fatal("expected extra_args to be non-nil")
|
||||
}
|
||||
if val, ok := opts.ExtraArgs["unknown_flag"]; !ok || val != "value" {
|
||||
t.Errorf("expected extra_args[unknown_flag]='value', got '%s'", val)
|
||||
}
|
||||
if val, ok := opts.ExtraArgs["another_bool_flag"]; !ok || val != "true" {
|
||||
t.Errorf("expected extra_args[another_bool_flag]='true', got '%s'", val)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "extra args with alternative field names",
|
||||
command: "llama-server -m /model.gguf -ngl 16 --custom-arg test --new-feature",
|
||||
expectErr: false,
|
||||
validate: func(t *testing.T, opts *backends.LlamaServerOptions) {
|
||||
// Check that alternative names worked for known fields
|
||||
if opts.Model != "/model.gguf" {
|
||||
t.Errorf("expected model '/model.gguf', got '%s'", opts.Model)
|
||||
}
|
||||
if opts.GPULayers != 16 {
|
||||
t.Errorf("expected gpu_layers 16, got %d", opts.GPULayers)
|
||||
}
|
||||
// Check that unknown args went to ExtraArgs
|
||||
if opts.ExtraArgs == nil {
|
||||
t.Fatal("expected extra_args to be non-nil")
|
||||
}
|
||||
if val, ok := opts.ExtraArgs["custom_arg"]; !ok || val != "test" {
|
||||
t.Errorf("expected extra_args[custom_arg]='test', got '%s'", val)
|
||||
}
|
||||
if val, ok := opts.ExtraArgs["new_feature"]; !ok || val != "true" {
|
||||
t.Errorf("expected extra_args[new_feature]='true', got '%s'", val)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "only extra args",
|
||||
command: "llama-server --experimental-feature --beta-mode enabled",
|
||||
expectErr: false,
|
||||
validate: func(t *testing.T, opts *backends.LlamaServerOptions) {
|
||||
if opts.ExtraArgs == nil {
|
||||
t.Fatal("expected extra_args to be non-nil")
|
||||
}
|
||||
if val, ok := opts.ExtraArgs["experimental_feature"]; !ok || val != "true" {
|
||||
t.Errorf("expected extra_args[experimental_feature]='true', got '%s'", val)
|
||||
}
|
||||
if val, ok := opts.ExtraArgs["beta_mode"]; !ok || val != "enabled" {
|
||||
t.Errorf("expected extra_args[beta_mode]='enabled', got '%s'", val)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var opts backends.LlamaServerOptions
|
||||
result, err := opts.ParseCommand(tt.command)
|
||||
|
||||
if tt.expectErr && err == nil {
|
||||
t.Error("expected error but got none")
|
||||
return
|
||||
}
|
||||
if !tt.expectErr && err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.expectErr && tt.validate != nil {
|
||||
llamaOpts, ok := result.(*backends.LlamaServerOptions)
|
||||
if !ok {
|
||||
t.Fatal("result is not *LlamaServerOptions")
|
||||
}
|
||||
tt.validate(t, llamaOpts)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestLlamaCppGetCommand_WithOverrides(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dockerInConfig bool
|
||||
dockerEnabled *bool
|
||||
commandOverride string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "no overrides - use config command",
|
||||
dockerInConfig: false,
|
||||
dockerEnabled: nil,
|
||||
commandOverride: "",
|
||||
expected: "/usr/bin/llama-server",
|
||||
},
|
||||
{
|
||||
name: "override to enable docker",
|
||||
dockerInConfig: false,
|
||||
dockerEnabled: boolPtr(true),
|
||||
commandOverride: "",
|
||||
expected: "docker",
|
||||
},
|
||||
{
|
||||
name: "override to disable docker",
|
||||
dockerInConfig: true,
|
||||
dockerEnabled: boolPtr(false),
|
||||
commandOverride: "",
|
||||
expected: "/usr/bin/llama-server",
|
||||
},
|
||||
{
|
||||
name: "command override",
|
||||
dockerInConfig: false,
|
||||
dockerEnabled: nil,
|
||||
commandOverride: "/custom/llama-server",
|
||||
expected: "/custom/llama-server",
|
||||
},
|
||||
{
|
||||
name: "docker takes precedence over command override",
|
||||
dockerInConfig: false,
|
||||
dockerEnabled: boolPtr(true),
|
||||
commandOverride: "/custom/llama-server",
|
||||
expected: "docker",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
backendConfig := &config.BackendConfig{
|
||||
LlamaCpp: config.BackendSettings{
|
||||
Command: "/usr/bin/llama-server",
|
||||
Docker: &config.DockerSettings{
|
||||
Enabled: tt.dockerInConfig,
|
||||
Image: "test-image",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
opts := backends.Options{
|
||||
BackendType: backends.BackendTypeLlamaCpp,
|
||||
LlamaServerOptions: &backends.LlamaServerOptions{
|
||||
Model: "test-model.gguf",
|
||||
},
|
||||
}
|
||||
|
||||
result := opts.GetCommand(backendConfig, tt.dockerEnabled, tt.commandOverride)
|
||||
if result != tt.expected {
|
||||
t.Errorf("GetCommand() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create bool pointer
|
||||
func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package backends
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"llamactl/pkg/validation"
|
||||
)
|
||||
@@ -29,6 +30,46 @@ type MlxServerOptions struct {
|
||||
TopK int `json:"top_k,omitempty"`
|
||||
MinP float64 `json:"min_p,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
|
||||
// ExtraArgs are additional command line arguments.
|
||||
// Example: {"verbose": "", "log-file": "/logs/mlx.log"}
|
||||
ExtraArgs map[string]string `json:"extra_args,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom JSON unmarshaling to collect unknown fields into ExtraArgs
|
||||
func (o *MlxServerOptions) UnmarshalJSON(data []byte) error {
|
||||
// First unmarshal into a map to capture all fields
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create a temporary struct for standard unmarshaling
|
||||
type tempOptions MlxServerOptions
|
||||
temp := tempOptions{}
|
||||
|
||||
// Standard unmarshal first
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Copy to our struct
|
||||
*o = MlxServerOptions(temp)
|
||||
|
||||
// Get all known canonical field names from struct tags
|
||||
knownFields := getKnownFieldNames(o)
|
||||
|
||||
// Collect unknown fields into ExtraArgs
|
||||
if o.ExtraArgs == nil {
|
||||
o.ExtraArgs = make(map[string]string)
|
||||
}
|
||||
for key, value := range raw {
|
||||
if !knownFields[key] {
|
||||
o.ExtraArgs[key] = fmt.Sprintf("%v", value)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *MlxServerOptions) GetPort() int {
|
||||
@@ -57,13 +98,30 @@ func (o *MlxServerOptions) Validate() error {
|
||||
return validation.ValidationError(fmt.Errorf("invalid port range: %d", o.Port))
|
||||
}
|
||||
|
||||
// Validate extra_args keys and values
|
||||
for key, value := range o.ExtraArgs {
|
||||
if err := validation.ValidateStringForInjection(key); err != nil {
|
||||
return validation.ValidationError(fmt.Errorf("extra_args key %q: %w", key, err))
|
||||
}
|
||||
if value != "" {
|
||||
if err := validation.ValidateStringForInjection(value); err != nil {
|
||||
return validation.ValidationError(fmt.Errorf("extra_args value for %q: %w", key, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildCommandArgs converts to command line arguments
|
||||
func (o *MlxServerOptions) BuildCommandArgs() []string {
|
||||
multipleFlags := map[string]struct{}{} // MLX doesn't currently have []string fields
|
||||
return BuildCommandArgs(o, multipleFlags)
|
||||
args := BuildCommandArgs(o, multipleFlags)
|
||||
|
||||
// Append extra args at the end
|
||||
args = append(args, convertExtraArgsToFlags(o.ExtraArgs)...)
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
func (o *MlxServerOptions) BuildDockerArgs() []string {
|
||||
|
||||
@@ -2,6 +2,7 @@ package backends_test
|
||||
|
||||
import (
|
||||
"llamactl/pkg/backends"
|
||||
"llamactl/pkg/config"
|
||||
"llamactl/pkg/testutil"
|
||||
"testing"
|
||||
)
|
||||
@@ -202,3 +203,129 @@ func TestMlxBuildCommandArgs_ZeroValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMlxCommand_ExtraArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
command string
|
||||
expectErr bool
|
||||
validate func(*testing.T, *backends.MlxServerOptions)
|
||||
}{
|
||||
{
|
||||
name: "extra args with known fields",
|
||||
command: "mlx_lm.server --model /path/to/model --port 8080 --unknown-flag value --new-bool-flag",
|
||||
expectErr: false,
|
||||
validate: func(t *testing.T, opts *backends.MlxServerOptions) {
|
||||
if opts.Model != "/path/to/model" {
|
||||
t.Errorf("expected model '/path/to/model', got '%s'", opts.Model)
|
||||
}
|
||||
if opts.Port != 8080 {
|
||||
t.Errorf("expected port 8080, got %d", opts.Port)
|
||||
}
|
||||
if opts.ExtraArgs == nil {
|
||||
t.Fatal("expected extra_args to be non-nil")
|
||||
}
|
||||
if val, ok := opts.ExtraArgs["unknown_flag"]; !ok || val != "value" {
|
||||
t.Errorf("expected extra_args[unknown_flag]='value', got '%s'", val)
|
||||
}
|
||||
if val, ok := opts.ExtraArgs["new_bool_flag"]; !ok || val != "true" {
|
||||
t.Errorf("expected extra_args[new_bool_flag]='true', got '%s'", val)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "only extra args",
|
||||
command: "mlx_lm.server --experimental-feature --custom-param test",
|
||||
expectErr: false,
|
||||
validate: func(t *testing.T, opts *backends.MlxServerOptions) {
|
||||
if opts.ExtraArgs == nil {
|
||||
t.Fatal("expected extra_args to be non-nil")
|
||||
}
|
||||
if val, ok := opts.ExtraArgs["experimental_feature"]; !ok || val != "true" {
|
||||
t.Errorf("expected extra_args[experimental_feature]='true', got '%s'", val)
|
||||
}
|
||||
if val, ok := opts.ExtraArgs["custom_param"]; !ok || val != "test" {
|
||||
t.Errorf("expected extra_args[custom_param]='test', got '%s'", val)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var opts backends.MlxServerOptions
|
||||
result, err := opts.ParseCommand(tt.command)
|
||||
|
||||
if tt.expectErr && err == nil {
|
||||
t.Error("expected error but got none")
|
||||
return
|
||||
}
|
||||
if !tt.expectErr && err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.expectErr && tt.validate != nil {
|
||||
mlxOpts, ok := result.(*backends.MlxServerOptions)
|
||||
if !ok {
|
||||
t.Fatal("result is not *MlxServerOptions")
|
||||
}
|
||||
tt.validate(t, mlxOpts)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestMlxGetCommand_NoDocker(t *testing.T) {
|
||||
// MLX backend should never use Docker
|
||||
backendConfig := &config.BackendConfig{
|
||||
MLX: config.BackendSettings{
|
||||
Command: "/usr/bin/mlx-server",
|
||||
Docker: &config.DockerSettings{
|
||||
Enabled: true, // Even if enabled in config
|
||||
Image: "test-image",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
opts := backends.Options{
|
||||
BackendType: backends.BackendTypeMlxLm,
|
||||
MlxServerOptions: &backends.MlxServerOptions{
|
||||
Model: "test-model",
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
dockerEnabled *bool
|
||||
commandOverride string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "ignores docker in config",
|
||||
dockerEnabled: nil,
|
||||
commandOverride: "",
|
||||
expected: "/usr/bin/mlx-server",
|
||||
},
|
||||
{
|
||||
name: "ignores docker override",
|
||||
dockerEnabled: boolPtr(true),
|
||||
commandOverride: "",
|
||||
expected: "/usr/bin/mlx-server",
|
||||
},
|
||||
{
|
||||
name: "respects command override",
|
||||
dockerEnabled: nil,
|
||||
commandOverride: "/custom/mlx-server",
|
||||
expected: "/custom/mlx-server",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := opts.GetCommand(backendConfig, tt.dockerEnabled, tt.commandOverride)
|
||||
if result != tt.expected {
|
||||
t.Errorf("GetCommand() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -211,3 +212,65 @@ func parseValue(value string) any {
|
||||
// Return as string
|
||||
return value
|
||||
}
|
||||
|
||||
// setFieldValue sets a field value using reflection, handling type conversions
|
||||
// Used by UnmarshalJSON implementations to handle alternative field names
|
||||
func setFieldValue(field reflect.Value, value any) {
|
||||
switch field.Kind() {
|
||||
case reflect.Int:
|
||||
if intVal, ok := value.(float64); ok {
|
||||
field.SetInt(int64(intVal))
|
||||
} else if strVal, ok := value.(string); ok {
|
||||
if intVal, err := strconv.Atoi(strVal); err == nil {
|
||||
field.SetInt(int64(intVal))
|
||||
}
|
||||
}
|
||||
case reflect.Float64:
|
||||
if floatVal, ok := value.(float64); ok {
|
||||
field.SetFloat(floatVal)
|
||||
} else if strVal, ok := value.(string); ok {
|
||||
if floatVal, err := strconv.ParseFloat(strVal, 64); err == nil {
|
||||
field.SetFloat(floatVal)
|
||||
}
|
||||
}
|
||||
case reflect.String:
|
||||
if strVal, ok := value.(string); ok {
|
||||
field.SetString(strVal)
|
||||
}
|
||||
case reflect.Bool:
|
||||
if boolVal, ok := value.(bool); ok {
|
||||
field.SetBool(boolVal)
|
||||
}
|
||||
case reflect.Slice:
|
||||
// Handle string slices
|
||||
if field.Type().Elem().Kind() == reflect.String {
|
||||
if slice, ok := value.([]any); ok {
|
||||
strSlice := make([]string, 0, len(slice))
|
||||
for _, v := range slice {
|
||||
if s, ok := v.(string); ok {
|
||||
strSlice = append(strSlice, s)
|
||||
}
|
||||
}
|
||||
field.Set(reflect.ValueOf(strSlice))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getKnownFieldNames extracts all known field names from struct json tags
|
||||
// Used by UnmarshalJSON implementations to identify unknown fields for ExtraArgs
|
||||
func getKnownFieldNames(v any) map[string]bool {
|
||||
fields := make(map[string]bool)
|
||||
t := reflect.TypeOf(v).Elem()
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
jsonTag := field.Tag.Get("json")
|
||||
if jsonTag != "" && jsonTag != "-" {
|
||||
// Handle "name,omitempty" format
|
||||
name := strings.Split(jsonTag, ",")[0]
|
||||
fields[name] = true
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package backends
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"llamactl/pkg/validation"
|
||||
)
|
||||
@@ -142,6 +143,46 @@ type VllmServerOptions struct {
|
||||
OverridePoolingConfig string `json:"override_pooling_config,omitempty"`
|
||||
OverrideNeuronConfig string `json:"override_neuron_config,omitempty"`
|
||||
OverrideKVCacheALIGNSize int `json:"override_kv_cache_align_size,omitempty"`
|
||||
|
||||
// ExtraArgs are additional command line arguments.
|
||||
// Example: {"verbose": "", "log-file": "/logs/vllm.log"}
|
||||
ExtraArgs map[string]string `json:"extra_args,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom JSON unmarshaling to collect unknown fields into ExtraArgs
|
||||
func (o *VllmServerOptions) UnmarshalJSON(data []byte) error {
|
||||
// First unmarshal into a map to capture all fields
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create a temporary struct for standard unmarshaling
|
||||
type tempOptions VllmServerOptions
|
||||
temp := tempOptions{}
|
||||
|
||||
// Standard unmarshal first
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Copy to our struct
|
||||
*o = VllmServerOptions(temp)
|
||||
|
||||
// Get all known canonical field names from struct tags
|
||||
knownFields := getKnownFieldNames(o)
|
||||
|
||||
// Collect unknown fields into ExtraArgs
|
||||
if o.ExtraArgs == nil {
|
||||
o.ExtraArgs = make(map[string]string)
|
||||
}
|
||||
for key, value := range raw {
|
||||
if !knownFields[key] {
|
||||
o.ExtraArgs[key] = fmt.Sprintf("%v", value)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *VllmServerOptions) GetPort() int {
|
||||
@@ -171,6 +212,18 @@ func (o *VllmServerOptions) Validate() error {
|
||||
return validation.ValidationError(fmt.Errorf("invalid port range: %d", o.Port))
|
||||
}
|
||||
|
||||
// Validate extra_args keys and values
|
||||
for key, value := range o.ExtraArgs {
|
||||
if err := validation.ValidateStringForInjection(key); err != nil {
|
||||
return validation.ValidationError(fmt.Errorf("extra_args key %q: %w", key, err))
|
||||
}
|
||||
if value != "" {
|
||||
if err := validation.ValidateStringForInjection(value); err != nil {
|
||||
return validation.ValidationError(fmt.Errorf("extra_args value for %q: %w", key, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -193,6 +246,9 @@ func (o *VllmServerOptions) BuildCommandArgs() []string {
|
||||
flagArgs := BuildCommandArgs(&optionsCopy, vllmMultiValuedFlags)
|
||||
args = append(args, flagArgs...)
|
||||
|
||||
// Append extra args at the end
|
||||
args = append(args, convertExtraArgsToFlags(o.ExtraArgs)...)
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -203,6 +259,9 @@ func (o *VllmServerOptions) BuildDockerArgs() []string {
|
||||
flagArgs := BuildCommandArgs(o, vllmMultiValuedFlags)
|
||||
args = append(args, flagArgs...)
|
||||
|
||||
// Append extra args at the end
|
||||
args = append(args, convertExtraArgsToFlags(o.ExtraArgs)...)
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
|
||||
@@ -321,3 +321,94 @@ func TestVllmBuildCommandArgs_PositionalModel(t *testing.T) {
|
||||
t.Errorf("Expected --port 8080 not found in %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVllmCommand_ExtraArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
command string
|
||||
expectErr bool
|
||||
validate func(*testing.T, *backends.VllmServerOptions)
|
||||
}{
|
||||
{
|
||||
name: "extra args with known fields",
|
||||
command: "vllm serve llama-model --tensor-parallel-size 2 --unknown-flag value --new-bool-flag",
|
||||
expectErr: false,
|
||||
validate: func(t *testing.T, opts *backends.VllmServerOptions) {
|
||||
if opts.Model != "llama-model" {
|
||||
t.Errorf("expected model 'llama-model', got '%s'", opts.Model)
|
||||
}
|
||||
if opts.TensorParallelSize != 2 {
|
||||
t.Errorf("expected tensor_parallel_size 2, got %d", opts.TensorParallelSize)
|
||||
}
|
||||
if opts.ExtraArgs == nil {
|
||||
t.Fatal("expected extra_args to be non-nil")
|
||||
}
|
||||
if val, ok := opts.ExtraArgs["unknown_flag"]; !ok || val != "value" {
|
||||
t.Errorf("expected extra_args[unknown_flag]='value', got '%s'", val)
|
||||
}
|
||||
if val, ok := opts.ExtraArgs["new_bool_flag"]; !ok || val != "true" {
|
||||
t.Errorf("expected extra_args[new_bool_flag]='true', got '%s'", val)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "only extra args",
|
||||
command: "vllm serve model --experimental-feature --custom-param test",
|
||||
expectErr: false,
|
||||
validate: func(t *testing.T, opts *backends.VllmServerOptions) {
|
||||
if opts.ExtraArgs == nil {
|
||||
t.Fatal("expected extra_args to be non-nil")
|
||||
}
|
||||
if val, ok := opts.ExtraArgs["experimental_feature"]; !ok || val != "true" {
|
||||
t.Errorf("expected extra_args[experimental_feature]='true', got '%s'", val)
|
||||
}
|
||||
if val, ok := opts.ExtraArgs["custom_param"]; !ok || val != "test" {
|
||||
t.Errorf("expected extra_args[custom_param]='test', got '%s'", val)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "extra args without model positional",
|
||||
command: "vllm serve --model my-model --new-feature enabled --beta-flag",
|
||||
expectErr: false,
|
||||
validate: func(t *testing.T, opts *backends.VllmServerOptions) {
|
||||
if opts.Model != "my-model" {
|
||||
t.Errorf("expected model 'my-model', got '%s'", opts.Model)
|
||||
}
|
||||
if opts.ExtraArgs == nil {
|
||||
t.Fatal("expected extra_args to be non-nil")
|
||||
}
|
||||
if val, ok := opts.ExtraArgs["new_feature"]; !ok || val != "enabled" {
|
||||
t.Errorf("expected extra_args[new_feature]='enabled', got '%s'", val)
|
||||
}
|
||||
if val, ok := opts.ExtraArgs["beta_flag"]; !ok || val != "true" {
|
||||
t.Errorf("expected extra_args[beta_flag]='true', got '%s'", val)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var opts backends.VllmServerOptions
|
||||
result, err := opts.ParseCommand(tt.command)
|
||||
|
||||
if tt.expectErr && err == nil {
|
||||
t.Error("expected error but got none")
|
||||
return
|
||||
}
|
||||
if !tt.expectErr && err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.expectErr && tt.validate != nil {
|
||||
vllmOpts, ok := result.(*backends.VllmServerOptions)
|
||||
if !ok {
|
||||
t.Fatal("result is not *VllmServerOptions")
|
||||
}
|
||||
tt.validate(t, vllmOpts)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -13,126 +15,126 @@ import (
|
||||
|
||||
// BackendSettings contains structured backend configuration
|
||||
type BackendSettings struct {
|
||||
Command string `yaml:"command"`
|
||||
Args []string `yaml:"args"`
|
||||
Environment map[string]string `yaml:"environment,omitempty"`
|
||||
Docker *DockerSettings `yaml:"docker,omitempty"`
|
||||
ResponseHeaders map[string]string `yaml:"response_headers,omitempty"`
|
||||
Command string `yaml:"command" json:"command"`
|
||||
Args []string `yaml:"args" json:"args"`
|
||||
Environment map[string]string `yaml:"environment,omitempty" json:"environment,omitempty"`
|
||||
Docker *DockerSettings `yaml:"docker,omitempty" json:"docker,omitempty"`
|
||||
ResponseHeaders map[string]string `yaml:"response_headers,omitempty" json:"response_headers,omitempty"`
|
||||
}
|
||||
|
||||
// DockerSettings contains Docker-specific configuration
|
||||
type DockerSettings struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Image string `yaml:"image"`
|
||||
Args []string `yaml:"args"`
|
||||
Environment map[string]string `yaml:"environment,omitempty"`
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
Image string `yaml:"image" json:"image"`
|
||||
Args []string `yaml:"args" json:"args"`
|
||||
Environment map[string]string `yaml:"environment,omitempty" json:"environment,omitempty"`
|
||||
}
|
||||
|
||||
// BackendConfig contains backend executable configurations
|
||||
type BackendConfig struct {
|
||||
LlamaCpp BackendSettings `yaml:"llama-cpp"`
|
||||
VLLM BackendSettings `yaml:"vllm"`
|
||||
MLX BackendSettings `yaml:"mlx"`
|
||||
LlamaCpp BackendSettings `yaml:"llama-cpp" json:"llama-cpp"`
|
||||
VLLM BackendSettings `yaml:"vllm" json:"vllm"`
|
||||
MLX BackendSettings `yaml:"mlx" json:"mlx"`
|
||||
}
|
||||
|
||||
// AppConfig represents the configuration for llamactl
|
||||
type AppConfig struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Backends BackendConfig `yaml:"backends"`
|
||||
Instances InstancesConfig `yaml:"instances"`
|
||||
Auth AuthConfig `yaml:"auth"`
|
||||
LocalNode string `yaml:"local_node,omitempty"`
|
||||
Nodes map[string]NodeConfig `yaml:"nodes,omitempty"`
|
||||
Version string `yaml:"-"`
|
||||
CommitHash string `yaml:"-"`
|
||||
BuildTime string `yaml:"-"`
|
||||
Server ServerConfig `yaml:"server" json:"server"`
|
||||
Backends BackendConfig `yaml:"backends" json:"backends"`
|
||||
Instances InstancesConfig `yaml:"instances" json:"instances"`
|
||||
Auth AuthConfig `yaml:"auth" json:"auth"`
|
||||
LocalNode string `yaml:"local_node,omitempty" json:"local_node,omitempty"`
|
||||
Nodes map[string]NodeConfig `yaml:"nodes,omitempty" json:"nodes,omitempty"`
|
||||
Version string `yaml:"-" json:"version"`
|
||||
CommitHash string `yaml:"-" json:"commit_hash"`
|
||||
BuildTime string `yaml:"-" json:"build_time"`
|
||||
}
|
||||
|
||||
// ServerConfig contains HTTP server configuration
|
||||
type ServerConfig struct {
|
||||
// Server host to bind to
|
||||
Host string `yaml:"host"`
|
||||
Host string `yaml:"host" json:"host"`
|
||||
|
||||
// Server port to bind to
|
||||
Port int `yaml:"port"`
|
||||
Port int `yaml:"port" json:"port"`
|
||||
|
||||
// Allowed origins for CORS (e.g., "http://localhost:3000")
|
||||
AllowedOrigins []string `yaml:"allowed_origins"`
|
||||
AllowedOrigins []string `yaml:"allowed_origins" json:"allowed_origins"`
|
||||
|
||||
// Allowed headers for CORS (e.g., "Accept", "Authorization", "Content-Type", "X-CSRF-Token")
|
||||
AllowedHeaders []string `yaml:"allowed_headers"`
|
||||
AllowedHeaders []string `yaml:"allowed_headers" json:"allowed_headers"`
|
||||
|
||||
// Enable Swagger UI for API documentation
|
||||
EnableSwagger bool `yaml:"enable_swagger"`
|
||||
EnableSwagger bool `yaml:"enable_swagger" json:"enable_swagger"`
|
||||
|
||||
// Response headers to send with responses
|
||||
ResponseHeaders map[string]string `yaml:"response_headers,omitempty"`
|
||||
ResponseHeaders map[string]string `yaml:"response_headers,omitempty" json:"response_headers,omitempty"`
|
||||
}
|
||||
|
||||
// InstancesConfig contains instance management configuration
|
||||
type InstancesConfig struct {
|
||||
// Port range for instances (e.g., 8000,9000)
|
||||
PortRange [2]int `yaml:"port_range"`
|
||||
PortRange [2]int `yaml:"port_range" json:"port_range"`
|
||||
|
||||
// Directory where all llamactl data will be stored (instances.json, logs, etc.)
|
||||
DataDir string `yaml:"data_dir"`
|
||||
DataDir string `yaml:"data_dir" json:"data_dir"`
|
||||
|
||||
// Instance config directory override
|
||||
InstancesDir string `yaml:"configs_dir"`
|
||||
InstancesDir string `yaml:"configs_dir" json:"configs_dir"`
|
||||
|
||||
// Logs directory override
|
||||
LogsDir string `yaml:"logs_dir"`
|
||||
LogsDir string `yaml:"logs_dir" json:"logs_dir"`
|
||||
|
||||
// Automatically create the data directory if it doesn't exist
|
||||
AutoCreateDirs bool `yaml:"auto_create_dirs"`
|
||||
AutoCreateDirs bool `yaml:"auto_create_dirs" json:"auto_create_dirs"`
|
||||
|
||||
// Maximum number of instances that can be created
|
||||
MaxInstances int `yaml:"max_instances"`
|
||||
MaxInstances int `yaml:"max_instances" json:"max_instances"`
|
||||
|
||||
// Maximum number of instances that can be running at the same time
|
||||
MaxRunningInstances int `yaml:"max_running_instances,omitempty"`
|
||||
MaxRunningInstances int `yaml:"max_running_instances,omitempty" json:"max_running_instances,omitempty"`
|
||||
|
||||
// Enable LRU eviction for instance logs
|
||||
EnableLRUEviction bool `yaml:"enable_lru_eviction"`
|
||||
EnableLRUEviction bool `yaml:"enable_lru_eviction" json:"enable_lru_eviction"`
|
||||
|
||||
// Default auto-restart setting for new instances
|
||||
DefaultAutoRestart bool `yaml:"default_auto_restart"`
|
||||
DefaultAutoRestart bool `yaml:"default_auto_restart" json:"default_auto_restart"`
|
||||
|
||||
// Default max restarts for new instances
|
||||
DefaultMaxRestarts int `yaml:"default_max_restarts"`
|
||||
DefaultMaxRestarts int `yaml:"default_max_restarts" json:"default_max_restarts"`
|
||||
|
||||
// Default restart delay for new instances (in seconds)
|
||||
DefaultRestartDelay int `yaml:"default_restart_delay"`
|
||||
DefaultRestartDelay int `yaml:"default_restart_delay" json:"default_restart_delay"`
|
||||
|
||||
// Default on-demand start setting for new instances
|
||||
DefaultOnDemandStart bool `yaml:"default_on_demand_start"`
|
||||
DefaultOnDemandStart bool `yaml:"default_on_demand_start" json:"default_on_demand_start"`
|
||||
|
||||
// How long to wait for an instance to start on demand (in seconds)
|
||||
OnDemandStartTimeout int `yaml:"on_demand_start_timeout,omitempty"`
|
||||
OnDemandStartTimeout int `yaml:"on_demand_start_timeout,omitempty" json:"on_demand_start_timeout,omitempty"`
|
||||
|
||||
// Interval for checking instance timeouts (in minutes)
|
||||
TimeoutCheckInterval int `yaml:"timeout_check_interval"`
|
||||
TimeoutCheckInterval int `yaml:"timeout_check_interval" json:"timeout_check_interval"`
|
||||
}
|
||||
|
||||
// AuthConfig contains authentication settings
|
||||
type AuthConfig struct {
|
||||
|
||||
// Require authentication for OpenAI compatible inference endpoints
|
||||
RequireInferenceAuth bool `yaml:"require_inference_auth"`
|
||||
RequireInferenceAuth bool `yaml:"require_inference_auth" json:"require_inference_auth"`
|
||||
|
||||
// List of keys for OpenAI compatible inference endpoints
|
||||
InferenceKeys []string `yaml:"inference_keys"`
|
||||
InferenceKeys []string `yaml:"inference_keys" json:"inference_keys"`
|
||||
|
||||
// Require authentication for management endpoints
|
||||
RequireManagementAuth bool `yaml:"require_management_auth"`
|
||||
RequireManagementAuth bool `yaml:"require_management_auth" json:"require_management_auth"`
|
||||
|
||||
// List of keys for management endpoints
|
||||
ManagementKeys []string `yaml:"management_keys"`
|
||||
ManagementKeys []string `yaml:"management_keys" json:"management_keys"`
|
||||
}
|
||||
|
||||
type NodeConfig struct {
|
||||
Address string `yaml:"address"`
|
||||
APIKey string `yaml:"api_key,omitempty"`
|
||||
Address string `yaml:"address" json:"address"`
|
||||
APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"`
|
||||
}
|
||||
|
||||
// LoadConfig loads configuration with the following precedence:
|
||||
@@ -231,6 +233,11 @@ func LoadConfig(configPath string) (AppConfig, error) {
|
||||
cfg.Instances.LogsDir = filepath.Join(cfg.Instances.DataDir, "logs")
|
||||
}
|
||||
|
||||
// Validate port range
|
||||
if cfg.Instances.PortRange[0] <= 0 || cfg.Instances.PortRange[1] <= 0 || cfg.Instances.PortRange[0] >= cfg.Instances.PortRange[1] {
|
||||
return AppConfig{}, fmt.Errorf("invalid port range: %v", cfg.Instances.PortRange)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@@ -604,3 +611,31 @@ func getDefaultConfigLocations() []string {
|
||||
|
||||
return locations
|
||||
}
|
||||
|
||||
// SanitizedCopy returns a copy of the AppConfig with sensitive information removed
|
||||
func (cfg *AppConfig) SanitizedCopy() (AppConfig, error) {
|
||||
// Deep copy via JSON marshal/unmarshal to avoid concurrent map access
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
log.Printf("Failed to marshal config for sanitization: %v", err)
|
||||
return AppConfig{}, err
|
||||
}
|
||||
|
||||
var sanitized AppConfig
|
||||
if err := json.Unmarshal(data, &sanitized); err != nil {
|
||||
log.Printf("Failed to unmarshal config for sanitization: %v", err)
|
||||
return AppConfig{}, err
|
||||
}
|
||||
|
||||
// Clear sensitive information
|
||||
sanitized.Auth.InferenceKeys = []string{}
|
||||
sanitized.Auth.ManagementKeys = []string{}
|
||||
|
||||
// Clear API keys from nodes
|
||||
for nodeName, node := range sanitized.Nodes {
|
||||
node.APIKey = ""
|
||||
sanitized.Nodes[nodeName] = node
|
||||
}
|
||||
|
||||
return sanitized, nil
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
"llamactl/pkg/config"
|
||||
"log"
|
||||
"net/http/httputil"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -182,15 +182,6 @@ func (i *Instance) GetPort() int {
|
||||
return i.options.GetPort()
|
||||
}
|
||||
|
||||
// GetProxy returns the reverse proxy for this instance
|
||||
func (i *Instance) GetProxy() (*httputil.ReverseProxy, error) {
|
||||
if i.proxy == nil {
|
||||
return nil, fmt.Errorf("instance %s has no proxy component", i.Name)
|
||||
}
|
||||
|
||||
return i.proxy.get()
|
||||
}
|
||||
|
||||
func (i *Instance) IsRemote() bool {
|
||||
opts := i.GetOptions()
|
||||
if opts == nil {
|
||||
@@ -242,13 +233,29 @@ func (i *Instance) ShouldTimeout() bool {
|
||||
return i.proxy.shouldTimeout()
|
||||
}
|
||||
|
||||
// GetInflightRequests returns the current number of inflight requests
|
||||
func (i *Instance) GetInflightRequests() int32 {
|
||||
if i.proxy == nil {
|
||||
return 0
|
||||
}
|
||||
return i.proxy.getInflightRequests()
|
||||
}
|
||||
|
||||
// ServeHTTP serves HTTP requests through the proxy with request tracking and shutdown handling
|
||||
func (i *Instance) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
|
||||
if i.proxy == nil {
|
||||
return fmt.Errorf("instance %s has no proxy component", i.Name)
|
||||
}
|
||||
return i.proxy.serveHTTP(w, r)
|
||||
}
|
||||
|
||||
func (i *Instance) getCommand() string {
|
||||
opts := i.GetOptions()
|
||||
if opts == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return opts.BackendOptions.GetCommand(i.globalBackendSettings)
|
||||
return opts.BackendOptions.GetCommand(i.globalBackendSettings, opts.DockerEnabled, opts.CommandOverride)
|
||||
}
|
||||
|
||||
func (i *Instance) buildCommandArgs() []string {
|
||||
@@ -257,7 +264,7 @@ func (i *Instance) buildCommandArgs() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
return opts.BackendOptions.BuildCommandArgs(i.globalBackendSettings)
|
||||
return opts.BackendOptions.BuildCommandArgs(i.globalBackendSettings, opts.DockerEnabled)
|
||||
}
|
||||
|
||||
func (i *Instance) buildEnvironment() map[string]string {
|
||||
@@ -266,29 +273,21 @@ func (i *Instance) buildEnvironment() map[string]string {
|
||||
return nil
|
||||
}
|
||||
|
||||
return opts.BackendOptions.BuildEnvironment(i.globalBackendSettings, opts.Environment)
|
||||
return opts.BackendOptions.BuildEnvironment(i.globalBackendSettings, opts.DockerEnabled, opts.Environment)
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler for Instance
|
||||
func (i *Instance) MarshalJSON() ([]byte, error) {
|
||||
// Get options
|
||||
opts := i.GetOptions()
|
||||
|
||||
// Determine if docker is enabled for this instance's backend
|
||||
dockerEnabled := opts.BackendOptions.IsDockerEnabled(i.globalBackendSettings)
|
||||
|
||||
return json.Marshal(&struct {
|
||||
Name string `json:"name"`
|
||||
Status *status `json:"status"`
|
||||
Created int64 `json:"created,omitempty"`
|
||||
Options *options `json:"options,omitempty"`
|
||||
DockerEnabled bool `json:"docker_enabled,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Status *status `json:"status"`
|
||||
Created int64 `json:"created,omitempty"`
|
||||
Options *options `json:"options,omitempty"`
|
||||
}{
|
||||
Name: i.Name,
|
||||
Status: i.status,
|
||||
Created: i.Created,
|
||||
Options: i.options,
|
||||
DockerEnabled: dockerEnabled,
|
||||
Name: i.Name,
|
||||
Status: i.status,
|
||||
Created: i.Created,
|
||||
Options: i.options,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -171,64 +171,6 @@ func TestSetOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProxy(t *testing.T) {
|
||||
globalConfig := &config.AppConfig{
|
||||
Backends: config.BackendConfig{
|
||||
LlamaCpp: config.BackendSettings{
|
||||
Command: "llama-server",
|
||||
Args: []string{},
|
||||
},
|
||||
MLX: config.BackendSettings{
|
||||
Command: "mlx_lm.server",
|
||||
Args: []string{},
|
||||
},
|
||||
VLLM: config.BackendSettings{
|
||||
Command: "vllm",
|
||||
Args: []string{"serve"},
|
||||
},
|
||||
},
|
||||
Instances: config.InstancesConfig{
|
||||
LogsDir: "/tmp/test",
|
||||
},
|
||||
Nodes: map[string]config.NodeConfig{},
|
||||
LocalNode: "main",
|
||||
}
|
||||
|
||||
options := &instance.Options{
|
||||
Nodes: map[string]struct{}{"main": {}},
|
||||
BackendOptions: backends.Options{
|
||||
BackendType: backends.BackendTypeLlamaCpp,
|
||||
LlamaServerOptions: &backends.LlamaServerOptions{
|
||||
Host: "localhost",
|
||||
Port: 8080,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Mock onStatusChange function
|
||||
mockOnStatusChange := func(oldStatus, newStatus instance.Status) {}
|
||||
|
||||
inst := instance.New("test-instance", globalConfig, options, mockOnStatusChange)
|
||||
|
||||
// Get proxy for the first time
|
||||
proxy1, err := inst.GetProxy()
|
||||
if err != nil {
|
||||
t.Fatalf("GetProxy failed: %v", err)
|
||||
}
|
||||
if proxy1 == nil {
|
||||
t.Error("Expected proxy to be created")
|
||||
}
|
||||
|
||||
// Get proxy again - should return cached version
|
||||
proxy2, err := inst.GetProxy()
|
||||
if err != nil {
|
||||
t.Fatalf("GetProxy failed: %v", err)
|
||||
}
|
||||
if proxy1 != proxy2 {
|
||||
t.Error("Expected cached proxy to be returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalJSON(t *testing.T) {
|
||||
globalConfig := &config.AppConfig{
|
||||
Backends: config.BackendConfig{
|
||||
@@ -613,11 +555,6 @@ func TestRemoteInstanceOperations(t *testing.T) {
|
||||
t.Error("Expected error when restarting remote instance")
|
||||
}
|
||||
|
||||
// GetProxy should fail for remote instance
|
||||
if _, err := inst.GetProxy(); err != nil {
|
||||
t.Error("Expected no error when getting proxy for remote instance")
|
||||
}
|
||||
|
||||
// GetLogs should fail for remote instance
|
||||
if _, err := inst.GetLogs(10); err == nil {
|
||||
t.Error("Expected error when getting logs for remote instance")
|
||||
|
||||
@@ -7,13 +7,14 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type logger struct {
|
||||
name string
|
||||
logDir string
|
||||
logFile *os.File
|
||||
logFile atomic.Pointer[os.File]
|
||||
logFilePath string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
@@ -47,11 +48,11 @@ func (i *logger) create() error {
|
||||
return fmt.Errorf("failed to create stdout log file: %w", err)
|
||||
}
|
||||
|
||||
i.logFile = logFile
|
||||
i.logFile.Store(logFile)
|
||||
|
||||
// Write a startup marker to both files
|
||||
timestamp := time.Now().Format("2006-01-02 15:04:05")
|
||||
fmt.Fprintf(i.logFile, "\n=== Instance %s started at %s ===\n", i.name, timestamp)
|
||||
fmt.Fprintf(logFile, "\n=== Instance %s started at %s ===\n", i.name, timestamp)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -102,11 +103,12 @@ func (i *logger) close() {
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
|
||||
if i.logFile != nil {
|
||||
logFile := i.logFile.Swap(nil)
|
||||
if logFile != nil {
|
||||
timestamp := time.Now().Format("2006-01-02 15:04:05")
|
||||
fmt.Fprintf(i.logFile, "=== Instance %s stopped at %s ===\n\n", i.name, timestamp)
|
||||
i.logFile.Close()
|
||||
i.logFile = nil
|
||||
fmt.Fprintf(logFile, "=== Instance %s stopped at %s ===\n\n", i.name, timestamp)
|
||||
logFile.Sync() // Ensure all buffered data is written to disk
|
||||
logFile.Close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,9 +119,9 @@ func (i *logger) readOutput(reader io.ReadCloser) {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if i.logFile != nil {
|
||||
fmt.Fprintln(i.logFile, line)
|
||||
i.logFile.Sync() // Ensure data is written to disk
|
||||
// Use atomic load to avoid lock contention on every line
|
||||
if logFile := i.logFile.Load(); logFile != nil {
|
||||
fmt.Fprintln(logFile, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"fmt"
|
||||
"llamactl/pkg/backends"
|
||||
"llamactl/pkg/config"
|
||||
"llamactl/pkg/validation"
|
||||
"log"
|
||||
"maps"
|
||||
"slices"
|
||||
"sync"
|
||||
)
|
||||
@@ -22,6 +24,11 @@ type Options struct {
|
||||
IdleTimeout *int `json:"idle_timeout,omitempty"` // minutes
|
||||
// Environment variables
|
||||
Environment map[string]string `json:"environment,omitempty"`
|
||||
|
||||
// Execution context overrides
|
||||
DockerEnabled *bool `json:"docker_enabled,omitempty"`
|
||||
CommandOverride string `json:"command_override,omitempty"`
|
||||
|
||||
// Assigned nodes
|
||||
Nodes map[string]struct{} `json:"-"`
|
||||
// Backend options
|
||||
@@ -138,15 +145,25 @@ func (c *Options) UnmarshalJSON(data []byte) error {
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling for Options
|
||||
func (c *Options) MarshalJSON() ([]byte, error) {
|
||||
// Use anonymous struct to avoid recursion
|
||||
type Alias Options
|
||||
aux := struct {
|
||||
|
||||
// Make a copy of the struct
|
||||
temp := *c
|
||||
|
||||
// Copy environment map to avoid concurrent access issues
|
||||
if temp.Environment != nil {
|
||||
envCopy := make(map[string]string, len(temp.Environment))
|
||||
maps.Copy(envCopy, temp.Environment)
|
||||
temp.Environment = envCopy
|
||||
}
|
||||
|
||||
aux := &struct {
|
||||
Nodes []string `json:"nodes,omitempty"` // Output as JSON array
|
||||
BackendType backends.BackendType `json:"backend_type"`
|
||||
BackendOptions map[string]any `json:"backend_options,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
Alias: (*Alias)(c),
|
||||
Alias: (*Alias)(&temp),
|
||||
}
|
||||
|
||||
// Convert nodes map to array (sorted for consistency)
|
||||
@@ -163,13 +180,12 @@ func (c *Options) MarshalJSON() ([]byte, error) {
|
||||
aux.BackendType = c.BackendOptions.BackendType
|
||||
|
||||
// Marshal the backends.Options struct to get the properly formatted backend options
|
||||
// Marshal a pointer to trigger the pointer receiver MarshalJSON method
|
||||
backendData, err := json.Marshal(&c.BackendOptions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal backend options: %w", err)
|
||||
}
|
||||
|
||||
// Unmarshal into a temporary struct to extract the backend_options map
|
||||
// Unmarshal into a new temporary map to extract the backend_options
|
||||
var tempBackend struct {
|
||||
BackendOptions map[string]any `json:"backend_options,omitempty"`
|
||||
}
|
||||
@@ -200,6 +216,28 @@ func (c *Options) validateAndApplyDefaults(name string, globalSettings *config.I
|
||||
*c.IdleTimeout = 0
|
||||
}
|
||||
|
||||
// Validate docker_enabled and command_override relationship
|
||||
if c.DockerEnabled != nil && *c.DockerEnabled && c.CommandOverride != "" {
|
||||
log.Printf("Instance %s: command_override cannot be set when docker_enabled is true, ignoring command_override", name)
|
||||
c.CommandOverride = "" // Clear invalid configuration
|
||||
}
|
||||
|
||||
// Validate command_override if set
|
||||
if c.CommandOverride != "" {
|
||||
if err := validation.ValidateStringForInjection(c.CommandOverride); err != nil {
|
||||
log.Printf("Instance %s: invalid command_override: %v, clearing value", name, err)
|
||||
c.CommandOverride = "" // Clear invalid value
|
||||
}
|
||||
}
|
||||
|
||||
// Validate docker_enabled for MLX backend
|
||||
if c.BackendOptions.BackendType == backends.BackendTypeMlxLm {
|
||||
if c.DockerEnabled != nil && *c.DockerEnabled {
|
||||
log.Printf("Instance %s: docker_enabled is not supported for MLX backend, ignoring", name)
|
||||
c.DockerEnabled = nil // Clear invalid configuration
|
||||
}
|
||||
}
|
||||
|
||||
// Apply defaults from global settings for nil fields
|
||||
if globalSettings != nil {
|
||||
if c.AutoRestart == nil {
|
||||
|
||||
@@ -132,14 +132,28 @@ func (p *process) stop() error {
|
||||
p.restartCancel = nil
|
||||
}
|
||||
|
||||
// Set status to stopped first to signal intentional stop
|
||||
p.instance.SetStatus(Stopped)
|
||||
// Set status to ShuttingDown first to reject new requests
|
||||
p.instance.SetStatus(ShuttingDown)
|
||||
|
||||
// Get the monitor done channel before releasing the lock
|
||||
monitorDone := p.monitorDone
|
||||
|
||||
p.mu.Unlock()
|
||||
|
||||
// Wait for inflight requests to complete (max 30 seconds)
|
||||
log.Printf("Instance %s shutting down, waiting for inflight requests to complete...", p.instance.Name)
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
inflight := p.instance.GetInflightRequests()
|
||||
if inflight == 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Now set status to stopped to signal intentional stop
|
||||
p.instance.SetStatus(Stopped)
|
||||
|
||||
// Stop the process with SIGINT if cmd exists
|
||||
if p.cmd != nil && p.cmd.Process != nil {
|
||||
if err := p.cmd.Process.Signal(syscall.SIGINT); err != nil {
|
||||
@@ -156,6 +170,7 @@ func (p *process) stop() error {
|
||||
select {
|
||||
case <-monitorDone:
|
||||
// Process exited normally
|
||||
log.Printf("Instance %s shut down gracefully", p.instance.Name)
|
||||
case <-time.After(30 * time.Second):
|
||||
// Force kill if it doesn't exit within 30 seconds
|
||||
if p.cmd != nil && p.cmd.Process != nil {
|
||||
|
||||
@@ -37,8 +37,9 @@ type proxy struct {
|
||||
proxyOnce sync.Once
|
||||
proxyErr error
|
||||
|
||||
lastRequestTime atomic.Int64
|
||||
timeProvider TimeProvider
|
||||
lastRequestTime atomic.Int64
|
||||
inflightRequests atomic.Int32
|
||||
timeProvider TimeProvider
|
||||
}
|
||||
|
||||
// newProxy creates a new Proxy for the given instance
|
||||
@@ -153,6 +154,23 @@ func (p *proxy) build() (*httputil.ReverseProxy, error) {
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
// serveHTTP handles HTTP requests with inflight tracking
|
||||
func (p *proxy) serveHTTP(w http.ResponseWriter, r *http.Request) error {
|
||||
// Get the reverse proxy
|
||||
reverseProxy, err := p.get()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Track inflight requests
|
||||
p.incInflightRequests()
|
||||
defer p.decInflightRequests()
|
||||
|
||||
// Serve the request
|
||||
reverseProxy.ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
// clear resets the proxy, allowing it to be recreated when options change.
|
||||
func (p *proxy) clear() {
|
||||
p.mu.Lock()
|
||||
@@ -160,7 +178,7 @@ func (p *proxy) clear() {
|
||||
|
||||
p.proxy = nil
|
||||
p.proxyErr = nil
|
||||
p.proxyOnce = sync.Once{} // Reset Once for next GetProxy call
|
||||
p.proxyOnce = sync.Once{}
|
||||
}
|
||||
|
||||
// updateLastRequestTime updates the last request access time for the instance
|
||||
@@ -199,3 +217,18 @@ func (p *proxy) shouldTimeout() bool {
|
||||
func (p *proxy) setTimeProvider(tp TimeProvider) {
|
||||
p.timeProvider = tp
|
||||
}
|
||||
|
||||
// incInflightRequests increments the inflight request counter
|
||||
func (p *proxy) incInflightRequests() {
|
||||
p.inflightRequests.Add(1)
|
||||
}
|
||||
|
||||
// decInflightRequests decrements the inflight request counter
|
||||
func (p *proxy) decInflightRequests() {
|
||||
p.inflightRequests.Add(-1)
|
||||
}
|
||||
|
||||
// getInflightRequests returns the current number of inflight requests
|
||||
func (p *proxy) getInflightRequests() int32 {
|
||||
return p.inflightRequests.Load()
|
||||
}
|
||||
|
||||
@@ -14,20 +14,23 @@ const (
|
||||
Running
|
||||
Failed
|
||||
Restarting
|
||||
ShuttingDown
|
||||
)
|
||||
|
||||
var nameToStatus = map[string]Status{
|
||||
"stopped": Stopped,
|
||||
"running": Running,
|
||||
"failed": Failed,
|
||||
"restarting": Restarting,
|
||||
"stopped": Stopped,
|
||||
"running": Running,
|
||||
"failed": Failed,
|
||||
"restarting": Restarting,
|
||||
"shutting_down": ShuttingDown,
|
||||
}
|
||||
|
||||
var statusToName = map[Status]string{
|
||||
Stopped: "stopped",
|
||||
Running: "running",
|
||||
Failed: "failed",
|
||||
Restarting: "restarting",
|
||||
Stopped: "stopped",
|
||||
Running: "running",
|
||||
Failed: "failed",
|
||||
Restarting: "restarting",
|
||||
ShuttingDown: "shutting_down",
|
||||
}
|
||||
|
||||
// Status enum JSON marshaling methods
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
func TestInstanceTimeoutLogic(t *testing.T) {
|
||||
testManager := createTestManager()
|
||||
testManager := createTestManager(t)
|
||||
defer testManager.Shutdown()
|
||||
|
||||
idleTimeout := 1 // 1 minute
|
||||
@@ -42,7 +42,7 @@ func TestInstanceTimeoutLogic(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestInstanceWithoutTimeoutNeverExpires(t *testing.T) {
|
||||
testManager := createTestManager()
|
||||
testManager := createTestManager(t)
|
||||
defer testManager.Shutdown()
|
||||
|
||||
noTimeoutInst := createInstanceWithTimeout(t, testManager, "no-timeout-test", "/path/to/model.gguf", nil)
|
||||
@@ -64,7 +64,7 @@ func TestInstanceWithoutTimeoutNeverExpires(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEvictLRUInstance_Success(t *testing.T) {
|
||||
manager := createTestManager()
|
||||
manager := createTestManager(t)
|
||||
defer manager.Shutdown()
|
||||
|
||||
// Create 3 instances with idle timeout enabled (value doesn't matter for LRU logic)
|
||||
@@ -121,7 +121,7 @@ func TestEvictLRUInstance_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEvictLRUInstance_NoRunningInstances(t *testing.T) {
|
||||
manager := createTestManager()
|
||||
manager := createTestManager(t)
|
||||
defer manager.Shutdown()
|
||||
|
||||
err := manager.EvictLRUInstance()
|
||||
@@ -134,7 +134,7 @@ func TestEvictLRUInstance_NoRunningInstances(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEvictLRUInstance_OnlyEvictsTimeoutEnabledInstances(t *testing.T) {
|
||||
manager := createTestManager()
|
||||
manager := createTestManager(t)
|
||||
defer manager.Shutdown()
|
||||
|
||||
// Create mix of instances: some with timeout enabled, some disabled
|
||||
|
||||
@@ -54,16 +54,10 @@ func New(globalConfig *config.AppConfig) InstanceManager {
|
||||
|
||||
// Initialize port allocator
|
||||
portRange := globalConfig.Instances.PortRange
|
||||
ports, err := newPortAllocator(portRange[0], portRange[1])
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create port allocator: %v", err)
|
||||
}
|
||||
ports := newPortAllocator(portRange[0], portRange[1])
|
||||
|
||||
// Initialize persistence
|
||||
persistence, err := newInstancePersister(globalConfig.Instances.InstancesDir)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create instance persister: %v", err)
|
||||
}
|
||||
persistence := newInstancePersister(globalConfig.Instances.InstancesDir)
|
||||
|
||||
// Initialize remote manager
|
||||
remote := newRemoteManager(globalConfig.Nodes, 30*time.Second)
|
||||
@@ -116,7 +110,7 @@ func (im *instanceManager) Shutdown() {
|
||||
defer wg.Done()
|
||||
fmt.Printf("Stopping instance %s...\n", inst.Name)
|
||||
if err := inst.Stop(); err != nil {
|
||||
fmt.Printf("Error stopping instance %s: %v\n", inst.Name, err)
|
||||
log.Printf("Error stopping instance %s: %v\n", inst.Name, err)
|
||||
}
|
||||
}(inst)
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ func TestDeleteInstance_RemovesPersistenceFile(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConcurrentAccess(t *testing.T) {
|
||||
mgr := createTestManager()
|
||||
mgr := createTestManager(t)
|
||||
defer mgr.Shutdown()
|
||||
|
||||
// Test concurrent operations
|
||||
@@ -113,7 +113,7 @@ func TestConcurrentAccess(t *testing.T) {
|
||||
}
|
||||
|
||||
// Concurrent list operations
|
||||
for i := 0; i < 3; i++ {
|
||||
for range 3 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
@@ -134,16 +134,17 @@ func TestConcurrentAccess(t *testing.T) {
|
||||
|
||||
// Helper functions for test configuration
|
||||
func createTestAppConfig(instancesDir string) *config.AppConfig {
|
||||
// Use 'sleep' as a test command instead of 'llama-server'
|
||||
// This allows tests to run in CI environments without requiring actual LLM binaries
|
||||
// The sleep command will be invoked with model paths and other args, which it ignores
|
||||
// Use 'sh -c "sleep 999999"' as a test command instead of 'llama-server'
|
||||
// The shell ignores all additional arguments passed after the command
|
||||
return &config.AppConfig{
|
||||
Backends: config.BackendConfig{
|
||||
LlamaCpp: config.BackendSettings{
|
||||
Command: "sleep",
|
||||
Command: "sh",
|
||||
Args: []string{"-c", "sleep 999999"},
|
||||
},
|
||||
MLX: config.BackendSettings{
|
||||
Command: "sleep",
|
||||
Command: "sh",
|
||||
Args: []string{"-c", "sleep 999999"},
|
||||
},
|
||||
},
|
||||
Instances: config.InstancesConfig{
|
||||
@@ -162,28 +163,8 @@ func createTestAppConfig(instancesDir string) *config.AppConfig {
|
||||
}
|
||||
}
|
||||
|
||||
func createTestManager() manager.InstanceManager {
|
||||
appConfig := &config.AppConfig{
|
||||
Backends: config.BackendConfig{
|
||||
LlamaCpp: config.BackendSettings{
|
||||
Command: "sleep",
|
||||
},
|
||||
MLX: config.BackendSettings{
|
||||
Command: "sleep",
|
||||
},
|
||||
},
|
||||
Instances: config.InstancesConfig{
|
||||
PortRange: [2]int{8000, 9000},
|
||||
LogsDir: "/tmp/test",
|
||||
MaxInstances: 10,
|
||||
MaxRunningInstances: 10,
|
||||
DefaultAutoRestart: true,
|
||||
DefaultMaxRestarts: 3,
|
||||
DefaultRestartDelay: 5,
|
||||
TimeoutCheckInterval: 5,
|
||||
},
|
||||
LocalNode: "main",
|
||||
Nodes: map[string]config.NodeConfig{},
|
||||
}
|
||||
func createTestManager(t *testing.T) manager.InstanceManager {
|
||||
tempDir := t.TempDir()
|
||||
appConfig := createTestAppConfig(tempDir)
|
||||
return manager.New(appConfig)
|
||||
}
|
||||
|
||||
@@ -330,7 +330,8 @@ func (im *instanceManager) DeleteInstance(name string) error {
|
||||
lock.Lock()
|
||||
defer im.unlockAndCleanup(name)
|
||||
|
||||
if inst.IsRunning() {
|
||||
status := inst.GetStatus()
|
||||
if status == instance.Running || status == instance.Restarting {
|
||||
return fmt.Errorf("instance with name %s is still running, stop it before deleting", name)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
func TestCreateInstance_FailsWithDuplicateName(t *testing.T) {
|
||||
mngr := createTestManager()
|
||||
mngr := createTestManager(t)
|
||||
options := &instance.Options{
|
||||
BackendOptions: backends.Options{
|
||||
BackendType: backends.BackendTypeLlamaCpp,
|
||||
@@ -36,6 +36,7 @@ func TestCreateInstance_FailsWithDuplicateName(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCreateInstance_FailsWhenMaxInstancesReached(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
appConfig := &config.AppConfig{
|
||||
Backends: config.BackendConfig{
|
||||
LlamaCpp: config.BackendSettings{
|
||||
@@ -44,6 +45,7 @@ func TestCreateInstance_FailsWhenMaxInstancesReached(t *testing.T) {
|
||||
},
|
||||
Instances: config.InstancesConfig{
|
||||
PortRange: [2]int{8000, 9000},
|
||||
InstancesDir: tempDir,
|
||||
MaxInstances: 1, // Very low limit for testing
|
||||
TimeoutCheckInterval: 5,
|
||||
},
|
||||
@@ -77,7 +79,7 @@ func TestCreateInstance_FailsWhenMaxInstancesReached(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCreateInstance_FailsWithPortConflict(t *testing.T) {
|
||||
manager := createTestManager()
|
||||
manager := createTestManager(t)
|
||||
|
||||
options1 := &instance.Options{
|
||||
BackendOptions: backends.Options{
|
||||
@@ -115,7 +117,7 @@ func TestCreateInstance_FailsWithPortConflict(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestInstanceOperations_FailWithNonExistentInstance(t *testing.T) {
|
||||
manager := createTestManager()
|
||||
manager := createTestManager(t)
|
||||
|
||||
options := &instance.Options{
|
||||
BackendOptions: backends.Options{
|
||||
@@ -143,7 +145,7 @@ func TestInstanceOperations_FailWithNonExistentInstance(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDeleteInstance_RunningInstanceFails(t *testing.T) {
|
||||
mgr := createTestManager()
|
||||
mgr := createTestManager(t)
|
||||
defer mgr.Shutdown()
|
||||
|
||||
options := &instance.Options{
|
||||
@@ -155,15 +157,13 @@ func TestDeleteInstance_RunningInstanceFails(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
_, err := mgr.CreateInstance("test-instance", options)
|
||||
inst, err := mgr.CreateInstance("test-instance", options)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateInstance failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = mgr.StartInstance("test-instance")
|
||||
if err != nil {
|
||||
t.Fatalf("StartInstance failed: %v", err)
|
||||
}
|
||||
// Simulate starting the instance
|
||||
inst.SetStatus(instance.Running)
|
||||
|
||||
// Should fail to delete running instance
|
||||
err = mgr.DeleteInstance("test-instance")
|
||||
@@ -173,7 +173,7 @@ func TestDeleteInstance_RunningInstanceFails(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateInstance(t *testing.T) {
|
||||
mgr := createTestManager()
|
||||
mgr := createTestManager(t)
|
||||
defer mgr.Shutdown()
|
||||
|
||||
options := &instance.Options{
|
||||
@@ -186,14 +186,14 @@ func TestUpdateInstance(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
_, err := mgr.CreateInstance("test-instance", options)
|
||||
inst, err := mgr.CreateInstance("test-instance", options)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateInstance failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = mgr.StartInstance("test-instance")
|
||||
if err != nil {
|
||||
t.Fatalf("StartInstance failed: %v", err)
|
||||
// Start the instance (will use 'yes' command from test config)
|
||||
if err := inst.Start(); err != nil {
|
||||
t.Fatalf("Failed to start instance: %v", err)
|
||||
}
|
||||
|
||||
// Update running instance with new model
|
||||
@@ -212,9 +212,9 @@ func TestUpdateInstance(t *testing.T) {
|
||||
t.Fatalf("UpdateInstance failed: %v", err)
|
||||
}
|
||||
|
||||
// Should still be running after update
|
||||
// Should be running after update (was running before, should be restarted)
|
||||
if !updated.IsRunning() {
|
||||
t.Error("Instance should be running after update")
|
||||
t.Errorf("Instance should be running after update, got: %v", updated.GetStatus())
|
||||
}
|
||||
|
||||
if updated.GetOptions().BackendOptions.LlamaServerOptions.Model != "/path/to/new-model.gguf" {
|
||||
@@ -223,7 +223,7 @@ func TestUpdateInstance(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateInstance_ReleasesOldPort(t *testing.T) {
|
||||
mgr := createTestManager()
|
||||
mgr := createTestManager(t)
|
||||
defer mgr.Shutdown()
|
||||
|
||||
options := &instance.Options{
|
||||
|
||||
@@ -15,35 +15,18 @@ import (
|
||||
type instancePersister struct {
|
||||
mu sync.Mutex
|
||||
instancesDir string
|
||||
enabled bool
|
||||
}
|
||||
|
||||
// newInstancePersister creates a new instance persister.
|
||||
// If instancesDir is empty, persistence is disabled.
|
||||
func newInstancePersister(instancesDir string) (*instancePersister, error) {
|
||||
if instancesDir == "" {
|
||||
return &instancePersister{
|
||||
enabled: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Ensure the instances directory exists
|
||||
if err := os.MkdirAll(instancesDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create instances directory: %w", err)
|
||||
}
|
||||
|
||||
func newInstancePersister(instancesDir string) *instancePersister {
|
||||
return &instancePersister{
|
||||
instancesDir: instancesDir,
|
||||
enabled: true,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Save persists an instance to disk with atomic write
|
||||
func (p *instancePersister) save(inst *instance.Instance) error {
|
||||
if !p.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
if inst == nil {
|
||||
return fmt.Errorf("cannot save nil instance")
|
||||
}
|
||||
@@ -103,10 +86,6 @@ func (p *instancePersister) save(inst *instance.Instance) error {
|
||||
|
||||
// Delete removes an instance's persistence file from disk.
|
||||
func (p *instancePersister) delete(name string) error {
|
||||
if !p.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
validatedName, err := p.validateInstanceName(name)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -131,10 +110,6 @@ func (p *instancePersister) delete(name string) error {
|
||||
// LoadAll loads all persisted instances from disk.
|
||||
// Returns a slice of instances and any errors encountered during loading.
|
||||
func (p *instancePersister) loadAll() ([]*instance.Instance, error) {
|
||||
if !p.enabled {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
|
||||
@@ -24,15 +24,7 @@ type portAllocator struct {
|
||||
}
|
||||
|
||||
// newPortAllocator creates a new port allocator for the given port range.
|
||||
// Returns an error if the port range is invalid.
|
||||
func newPortAllocator(minPort, maxPort int) (*portAllocator, error) {
|
||||
if minPort <= 0 || maxPort <= 0 {
|
||||
return nil, fmt.Errorf("invalid port range: min=%d, max=%d (must be > 0)", minPort, maxPort)
|
||||
}
|
||||
if minPort > maxPort {
|
||||
return nil, fmt.Errorf("invalid port range: min=%d > max=%d", minPort, maxPort)
|
||||
}
|
||||
|
||||
func newPortAllocator(minPort, maxPort int) *portAllocator {
|
||||
rangeSize := maxPort - minPort + 1
|
||||
bitmapSize := (rangeSize + 63) / 64 // Round up to nearest uint64
|
||||
|
||||
@@ -42,7 +34,7 @@ func newPortAllocator(minPort, maxPort int) (*portAllocator, error) {
|
||||
minPort: minPort,
|
||||
maxPort: maxPort,
|
||||
rangeSize: rangeSize,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// allocate finds and allocates the first available port for the given instance.
|
||||
|
||||
@@ -66,17 +66,16 @@ func (h *Handler) LlamaCppUIProxy() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
proxy, err := inst.GetProxy()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to get proxy", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if !inst.IsRemote() {
|
||||
h.stripLlamaCppPrefix(r, inst.Name)
|
||||
}
|
||||
|
||||
proxy.ServeHTTP(w, r)
|
||||
// Use instance's ServeHTTP which tracks inflight requests and handles shutting down state
|
||||
err = inst.ServeHTTP(w, r)
|
||||
if err != nil {
|
||||
// Error is already handled in ServeHTTP (response written)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +109,12 @@ func (h *Handler) LlamaCppProxy() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if instance is shutting down before autostart logic
|
||||
if inst.GetStatus() == instance.ShuttingDown {
|
||||
writeError(w, http.StatusServiceUnavailable, "instance_shutting_down", "Instance is shutting down")
|
||||
return
|
||||
}
|
||||
|
||||
if !inst.IsRemote() && !inst.IsRunning() {
|
||||
err := h.ensureInstanceRunning(inst)
|
||||
if err != nil {
|
||||
@@ -118,17 +123,16 @@ func (h *Handler) LlamaCppProxy() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
proxy, err := inst.GetProxy()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to get proxy", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if !inst.IsRemote() {
|
||||
h.stripLlamaCppPrefix(r, inst.Name)
|
||||
}
|
||||
|
||||
proxy.ServeHTTP(w, r)
|
||||
// Use instance's ServeHTTP which tracks inflight requests and handles shutting down state
|
||||
err = inst.ServeHTTP(w, r)
|
||||
if err != nil {
|
||||
// Error is already handled in ServeHTTP (response written)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -332,12 +332,6 @@ func (h *Handler) InstanceProxy() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
proxy, err := inst.GetProxy()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "proxy_failed", "Failed to get proxy: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if !inst.IsRemote() {
|
||||
// Strip the "/api/v1/instances/<name>/proxy" prefix from the request URL
|
||||
prefix := fmt.Sprintf("/api/v1/instances/%s/proxy", inst.Name)
|
||||
@@ -348,6 +342,11 @@ func (h *Handler) InstanceProxy() http.HandlerFunc {
|
||||
r.Header.Set("X-Forwarded-Host", r.Header.Get("Host"))
|
||||
r.Header.Set("X-Forwarded-Proto", "http")
|
||||
|
||||
proxy.ServeHTTP(w, r)
|
||||
// Use instance's ServeHTTP which tracks inflight requests and handles shutting down state
|
||||
err = inst.ServeHTTP(w, r)
|
||||
if err != nil {
|
||||
// Error is already handled in ServeHTTP (response written)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"llamactl/pkg/instance"
|
||||
"llamactl/pkg/validation"
|
||||
"net/http"
|
||||
)
|
||||
@@ -106,6 +107,12 @@ func (h *Handler) OpenAIProxy() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if instance is shutting down before autostart logic
|
||||
if inst.GetStatus() == instance.ShuttingDown {
|
||||
writeError(w, http.StatusServiceUnavailable, "instance_shutting_down", "Instance is shutting down")
|
||||
return
|
||||
}
|
||||
|
||||
if !inst.IsRemote() && !inst.IsRunning() {
|
||||
err := h.ensureInstanceRunning(inst)
|
||||
if err != nil {
|
||||
@@ -114,16 +121,15 @@ func (h *Handler) OpenAIProxy() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
proxy, err := inst.GetProxy()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "proxy_failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Recreate the request body from the bytes we read
|
||||
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
r.ContentLength = int64(len(bodyBytes))
|
||||
|
||||
proxy.ServeHTTP(w, r)
|
||||
// Use instance's ServeHTTP which tracks inflight requests and handles shutting down state
|
||||
err = inst.ServeHTTP(w, r)
|
||||
if err != nil {
|
||||
// Error is already handled in ServeHTTP (response written)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,3 +20,23 @@ func (h *Handler) VersionHandler() http.HandlerFunc {
|
||||
writeText(w, http.StatusOK, versionInfo)
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigHandler godoc
|
||||
// @Summary Get server configuration
|
||||
// @Description Returns the current server configuration (sanitized)
|
||||
// @Tags System
|
||||
// @Security ApiKeyAuth
|
||||
// @Produces application/json
|
||||
// @Success 200 {object} config.AppConfig "Sanitized configuration"
|
||||
// @Failure 500 {string} string "Internal Server Error"
|
||||
// @Router /api/v1/config [get]
|
||||
func (h *Handler) ConfigHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sanitizedConfig, err := h.cfg.SanitizedCopy()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "sanitized_copy_error", "Failed to get sanitized config")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, sanitizedConfig)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
@@ -42,7 +42,9 @@ func SetupRouter(handler *Handler) *chi.Mux {
|
||||
r.Use(authMiddleware.AuthMiddleware(KeyTypeManagement))
|
||||
}
|
||||
|
||||
r.Get("/version", handler.VersionHandler()) // Get server version
|
||||
r.Get("/version", handler.VersionHandler())
|
||||
|
||||
r.Get("/config", handler.ConfigHandler())
|
||||
|
||||
// Backend-specific endpoints
|
||||
r.Route("/backends", func(r chi.Router) {
|
||||
@@ -159,7 +161,7 @@ func SetupRouter(handler *Handler) *chi.Mux {
|
||||
|
||||
// Serve WebUI files
|
||||
if err := webui.SetupWebUI(r); err != nil {
|
||||
fmt.Printf("Failed to set up WebUI: %v\n", err)
|
||||
log.Printf("Failed to set up WebUI: %v\n", err)
|
||||
}
|
||||
|
||||
return r
|
||||
|
||||
@@ -239,25 +239,3 @@ func TestValidateInstanceOptions_MultipleFieldInjection(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateInstanceOptions_NonStringFields(t *testing.T) {
|
||||
// Test that non-string fields don't interfere with validation
|
||||
options := backends.Options{
|
||||
BackendType: backends.BackendTypeLlamaCpp,
|
||||
LlamaServerOptions: &backends.LlamaServerOptions{
|
||||
Port: 8080,
|
||||
GPULayers: 32,
|
||||
CtxSize: 4096,
|
||||
Temperature: 0.7,
|
||||
TopK: 40,
|
||||
TopP: 0.9,
|
||||
Verbose: true,
|
||||
FlashAttn: false,
|
||||
},
|
||||
}
|
||||
|
||||
err := options.ValidateInstanceOptions()
|
||||
if err != nil {
|
||||
t.Errorf("ValidateInstanceOptions with non-string fields should not error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
1147
webui/package-lock.json
generated
1147
webui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -25,34 +25,34 @@
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.525.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"lucide-react": "^0.554.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"zod": "^4.0.5"
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/node": "^24.0.15",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"eslint": "^9.32.0",
|
||||
"@types/eslint__js": "^9.14.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.4",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/ui": "^4.0.8",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"jsdom": "^26.1.0",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.38.0",
|
||||
"vite": "^7.1.11",
|
||||
"vitest": "^3.2.4"
|
||||
"jsdom": "^27.2.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.47.0",
|
||||
"vite": "^7.2.2",
|
||||
"vitest": "^4.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@ import userEvent from '@testing-library/user-event'
|
||||
import App from '@/App'
|
||||
import { InstancesProvider } from '@/contexts/InstancesContext'
|
||||
import { instancesApi } from '@/lib/api'
|
||||
import type { Instance } from '@/types/instance'
|
||||
import { BackendType } from '@/types/instance'
|
||||
import {BackendType, type Instance } from '@/types/instance'
|
||||
import { AuthProvider } from '@/contexts/AuthContext'
|
||||
|
||||
// Mock the API
|
||||
@@ -49,6 +48,21 @@ vi.mock('@/lib/healthService', () => ({
|
||||
})),
|
||||
}))
|
||||
|
||||
// Mock the ConfigContext helper hooks
|
||||
vi.mock('@/hooks/useConfig', () => ({
|
||||
useInstanceDefaults: () => ({
|
||||
autoRestart: true,
|
||||
maxRestarts: 3,
|
||||
restartDelay: 5,
|
||||
onDemandStart: false,
|
||||
}),
|
||||
useBackendSettings: () => ({
|
||||
command: '/usr/bin/llama-server',
|
||||
dockerEnabled: false,
|
||||
dockerImage: '',
|
||||
}),
|
||||
}))
|
||||
|
||||
function renderApp() {
|
||||
return render(
|
||||
<AuthProvider>
|
||||
@@ -119,8 +133,12 @@ describe('App Component - Critical Business Logic Only', () => {
|
||||
// Verify correct API call
|
||||
await waitFor(() => {
|
||||
expect(instancesApi.create).toHaveBeenCalledWith('new-test-instance', {
|
||||
auto_restart: true, // Default value
|
||||
backend_type: BackendType.LLAMA_CPP
|
||||
auto_restart: true, // Default value from config
|
||||
backend_type: BackendType.LLAMA_CPP,
|
||||
docker_enabled: false,
|
||||
max_restarts: 3,
|
||||
on_demand_start: false,
|
||||
restart_delay: 5
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -3,22 +3,36 @@ import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { getBackendFieldType, basicBackendFieldsConfig } from '@/lib/zodFormUtils'
|
||||
import ExtraArgsInput from '@/components/form/ExtraArgsInput'
|
||||
|
||||
interface BackendFormFieldProps {
|
||||
fieldKey: string
|
||||
value: string | number | boolean | string[] | undefined
|
||||
onChange: (key: string, value: string | number | boolean | string[] | undefined) => void
|
||||
value: string | number | boolean | string[] | Record<string, string> | undefined
|
||||
onChange: (key: string, value: string | number | boolean | string[] | Record<string, string> | undefined) => void
|
||||
}
|
||||
|
||||
const BackendFormField: React.FC<BackendFormFieldProps> = ({ fieldKey, value, onChange }) => {
|
||||
// Special handling for extra_args
|
||||
if (fieldKey === 'extra_args') {
|
||||
return (
|
||||
<ExtraArgsInput
|
||||
id={fieldKey}
|
||||
label="Extra Arguments"
|
||||
value={value as Record<string, string> | undefined}
|
||||
onChange={(newValue) => onChange(fieldKey, newValue)}
|
||||
description="Additional command line arguments to pass to the backend"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Get configuration for basic fields, or use field name for advanced fields
|
||||
const config = basicBackendFieldsConfig[fieldKey as string] || { label: fieldKey }
|
||||
|
||||
const config = basicBackendFieldsConfig[fieldKey] || { label: fieldKey }
|
||||
|
||||
// Get type from Zod schema
|
||||
const fieldType = getBackendFieldType(fieldKey)
|
||||
|
||||
const handleChange = (newValue: string | number | boolean | string[] | undefined) => {
|
||||
onChange(fieldKey as string, newValue)
|
||||
onChange(fieldKey, newValue)
|
||||
}
|
||||
|
||||
const renderField = () => {
|
||||
|
||||
@@ -21,6 +21,8 @@ const HealthBadge: React.FC<HealthBadgeProps> = ({ health }) => {
|
||||
return <Loader2 className="h-3 w-3 animate-spin" />;
|
||||
case "restarting":
|
||||
return <Loader2 className="h-3 w-3 animate-spin" />;
|
||||
case "shutting_down":
|
||||
return <Loader2 className="h-3 w-3 animate-spin" />;
|
||||
case "stopped":
|
||||
return <Clock className="h-3 w-3" />;
|
||||
case "failed":
|
||||
@@ -36,6 +38,8 @@ const HealthBadge: React.FC<HealthBadgeProps> = ({ health }) => {
|
||||
return "outline";
|
||||
case "restarting":
|
||||
return "outline";
|
||||
case "shutting_down":
|
||||
return "outline";
|
||||
case "stopped":
|
||||
return "secondary";
|
||||
case "failed":
|
||||
@@ -51,6 +55,8 @@ const HealthBadge: React.FC<HealthBadgeProps> = ({ health }) => {
|
||||
return "Starting";
|
||||
case "restarting":
|
||||
return "Restarting";
|
||||
case "shutting_down":
|
||||
return "Shutting Down";
|
||||
case "stopped":
|
||||
return "Stopped";
|
||||
case "failed":
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { Instance } from "@/types/instance";
|
||||
import { Edit, FileText, Play, Square, Trash2, MoreHorizontal } from "lucide-react";
|
||||
import { Edit, FileText, Play, Square, Trash2, MoreHorizontal, Download } from "lucide-react";
|
||||
import LogsDialog from "@/components/LogDialog";
|
||||
import HealthBadge from "@/components/HealthBadge";
|
||||
import BackendBadge from "@/components/BackendBadge";
|
||||
import { useState } from "react";
|
||||
import { useInstanceHealth } from "@/hooks/useInstanceHealth";
|
||||
import { instancesApi } from "@/lib/api";
|
||||
|
||||
interface InstanceCardProps {
|
||||
instance: Instance;
|
||||
@@ -52,6 +53,36 @@ function InstanceCard({
|
||||
setIsLogsOpen(true);
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
void (async () => {
|
||||
try {
|
||||
// Fetch the most up-to-date instance data from the backend
|
||||
const instanceData = await instancesApi.get(instance.name);
|
||||
|
||||
// Convert to JSON string with pretty formatting (matching backend format)
|
||||
const jsonString = JSON.stringify(instanceData, null, 2);
|
||||
|
||||
// Create a blob and download link
|
||||
const blob = new Blob([jsonString], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${instance.name}.json`;
|
||||
|
||||
// Trigger download
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// Cleanup
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error("Failed to export instance:", error);
|
||||
alert(`Failed to export instance: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const running = instance.status === "running";
|
||||
|
||||
return (
|
||||
@@ -66,7 +97,7 @@ function InstanceCard({
|
||||
|
||||
{/* Badges row */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<BackendBadge backend={instance.options?.backend_type} docker={instance.docker_enabled} />
|
||||
<BackendBadge backend={instance.options?.backend_type} docker={instance.options?.docker_enabled} />
|
||||
{running && <HealthBadge health={health} />}
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,6 +162,18 @@ function InstanceCard({
|
||||
Logs
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleExport}
|
||||
title="Export instance"
|
||||
data-testid="export-instance-button"
|
||||
className="flex-1"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
Export
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -9,9 +9,12 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { BackendType, type CreateInstanceOptions, type Instance } from "@/types/instance";
|
||||
import type { BackendOptions } from "@/schemas/instanceOptions";
|
||||
import ParseCommandDialog from "@/components/ParseCommandDialog";
|
||||
import InstanceSettingsCard from "@/components/instance/InstanceSettingsCard";
|
||||
import BackendConfigurationCard from "@/components/instance/BackendConfigurationCard";
|
||||
import { Upload } from "lucide-react";
|
||||
import { useInstanceDefaults, useBackendSettings } from "@/hooks/useConfig";
|
||||
|
||||
interface InstanceDialogProps {
|
||||
open: boolean;
|
||||
@@ -27,12 +30,18 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
||||
instance,
|
||||
}) => {
|
||||
const isEditing = !!instance;
|
||||
const instanceDefaults = useInstanceDefaults();
|
||||
|
||||
const [instanceName, setInstanceName] = useState("");
|
||||
const [formData, setFormData] = useState<CreateInstanceOptions>({});
|
||||
const [nameError, setNameError] = useState("");
|
||||
const [showParseDialog, setShowParseDialog] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Get backend settings for all backends (we'll use this to update docker_enabled on backend type change)
|
||||
const llamaCppSettings = useBackendSettings(BackendType.LLAMA_CPP);
|
||||
const vllmSettings = useBackendSettings(BackendType.VLLM);
|
||||
const mlxSettings = useBackendSettings(BackendType.MLX_LM);
|
||||
|
||||
// Reset form when dialog opens/closes or when instance changes
|
||||
useEffect(() => {
|
||||
@@ -42,43 +51,58 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
||||
setInstanceName(instance.name);
|
||||
setFormData(instance.options || {});
|
||||
} else {
|
||||
// Reset form for new instance
|
||||
// Reset form for new instance with defaults from config
|
||||
setInstanceName("");
|
||||
setFormData({
|
||||
auto_restart: true, // Default value
|
||||
auto_restart: instanceDefaults?.autoRestart ?? true,
|
||||
max_restarts: instanceDefaults?.maxRestarts,
|
||||
restart_delay: instanceDefaults?.restartDelay,
|
||||
on_demand_start: instanceDefaults?.onDemandStart,
|
||||
backend_type: BackendType.LLAMA_CPP, // Default backend type
|
||||
docker_enabled: llamaCppSettings?.dockerEnabled ?? false,
|
||||
backend_options: {},
|
||||
});
|
||||
}
|
||||
setNameError(""); // Reset any name errors
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, instance]);
|
||||
|
||||
const handleFieldChange = (key: keyof CreateInstanceOptions, value: any) => {
|
||||
const handleFieldChange = (key: keyof CreateInstanceOptions, value: unknown) => {
|
||||
setFormData((prev) => {
|
||||
// If backend_type is changing, clear backend_options
|
||||
// If backend_type is changing, update docker_enabled default and clear backend_options
|
||||
if (key === 'backend_type' && prev.backend_type !== value) {
|
||||
let dockerEnabled = false;
|
||||
if (value === BackendType.LLAMA_CPP) {
|
||||
dockerEnabled = llamaCppSettings?.dockerEnabled ?? false;
|
||||
} else if (value === BackendType.VLLM) {
|
||||
dockerEnabled = vllmSettings?.dockerEnabled ?? false;
|
||||
} else if (value === BackendType.MLX_LM) {
|
||||
dockerEnabled = mlxSettings?.dockerEnabled ?? false;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[key]: value,
|
||||
backend_type: value as CreateInstanceOptions['backend_type'],
|
||||
docker_enabled: dockerEnabled,
|
||||
backend_options: {}, // Clear backend options when backend type changes
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[key]: value,
|
||||
};
|
||||
} as CreateInstanceOptions;
|
||||
});
|
||||
};
|
||||
|
||||
const handleBackendFieldChange = (key: string, value: any) => {
|
||||
const handleBackendFieldChange = (key: string, value: unknown) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
backend_options: {
|
||||
...prev.backend_options,
|
||||
[key]: value,
|
||||
} as any,
|
||||
} as BackendOptions,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -103,12 +127,22 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate docker_enabled and command_override relationship
|
||||
if (formData.backend_type !== BackendType.MLX_LM) {
|
||||
if (formData.docker_enabled === true && formData.command_override) {
|
||||
setNameError("Command override cannot be set when Docker is enabled");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up undefined values to avoid sending empty fields
|
||||
const cleanOptions: CreateInstanceOptions = {};
|
||||
const cleanOptions: CreateInstanceOptions = {} as CreateInstanceOptions;
|
||||
Object.entries(formData).forEach(([key, value]) => {
|
||||
const typedKey = key as keyof CreateInstanceOptions;
|
||||
|
||||
if (key === 'backend_options' && value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
// Handle backend_options specially - clean nested object
|
||||
const cleanBackendOptions: any = {};
|
||||
const cleanBackendOptions: Record<string, unknown> = {};
|
||||
Object.entries(value).forEach(([backendKey, backendValue]) => {
|
||||
if (backendValue !== undefined && backendValue !== null && (typeof backendValue !== 'string' || backendValue.trim() !== "")) {
|
||||
// Handle arrays - don't include empty arrays
|
||||
@@ -121,7 +155,7 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
||||
|
||||
// Only include backend_options if it has content
|
||||
if (Object.keys(cleanBackendOptions).length > 0) {
|
||||
(cleanOptions as any)[key] = cleanBackendOptions;
|
||||
(cleanOptions as Record<string, unknown>)[typedKey] = cleanBackendOptions as BackendOptions;
|
||||
}
|
||||
} else if (value !== undefined && value !== null) {
|
||||
// Skip empty strings
|
||||
@@ -132,7 +166,7 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
||||
if (Array.isArray(value) && value.length === 0) {
|
||||
return;
|
||||
}
|
||||
(cleanOptions as any)[key] = value;
|
||||
(cleanOptions as Record<string, unknown>)[typedKey] = value;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -153,6 +187,49 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
||||
setShowParseDialog(false);
|
||||
};
|
||||
|
||||
const handleImportFile = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string;
|
||||
const importedData = JSON.parse(content) as { name?: string; options?: CreateInstanceOptions };
|
||||
|
||||
// Validate that it's an instance export
|
||||
if (!importedData.name || !importedData.options) {
|
||||
alert('Invalid instance file: Missing required fields (name, options)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the instance name (only for new instances, not editing)
|
||||
if (!isEditing && typeof importedData.name === 'string') {
|
||||
handleNameChange(importedData.name);
|
||||
}
|
||||
|
||||
// Populate all the options from the imported file
|
||||
if (importedData.options) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
...importedData.options,
|
||||
}));
|
||||
}
|
||||
|
||||
// Reset the file input
|
||||
event.target.value = '';
|
||||
} catch (error) {
|
||||
console.error('Failed to parse instance file:', error);
|
||||
alert(`Failed to parse instance file: ${error instanceof Error ? error.message : 'Invalid JSON'}`);
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
// Save button label logic
|
||||
let saveButtonLabel = "Create Instance";
|
||||
@@ -168,14 +245,38 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditing ? "Edit Instance" : "Create New Instance"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditing
|
||||
? "Modify the instance configuration below."
|
||||
: "Configure your new llama-server instance below."}
|
||||
</DialogDescription>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<DialogTitle>
|
||||
{isEditing ? "Edit Instance" : "Create New Instance"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditing
|
||||
? "Modify the instance configuration below."
|
||||
: "Configure your new llama-server instance below."}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleImportFile}
|
||||
title="Import instance configuration from JSON file"
|
||||
className="ml-2"
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Import
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
|
||||
@@ -56,9 +56,9 @@ function InstanceList({ editInstance }: InstanceListProps) {
|
||||
<MemoizedInstanceCard
|
||||
key={instance.name}
|
||||
instance={instance}
|
||||
startInstance={startInstance}
|
||||
stopInstance={stopInstance}
|
||||
deleteInstance={deleteInstance}
|
||||
startInstance={() => { void startInstance(instance.name) }}
|
||||
stopInstance={() => { void stopInstance(instance.name) }}
|
||||
deleteInstance={() => { void deleteInstance(instance.name) }}
|
||||
editInstance={editInstance}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -54,7 +54,7 @@ const ParseCommandDialog: React.FC<ParseCommandDialogProps> = ({
|
||||
options = await backendsApi.vllm.parseCommand(command);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported backend type: ${backendType}`);
|
||||
throw new Error(`Unsupported backend type: ${String(backendType)}`);
|
||||
}
|
||||
|
||||
onParsed(options);
|
||||
|
||||
@@ -2,8 +2,22 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import InstanceDialog from '@/components/InstanceDialog'
|
||||
import type { Instance } from '@/types/instance'
|
||||
import { BackendType } from '@/types/instance'
|
||||
import { BackendType, type Instance } from '@/types/instance'
|
||||
|
||||
// Mock the ConfigContext helper hooks
|
||||
vi.mock('@/hooks/useConfig', () => ({
|
||||
useInstanceDefaults: () => ({
|
||||
autoRestart: true,
|
||||
maxRestarts: 3,
|
||||
restartDelay: 5,
|
||||
onDemandStart: false,
|
||||
}),
|
||||
useBackendSettings: () => ({
|
||||
command: '/usr/bin/llama-server',
|
||||
dockerEnabled: false,
|
||||
dockerImage: '',
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('InstanceModal - Form Logic and Validation', () => {
|
||||
const mockOnSave = vi.fn()
|
||||
@@ -75,7 +89,7 @@ afterEach(() => {
|
||||
|
||||
it('submits form with correct data structure', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
|
||||
render(
|
||||
<InstanceDialog
|
||||
open={true}
|
||||
@@ -86,13 +100,17 @@ afterEach(() => {
|
||||
|
||||
// Fill required name
|
||||
await user.type(screen.getByLabelText(/Instance Name/), 'my-instance')
|
||||
|
||||
|
||||
// Submit form
|
||||
await user.click(screen.getByTestId('dialog-save-button'))
|
||||
|
||||
expect(mockOnSave).toHaveBeenCalledWith('my-instance', {
|
||||
auto_restart: true, // Default value
|
||||
backend_type: BackendType.LLAMA_CPP
|
||||
auto_restart: true, // Default value from config
|
||||
backend_type: BackendType.LLAMA_CPP,
|
||||
docker_enabled: false,
|
||||
max_restarts: 3,
|
||||
on_demand_start: false,
|
||||
restart_delay: 5
|
||||
})
|
||||
})
|
||||
|
||||
@@ -253,7 +271,7 @@ afterEach(() => {
|
||||
|
||||
it('includes restart options in form submission when enabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
|
||||
render(
|
||||
<InstanceDialog
|
||||
open={true}
|
||||
@@ -264,17 +282,23 @@ afterEach(() => {
|
||||
|
||||
// Fill form
|
||||
await user.type(screen.getByLabelText(/Instance Name/), 'test-instance')
|
||||
|
||||
// Set restart options
|
||||
await user.type(screen.getByLabelText(/Max Restarts/), '5')
|
||||
await user.type(screen.getByLabelText(/Restart Delay/), '10')
|
||||
|
||||
// Clear default values and set new restart options
|
||||
const maxRestartsInput = screen.getByLabelText(/Max Restarts/)
|
||||
const restartDelayInput = screen.getByLabelText(/Restart Delay/)
|
||||
await user.clear(maxRestartsInput)
|
||||
await user.type(maxRestartsInput, '5')
|
||||
await user.clear(restartDelayInput)
|
||||
await user.type(restartDelayInput, '10')
|
||||
|
||||
await user.click(screen.getByTestId('dialog-save-button'))
|
||||
|
||||
expect(mockOnSave).toHaveBeenCalledWith('test-instance', {
|
||||
auto_restart: true,
|
||||
backend_type: BackendType.LLAMA_CPP,
|
||||
docker_enabled: false,
|
||||
max_restarts: 5,
|
||||
on_demand_start: false,
|
||||
restart_delay: 10
|
||||
})
|
||||
})
|
||||
@@ -284,7 +308,7 @@ afterEach(() => {
|
||||
describe('Form Data Handling', () => {
|
||||
it('cleans up undefined values before submission', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
|
||||
render(
|
||||
<InstanceDialog
|
||||
open={true}
|
||||
@@ -298,16 +322,20 @@ afterEach(() => {
|
||||
|
||||
await user.click(screen.getByTestId('dialog-save-button'))
|
||||
|
||||
// Should only include non-empty values
|
||||
// Should include default values from config
|
||||
expect(mockOnSave).toHaveBeenCalledWith('clean-instance', {
|
||||
auto_restart: true, // Only this default value should be included
|
||||
backend_type: BackendType.LLAMA_CPP
|
||||
auto_restart: true,
|
||||
backend_type: BackendType.LLAMA_CPP,
|
||||
docker_enabled: false,
|
||||
max_restarts: 3,
|
||||
on_demand_start: false,
|
||||
restart_delay: 5
|
||||
})
|
||||
})
|
||||
|
||||
it('handles numeric fields correctly', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
|
||||
render(
|
||||
<InstanceDialog
|
||||
open={true}
|
||||
@@ -317,7 +345,7 @@ afterEach(() => {
|
||||
)
|
||||
|
||||
await user.type(screen.getByLabelText(/Instance Name/), 'numeric-test')
|
||||
|
||||
|
||||
// Test GPU layers field (numeric)
|
||||
const gpuLayersInput = screen.getByLabelText(/GPU Layers/)
|
||||
await user.type(gpuLayersInput, '15')
|
||||
@@ -328,6 +356,10 @@ afterEach(() => {
|
||||
auto_restart: true,
|
||||
backend_type: BackendType.LLAMA_CPP,
|
||||
backend_options: { gpu_layers: 15 }, // Should be number, not string
|
||||
docker_enabled: false,
|
||||
max_restarts: 3,
|
||||
on_demand_start: false,
|
||||
restart_delay: 5
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
26
webui/src/components/form/EnvVarsInput.tsx
Normal file
26
webui/src/components/form/EnvVarsInput.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
import KeyValueInput from './KeyValueInput'
|
||||
|
||||
interface EnvVarsInputProps {
|
||||
id: string
|
||||
label: string
|
||||
value: Record<string, string> | undefined
|
||||
onChange: (value: Record<string, string> | undefined) => void
|
||||
description?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const EnvVarsInput: React.FC<EnvVarsInputProps> = (props) => {
|
||||
return (
|
||||
<KeyValueInput
|
||||
{...props}
|
||||
keyPlaceholder="Variable name"
|
||||
valuePlaceholder="Variable value"
|
||||
addButtonText="Add Variable"
|
||||
allowEmptyValues={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default EnvVarsInput
|
||||
@@ -1,144 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { X, Plus } from 'lucide-react'
|
||||
|
||||
interface EnvironmentVariablesInputProps {
|
||||
id: string
|
||||
label: string
|
||||
value: Record<string, string> | undefined
|
||||
onChange: (value: Record<string, string> | undefined) => void
|
||||
description?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface EnvVar {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const EnvironmentVariablesInput: React.FC<EnvironmentVariablesInputProps> = ({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
description,
|
||||
disabled = false,
|
||||
className
|
||||
}) => {
|
||||
// Convert the value object to an array of key-value pairs for editing
|
||||
const envVarsFromValue = value
|
||||
? Object.entries(value).map(([key, val]) => ({ key, value: val }))
|
||||
: []
|
||||
|
||||
const [envVars, setEnvVars] = useState<EnvVar[]>(
|
||||
envVarsFromValue.length > 0 ? envVarsFromValue : [{ key: '', value: '' }]
|
||||
)
|
||||
|
||||
// Update parent component when env vars change
|
||||
const updateParent = (newEnvVars: EnvVar[]) => {
|
||||
// Filter out empty entries
|
||||
const validVars = newEnvVars.filter(env => env.key.trim() !== '' && env.value.trim() !== '')
|
||||
|
||||
if (validVars.length === 0) {
|
||||
onChange(undefined)
|
||||
} else {
|
||||
const envObject = validVars.reduce((acc, env) => {
|
||||
acc[env.key.trim()] = env.value.trim()
|
||||
return acc
|
||||
}, {} as Record<string, string>)
|
||||
onChange(envObject)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyChange = (index: number, newKey: string) => {
|
||||
const newEnvVars = [...envVars]
|
||||
newEnvVars[index].key = newKey
|
||||
setEnvVars(newEnvVars)
|
||||
updateParent(newEnvVars)
|
||||
}
|
||||
|
||||
const handleValueChange = (index: number, newValue: string) => {
|
||||
const newEnvVars = [...envVars]
|
||||
newEnvVars[index].value = newValue
|
||||
setEnvVars(newEnvVars)
|
||||
updateParent(newEnvVars)
|
||||
}
|
||||
|
||||
const addEnvVar = () => {
|
||||
const newEnvVars = [...envVars, { key: '', value: '' }]
|
||||
setEnvVars(newEnvVars)
|
||||
}
|
||||
|
||||
const removeEnvVar = (index: number) => {
|
||||
if (envVars.length === 1) {
|
||||
// Reset to empty if it's the last one
|
||||
const newEnvVars = [{ key: '', value: '' }]
|
||||
setEnvVars(newEnvVars)
|
||||
updateParent(newEnvVars)
|
||||
} else {
|
||||
const newEnvVars = envVars.filter((_, i) => i !== index)
|
||||
setEnvVars(newEnvVars)
|
||||
updateParent(newEnvVars)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`grid gap-2 ${className || ''}`}>
|
||||
<Label htmlFor={id}>
|
||||
{label}
|
||||
</Label>
|
||||
<div className="space-y-2">
|
||||
{envVars.map((envVar, index) => (
|
||||
<div key={index} className="flex gap-2 items-center">
|
||||
<Input
|
||||
placeholder="Variable name"
|
||||
value={envVar.key}
|
||||
onChange={(e) => handleKeyChange(index, e.target.value)}
|
||||
disabled={disabled}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Variable value"
|
||||
value={envVar.value}
|
||||
onChange={(e) => handleValueChange(index, e.target.value)}
|
||||
disabled={disabled}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => removeEnvVar(index)}
|
||||
disabled={disabled}
|
||||
className="shrink-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addEnvVar}
|
||||
disabled={disabled}
|
||||
className="w-fit"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Variable
|
||||
</Button>
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Environment variables that will be passed to the backend process
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EnvironmentVariablesInput
|
||||
27
webui/src/components/form/ExtraArgsInput.tsx
Normal file
27
webui/src/components/form/ExtraArgsInput.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
import KeyValueInput from './KeyValueInput'
|
||||
|
||||
interface ExtraArgsInputProps {
|
||||
id: string
|
||||
label: string
|
||||
value: Record<string, string> | undefined
|
||||
onChange: (value: Record<string, string> | undefined) => void
|
||||
description?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const ExtraArgsInput: React.FC<ExtraArgsInputProps> = (props) => {
|
||||
return (
|
||||
<KeyValueInput
|
||||
{...props}
|
||||
keyPlaceholder="Flag name (without --)"
|
||||
valuePlaceholder="Value (empty for boolean flags)"
|
||||
addButtonText="Add Argument"
|
||||
helperText="Additional command line arguments to pass to the backend. Leave value empty for boolean flags."
|
||||
allowEmptyValues={true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExtraArgsInput
|
||||
171
webui/src/components/form/KeyValueInput.tsx
Normal file
171
webui/src/components/form/KeyValueInput.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { X, Plus } from 'lucide-react'
|
||||
|
||||
interface KeyValueInputProps {
|
||||
id: string
|
||||
label: string
|
||||
value: Record<string, string> | undefined
|
||||
onChange: (value: Record<string, string> | undefined) => void
|
||||
description?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
keyPlaceholder?: string
|
||||
valuePlaceholder?: string
|
||||
addButtonText?: string
|
||||
helperText?: string
|
||||
allowEmptyValues?: boolean // If true, entries with empty values are considered valid
|
||||
}
|
||||
|
||||
interface KeyValuePair {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const KeyValueInput: React.FC<KeyValueInputProps> = ({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
description,
|
||||
disabled = false,
|
||||
className,
|
||||
keyPlaceholder = 'Key',
|
||||
valuePlaceholder = 'Value',
|
||||
addButtonText = 'Add Entry',
|
||||
helperText,
|
||||
allowEmptyValues = false
|
||||
}) => {
|
||||
// Convert the value object to an array of key-value pairs for editing
|
||||
const pairsFromValue = value
|
||||
? Object.entries(value).map(([key, val]) => ({ key, value: val }))
|
||||
: []
|
||||
|
||||
const [pairs, setPairs] = useState<KeyValuePair[]>(
|
||||
pairsFromValue.length > 0 ? pairsFromValue : [{ key: '', value: '' }]
|
||||
)
|
||||
|
||||
// Sync internal state when value prop changes
|
||||
useEffect(() => {
|
||||
const newPairsFromValue = value
|
||||
? Object.entries(value).map(([key, val]) => ({ key, value: val }))
|
||||
: []
|
||||
|
||||
if (newPairsFromValue.length > 0) {
|
||||
setPairs(newPairsFromValue)
|
||||
} else if (!value) {
|
||||
// Reset to single empty row if value is explicitly undefined/null
|
||||
setPairs([{ key: '', value: '' }])
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value])
|
||||
|
||||
// Update parent component when pairs change
|
||||
const updateParent = (newPairs: KeyValuePair[]) => {
|
||||
// Filter based on validation rules
|
||||
const validPairs = allowEmptyValues
|
||||
? newPairs.filter(pair => pair.key.trim() !== '')
|
||||
: newPairs.filter(pair => pair.key.trim() !== '' && pair.value.trim() !== '')
|
||||
|
||||
if (validPairs.length === 0) {
|
||||
onChange(undefined)
|
||||
} else {
|
||||
const pairsObject = validPairs.reduce((acc, pair) => {
|
||||
acc[pair.key.trim()] = pair.value.trim()
|
||||
return acc
|
||||
}, {} as Record<string, string>)
|
||||
onChange(pairsObject)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyChange = (index: number, newKey: string) => {
|
||||
const newPairs = [...pairs]
|
||||
newPairs[index].key = newKey
|
||||
setPairs(newPairs)
|
||||
updateParent(newPairs)
|
||||
}
|
||||
|
||||
const handleValueChange = (index: number, newValue: string) => {
|
||||
const newPairs = [...pairs]
|
||||
newPairs[index].value = newValue
|
||||
setPairs(newPairs)
|
||||
updateParent(newPairs)
|
||||
}
|
||||
|
||||
const addPair = () => {
|
||||
const newPairs = [...pairs, { key: '', value: '' }]
|
||||
setPairs(newPairs)
|
||||
}
|
||||
|
||||
const removePair = (index: number) => {
|
||||
if (pairs.length === 1) {
|
||||
// Reset to empty if it's the last one
|
||||
const newPairs = [{ key: '', value: '' }]
|
||||
setPairs(newPairs)
|
||||
updateParent(newPairs)
|
||||
} else {
|
||||
const newPairs = pairs.filter((_, i) => i !== index)
|
||||
setPairs(newPairs)
|
||||
updateParent(newPairs)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`grid gap-2 ${className || ''}`}>
|
||||
<Label htmlFor={id}>
|
||||
{label}
|
||||
</Label>
|
||||
<div className="space-y-2">
|
||||
{pairs.map((pair, index) => (
|
||||
<div key={index} className="flex gap-2 items-center">
|
||||
<Input
|
||||
placeholder={keyPlaceholder}
|
||||
value={pair.key}
|
||||
onChange={(e) => handleKeyChange(index, e.target.value)}
|
||||
disabled={disabled}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
placeholder={valuePlaceholder}
|
||||
value={pair.value}
|
||||
onChange={(e) => handleValueChange(index, e.target.value)}
|
||||
disabled={disabled}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => removePair(index)}
|
||||
disabled={disabled}
|
||||
className="shrink-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addPair}
|
||||
disabled={disabled}
|
||||
className="w-fit"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{addButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{helperText && (
|
||||
<p className="text-xs text-muted-foreground">{helperText}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default KeyValueInput
|
||||
@@ -47,8 +47,18 @@ const BackendConfiguration: React.FC<BackendConfigurationProps> = ({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extra Args - Always visible as a separate section */}
|
||||
<div className="space-y-4">
|
||||
<BackendFormField
|
||||
key="extra_args"
|
||||
fieldKey="extra_args"
|
||||
value={(formData.backend_options as any)?.extra_args}
|
||||
onChange={onBackendFieldChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BackendConfiguration
|
||||
export default BackendConfiguration
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Terminal, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { getBasicBackendFields, getAdvancedBackendFields } from '@/lib/zodFormUtils'
|
||||
import BackendFormField from '@/components/BackendFormField'
|
||||
import SelectInput from '@/components/form/SelectInput'
|
||||
import ExecutionContextSection from '@/components/instance/ExecutionContextSection'
|
||||
|
||||
interface BackendConfigurationCardProps {
|
||||
formData: CreateInstanceOptions
|
||||
@@ -59,6 +60,12 @@ const BackendConfigurationCard: React.FC<BackendConfigurationCardProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Execution Context Section */}
|
||||
<ExecutionContextSection
|
||||
formData={formData}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
||||
{/* Basic Backend Options */}
|
||||
{basicBackendFields.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
@@ -109,6 +116,16 @@ const BackendConfigurationCard: React.FC<BackendConfigurationCardProps> = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extra Arguments - Always visible */}
|
||||
<div className="space-y-4">
|
||||
<BackendFormField
|
||||
key="extra_args"
|
||||
fieldKey="extra_args"
|
||||
value={(formData.backend_options as Record<string, unknown>)?.extra_args as Record<string, string> | undefined}
|
||||
onChange={onBackendFieldChange}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
76
webui/src/components/instance/ExecutionContextSection.tsx
Normal file
76
webui/src/components/instance/ExecutionContextSection.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react'
|
||||
import { BackendType, type CreateInstanceOptions } from '@/types/instance'
|
||||
import CheckboxInput from '@/components/form/CheckboxInput'
|
||||
import TextInput from '@/components/form/TextInput'
|
||||
import EnvVarsInput from '@/components/form/EnvVarsInput'
|
||||
import { useBackendSettings } from '@/hooks/useConfig'
|
||||
|
||||
interface ExecutionContextSectionProps {
|
||||
formData: CreateInstanceOptions
|
||||
onChange: (key: keyof CreateInstanceOptions, value: unknown) => void
|
||||
}
|
||||
|
||||
const ExecutionContextSection: React.FC<ExecutionContextSectionProps> = ({
|
||||
formData,
|
||||
onChange
|
||||
}) => {
|
||||
const backendSettings = useBackendSettings(formData.backend_type)
|
||||
|
||||
// Get placeholder for command override based on backend type and config
|
||||
const getCommandPlaceholder = () => {
|
||||
if (backendSettings?.command) {
|
||||
return backendSettings.command
|
||||
}
|
||||
|
||||
// Fallback placeholders if config is not loaded
|
||||
switch (formData.backend_type) {
|
||||
case BackendType.LLAMA_CPP:
|
||||
return "llama-server"
|
||||
case BackendType.VLLM:
|
||||
return "vllm"
|
||||
case BackendType.MLX_LM:
|
||||
return "mlx_lm.server"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-md font-medium">Execution Context</h3>
|
||||
|
||||
{/* Docker Mode Toggle - only for backends that support Docker */}
|
||||
{formData.backend_type !== BackendType.MLX_LM && (
|
||||
<CheckboxInput
|
||||
id="docker_enabled"
|
||||
label="Enable Docker"
|
||||
value={formData.docker_enabled}
|
||||
onChange={(value) => onChange('docker_enabled', value)}
|
||||
description="Run backend in Docker container"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Command Override - only shown when Docker is disabled or backend is MLX */}
|
||||
{(formData.backend_type === BackendType.MLX_LM || formData.docker_enabled !== true) && (
|
||||
<TextInput
|
||||
id="command_override"
|
||||
label="Command Override"
|
||||
value={formData.command_override || ''}
|
||||
onChange={(value) => onChange('command_override', value)}
|
||||
placeholder={getCommandPlaceholder()}
|
||||
description="Custom path to backend executable (leave empty to use config default)"
|
||||
/>
|
||||
)}
|
||||
|
||||
<EnvVarsInput
|
||||
id="environment"
|
||||
label="Environment Variables"
|
||||
value={formData.environment}
|
||||
onChange={(value) => onChange('environment', value)}
|
||||
description="Custom environment variables for the instance"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExecutionContextSection
|
||||
@@ -1,12 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import type { CreateInstanceOptions } from '@/types/instance'
|
||||
import { type CreateInstanceOptions } from '@/types/instance'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import AutoRestartConfiguration from '@/components/instance/AutoRestartConfiguration'
|
||||
import NumberInput from '@/components/form/NumberInput'
|
||||
import CheckboxInput from '@/components/form/CheckboxInput'
|
||||
import EnvironmentVariablesInput from '@/components/form/EnvironmentVariablesInput'
|
||||
import SelectInput from '@/components/form/SelectInput'
|
||||
import { nodesApi, type NodesMap } from '@/lib/api'
|
||||
|
||||
@@ -131,14 +130,6 @@ const InstanceSettingsCard: React.FC<InstanceSettingsCardProps> = ({
|
||||
onChange={(value) => onChange('on_demand_start', value)}
|
||||
description="Start instance only when needed"
|
||||
/>
|
||||
|
||||
<EnvironmentVariablesInput
|
||||
id="environment"
|
||||
label="Environment Variables"
|
||||
value={formData.environment}
|
||||
onChange={(value) => onChange('environment', value)}
|
||||
description="Custom environment variables for the instance"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -147,16 +147,3 @@ export const useAuth = (): AuthContextType => {
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// Helper hook for getting auth headers
|
||||
export const useAuthHeaders = (): HeadersInit => {
|
||||
const { apiKey, isAuthenticated } = useAuth()
|
||||
|
||||
if (!isAuthenticated || !apiKey) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
}
|
||||
}
|
||||
|
||||
62
webui/src/contexts/ConfigContext.tsx
Normal file
62
webui/src/contexts/ConfigContext.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { type ReactNode, createContext, useContext, useEffect, useState, useRef } from 'react'
|
||||
import { serverApi } from '@/lib/api'
|
||||
import type { AppConfig } from '@/types/config'
|
||||
import { useAuth } from './AuthContext'
|
||||
|
||||
interface ConfigContextType {
|
||||
config: AppConfig | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const ConfigContext = createContext<ConfigContextType | undefined>(undefined)
|
||||
|
||||
interface ConfigProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const ConfigProvider = ({ children }: ConfigProviderProps) => {
|
||||
const { isAuthenticated } = useAuth()
|
||||
const [config, setConfig] = useState<AppConfig | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const loadedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || loadedRef.current) {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
loadedRef.current = true
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const data = await serverApi.getConfig()
|
||||
setConfig(data)
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to load configuration'
|
||||
setError(errorMessage)
|
||||
console.error('Error loading config:', err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void loadConfig()
|
||||
}, [isAuthenticated])
|
||||
|
||||
return (
|
||||
<ConfigContext.Provider value={{ config, isLoading, error }}>
|
||||
{children}
|
||||
</ConfigContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useConfig = (): ConfigContextType => {
|
||||
const context = useContext(ConfigContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useConfig must be used within a ConfigProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
51
webui/src/hooks/useConfig.ts
Normal file
51
webui/src/hooks/useConfig.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useConfig } from '@/contexts/ConfigContext'
|
||||
|
||||
// Helper hook to get instance default values from config
|
||||
export const useInstanceDefaults = () => {
|
||||
const { config } = useConfig()
|
||||
|
||||
if (!config || !config.instances) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
autoRestart: config.instances.default_auto_restart,
|
||||
maxRestarts: config.instances.default_max_restarts,
|
||||
restartDelay: config.instances.default_restart_delay,
|
||||
onDemandStart: config.instances.default_on_demand_start,
|
||||
}
|
||||
}
|
||||
|
||||
// Helper hook to get specific backend settings by backend type
|
||||
export const useBackendSettings = (backendType: string | undefined) => {
|
||||
const { config } = useConfig()
|
||||
|
||||
if (!config || !config.backends || !backendType) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Map backend type to config key
|
||||
const backendKey = backendType === 'llama_cpp'
|
||||
? 'llama-cpp'
|
||||
: backendType === 'mlx_lm'
|
||||
? 'mlx'
|
||||
: backendType === 'vllm'
|
||||
? 'vllm'
|
||||
: null
|
||||
|
||||
if (!backendKey) {
|
||||
return null
|
||||
}
|
||||
|
||||
const backendConfig = config.backends[backendKey as keyof typeof config.backends]
|
||||
|
||||
if (!backendConfig) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
command: backendConfig.command || '',
|
||||
dockerEnabled: backendConfig.docker?.enabled ?? false,
|
||||
dockerImage: backendConfig.docker?.image || '',
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export function useInstanceHealth(instanceName: string, instanceStatus: Instance
|
||||
|
||||
// Trigger health check when instance status changes to active states
|
||||
useEffect(() => {
|
||||
if (instanceStatus === 'running' || instanceStatus === 'restarting') {
|
||||
if (instanceStatus === 'running' || instanceStatus === 'restarting' || instanceStatus === 'shutting_down') {
|
||||
healthService.refreshHealth(instanceName).catch(error => {
|
||||
console.error(`Failed to refresh health for ${instanceName}:`, error)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CreateInstanceOptions, Instance } from "@/types/instance";
|
||||
import type { AppConfig } from "@/types/config";
|
||||
import { handleApiError } from "./errorUtils";
|
||||
|
||||
// Adding baseURI as a prefix to support being served behind a subpath
|
||||
@@ -73,6 +74,9 @@ export const serverApi = {
|
||||
|
||||
// GET /backends/llama-cpp/devices
|
||||
getDevices: () => apiCall<string>("/backends/llama-cpp/devices", {}, "text"),
|
||||
|
||||
// GET /config
|
||||
getConfig: () => apiCall<AppConfig>("/config"),
|
||||
};
|
||||
|
||||
// Backend API functions
|
||||
|
||||
@@ -5,11 +5,12 @@ type HealthCallback = (health: HealthStatus) => void
|
||||
|
||||
// Polling intervals based on health state (in milliseconds)
|
||||
const POLLING_INTERVALS: Record<HealthState, number> = {
|
||||
'starting': 5000, // 5 seconds - frequent during startup
|
||||
'restarting': 5000, // 5 seconds - restart in progress
|
||||
'ready': 60000, // 60 seconds - stable state
|
||||
'stopped': 0, // No polling
|
||||
'failed': 0, // No polling
|
||||
'starting': 5000, // 5 seconds - frequent during startup
|
||||
'restarting': 5000, // 5 seconds - restart in progress
|
||||
'shutting_down': 3000, // 3 seconds - monitor shutdown progress
|
||||
'ready': 60000, // 60 seconds - stable state
|
||||
'stopped': 0, // No polling
|
||||
'failed': 0, // No polling
|
||||
}
|
||||
|
||||
class HealthService {
|
||||
@@ -96,6 +97,7 @@ class HealthService {
|
||||
case 'running': return 'starting' // Should not happen as we check HTTP for running
|
||||
case 'failed': return 'failed'
|
||||
case 'restarting': return 'restarting'
|
||||
case 'shutting_down': return 'shutting_down'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ export function getAdvancedBackendFields(backendType?: string): string[] {
|
||||
const fieldGetter = backendFieldGetters[normalizedType] || getAllLlamaCppFieldKeys
|
||||
const basicConfig = backendFieldConfigs[normalizedType] || basicLlamaCppFieldsConfig
|
||||
|
||||
return fieldGetter().filter(key => !(key in basicConfig))
|
||||
return fieldGetter().filter(key => !(key in basicConfig) && key !== 'extra_args')
|
||||
}
|
||||
|
||||
// Combined backend fields config for use in BackendFormField
|
||||
|
||||
@@ -4,13 +4,16 @@ import App from './App'
|
||||
import { InstancesProvider } from './contexts/InstancesContext'
|
||||
import './index.css'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { ConfigProvider } from './contexts/ConfigContext'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<AuthProvider>
|
||||
<InstancesProvider>
|
||||
<App />
|
||||
</InstancesProvider>
|
||||
<ConfigProvider>
|
||||
<InstancesProvider>
|
||||
<App />
|
||||
</InstancesProvider>
|
||||
</ConfigProvider>
|
||||
</AuthProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -167,6 +167,9 @@ export const LlamaCppBackendOptionsSchema = z.object({
|
||||
fim_qwen_7b_default: z.boolean().optional(),
|
||||
fim_qwen_7b_spec: z.boolean().optional(),
|
||||
fim_qwen_14b_spec: z.boolean().optional(),
|
||||
|
||||
// Extra args
|
||||
extra_args: z.record(z.string(), z.string()).optional(),
|
||||
})
|
||||
|
||||
// Infer the TypeScript type from the schema
|
||||
|
||||
@@ -25,6 +25,9 @@ export const MlxBackendOptionsSchema = z.object({
|
||||
top_k: z.number().optional(),
|
||||
min_p: z.number().optional(),
|
||||
max_tokens: z.number().optional(),
|
||||
|
||||
// Extra args
|
||||
extra_args: z.record(z.string(), z.string()).optional(),
|
||||
})
|
||||
|
||||
// Infer the TypeScript type from the schema
|
||||
|
||||
@@ -125,6 +125,9 @@ export const VllmBackendOptionsSchema = z.object({
|
||||
override_pooling_config: z.string().optional(),
|
||||
override_neuron_config: z.string().optional(),
|
||||
override_kv_cache_align_size: z.number().optional(),
|
||||
|
||||
// Extra args
|
||||
extra_args: z.record(z.string(), z.string()).optional(),
|
||||
})
|
||||
|
||||
// Infer the TypeScript type from the schema
|
||||
|
||||
@@ -36,6 +36,10 @@ export const CreateInstanceOptionsSchema = z.object({
|
||||
// Environment variables
|
||||
environment: z.record(z.string(), z.string()).optional(),
|
||||
|
||||
// Execution context overrides
|
||||
docker_enabled: z.boolean().optional(),
|
||||
command_override: z.string().optional(),
|
||||
|
||||
// Backend configuration
|
||||
backend_type: z.enum([BackendType.LLAMA_CPP, BackendType.MLX_LM, BackendType.VLLM]).optional(),
|
||||
backend_options: BackendOptionsSchema.optional(),
|
||||
|
||||
70
webui/src/types/config.ts
Normal file
70
webui/src/types/config.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
export interface BackendSettings {
|
||||
command: string
|
||||
args: string[]
|
||||
environment?: Record<string, string>
|
||||
docker?: DockerSettings
|
||||
response_headers?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface DockerSettings {
|
||||
enabled: boolean
|
||||
image: string
|
||||
args: string[]
|
||||
environment?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface BackendConfig {
|
||||
'llama-cpp': BackendSettings
|
||||
vllm: BackendSettings
|
||||
mlx: BackendSettings
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
host: string
|
||||
port: number
|
||||
allowed_origins: string[]
|
||||
allowed_headers: string[]
|
||||
enable_swagger: boolean
|
||||
response_headers?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface InstancesConfig {
|
||||
port_range: [number, number]
|
||||
data_dir: string
|
||||
configs_dir: string
|
||||
logs_dir: string
|
||||
auto_create_dirs: boolean
|
||||
max_instances: number
|
||||
max_running_instances: number
|
||||
enable_lru_eviction: boolean
|
||||
default_auto_restart: boolean
|
||||
default_max_restarts: number
|
||||
default_restart_delay: number
|
||||
default_on_demand_start: boolean
|
||||
on_demand_start_timeout: number
|
||||
timeout_check_interval: number
|
||||
}
|
||||
|
||||
export interface AuthConfig {
|
||||
require_inference_auth: boolean
|
||||
inference_keys: string[] // Will be empty in sanitized response
|
||||
require_management_auth: boolean
|
||||
management_keys: string[] // Will be empty in sanitized response
|
||||
}
|
||||
|
||||
export interface NodeConfig {
|
||||
address: string
|
||||
api_key: string // Will be empty in sanitized response
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
server: ServerConfig
|
||||
backends: BackendConfig
|
||||
instances: InstancesConfig
|
||||
auth: AuthConfig
|
||||
local_node: string
|
||||
nodes: Record<string, NodeConfig>
|
||||
version?: string
|
||||
commit_hash?: string
|
||||
build_time?: string
|
||||
}
|
||||
@@ -11,9 +11,9 @@ export const BackendType = {
|
||||
|
||||
export type BackendTypeValue = typeof BackendType[keyof typeof BackendType]
|
||||
|
||||
export type InstanceStatus = 'running' | 'stopped' | 'failed' | 'restarting'
|
||||
export type InstanceStatus = 'running' | 'stopped' | 'failed' | 'restarting' | 'shutting_down'
|
||||
|
||||
export type HealthState = 'stopped' | 'starting' | 'ready' | 'failed' | 'restarting'
|
||||
export type HealthState = 'stopped' | 'starting' | 'ready' | 'failed' | 'restarting' | 'shutting_down'
|
||||
|
||||
export interface HealthStatus {
|
||||
state: HealthState
|
||||
@@ -27,5 +27,4 @@ export interface Instance {
|
||||
name: string;
|
||||
status: InstanceStatus;
|
||||
options?: CreateInstanceOptions;
|
||||
docker_enabled?: boolean; // indicates backend is running via Docker
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"types": ["vite/client"]
|
||||
"types": ["vite/client", "@types/node"]
|
||||
},
|
||||
"include": ["src", "src/vite-env.d.ts"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
|
||||
Reference in New Issue
Block a user