mirror of
https://github.com/lordmathis/llamactl.git
synced 2025-12-23 17:44:24 +00:00
Compare commits
69 Commits
470f90076f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e0726a08c4 | |||
| 16e2093cc9 | |||
| 70dc635e4c | |||
|
|
e0d342f31f | ||
| 9cea295305 | |||
| 1f78d3f780 | |||
| e7baeb9ece | |||
| 3cec850e74 | |||
| 67098d7801 | |||
| 3c95e76137 | |||
| 761cdfe7d8 | |||
| 99eba3daa9 | |||
| d9d7b6d814 | |||
| 5062c882de | |||
| ee122d669c | |||
| 41d904475c | |||
| 7f5292412c | |||
| ec84a7d331 | |||
| b45219a01e | |||
| 463bb561e1 | |||
| ebdb9143c0 | |||
| 4269d04381 | |||
| c734329a62 | |||
| 15fcf7c377 | |||
|
|
795f530956 | ||
|
|
4507358310 | ||
| f3c02b4939 | |||
| 0a85409deb | |||
| 22fd295250 | |||
| c0cecdd377 | |||
| 4d57b37a5d | |||
| c13b71d07f | |||
| 406a711682 | |||
| 0b3d654945 | |||
| e2a49402d6 | |||
| 48836c9c12 | |||
| 4200b8eed9 | |||
|
|
9a7ae87df8 | ||
| e54c495528 | |||
|
|
83006968ca | ||
|
|
c8d9c6907c | ||
| c776785f30 | |||
| 1cfbd42eda | |||
| 8fee27054d | |||
| fd33837026 | |||
| 3c4ebf7403 | |||
| b7a0f7e3d8 | |||
| d5b68a900f | |||
| 00cd8c8877 | |||
| 4b1b12a7a8 | |||
| 0ce9016488 | |||
| 1acbcafe1c | |||
| 00a502a268 | |||
| 54fe0f7421 | |||
| cd1bd64889 | |||
| 0fee7abc7c | |||
| 02193bd309 | |||
| 0217f7cc4e | |||
| fa311c46ac | |||
| 99927160c2 | |||
| c37c1b8161 | |||
| 80d5d44a0b | |||
| 2d0acc60f2 | |||
| a1b6f0c1b0 | |||
| 991ce3c678 | |||
| d9c666a245 | |||
| 85cf712b03 | |||
| 5ccf493e04 | |||
| 9eee42c673 |
36
.github/workflows/release.yaml
vendored
36
.github/workflows/release.yaml
vendored
@@ -45,15 +45,23 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
name: Build Binaries
|
name: Build Binaries
|
||||||
needs: build-webui
|
needs: build-webui
|
||||||
runs-on: ubuntu-latest
|
runs-on: ${{ matrix.runner }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
goos: [linux, windows, darwin]
|
include:
|
||||||
goarch: [amd64, arm64]
|
- goos: linux
|
||||||
exclude:
|
goarch: amd64
|
||||||
# Windows ARM64 support is limited
|
runner: ubuntu-latest
|
||||||
- goos: windows
|
- goos: linux
|
||||||
goarch: arm64
|
goarch: arm64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
cc: aarch64-linux-gnu-gcc
|
||||||
|
- goos: darwin
|
||||||
|
goarch: arm64
|
||||||
|
runner: macos-latest
|
||||||
|
- goos: windows
|
||||||
|
goarch: amd64
|
||||||
|
runner: windows-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -70,11 +78,19 @@ jobs:
|
|||||||
name: webui-dist
|
name: webui-dist
|
||||||
path: webui/dist/
|
path: webui/dist/
|
||||||
|
|
||||||
|
- name: Install cross-compilation tools (Linux ARM64 only)
|
||||||
|
if: matrix.cc != ''
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y gcc-aarch64-linux-gnu
|
||||||
|
|
||||||
- name: Build binary
|
- name: Build binary
|
||||||
env:
|
env:
|
||||||
GOOS: ${{ matrix.goos }}
|
GOOS: ${{ matrix.goos }}
|
||||||
GOARCH: ${{ matrix.goarch }}
|
GOARCH: ${{ matrix.goarch }}
|
||||||
CGO_ENABLED: 0
|
CGO_ENABLED: 1
|
||||||
|
CC: ${{ matrix.cc }}
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
# Set binary extension for Windows
|
# Set binary extension for Windows
|
||||||
BINARY_NAME="llamactl"
|
BINARY_NAME="llamactl"
|
||||||
@@ -91,8 +107,10 @@ jobs:
|
|||||||
ARCHIVE_OS="macos"
|
ARCHIVE_OS="macos"
|
||||||
fi
|
fi
|
||||||
ARCHIVE_NAME="llamactl-${{ github.ref_name }}-${ARCHIVE_OS}-${{ matrix.goarch }}"
|
ARCHIVE_NAME="llamactl-${{ github.ref_name }}-${ARCHIVE_OS}-${{ matrix.goarch }}"
|
||||||
|
|
||||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||||
zip "${ARCHIVE_NAME}.zip" "${BINARY_NAME}"
|
# Use 7z on Windows (pre-installed)
|
||||||
|
7z a "${ARCHIVE_NAME}.zip" "${BINARY_NAME}"
|
||||||
echo "ASSET_PATH=${ARCHIVE_NAME}.zip" >> $GITHUB_ENV
|
echo "ASSET_PATH=${ARCHIVE_NAME}.zip" >> $GITHUB_ENV
|
||||||
else
|
else
|
||||||
tar -czf "${ARCHIVE_NAME}.tar.gz" "${BINARY_NAME}"
|
tar -czf "${ARCHIVE_NAME}.tar.gz" "${BINARY_NAME}"
|
||||||
@@ -179,4 +197,4 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
files: assets/checksums.txt
|
files: assets/checksums.txt
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
1
.vscode/launch.json
vendored
1
.vscode/launch.json
vendored
@@ -14,6 +14,7 @@
|
|||||||
"GO_ENV": "development",
|
"GO_ENV": "development",
|
||||||
"LLAMACTL_CONFIG_PATH": "${workspaceFolder}/llamactl.dev.yaml"
|
"LLAMACTL_CONFIG_PATH": "${workspaceFolder}/llamactl.dev.yaml"
|
||||||
},
|
},
|
||||||
|
"console": "integratedTerminal",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
**🚀 Easy Model Management**
|
**🚀 Easy Model Management**
|
||||||
- **Multiple Models Simultaneously**: Run different models at the same time (7B for speed, 70B for quality)
|
- **Multiple Models Simultaneously**: Run different models at the same time (7B for speed, 70B for quality)
|
||||||
|
- **Dynamic Multi-Model Instances**: llama.cpp router mode - serve multiple models from a single instance with on-demand loading
|
||||||
- **Smart Resource Management**: Automatic idle timeout, LRU eviction, and configurable instance limits
|
- **Smart Resource Management**: Automatic idle timeout, LRU eviction, and configurable instance limits
|
||||||
- **Web Dashboard**: Modern React UI for managing instances, monitoring health, and viewing logs
|
- **Web Dashboard**: Modern React UI for managing instances, monitoring health, and viewing logs
|
||||||
|
|
||||||
@@ -183,7 +184,6 @@ data_dir: ~/.local/share/llamactl # Main data directory (database, instances, l
|
|||||||
|
|
||||||
instances:
|
instances:
|
||||||
port_range: [8000, 9000] # Port range for instances
|
port_range: [8000, 9000] # Port range for instances
|
||||||
configs_dir: ~/.local/share/llamactl/instances # Instance configs directory (platform dependent)
|
|
||||||
logs_dir: ~/.local/share/llamactl/logs # Logs directory (platform dependent)
|
logs_dir: ~/.local/share/llamactl/logs # Logs directory (platform dependent)
|
||||||
auto_create_dirs: true # Auto-create data/config/logs dirs if missing
|
auto_create_dirs: true # Auto-create data/config/logs dirs if missing
|
||||||
max_instances: -1 # Max instances (-1 = unlimited)
|
max_instances: -1 # Max instances (-1 = unlimited)
|
||||||
@@ -195,6 +195,9 @@ instances:
|
|||||||
default_on_demand_start: true # Default on-demand start setting
|
default_on_demand_start: true # Default on-demand start setting
|
||||||
on_demand_start_timeout: 120 # Default on-demand start timeout in seconds
|
on_demand_start_timeout: 120 # Default on-demand start timeout in seconds
|
||||||
timeout_check_interval: 5 # Idle instance timeout check in minutes
|
timeout_check_interval: 5 # Idle instance timeout check in minutes
|
||||||
|
log_rotation_enabled: true # Enable log rotation (default: true)
|
||||||
|
log_rotation_max_size: 100 # Max log file size in MB before rotation (default: 100)
|
||||||
|
log_rotation_compress: false # Compress rotated log files (default: false)
|
||||||
|
|
||||||
database:
|
database:
|
||||||
path: ~/.local/share/llamactl/llamactl.db # Database file path (platform dependent)
|
path: ~/.local/share/llamactl/llamactl.db # Database file path (platform dependent)
|
||||||
@@ -203,8 +206,7 @@ database:
|
|||||||
connection_max_lifetime: 5m # Connection max lifetime
|
connection_max_lifetime: 5m # Connection max lifetime
|
||||||
|
|
||||||
auth:
|
auth:
|
||||||
require_inference_auth: true # Require auth for inference endpoints
|
require_inference_auth: true # Require auth for inference endpoints, API keys are created in web UI
|
||||||
inference_keys: [] # Keys for inference endpoints
|
|
||||||
require_management_auth: true # Require auth for management endpoints
|
require_management_auth: true # Require auth for management endpoints
|
||||||
management_keys: [] # Keys for management endpoints
|
management_keys: [] # Keys for management endpoints
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"llamactl/pkg/config"
|
"llamactl/pkg/config"
|
||||||
"llamactl/pkg/database"
|
"llamactl/pkg/database"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// version is set at build time using -ldflags "-X main.version=1.0.0"
|
// version is set at build time using -ldflags "-X main.version=1.0.0"
|
||||||
@@ -48,18 +50,13 @@ func main() {
|
|||||||
cfg.CommitHash = commitHash
|
cfg.CommitHash = commitHash
|
||||||
cfg.BuildTime = buildTime
|
cfg.BuildTime = buildTime
|
||||||
|
|
||||||
// Create the data directory if it doesn't exist
|
// Create data directory if it doesn't exist
|
||||||
if cfg.Instances.AutoCreateDirs {
|
if cfg.Instances.AutoCreateDirs {
|
||||||
// Create the main data directory
|
// Create the main data directory
|
||||||
if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
|
if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
|
||||||
log.Printf("Error creating data directory %s: %v\nData persistence may not be available.", cfg.DataDir, err)
|
log.Printf("Error creating data directory %s: %v\nData persistence may not be available.", cfg.DataDir, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create instances directory
|
|
||||||
if err := os.MkdirAll(cfg.Instances.InstancesDir, 0755); err != nil {
|
|
||||||
log.Printf("Error creating instances directory %s: %v\nPersistence will not be available.", cfg.Instances.InstancesDir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create logs directory
|
// Create logs directory
|
||||||
if err := os.MkdirAll(cfg.Instances.LogsDir, 0755); err != nil {
|
if err := os.MkdirAll(cfg.Instances.LogsDir, 0755); err != nil {
|
||||||
log.Printf("Error creating log directory %s: %v\nInstance logs will not be available.", cfg.Instances.LogsDir, err)
|
log.Printf("Error creating log directory %s: %v\nInstance logs will not be available.", cfg.Instances.LogsDir, err)
|
||||||
@@ -82,16 +79,11 @@ func main() {
|
|||||||
log.Fatalf("Failed to run database migrations: %v", err)
|
log.Fatalf("Failed to run database migrations: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate from JSON files if needed (one-time migration)
|
|
||||||
if err := migrateFromJSON(&cfg, db); err != nil {
|
|
||||||
log.Printf("Warning: Failed to migrate from JSON: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize the instance manager with dependency injection
|
// Initialize the instance manager with dependency injection
|
||||||
instanceManager := manager.New(&cfg, db)
|
instanceManager := manager.New(&cfg, db)
|
||||||
|
|
||||||
// Create a new handler with the instance manager
|
// Create a new handler with the instance manager
|
||||||
handler := server.NewHandler(instanceManager, cfg)
|
handler := server.NewHandler(instanceManager, cfg, db)
|
||||||
|
|
||||||
// Setup the router with the handler
|
// Setup the router with the handler
|
||||||
r := server.SetupRouter(handler)
|
r := server.SetupRouter(handler)
|
||||||
@@ -116,14 +108,23 @@ func main() {
|
|||||||
<-stop
|
<-stop
|
||||||
fmt.Println("Shutting down server...")
|
fmt.Println("Shutting down server...")
|
||||||
|
|
||||||
if err := server.Close(); err != nil {
|
// Create shutdown context with timeout
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer shutdownCancel()
|
||||||
|
|
||||||
|
// Shutdown HTTP server gracefully
|
||||||
|
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||||
log.Printf("Error shutting down server: %v\n", err)
|
log.Printf("Error shutting down server: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("Server shut down gracefully.")
|
fmt.Println("Server shut down gracefully.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for all instances to stop
|
// Stop all instances and cleanup
|
||||||
instanceManager.Shutdown()
|
instanceManager.Shutdown()
|
||||||
|
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
log.Printf("Error closing database: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Println("Exiting llamactl.")
|
fmt.Println("Exiting llamactl.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"llamactl/pkg/config"
|
|
||||||
"llamactl/pkg/database"
|
|
||||||
"llamactl/pkg/instance"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
// migrateFromJSON migrates instances from JSON files to SQLite database
|
|
||||||
// This is a one-time migration that runs on first startup with existing JSON files.
|
|
||||||
func migrateFromJSON(cfg *config.AppConfig, db database.DB) error {
|
|
||||||
instancesDir := cfg.Instances.InstancesDir
|
|
||||||
if instancesDir == "" {
|
|
||||||
return nil // No instances directory configured
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if instances directory exists
|
|
||||||
if _, err := os.Stat(instancesDir); os.IsNotExist(err) {
|
|
||||||
return nil // No instances directory, nothing to migrate
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if database is empty (no instances)
|
|
||||||
existing, err := db.LoadAll()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to check existing instances: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(existing) > 0 {
|
|
||||||
return nil // Database already has instances, skip migration
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find all JSON files
|
|
||||||
files, err := filepath.Glob(filepath.Join(instancesDir, "*.json"))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to list instance files: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(files) == 0 {
|
|
||||||
return nil // No JSON files to migrate
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("Migrating %d instances from JSON to SQLite...", len(files))
|
|
||||||
|
|
||||||
// Migrate each JSON file
|
|
||||||
var migrated int
|
|
||||||
for _, file := range files {
|
|
||||||
if err := migrateJSONFile(file, db); err != nil {
|
|
||||||
log.Printf("Failed to migrate %s: %v", file, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
migrated++
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("Successfully migrated %d/%d instances to SQLite", migrated, len(files))
|
|
||||||
|
|
||||||
// Archive old JSON files
|
|
||||||
if migrated > 0 {
|
|
||||||
archiveDir := filepath.Join(instancesDir, "json_archive")
|
|
||||||
if err := os.MkdirAll(archiveDir, 0755); err == nil {
|
|
||||||
for _, file := range files {
|
|
||||||
newPath := filepath.Join(archiveDir, filepath.Base(file))
|
|
||||||
if err := os.Rename(file, newPath); err != nil {
|
|
||||||
log.Printf("Failed to archive %s: %v", file, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.Printf("Archived old JSON files to %s", archiveDir)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// migrateJSONFile migrates a single JSON file to the database
|
|
||||||
func migrateJSONFile(filename string, db database.DB) error {
|
|
||||||
data, err := os.ReadFile(filename)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var inst instance.Instance
|
|
||||||
if err := json.Unmarshal(data, &inst); err != nil {
|
|
||||||
return fmt.Errorf("failed to unmarshal instance: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.Save(&inst); err != nil {
|
|
||||||
return fmt.Errorf("failed to save instance to database: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("Migrated instance %s from JSON to SQLite", inst.Name)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -74,7 +74,6 @@ database:
|
|||||||
|
|
||||||
auth:
|
auth:
|
||||||
require_inference_auth: true # Require auth for inference endpoints
|
require_inference_auth: true # Require auth for inference endpoints
|
||||||
inference_keys: [] # Keys for inference endpoints
|
|
||||||
require_management_auth: true # Require auth for management endpoints
|
require_management_auth: true # Require auth for management endpoints
|
||||||
management_keys: [] # Keys for management endpoints
|
management_keys: [] # Keys for management endpoints
|
||||||
|
|
||||||
@@ -231,6 +230,9 @@ instances:
|
|||||||
default_on_demand_start: true # Default on-demand start setting
|
default_on_demand_start: true # Default on-demand start setting
|
||||||
on_demand_start_timeout: 120 # Default on-demand start timeout in seconds
|
on_demand_start_timeout: 120 # Default on-demand start timeout in seconds
|
||||||
timeout_check_interval: 5 # Default instance timeout check interval in minutes
|
timeout_check_interval: 5 # Default instance timeout check interval in minutes
|
||||||
|
log_rotation_enabled: true # Enable log rotation (default: true)
|
||||||
|
log_rotation_max_size: 100 # Max log file size in MB before rotation (default: 100)
|
||||||
|
log_rotation_compress: false # Compress rotated log files (default: false)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Environment Variables:**
|
**Environment Variables:**
|
||||||
@@ -247,6 +249,9 @@ instances:
|
|||||||
- `LLAMACTL_DEFAULT_ON_DEMAND_START` - Default on-demand start setting (true/false)
|
- `LLAMACTL_DEFAULT_ON_DEMAND_START` - Default on-demand start setting (true/false)
|
||||||
- `LLAMACTL_ON_DEMAND_START_TIMEOUT` - Default on-demand start timeout in seconds
|
- `LLAMACTL_ON_DEMAND_START_TIMEOUT` - Default on-demand start timeout in seconds
|
||||||
- `LLAMACTL_TIMEOUT_CHECK_INTERVAL` - Default instance timeout check interval in minutes
|
- `LLAMACTL_TIMEOUT_CHECK_INTERVAL` - Default instance timeout check interval in minutes
|
||||||
|
- `LLAMACTL_LOG_ROTATION_ENABLED` - Enable log rotation (true/false)
|
||||||
|
- `LLAMACTL_LOG_ROTATION_MAX_SIZE` - Max log file size in MB
|
||||||
|
- `LLAMACTL_LOG_ROTATION_COMPRESS` - Compress rotated logs (true/false)
|
||||||
|
|
||||||
### Database Configuration
|
### Database Configuration
|
||||||
|
|
||||||
@@ -266,17 +271,33 @@ database:
|
|||||||
|
|
||||||
### Authentication Configuration
|
### Authentication Configuration
|
||||||
|
|
||||||
|
llamactl supports two types of authentication:
|
||||||
|
|
||||||
|
- **Management API Keys**: For accessing the web UI and management API (creating/managing instances). These can be configured in the config file or via environment variables.
|
||||||
|
- **Inference API Keys**: For accessing the OpenAI-compatible inference endpoints. These are managed via the web UI (Settings → API Keys) and stored in the database.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
auth:
|
auth:
|
||||||
require_inference_auth: true # Require API key for OpenAI endpoints (default: true)
|
require_inference_auth: true # Require API key for OpenAI endpoints (default: true)
|
||||||
inference_keys: [] # List of valid inference API keys
|
|
||||||
require_management_auth: true # Require API key for management endpoints (default: true)
|
require_management_auth: true # Require API key for management endpoints (default: true)
|
||||||
management_keys: [] # List of valid management API keys
|
management_keys: [] # List of valid management API keys
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Managing Inference API Keys:**
|
||||||
|
|
||||||
|
Inference API keys are managed through the web UI or management API and stored in the database. To create and manage inference keys:
|
||||||
|
|
||||||
|
1. Open the web UI and log in with a management API key
|
||||||
|
2. Navigate to **Settings → API Keys**
|
||||||
|
3. Click **Create API Key**
|
||||||
|
4. Configure the key:
|
||||||
|
- **Name**: A descriptive name for the key
|
||||||
|
- **Expiration**: Optional expiration date
|
||||||
|
- **Permissions**: Grant access to all instances or specific instances only
|
||||||
|
5. Copy the generated key - it won't be shown again
|
||||||
|
|
||||||
**Environment Variables:**
|
**Environment Variables:**
|
||||||
- `LLAMACTL_REQUIRE_INFERENCE_AUTH` - Require auth for OpenAI endpoints (true/false)
|
- `LLAMACTL_REQUIRE_INFERENCE_AUTH` - Require auth for OpenAI endpoints (true/false)
|
||||||
- `LLAMACTL_INFERENCE_KEYS` - Comma-separated inference API keys
|
|
||||||
- `LLAMACTL_REQUIRE_MANAGEMENT_AUTH` - Require auth for management endpoints (true/false)
|
- `LLAMACTL_REQUIRE_MANAGEMENT_AUTH` - Require auth for management endpoints (true/false)
|
||||||
- `LLAMACTL_MANAGEMENT_KEYS` - Comma-separated management API keys
|
- `LLAMACTL_MANAGEMENT_KEYS` - Comma-separated management API keys
|
||||||
|
|
||||||
|
|||||||
546
docs/docs.go
546
docs/docs.go
@@ -19,6 +19,235 @@ const docTemplate = `{
|
|||||||
"host": "{{.Host}}",
|
"host": "{{.Host}}",
|
||||||
"basePath": "{{.BasePath}}",
|
"basePath": "{{.BasePath}}",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"/api/v1/auth/keys": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Returns a list of all API keys for the system user (excludes key hash and plain-text key)",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Keys"
|
||||||
|
],
|
||||||
|
"summary": "List all API keys",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "List of API keys",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/server.KeyResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"description": "Creates a new API key with the specified permissions and returns the plain-text key (only shown once)",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Keys"
|
||||||
|
],
|
||||||
|
"summary": "Create a new API key",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "API key configuration",
|
||||||
|
"name": "key",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/server.CreateKeyRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Created API key with plain-text key",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/server.CreateKeyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid request body or validation error",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/auth/keys/{id}": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Returns details for a specific API key by ID (excludes key hash and plain-text key)",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Keys"
|
||||||
|
],
|
||||||
|
"summary": "Get details of a specific API key",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Key ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "API key details",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/server.KeyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid key ID",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "API key not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Deletes an API key by ID",
|
||||||
|
"tags": [
|
||||||
|
"Keys"
|
||||||
|
],
|
||||||
|
"summary": "Delete an API key",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Key ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "API key deleted successfully"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid key ID",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "API key not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/auth/keys/{id}/permissions": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Returns the instance-level permissions for a specific API key (includes instance names)",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Keys"
|
||||||
|
],
|
||||||
|
"summary": "Get API key permissions",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Key ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "List of key permissions",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/server.KeyPermissionResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid key ID",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "API key not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/backends/llama-cpp/devices": {
|
"/api/v1/backends/llama-cpp/devices": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -770,6 +999,156 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/llama-cpp/{name}/models": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Returns a list of models available in the specified llama.cpp instance",
|
||||||
|
"tags": [
|
||||||
|
"Llama.cpp"
|
||||||
|
],
|
||||||
|
"summary": "List models in a llama.cpp instance",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Instance Name",
|
||||||
|
"name": "name",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Models list response",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid instance",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/llama-cpp/{name}/models/{model}/load": {
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Loads the specified model in the given llama.cpp instance",
|
||||||
|
"tags": [
|
||||||
|
"Llama.cpp"
|
||||||
|
],
|
||||||
|
"summary": "Load a model in a llama.cpp instance",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Instance Name",
|
||||||
|
"name": "name",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Model Name",
|
||||||
|
"name": "model",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success message",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid request",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/llama-cpp/{name}/models/{model}/unload": {
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Unloads the specified model in the given llama.cpp instance",
|
||||||
|
"tags": [
|
||||||
|
"Llama.cpp"
|
||||||
|
],
|
||||||
|
"summary": "Unload a model in a llama.cpp instance",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Instance Name",
|
||||||
|
"name": "name",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Model Name",
|
||||||
|
"name": "model",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success message",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid request",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/nodes": {
|
"/api/v1/nodes": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -1503,6 +1882,17 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"definitions": {
|
"definitions": {
|
||||||
|
"auth.PermissionMode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"allow_all",
|
||||||
|
"per_instance"
|
||||||
|
],
|
||||||
|
"x-enum-varnames": [
|
||||||
|
"PermissionModeAllowAll",
|
||||||
|
"PermissionModePerInstance"
|
||||||
|
]
|
||||||
|
},
|
||||||
"config.AppConfig": {
|
"config.AppConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1518,6 +1908,13 @@ const docTemplate = `{
|
|||||||
"commit_hash": {
|
"commit_hash": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"data_dir": {
|
||||||
|
"description": "Directory where all llamactl data will be stored (database, instances, logs, etc.)",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"$ref": "#/definitions/config.DatabaseConfig"
|
||||||
|
},
|
||||||
"instances": {
|
"instances": {
|
||||||
"$ref": "#/definitions/config.InstancesConfig"
|
"$ref": "#/definitions/config.InstancesConfig"
|
||||||
},
|
},
|
||||||
@@ -1541,13 +1938,6 @@ const docTemplate = `{
|
|||||||
"config.AuthConfig": {
|
"config.AuthConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"inference_keys": {
|
|
||||||
"description": "List of keys for OpenAI compatible inference endpoints",
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"management_keys": {
|
"management_keys": {
|
||||||
"description": "List of keys for management endpoints",
|
"description": "List of keys for management endpoints",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
@@ -1608,6 +1998,26 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"config.DatabaseConfig": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"connection_max_lifetime": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "1h"
|
||||||
|
},
|
||||||
|
"max_idle_connections": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"max_open_connections": {
|
||||||
|
"description": "Connection settings",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"description": "Database file path (relative to the top-level data_dir or absolute)",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"config.DockerSettings": {
|
"config.DockerSettings": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1638,14 +2048,6 @@ const docTemplate = `{
|
|||||||
"description": "Automatically create the data directory if it doesn't exist",
|
"description": "Automatically create the data directory if it doesn't exist",
|
||||||
"type": "boolean"
|
"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": {
|
"default_auto_restart": {
|
||||||
"description": "Default auto-restart setting for new instances",
|
"description": "Default auto-restart setting for new instances",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
@@ -1666,8 +2068,23 @@ const docTemplate = `{
|
|||||||
"description": "Enable LRU eviction for instance logs",
|
"description": "Enable LRU eviction for instance logs",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"logRotationCompress": {
|
||||||
|
"description": "Whether to compress rotated log files",
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"logRotationEnabled": {
|
||||||
|
"description": "Log rotation enabled",
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"logRotationMaxSize": {
|
||||||
|
"description": "Maximum log file size in MB before rotation",
|
||||||
|
"type": "integer",
|
||||||
|
"default": 100
|
||||||
|
},
|
||||||
"logs_dir": {
|
"logs_dir": {
|
||||||
"description": "Logs directory override",
|
"description": "Logs directory override (relative to data_dir if not absolute)",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"max_instances": {
|
"max_instances": {
|
||||||
@@ -1748,7 +2165,10 @@ const docTemplate = `{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"created": {
|
"created": {
|
||||||
"description": "Unix timestamp when the instance was created",
|
"description": "Unix timestamp when instance was created",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
@@ -1794,6 +2214,98 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"server.CreateKeyRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"expires_at": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"instance_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"permission_mode": {
|
||||||
|
"$ref": "#/definitions/auth.PermissionMode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server.CreateKeyResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"created_at": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"last_used_at": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"permission_mode": {
|
||||||
|
"$ref": "#/definitions/auth.PermissionMode"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server.KeyPermissionResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"instance_id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"instance_name": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server.KeyResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"created_at": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"last_used_at": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"permission_mode": {
|
||||||
|
"$ref": "#/definitions/auth.PermissionMode"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"server.NodeResponse": {
|
"server.NodeResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -222,6 +222,100 @@ curl -X DELETE http://localhost:8080/api/v1/instances/{name} \
|
|||||||
-H "Authorization: Bearer <token>"
|
-H "Authorization: Bearer <token>"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Multi-Model llama.cpp Instances
|
||||||
|
|
||||||
|
!!! info "llama.cpp Router Mode"
|
||||||
|
llama.cpp instances support [**router mode**](https://huggingface.co/blog/ggml-org/model-management-in-llamacpp), allowing a single instance to serve multiple models dynamically. Models are loaded on-demand from the llama.cpp cache without restarting the instance.
|
||||||
|
|
||||||
|
### Creating a Multi-Model Instance
|
||||||
|
|
||||||
|
**Via Web UI**
|
||||||
|
|
||||||
|
1. Click **"Create Instance"**
|
||||||
|
2. Select **Backend Type**: "Llama Server"
|
||||||
|
3. Leave **Backend Options** empty `{}` or omit the model field
|
||||||
|
4. Create the instance
|
||||||
|
|
||||||
|
**Via API**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create instance without specifying a model (router mode)
|
||||||
|
curl -X POST http://localhost:8080/api/v1/instances/my-router \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-d '{
|
||||||
|
"backend_type": "llama_cpp",
|
||||||
|
"backend_options": {},
|
||||||
|
"nodes": ["main"]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Managing Models
|
||||||
|
|
||||||
|
**Via Web UI**
|
||||||
|
|
||||||
|
1. Start the router mode instance
|
||||||
|
2. Instance card displays a badge showing loaded/total models (e.g., "2/5 models")
|
||||||
|
3. Click the **"Models"** button on the instance card
|
||||||
|
4. Models dialog opens showing:
|
||||||
|
- All available models from llama.cpp instance
|
||||||
|
- Status indicator (loaded, loading, or unloaded)
|
||||||
|
- Load/Unload buttons for each model
|
||||||
|
5. Click **"Load"** to load a model into memory
|
||||||
|
6. Click **"Unload"** to free up memory
|
||||||
|
|
||||||
|
**Via API**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List available models
|
||||||
|
curl http://localhost:8080/api/v1/llama-cpp/my-router/models \
|
||||||
|
-H "Authorization: Bearer <token>"
|
||||||
|
|
||||||
|
# Load a model
|
||||||
|
curl -X POST http://localhost:8080/api/v1/llama-cpp/my-router/models/Mistral-7B-Instruct-v0.3.Q4_K_M.gguf/load \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-d '{"model": "Mistral-7B-Instruct-v0.3.Q4_K_M.gguf"}'
|
||||||
|
|
||||||
|
# Unload a model
|
||||||
|
curl -X POST http://localhost:8080/api/v1/llama-cpp/my-router/models/Mistral-7B-Instruct-v0.3.Q4_K_M.gguf/unload \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-d '{"model": "Mistral-7B-Instruct-v0.3.Q4_K_M.gguf"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Multi-Model Instances
|
||||||
|
|
||||||
|
When making inference requests to a multi-model instance, specify the model using the format `instance_name/model_name`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# OpenAI-compatible chat completion with specific model
|
||||||
|
curl -X POST http://localhost:8080/v1/chat/completions \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer <inference-key>" \
|
||||||
|
-d '{
|
||||||
|
"model": "my-router/Mistral-7B-Instruct-v0.3.Q4_K_M.gguf",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "Hello!"}
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
|
||||||
|
# List all available models (includes multi-model instances)
|
||||||
|
curl http://localhost:8080/v1/models \
|
||||||
|
-H "Authorization: Bearer <inference-key>"
|
||||||
|
```
|
||||||
|
|
||||||
|
The response from `/v1/models` will include each model from multi-model instances as separate entries in the format `instance_name/model_name`.
|
||||||
|
|
||||||
|
### Model Discovery
|
||||||
|
|
||||||
|
Models are automatically discovered from the llama.cpp cache directory. The default cache locations are:
|
||||||
|
|
||||||
|
- **Linux/macOS**: `~/.cache/llama.cpp/`
|
||||||
|
- **Windows**: `%LOCALAPPDATA%\llama.cpp\`
|
||||||
|
|
||||||
|
Place your GGUF model files in the cache directory, and they will appear in the models list when you start a router mode instance.
|
||||||
|
|
||||||
## Instance Proxy
|
## Instance Proxy
|
||||||
|
|
||||||
Llamactl proxies all requests to the underlying backend instances (llama-server, MLX, or vLLM).
|
Llamactl proxies all requests to the underlying backend instances (llama-server, MLX, or vLLM).
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ Before you start, let's clarify a few key terms:
|
|||||||
|
|
||||||
Llamactl uses two types of API keys:
|
Llamactl uses two types of API keys:
|
||||||
|
|
||||||
- **Management API Key**: Used to authenticate with the Llamactl management API (creating, starting, stopping instances).
|
- **Management API Key**: Used to authenticate with the Llamactl management API and web UI. If not configured, one is auto-generated at startup and printed to the terminal.
|
||||||
- **Inference API Key**: Used to authenticate requests to the OpenAI-compatible endpoints (`/v1/chat/completions`, `/v1/completions`, etc.).
|
- **Inference API Key**: Used to authenticate requests to the OpenAI-compatible endpoints (`/v1/chat/completions`, `/v1/completions`, etc.). These are created and managed via the web UI.
|
||||||
|
|
||||||
By default, authentication is required. If you don't configure these keys in your configuration file, llamactl will auto-generate them and print them to the terminal on startup. You can also configure custom keys or disable authentication entirely in the [Configuration](configuration.md) guide.
|
By default, authentication is required for both management and inference endpoints. You can configure custom management keys or disable authentication in the [Configuration](configuration.md) guide.
|
||||||
|
|
||||||
## Start Llamactl
|
## Start Llamactl
|
||||||
|
|
||||||
@@ -38,24 +38,17 @@ llamactl
|
|||||||
|
|
||||||
sk-management-...
|
sk-management-...
|
||||||
|
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
⚠️ INFERENCE AUTHENTICATION REQUIRED
|
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
🔑 Generated Inference API Key:
|
|
||||||
|
|
||||||
sk-inference-...
|
|
||||||
|
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
⚠️ IMPORTANT
|
⚠️ IMPORTANT
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
• These keys are auto-generated and will change on restart
|
• This key is auto-generated and will change on restart
|
||||||
• For production, add explicit keys to your configuration
|
• For production, add explicit management_keys to your configuration
|
||||||
• Copy these keys before they disappear from the terminal
|
• Copy this key before it disappears from the terminal
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
Llamactl server listening on 0.0.0.0:8080
|
Llamactl server listening on 0.0.0.0:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
Copy the **Management** and **Inference** API Keys from the terminal - you'll need them to access the web UI and make inference requests.
|
Copy the **Management API Key** from the terminal - you'll need it to access the web UI.
|
||||||
|
|
||||||
By default, Llamactl will start on `http://localhost:8080`.
|
By default, Llamactl will start on `http://localhost:8080`.
|
||||||
|
|
||||||
@@ -82,7 +75,7 @@ You should see the Llamactl web interface.
|
|||||||
- **Additional Options**: Backend-specific parameters
|
- **Additional Options**: Backend-specific parameters
|
||||||
|
|
||||||
!!! tip "Auto-Assignment"
|
!!! 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.
|
Llamactl automatically assigns ports from the configured port range (default: 8000-9000) and manages API keys if authentication is enabled. You typically don't need to manually specify these values.
|
||||||
|
|
||||||
!!! note "Remote Node Deployment"
|
!!! note "Remote Node Deployment"
|
||||||
If you have configured remote nodes in your configuration file, you can select which node to deploy the instance to. This allows you to distribute instances across multiple machines. See the [Configuration](configuration.md#remote-node-configuration) guide for details on setting up remote nodes.
|
If you have configured remote nodes in your configuration file, you can select which node to deploy the instance to. This allows you to distribute instances across multiple machines. See the [Configuration](configuration.md#remote-node-configuration) guide for details on setting up remote nodes.
|
||||||
@@ -98,6 +91,24 @@ Once created, you can:
|
|||||||
- **View logs** by clicking the logs button
|
- **View logs** by clicking the logs button
|
||||||
- **Stop** the instance when needed
|
- **Stop** the instance when needed
|
||||||
|
|
||||||
|
## Create an Inference API Key
|
||||||
|
|
||||||
|
To make inference requests to your instances, you'll need an inference API key:
|
||||||
|
|
||||||
|
1. In the web UI, click the **Settings** icon (gear icon in the top-right)
|
||||||
|
2. Navigate to the **API Keys** tab
|
||||||
|
3. Click **Create API Key**
|
||||||
|
4. Configure your key:
|
||||||
|
- **Name**: Give it a descriptive name (e.g., "Production Key", "Development Key")
|
||||||
|
- **Expiration**: Optionally set an expiration date for the key
|
||||||
|
- **Permissions**: Choose whether the key can access all instances or only specific ones
|
||||||
|
5. Click **Create**
|
||||||
|
6. **Copy the generated key** - it will only be shown once!
|
||||||
|
|
||||||
|
The key will look like: `llamactl-...`
|
||||||
|
|
||||||
|
You can create multiple inference keys with different permissions for different use cases (e.g., one for development, one for production, or keys limited to specific instances).
|
||||||
|
|
||||||
## Example Configurations
|
## Example Configurations
|
||||||
|
|
||||||
Here are basic example configurations for each backend:
|
Here are basic example configurations for each backend:
|
||||||
@@ -246,7 +257,7 @@ print(response.choices[0].message.content)
|
|||||||
```
|
```
|
||||||
|
|
||||||
!!! note "API Key"
|
!!! note "API Key"
|
||||||
If you disabled authentication in your config, you can use any value for `api_key` (e.g., `"not-needed"`). Otherwise, use the inference API key shown in the terminal output on startup.
|
If you disabled authentication in your config, you can use any value for `api_key` (e.g., `"not-needed"`). Otherwise, use the inference API key you created via the web UI (Settings → API Keys).
|
||||||
|
|
||||||
### List Available Models
|
### List Available Models
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,235 @@
|
|||||||
},
|
},
|
||||||
"basePath": "/api/v1",
|
"basePath": "/api/v1",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"/api/v1/auth/keys": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Returns a list of all API keys for the system user (excludes key hash and plain-text key)",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Keys"
|
||||||
|
],
|
||||||
|
"summary": "List all API keys",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "List of API keys",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/server.KeyResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"description": "Creates a new API key with the specified permissions and returns the plain-text key (only shown once)",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Keys"
|
||||||
|
],
|
||||||
|
"summary": "Create a new API key",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "API key configuration",
|
||||||
|
"name": "key",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/server.CreateKeyRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Created API key with plain-text key",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/server.CreateKeyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid request body or validation error",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/auth/keys/{id}": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Returns details for a specific API key by ID (excludes key hash and plain-text key)",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Keys"
|
||||||
|
],
|
||||||
|
"summary": "Get details of a specific API key",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Key ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "API key details",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/server.KeyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid key ID",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "API key not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Deletes an API key by ID",
|
||||||
|
"tags": [
|
||||||
|
"Keys"
|
||||||
|
],
|
||||||
|
"summary": "Delete an API key",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Key ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "API key deleted successfully"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid key ID",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "API key not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/auth/keys/{id}/permissions": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Returns the instance-level permissions for a specific API key (includes instance names)",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Keys"
|
||||||
|
],
|
||||||
|
"summary": "Get API key permissions",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Key ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "List of key permissions",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/server.KeyPermissionResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid key ID",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "API key not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/backends/llama-cpp/devices": {
|
"/api/v1/backends/llama-cpp/devices": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -763,6 +992,156 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/llama-cpp/{name}/models": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Returns a list of models available in the specified llama.cpp instance",
|
||||||
|
"tags": [
|
||||||
|
"Llama.cpp"
|
||||||
|
],
|
||||||
|
"summary": "List models in a llama.cpp instance",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Instance Name",
|
||||||
|
"name": "name",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Models list response",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid instance",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/llama-cpp/{name}/models/{model}/load": {
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Loads the specified model in the given llama.cpp instance",
|
||||||
|
"tags": [
|
||||||
|
"Llama.cpp"
|
||||||
|
],
|
||||||
|
"summary": "Load a model in a llama.cpp instance",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Instance Name",
|
||||||
|
"name": "name",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Model Name",
|
||||||
|
"name": "model",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success message",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid request",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/llama-cpp/{name}/models/{model}/unload": {
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Unloads the specified model in the given llama.cpp instance",
|
||||||
|
"tags": [
|
||||||
|
"Llama.cpp"
|
||||||
|
],
|
||||||
|
"summary": "Unload a model in a llama.cpp instance",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Instance Name",
|
||||||
|
"name": "name",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Model Name",
|
||||||
|
"name": "model",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success message",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid request",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/nodes": {
|
"/api/v1/nodes": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -1496,6 +1875,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"definitions": {
|
"definitions": {
|
||||||
|
"auth.PermissionMode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"allow_all",
|
||||||
|
"per_instance"
|
||||||
|
],
|
||||||
|
"x-enum-varnames": [
|
||||||
|
"PermissionModeAllowAll",
|
||||||
|
"PermissionModePerInstance"
|
||||||
|
]
|
||||||
|
},
|
||||||
"config.AppConfig": {
|
"config.AppConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1511,6 +1901,13 @@
|
|||||||
"commit_hash": {
|
"commit_hash": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"data_dir": {
|
||||||
|
"description": "Directory where all llamactl data will be stored (database, instances, logs, etc.)",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"$ref": "#/definitions/config.DatabaseConfig"
|
||||||
|
},
|
||||||
"instances": {
|
"instances": {
|
||||||
"$ref": "#/definitions/config.InstancesConfig"
|
"$ref": "#/definitions/config.InstancesConfig"
|
||||||
},
|
},
|
||||||
@@ -1534,13 +1931,6 @@
|
|||||||
"config.AuthConfig": {
|
"config.AuthConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"inference_keys": {
|
|
||||||
"description": "List of keys for OpenAI compatible inference endpoints",
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"management_keys": {
|
"management_keys": {
|
||||||
"description": "List of keys for management endpoints",
|
"description": "List of keys for management endpoints",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
@@ -1601,6 +1991,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"config.DatabaseConfig": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"connection_max_lifetime": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "1h"
|
||||||
|
},
|
||||||
|
"max_idle_connections": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"max_open_connections": {
|
||||||
|
"description": "Connection settings",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"description": "Database file path (relative to the top-level data_dir or absolute)",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"config.DockerSettings": {
|
"config.DockerSettings": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1631,14 +2041,6 @@
|
|||||||
"description": "Automatically create the data directory if it doesn't exist",
|
"description": "Automatically create the data directory if it doesn't exist",
|
||||||
"type": "boolean"
|
"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": {
|
"default_auto_restart": {
|
||||||
"description": "Default auto-restart setting for new instances",
|
"description": "Default auto-restart setting for new instances",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
@@ -1659,8 +2061,23 @@
|
|||||||
"description": "Enable LRU eviction for instance logs",
|
"description": "Enable LRU eviction for instance logs",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"logRotationCompress": {
|
||||||
|
"description": "Whether to compress rotated log files",
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"logRotationEnabled": {
|
||||||
|
"description": "Log rotation enabled",
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"logRotationMaxSize": {
|
||||||
|
"description": "Maximum log file size in MB before rotation",
|
||||||
|
"type": "integer",
|
||||||
|
"default": 100
|
||||||
|
},
|
||||||
"logs_dir": {
|
"logs_dir": {
|
||||||
"description": "Logs directory override",
|
"description": "Logs directory override (relative to data_dir if not absolute)",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"max_instances": {
|
"max_instances": {
|
||||||
@@ -1741,7 +2158,10 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"created": {
|
"created": {
|
||||||
"description": "Unix timestamp when the instance was created",
|
"description": "Unix timestamp when instance was created",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
@@ -1787,6 +2207,98 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"server.CreateKeyRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"expires_at": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"instance_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"permission_mode": {
|
||||||
|
"$ref": "#/definitions/auth.PermissionMode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server.CreateKeyResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"created_at": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"last_used_at": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"permission_mode": {
|
||||||
|
"$ref": "#/definitions/auth.PermissionMode"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server.KeyPermissionResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"instance_id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"instance_name": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server.KeyResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"created_at": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"last_used_at": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"permission_mode": {
|
||||||
|
"$ref": "#/definitions/auth.PermissionMode"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"server.NodeResponse": {
|
"server.NodeResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
basePath: /api/v1
|
basePath: /api/v1
|
||||||
definitions:
|
definitions:
|
||||||
|
auth.PermissionMode:
|
||||||
|
enum:
|
||||||
|
- allow_all
|
||||||
|
- per_instance
|
||||||
|
type: string
|
||||||
|
x-enum-varnames:
|
||||||
|
- PermissionModeAllowAll
|
||||||
|
- PermissionModePerInstance
|
||||||
config.AppConfig:
|
config.AppConfig:
|
||||||
properties:
|
properties:
|
||||||
auth:
|
auth:
|
||||||
@@ -10,6 +18,12 @@ definitions:
|
|||||||
type: string
|
type: string
|
||||||
commit_hash:
|
commit_hash:
|
||||||
type: string
|
type: string
|
||||||
|
data_dir:
|
||||||
|
description: Directory where all llamactl data will be stored (database, instances,
|
||||||
|
logs, etc.)
|
||||||
|
type: string
|
||||||
|
database:
|
||||||
|
$ref: '#/definitions/config.DatabaseConfig'
|
||||||
instances:
|
instances:
|
||||||
$ref: '#/definitions/config.InstancesConfig'
|
$ref: '#/definitions/config.InstancesConfig'
|
||||||
local_node:
|
local_node:
|
||||||
@@ -25,11 +39,6 @@ definitions:
|
|||||||
type: object
|
type: object
|
||||||
config.AuthConfig:
|
config.AuthConfig:
|
||||||
properties:
|
properties:
|
||||||
inference_keys:
|
|
||||||
description: List of keys for OpenAI compatible inference endpoints
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
type: array
|
|
||||||
management_keys:
|
management_keys:
|
||||||
description: List of keys for management endpoints
|
description: List of keys for management endpoints
|
||||||
items:
|
items:
|
||||||
@@ -70,6 +79,20 @@ definitions:
|
|||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
type: object
|
type: object
|
||||||
|
config.DatabaseConfig:
|
||||||
|
properties:
|
||||||
|
connection_max_lifetime:
|
||||||
|
example: 1h
|
||||||
|
type: string
|
||||||
|
max_idle_connections:
|
||||||
|
type: integer
|
||||||
|
max_open_connections:
|
||||||
|
description: Connection settings
|
||||||
|
type: integer
|
||||||
|
path:
|
||||||
|
description: Database file path (relative to the top-level data_dir or absolute)
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
config.DockerSettings:
|
config.DockerSettings:
|
||||||
properties:
|
properties:
|
||||||
args:
|
args:
|
||||||
@@ -90,13 +113,6 @@ definitions:
|
|||||||
auto_create_dirs:
|
auto_create_dirs:
|
||||||
description: Automatically create the data directory if it doesn't exist
|
description: Automatically create the data directory if it doesn't exist
|
||||||
type: boolean
|
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:
|
default_auto_restart:
|
||||||
description: Default auto-restart setting for new instances
|
description: Default auto-restart setting for new instances
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -112,8 +128,20 @@ definitions:
|
|||||||
enable_lru_eviction:
|
enable_lru_eviction:
|
||||||
description: Enable LRU eviction for instance logs
|
description: Enable LRU eviction for instance logs
|
||||||
type: boolean
|
type: boolean
|
||||||
|
logRotationCompress:
|
||||||
|
default: false
|
||||||
|
description: Whether to compress rotated log files
|
||||||
|
type: boolean
|
||||||
|
logRotationEnabled:
|
||||||
|
default: true
|
||||||
|
description: Log rotation enabled
|
||||||
|
type: boolean
|
||||||
|
logRotationMaxSize:
|
||||||
|
default: 100
|
||||||
|
description: Maximum log file size in MB before rotation
|
||||||
|
type: integer
|
||||||
logs_dir:
|
logs_dir:
|
||||||
description: Logs directory override
|
description: Logs directory override (relative to data_dir if not absolute)
|
||||||
type: string
|
type: string
|
||||||
max_instances:
|
max_instances:
|
||||||
description: Maximum number of instances that can be created
|
description: Maximum number of instances that can be created
|
||||||
@@ -171,7 +199,9 @@ definitions:
|
|||||||
instance.Instance:
|
instance.Instance:
|
||||||
properties:
|
properties:
|
||||||
created:
|
created:
|
||||||
description: Unix timestamp when the instance was created
|
description: Unix timestamp when instance was created
|
||||||
|
type: integer
|
||||||
|
id:
|
||||||
type: integer
|
type: integer
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
@@ -203,6 +233,66 @@ definitions:
|
|||||||
description: seconds
|
description: seconds
|
||||||
type: integer
|
type: integer
|
||||||
type: object
|
type: object
|
||||||
|
server.CreateKeyRequest:
|
||||||
|
properties:
|
||||||
|
expires_at:
|
||||||
|
type: integer
|
||||||
|
instance_ids:
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
type: array
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
permission_mode:
|
||||||
|
$ref: '#/definitions/auth.PermissionMode'
|
||||||
|
type: object
|
||||||
|
server.CreateKeyResponse:
|
||||||
|
properties:
|
||||||
|
created_at:
|
||||||
|
type: integer
|
||||||
|
expires_at:
|
||||||
|
type: integer
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
key:
|
||||||
|
type: string
|
||||||
|
last_used_at:
|
||||||
|
type: integer
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
permission_mode:
|
||||||
|
$ref: '#/definitions/auth.PermissionMode'
|
||||||
|
updated_at:
|
||||||
|
type: integer
|
||||||
|
user_id:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
server.KeyPermissionResponse:
|
||||||
|
properties:
|
||||||
|
instance_id:
|
||||||
|
type: integer
|
||||||
|
instance_name:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
server.KeyResponse:
|
||||||
|
properties:
|
||||||
|
created_at:
|
||||||
|
type: integer
|
||||||
|
expires_at:
|
||||||
|
type: integer
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
last_used_at:
|
||||||
|
type: integer
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
permission_mode:
|
||||||
|
$ref: '#/definitions/auth.PermissionMode'
|
||||||
|
updated_at:
|
||||||
|
type: integer
|
||||||
|
user_id:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
server.NodeResponse:
|
server.NodeResponse:
|
||||||
properties:
|
properties:
|
||||||
address:
|
address:
|
||||||
@@ -242,6 +332,156 @@ info:
|
|||||||
title: llamactl API
|
title: llamactl API
|
||||||
version: "1.0"
|
version: "1.0"
|
||||||
paths:
|
paths:
|
||||||
|
/api/v1/auth/keys:
|
||||||
|
get:
|
||||||
|
description: Returns a list of all API keys for the system user (excludes key
|
||||||
|
hash and plain-text key)
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: List of API keys
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/server.KeyResponse'
|
||||||
|
type: array
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
summary: List all API keys
|
||||||
|
tags:
|
||||||
|
- Keys
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Creates a new API key with the specified permissions and returns
|
||||||
|
the plain-text key (only shown once)
|
||||||
|
parameters:
|
||||||
|
- description: API key configuration
|
||||||
|
in: body
|
||||||
|
name: key
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/server.CreateKeyRequest'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Created API key with plain-text key
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/server.CreateKeyResponse'
|
||||||
|
"400":
|
||||||
|
description: Invalid request body or validation error
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Create a new API key
|
||||||
|
tags:
|
||||||
|
- Keys
|
||||||
|
/api/v1/auth/keys/{id}:
|
||||||
|
delete:
|
||||||
|
description: Deletes an API key by ID
|
||||||
|
parameters:
|
||||||
|
- description: Key ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: API key deleted successfully
|
||||||
|
"400":
|
||||||
|
description: Invalid key ID
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"404":
|
||||||
|
description: API key not found
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
summary: Delete an API key
|
||||||
|
tags:
|
||||||
|
- Keys
|
||||||
|
get:
|
||||||
|
description: Returns details for a specific API key by ID (excludes key hash
|
||||||
|
and plain-text key)
|
||||||
|
parameters:
|
||||||
|
- description: Key ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: API key details
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/server.KeyResponse'
|
||||||
|
"400":
|
||||||
|
description: Invalid key ID
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"404":
|
||||||
|
description: API key not found
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
summary: Get details of a specific API key
|
||||||
|
tags:
|
||||||
|
- Keys
|
||||||
|
/api/v1/auth/keys/{id}/permissions:
|
||||||
|
get:
|
||||||
|
description: Returns the instance-level permissions for a specific API key (includes
|
||||||
|
instance names)
|
||||||
|
parameters:
|
||||||
|
- description: Key ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: List of key permissions
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/server.KeyPermissionResponse'
|
||||||
|
type: array
|
||||||
|
"400":
|
||||||
|
description: Invalid key ID
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"404":
|
||||||
|
description: API key not found
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
summary: Get API key permissions
|
||||||
|
tags:
|
||||||
|
- Keys
|
||||||
/api/v1/backends/llama-cpp/devices:
|
/api/v1/backends/llama-cpp/devices:
|
||||||
get:
|
get:
|
||||||
description: Returns a list of available devices for the llama server
|
description: Returns a list of available devices for the llama server
|
||||||
@@ -718,6 +958,102 @@ paths:
|
|||||||
summary: Stop a running instance
|
summary: Stop a running instance
|
||||||
tags:
|
tags:
|
||||||
- Instances
|
- Instances
|
||||||
|
/api/v1/llama-cpp/{name}/models:
|
||||||
|
get:
|
||||||
|
description: Returns a list of models available in the specified llama.cpp instance
|
||||||
|
parameters:
|
||||||
|
- description: Instance Name
|
||||||
|
in: path
|
||||||
|
name: name
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Models list response
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
"400":
|
||||||
|
description: Invalid instance
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
summary: List models in a llama.cpp instance
|
||||||
|
tags:
|
||||||
|
- Llama.cpp
|
||||||
|
/api/v1/llama-cpp/{name}/models/{model}/load:
|
||||||
|
post:
|
||||||
|
description: Loads the specified model in the given llama.cpp instance
|
||||||
|
parameters:
|
||||||
|
- description: Instance Name
|
||||||
|
in: path
|
||||||
|
name: name
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
- description: Model Name
|
||||||
|
in: path
|
||||||
|
name: model
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Success message
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"400":
|
||||||
|
description: Invalid request
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
summary: Load a model in a llama.cpp instance
|
||||||
|
tags:
|
||||||
|
- Llama.cpp
|
||||||
|
/api/v1/llama-cpp/{name}/models/{model}/unload:
|
||||||
|
post:
|
||||||
|
description: Unloads the specified model in the given llama.cpp instance
|
||||||
|
parameters:
|
||||||
|
- description: Instance Name
|
||||||
|
in: path
|
||||||
|
name: name
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
- description: Model Name
|
||||||
|
in: path
|
||||||
|
name: model
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Success message
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"400":
|
||||||
|
description: Invalid request
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
summary: Unload a model in a llama.cpp instance
|
||||||
|
tags:
|
||||||
|
- Llama.cpp
|
||||||
/api/v1/nodes:
|
/api/v1/nodes:
|
||||||
get:
|
get:
|
||||||
description: Returns a map of all nodes configured in the server (node name
|
description: Returns a map of all nodes configured in the server (node name
|
||||||
|
|||||||
@@ -115,15 +115,15 @@ vllm serve microsoft/DialoGPT-medium --port 8081
|
|||||||
require_inference_auth: false
|
require_inference_auth: false
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Configure API keys:**
|
2. **Configure management API keys:**
|
||||||
```yaml
|
```yaml
|
||||||
auth:
|
auth:
|
||||||
management_keys:
|
management_keys:
|
||||||
- "your-management-key"
|
- "your-management-key"
|
||||||
inference_keys:
|
|
||||||
- "your-inference-key"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For inference API keys, create them via the web UI (Settings → API Keys) after logging in with your management key.
|
||||||
|
|
||||||
3. **Use correct Authorization header:**
|
3. **Use correct Authorization header:**
|
||||||
```bash
|
```bash
|
||||||
curl -H "Authorization: Bearer your-api-key" \
|
curl -H "Authorization: Bearer your-api-key" \
|
||||||
|
|||||||
11
go.mod
11
go.mod
@@ -3,10 +3,14 @@ module llamactl
|
|||||||
go 1.24.5
|
go 1.24.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/DeRuina/timberjack v1.3.9
|
||||||
github.com/go-chi/chi/v5 v5.2.2
|
github.com/go-chi/chi/v5 v5.2.2
|
||||||
github.com/go-chi/cors v1.2.2
|
github.com/go-chi/cors v1.2.2
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.24
|
||||||
github.com/swaggo/http-swagger v1.3.4
|
github.com/swaggo/http-swagger v1.3.4
|
||||||
github.com/swaggo/swag v1.16.5
|
github.com/swaggo/swag v1.16.5
|
||||||
|
golang.org/x/crypto v0.46.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,16 +20,13 @@ require (
|
|||||||
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
||||||
github.com/go-openapi/spec v0.21.0 // indirect
|
github.com/go-openapi/spec v0.21.0 // indirect
|
||||||
github.com/go-openapi/swag v0.23.1 // indirect
|
github.com/go-openapi/swag v0.23.1 // indirect
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1 // indirect
|
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
|
||||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
|
github.com/klauspost/compress v1.17.11 // indirect
|
||||||
github.com/mailru/easyjson v0.9.0 // indirect
|
github.com/mailru/easyjson v0.9.0 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.24 // indirect
|
|
||||||
github.com/swaggo/files v1.0.1 // indirect
|
github.com/swaggo/files v1.0.1 // indirect
|
||||||
go.uber.org/atomic v1.7.0 // indirect
|
|
||||||
golang.org/x/mod v0.29.0 // indirect
|
golang.org/x/mod v0.29.0 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.47.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.18.0 // indirect
|
||||||
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/tools v0.38.0 // indirect
|
golang.org/x/tools v0.38.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
39
go.sum
39
go.sum
@@ -1,9 +1,11 @@
|
|||||||
|
github.com/DeRuina/timberjack v1.3.9 h1:6UXZ1I7ExPGTX/1UNYawR58LlOJUHKBPiYC7WQ91eBo=
|
||||||
|
github.com/DeRuina/timberjack v1.3.9/go.mod h1:RLoeQrwrCGIEF8gO5nV5b/gMD0QIy7bzQhBUgpp1EqE=
|
||||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
|
||||||
|
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
|
||||||
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
|
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
|
||||||
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||||
@@ -16,35 +18,28 @@ github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9Z
|
|||||||
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
|
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
|
||||||
github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
|
github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
|
||||||
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
|
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y=
|
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks=
|
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
|
||||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
|
||||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
|
||||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
|
||||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
|
||||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
|
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||||
|
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
|
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
|
||||||
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||||
@@ -54,27 +49,21 @@ github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4
|
|||||||
github.com/swaggo/swag v1.16.5 h1:nMf2fEV1TetMTJb4XzD0Lz7jFfKJmJKGTygEey8NSxM=
|
github.com/swaggo/swag v1.16.5 h1:nMf2fEV1TetMTJb4XzD0Lz7jFfKJmJKGTygEey8NSxM=
|
||||||
github.com/swaggo/swag v1.16.5/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
github.com/swaggo/swag v1.16.5/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
|
|
||||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
|
|
||||||
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
|
|
||||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
|
||||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
|
||||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
@@ -83,6 +72,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
@@ -93,8 +84,6 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
|||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
|
|
||||||
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
|
|
||||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
site_name: Llamatl Documentation
|
site_name: Llamactl Documentation
|
||||||
site_description: User documentation for Llamatl - A management tool for Llama.cpp instances
|
site_description: User documentation for Llamactl - A management tool for Llama.cpp instances
|
||||||
site_author: Llamatl Team
|
site_author: Llamactl Team
|
||||||
site_url: https://llamactl.org
|
site_url: https://llamactl.org
|
||||||
|
|
||||||
repo_name: lordmathis/llamactl
|
repo_name: lordmathis/llamactl
|
||||||
|
|||||||
73
pkg/auth/hash.go
Normal file
73
pkg/auth/hash.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Argon2 parameters
|
||||||
|
time uint32 = 1
|
||||||
|
memory uint32 = 64 * 1024 // 64 MB
|
||||||
|
threads uint8 = 4
|
||||||
|
keyLen uint32 = 32
|
||||||
|
saltLen uint32 = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
// HashKey hashes an API key using Argon2id
|
||||||
|
func HashKey(plainTextKey string) (string, error) {
|
||||||
|
// Generate random salt
|
||||||
|
salt := make([]byte, saltLen)
|
||||||
|
if _, err := rand.Read(salt); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to generate salt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive key using Argon2id
|
||||||
|
hash := argon2.IDKey([]byte(plainTextKey), salt, time, memory, threads, keyLen)
|
||||||
|
|
||||||
|
// Format: $argon2id$v=19$m=65536,t=1,p=4$<base64-salt>$<base64-hash>
|
||||||
|
saltB64 := base64.RawStdEncoding.EncodeToString(salt)
|
||||||
|
hashB64 := base64.RawStdEncoding.EncodeToString(hash)
|
||||||
|
|
||||||
|
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", memory, time, threads, saltB64, hashB64), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyKey verifies a plain-text key against an Argon2id hash
|
||||||
|
func VerifyKey(plainTextKey, hash string) bool {
|
||||||
|
// Parse the hash format
|
||||||
|
parts := strings.Split(hash, "$")
|
||||||
|
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract parameters
|
||||||
|
var version, time, memory, threads int
|
||||||
|
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != 19 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode salt and hash
|
||||||
|
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedHash, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute hash of the provided key
|
||||||
|
computedHash := argon2.IDKey([]byte(plainTextKey), salt, uint32(time), uint32(memory), uint8(threads), uint32(len(expectedHash)))
|
||||||
|
|
||||||
|
// Compare hashes using constant-time comparison
|
||||||
|
return subtle.ConstantTimeCompare(computedHash, expectedHash) == 1
|
||||||
|
}
|
||||||
46
pkg/auth/key.go
Normal file
46
pkg/auth/key.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PermissionMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PermissionModeAllowAll PermissionMode = "allow_all"
|
||||||
|
PermissionModePerInstance PermissionMode = "per_instance"
|
||||||
|
)
|
||||||
|
|
||||||
|
type APIKey struct {
|
||||||
|
ID int
|
||||||
|
KeyHash string
|
||||||
|
Name string
|
||||||
|
UserID string
|
||||||
|
PermissionMode PermissionMode
|
||||||
|
ExpiresAt *int64
|
||||||
|
CreatedAt int64
|
||||||
|
UpdatedAt int64
|
||||||
|
LastUsedAt *int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeyPermission struct {
|
||||||
|
KeyID int
|
||||||
|
InstanceID int
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateKey generates a cryptographically secure API key with the given prefix
|
||||||
|
func GenerateKey(prefix string) (string, error) {
|
||||||
|
// Generate 32 random bytes
|
||||||
|
bytes := make([]byte, 32)
|
||||||
|
_, err := rand.Read(bytes)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to hex (64 characters)
|
||||||
|
hexStr := hex.EncodeToString(bytes)
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s-%s", prefix, hexStr), nil
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ const (
|
|||||||
BackendTypeLlamaCpp BackendType = "llama_cpp"
|
BackendTypeLlamaCpp BackendType = "llama_cpp"
|
||||||
BackendTypeMlxLm BackendType = "mlx_lm"
|
BackendTypeMlxLm BackendType = "mlx_lm"
|
||||||
BackendTypeVllm BackendType = "vllm"
|
BackendTypeVllm BackendType = "vllm"
|
||||||
|
BackendTypeUnknown BackendType = "unknown"
|
||||||
)
|
)
|
||||||
|
|
||||||
type backend interface {
|
type backend interface {
|
||||||
@@ -55,13 +56,15 @@ func (o *Options) UnmarshalJSON(data []byte) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create backend from constructor map
|
// Create backend from constructor map
|
||||||
if o.BackendOptions != nil {
|
constructor, exists := backendConstructors[o.BackendType]
|
||||||
constructor, exists := backendConstructors[o.BackendType]
|
if !exists {
|
||||||
if !exists {
|
return fmt.Errorf("unsupported backend type: %s", o.BackendType)
|
||||||
return fmt.Errorf("unsupported backend type: %s", o.BackendType)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
backend := constructor()
|
backend := constructor()
|
||||||
|
|
||||||
|
// If backend_options is provided, unmarshal into the backend
|
||||||
|
if o.BackendOptions != nil {
|
||||||
optionsData, err := json.Marshal(o.BackendOptions)
|
optionsData, err := json.Marshal(o.BackendOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to marshal backend options: %w", err)
|
return fmt.Errorf("failed to marshal backend options: %w", err)
|
||||||
@@ -70,10 +73,11 @@ func (o *Options) UnmarshalJSON(data []byte) error {
|
|||||||
if err := json.Unmarshal(optionsData, backend); err != nil {
|
if err := json.Unmarshal(optionsData, backend); err != nil {
|
||||||
return fmt.Errorf("failed to unmarshal backend options: %w", err)
|
return fmt.Errorf("failed to unmarshal backend options: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in the appropriate typed field for backward compatibility
|
|
||||||
o.setBackendOptions(backend)
|
|
||||||
}
|
}
|
||||||
|
// If backend_options is nil or empty, backend remains as empty struct (for router mode)
|
||||||
|
|
||||||
|
// Store in the appropriate typed field
|
||||||
|
o.setBackendOptions(backend)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -327,20 +327,30 @@ func (o *LlamaServerOptions) UnmarshalJSON(data []byte) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (o *LlamaServerOptions) GetPort() int {
|
func (o *LlamaServerOptions) GetPort() int {
|
||||||
|
if o == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
return o.Port
|
return o.Port
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *LlamaServerOptions) SetPort(port int) {
|
func (o *LlamaServerOptions) SetPort(port int) {
|
||||||
|
if o == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
o.Port = port
|
o.Port = port
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *LlamaServerOptions) GetHost() string {
|
func (o *LlamaServerOptions) GetHost() string {
|
||||||
|
if o == nil {
|
||||||
|
return "localhost"
|
||||||
|
}
|
||||||
return o.Host
|
return o.Host
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *LlamaServerOptions) Validate() error {
|
func (o *LlamaServerOptions) Validate() error {
|
||||||
|
// Allow nil options for router mode where llama.cpp manages models dynamically
|
||||||
if o == nil {
|
if o == nil {
|
||||||
return validation.ValidationError(fmt.Errorf("llama server options cannot be nil for llama.cpp backend"))
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use reflection to check all string fields for injection patterns
|
// Use reflection to check all string fields for injection patterns
|
||||||
@@ -370,6 +380,9 @@ func (o *LlamaServerOptions) Validate() error {
|
|||||||
|
|
||||||
// BuildCommandArgs converts InstanceOptions to command line arguments
|
// BuildCommandArgs converts InstanceOptions to command line arguments
|
||||||
func (o *LlamaServerOptions) BuildCommandArgs() []string {
|
func (o *LlamaServerOptions) BuildCommandArgs() []string {
|
||||||
|
if o == nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
// Llama uses multiple flags for arrays by default (not comma-separated)
|
// Llama uses multiple flags for arrays by default (not comma-separated)
|
||||||
// Use package-level llamaMultiValuedFlags variable
|
// Use package-level llamaMultiValuedFlags variable
|
||||||
args := BuildCommandArgs(o, llamaMultiValuedFlags)
|
args := BuildCommandArgs(o, llamaMultiValuedFlags)
|
||||||
@@ -381,6 +394,9 @@ func (o *LlamaServerOptions) BuildCommandArgs() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (o *LlamaServerOptions) BuildDockerArgs() []string {
|
func (o *LlamaServerOptions) BuildDockerArgs() []string {
|
||||||
|
if o == nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
// For llama, Docker args are the same as normal args
|
// For llama, Docker args are the same as normal args
|
||||||
return o.BuildCommandArgs()
|
return o.BuildCommandArgs()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,234 +6,18 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BackendSettings contains structured backend configuration
|
|
||||||
type BackendSettings struct {
|
|
||||||
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" 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" 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" json:"server"`
|
|
||||||
Backends BackendConfig `yaml:"backends" json:"backends"`
|
|
||||||
Instances InstancesConfig `yaml:"instances" json:"instances"`
|
|
||||||
Database DatabaseConfig `yaml:"database" json:"database"`
|
|
||||||
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"`
|
|
||||||
|
|
||||||
// Directory where all llamactl data will be stored (database, instances, logs, etc.)
|
|
||||||
DataDir string `yaml:"data_dir" json:"data_dir"`
|
|
||||||
|
|
||||||
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" json:"host"`
|
|
||||||
|
|
||||||
// Server port to bind to
|
|
||||||
Port int `yaml:"port" json:"port"`
|
|
||||||
|
|
||||||
// Allowed origins for CORS (e.g., "http://localhost:3000")
|
|
||||||
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" json:"allowed_headers"`
|
|
||||||
|
|
||||||
// Enable Swagger UI for API documentation
|
|
||||||
EnableSwagger bool `yaml:"enable_swagger" json:"enable_swagger"`
|
|
||||||
|
|
||||||
// Response headers to send with responses
|
|
||||||
ResponseHeaders map[string]string `yaml:"response_headers,omitempty" json:"response_headers,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DatabaseConfig contains database configuration settings
|
|
||||||
type DatabaseConfig struct {
|
|
||||||
// Database file path (relative to the top-level data_dir or absolute)
|
|
||||||
Path string `yaml:"path" json:"path"`
|
|
||||||
|
|
||||||
// Connection settings
|
|
||||||
MaxOpenConnections int `yaml:"max_open_connections" json:"max_open_connections"`
|
|
||||||
MaxIdleConnections int `yaml:"max_idle_connections" json:"max_idle_connections"`
|
|
||||||
ConnMaxLifetime time.Duration `yaml:"connection_max_lifetime" json:"connection_max_lifetime"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// InstancesConfig contains instance management configuration
|
|
||||||
type InstancesConfig struct {
|
|
||||||
// Port range for instances (e.g., 8000,9000)
|
|
||||||
PortRange [2]int `yaml:"port_range" json:"port_range"`
|
|
||||||
|
|
||||||
|
|
||||||
// Instance config directory override (relative to data_dir if not absolute)
|
|
||||||
InstancesDir string `yaml:"configs_dir" json:"configs_dir"`
|
|
||||||
|
|
||||||
// Logs directory override (relative to data_dir if not absolute)
|
|
||||||
LogsDir string `yaml:"logs_dir" json:"logs_dir"`
|
|
||||||
|
|
||||||
// Automatically create the data directory if it doesn't exist
|
|
||||||
AutoCreateDirs bool `yaml:"auto_create_dirs" json:"auto_create_dirs"`
|
|
||||||
|
|
||||||
// Maximum number of instances that can be created
|
|
||||||
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" json:"max_running_instances,omitempty"`
|
|
||||||
|
|
||||||
// Enable LRU eviction for instance logs
|
|
||||||
EnableLRUEviction bool `yaml:"enable_lru_eviction" json:"enable_lru_eviction"`
|
|
||||||
|
|
||||||
// Default auto-restart setting for new instances
|
|
||||||
DefaultAutoRestart bool `yaml:"default_auto_restart" json:"default_auto_restart"`
|
|
||||||
|
|
||||||
// Default max restarts for new instances
|
|
||||||
DefaultMaxRestarts int `yaml:"default_max_restarts" json:"default_max_restarts"`
|
|
||||||
|
|
||||||
// Default restart delay for new instances (in seconds)
|
|
||||||
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" 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" json:"on_demand_start_timeout,omitempty"`
|
|
||||||
|
|
||||||
// Interval for checking instance timeouts (in minutes)
|
|
||||||
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" json:"require_inference_auth"`
|
|
||||||
|
|
||||||
// List of keys for OpenAI compatible inference endpoints
|
|
||||||
InferenceKeys []string `yaml:"inference_keys" json:"inference_keys"`
|
|
||||||
|
|
||||||
// Require authentication for management endpoints
|
|
||||||
RequireManagementAuth bool `yaml:"require_management_auth" json:"require_management_auth"`
|
|
||||||
|
|
||||||
// List of keys for management endpoints
|
|
||||||
ManagementKeys []string `yaml:"management_keys" json:"management_keys"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type NodeConfig struct {
|
|
||||||
Address string `yaml:"address" json:"address"`
|
|
||||||
APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadConfig loads configuration with the following precedence:
|
// LoadConfig loads configuration with the following precedence:
|
||||||
// 1. Hardcoded defaults
|
// 1. Hardcoded defaults
|
||||||
// 2. Config file
|
// 2. Config file
|
||||||
// 3. Environment variables
|
// 3. Environment variables
|
||||||
func LoadConfig(configPath string) (AppConfig, error) {
|
func LoadConfig(configPath string) (AppConfig, error) {
|
||||||
// 1. Start with defaults
|
// 1. Start with defaults
|
||||||
defaultDataDir := getDefaultDataDirectory()
|
defaultDataDir := getDefaultDataDir()
|
||||||
|
cfg := getDefaultConfig(defaultDataDir)
|
||||||
cfg := AppConfig{
|
|
||||||
Server: ServerConfig{
|
|
||||||
Host: "0.0.0.0",
|
|
||||||
Port: 8080,
|
|
||||||
AllowedOrigins: []string{"*"}, // Default to allow all origins
|
|
||||||
AllowedHeaders: []string{"*"}, // Default to allow all headers
|
|
||||||
EnableSwagger: false,
|
|
||||||
},
|
|
||||||
LocalNode: "main",
|
|
||||||
Nodes: map[string]NodeConfig{},
|
|
||||||
DataDir: defaultDataDir,
|
|
||||||
Backends: BackendConfig{
|
|
||||||
LlamaCpp: BackendSettings{
|
|
||||||
Command: "llama-server",
|
|
||||||
Args: []string{},
|
|
||||||
Environment: map[string]string{},
|
|
||||||
Docker: &DockerSettings{
|
|
||||||
Enabled: false,
|
|
||||||
Image: "ghcr.io/ggml-org/llama.cpp:server",
|
|
||||||
Args: []string{
|
|
||||||
"run", "--rm", "--network", "host", "--gpus", "all",
|
|
||||||
"-v", filepath.Join(defaultDataDir, "llama.cpp") + ":/root/.cache/llama.cpp"},
|
|
||||||
Environment: map[string]string{},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
VLLM: BackendSettings{
|
|
||||||
Command: "vllm",
|
|
||||||
Args: []string{"serve"},
|
|
||||||
Docker: &DockerSettings{
|
|
||||||
Enabled: false,
|
|
||||||
Image: "vllm/vllm-openai:latest",
|
|
||||||
Args: []string{
|
|
||||||
"run", "--rm", "--network", "host", "--gpus", "all", "--shm-size", "1g",
|
|
||||||
"-v", filepath.Join(defaultDataDir, "huggingface") + ":/root/.cache/huggingface",
|
|
||||||
},
|
|
||||||
Environment: map[string]string{},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MLX: BackendSettings{
|
|
||||||
Command: "mlx_lm.server",
|
|
||||||
Args: []string{},
|
|
||||||
// No Docker section for MLX - not supported
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Instances: InstancesConfig{
|
|
||||||
PortRange: [2]int{8000, 9000},
|
|
||||||
// NOTE: empty strings are set as placeholder values since InstancesDir and LogsDir
|
|
||||||
// should be relative path to DataDir if not explicitly set.
|
|
||||||
InstancesDir: "",
|
|
||||||
LogsDir: "",
|
|
||||||
AutoCreateDirs: true,
|
|
||||||
MaxInstances: -1, // -1 means unlimited
|
|
||||||
MaxRunningInstances: -1, // -1 means unlimited
|
|
||||||
EnableLRUEviction: true,
|
|
||||||
DefaultAutoRestart: true,
|
|
||||||
DefaultMaxRestarts: 3,
|
|
||||||
DefaultRestartDelay: 5,
|
|
||||||
DefaultOnDemandStart: true,
|
|
||||||
OnDemandStartTimeout: 120, // 2 minutes
|
|
||||||
TimeoutCheckInterval: 5, // Check timeouts every 5 minutes
|
|
||||||
},
|
|
||||||
Database: DatabaseConfig{
|
|
||||||
Path: "", // Will be set to data_dir/llamactl.db if empty
|
|
||||||
MaxOpenConnections: 25,
|
|
||||||
MaxIdleConnections: 5,
|
|
||||||
ConnMaxLifetime: 5 * time.Minute,
|
|
||||||
},
|
|
||||||
Auth: AuthConfig{
|
|
||||||
RequireInferenceAuth: true,
|
|
||||||
InferenceKeys: []string{},
|
|
||||||
RequireManagementAuth: true,
|
|
||||||
ManagementKeys: []string{},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Load from config file
|
// 2. Load from config file
|
||||||
if err := loadConfigFile(&cfg, configPath); err != nil {
|
if err := loadConfigFile(&cfg, configPath); err != nil {
|
||||||
@@ -249,9 +33,6 @@ func LoadConfig(configPath string) (AppConfig, error) {
|
|||||||
loadEnvVars(&cfg)
|
loadEnvVars(&cfg)
|
||||||
|
|
||||||
// Set default directories if not specified
|
// Set default directories if not specified
|
||||||
if cfg.Instances.InstancesDir == "" {
|
|
||||||
cfg.Instances.InstancesDir = filepath.Join(cfg.DataDir, "instances")
|
|
||||||
}
|
|
||||||
if cfg.Instances.LogsDir == "" {
|
if cfg.Instances.LogsDir == "" {
|
||||||
cfg.Instances.LogsDir = filepath.Join(cfg.DataDir, "logs")
|
cfg.Instances.LogsDir = filepath.Join(cfg.DataDir, "logs")
|
||||||
}
|
}
|
||||||
@@ -292,372 +73,6 @@ func loadConfigFile(cfg *AppConfig, configPath string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadEnvVars overrides config with environment variables
|
|
||||||
func loadEnvVars(cfg *AppConfig) {
|
|
||||||
// Server config
|
|
||||||
if host := os.Getenv("LLAMACTL_HOST"); host != "" {
|
|
||||||
cfg.Server.Host = host
|
|
||||||
}
|
|
||||||
if port := os.Getenv("LLAMACTL_PORT"); port != "" {
|
|
||||||
if p, err := strconv.Atoi(port); err == nil {
|
|
||||||
cfg.Server.Port = p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if allowedOrigins := os.Getenv("LLAMACTL_ALLOWED_ORIGINS"); allowedOrigins != "" {
|
|
||||||
cfg.Server.AllowedOrigins = strings.Split(allowedOrigins, ",")
|
|
||||||
}
|
|
||||||
if enableSwagger := os.Getenv("LLAMACTL_ENABLE_SWAGGER"); enableSwagger != "" {
|
|
||||||
if b, err := strconv.ParseBool(enableSwagger); err == nil {
|
|
||||||
cfg.Server.EnableSwagger = b
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Data config
|
|
||||||
if dataDir := os.Getenv("LLAMACTL_DATA_DIRECTORY"); dataDir != "" {
|
|
||||||
cfg.DataDir = dataDir
|
|
||||||
}
|
|
||||||
if instancesDir := os.Getenv("LLAMACTL_INSTANCES_DIR"); instancesDir != "" {
|
|
||||||
cfg.Instances.InstancesDir = instancesDir
|
|
||||||
}
|
|
||||||
if logsDir := os.Getenv("LLAMACTL_LOGS_DIR"); logsDir != "" {
|
|
||||||
cfg.Instances.LogsDir = logsDir
|
|
||||||
}
|
|
||||||
if autoCreate := os.Getenv("LLAMACTL_AUTO_CREATE_DATA_DIR"); autoCreate != "" {
|
|
||||||
if b, err := strconv.ParseBool(autoCreate); err == nil {
|
|
||||||
cfg.Instances.AutoCreateDirs = b
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Instance config
|
|
||||||
if portRange := os.Getenv("LLAMACTL_INSTANCE_PORT_RANGE"); portRange != "" {
|
|
||||||
if ports := ParsePortRange(portRange); ports != [2]int{0, 0} {
|
|
||||||
cfg.Instances.PortRange = ports
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if maxInstances := os.Getenv("LLAMACTL_MAX_INSTANCES"); maxInstances != "" {
|
|
||||||
if m, err := strconv.Atoi(maxInstances); err == nil {
|
|
||||||
cfg.Instances.MaxInstances = m
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if maxRunning := os.Getenv("LLAMACTL_MAX_RUNNING_INSTANCES"); maxRunning != "" {
|
|
||||||
if m, err := strconv.Atoi(maxRunning); err == nil {
|
|
||||||
cfg.Instances.MaxRunningInstances = m
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if enableLRUEviction := os.Getenv("LLAMACTL_ENABLE_LRU_EVICTION"); enableLRUEviction != "" {
|
|
||||||
if b, err := strconv.ParseBool(enableLRUEviction); err == nil {
|
|
||||||
cfg.Instances.EnableLRUEviction = b
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Backend config
|
|
||||||
// LlamaCpp backend
|
|
||||||
if llamaCmd := os.Getenv("LLAMACTL_LLAMACPP_COMMAND"); llamaCmd != "" {
|
|
||||||
cfg.Backends.LlamaCpp.Command = llamaCmd
|
|
||||||
}
|
|
||||||
if llamaArgs := os.Getenv("LLAMACTL_LLAMACPP_ARGS"); llamaArgs != "" {
|
|
||||||
cfg.Backends.LlamaCpp.Args = strings.Split(llamaArgs, " ")
|
|
||||||
}
|
|
||||||
if llamaEnv := os.Getenv("LLAMACTL_LLAMACPP_ENV"); llamaEnv != "" {
|
|
||||||
if cfg.Backends.LlamaCpp.Environment == nil {
|
|
||||||
cfg.Backends.LlamaCpp.Environment = make(map[string]string)
|
|
||||||
}
|
|
||||||
parseEnvVars(llamaEnv, cfg.Backends.LlamaCpp.Environment)
|
|
||||||
}
|
|
||||||
if llamaDockerEnabled := os.Getenv("LLAMACTL_LLAMACPP_DOCKER_ENABLED"); llamaDockerEnabled != "" {
|
|
||||||
if b, err := strconv.ParseBool(llamaDockerEnabled); err == nil {
|
|
||||||
if cfg.Backends.LlamaCpp.Docker == nil {
|
|
||||||
cfg.Backends.LlamaCpp.Docker = &DockerSettings{}
|
|
||||||
}
|
|
||||||
cfg.Backends.LlamaCpp.Docker.Enabled = b
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if llamaDockerImage := os.Getenv("LLAMACTL_LLAMACPP_DOCKER_IMAGE"); llamaDockerImage != "" {
|
|
||||||
if cfg.Backends.LlamaCpp.Docker == nil {
|
|
||||||
cfg.Backends.LlamaCpp.Docker = &DockerSettings{}
|
|
||||||
}
|
|
||||||
cfg.Backends.LlamaCpp.Docker.Image = llamaDockerImage
|
|
||||||
}
|
|
||||||
if llamaDockerArgs := os.Getenv("LLAMACTL_LLAMACPP_DOCKER_ARGS"); llamaDockerArgs != "" {
|
|
||||||
if cfg.Backends.LlamaCpp.Docker == nil {
|
|
||||||
cfg.Backends.LlamaCpp.Docker = &DockerSettings{}
|
|
||||||
}
|
|
||||||
cfg.Backends.LlamaCpp.Docker.Args = strings.Split(llamaDockerArgs, " ")
|
|
||||||
}
|
|
||||||
if llamaDockerEnv := os.Getenv("LLAMACTL_LLAMACPP_DOCKER_ENV"); llamaDockerEnv != "" {
|
|
||||||
if cfg.Backends.LlamaCpp.Docker == nil {
|
|
||||||
cfg.Backends.LlamaCpp.Docker = &DockerSettings{}
|
|
||||||
}
|
|
||||||
if cfg.Backends.LlamaCpp.Docker.Environment == nil {
|
|
||||||
cfg.Backends.LlamaCpp.Docker.Environment = make(map[string]string)
|
|
||||||
}
|
|
||||||
parseEnvVars(llamaDockerEnv, cfg.Backends.LlamaCpp.Docker.Environment)
|
|
||||||
}
|
|
||||||
if llamaEnv := os.Getenv("LLAMACTL_LLAMACPP_RESPONSE_HEADERS"); llamaEnv != "" {
|
|
||||||
if cfg.Backends.LlamaCpp.ResponseHeaders == nil {
|
|
||||||
cfg.Backends.LlamaCpp.ResponseHeaders = make(map[string]string)
|
|
||||||
}
|
|
||||||
parseHeaders(llamaEnv, cfg.Backends.LlamaCpp.ResponseHeaders)
|
|
||||||
}
|
|
||||||
|
|
||||||
// vLLM backend
|
|
||||||
if vllmCmd := os.Getenv("LLAMACTL_VLLM_COMMAND"); vllmCmd != "" {
|
|
||||||
cfg.Backends.VLLM.Command = vllmCmd
|
|
||||||
}
|
|
||||||
if vllmArgs := os.Getenv("LLAMACTL_VLLM_ARGS"); vllmArgs != "" {
|
|
||||||
cfg.Backends.VLLM.Args = strings.Split(vllmArgs, " ")
|
|
||||||
}
|
|
||||||
if vllmEnv := os.Getenv("LLAMACTL_VLLM_ENV"); vllmEnv != "" {
|
|
||||||
if cfg.Backends.VLLM.Environment == nil {
|
|
||||||
cfg.Backends.VLLM.Environment = make(map[string]string)
|
|
||||||
}
|
|
||||||
parseEnvVars(vllmEnv, cfg.Backends.VLLM.Environment)
|
|
||||||
}
|
|
||||||
if vllmDockerEnabled := os.Getenv("LLAMACTL_VLLM_DOCKER_ENABLED"); vllmDockerEnabled != "" {
|
|
||||||
if b, err := strconv.ParseBool(vllmDockerEnabled); err == nil {
|
|
||||||
if cfg.Backends.VLLM.Docker == nil {
|
|
||||||
cfg.Backends.VLLM.Docker = &DockerSettings{}
|
|
||||||
}
|
|
||||||
cfg.Backends.VLLM.Docker.Enabled = b
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if vllmDockerImage := os.Getenv("LLAMACTL_VLLM_DOCKER_IMAGE"); vllmDockerImage != "" {
|
|
||||||
if cfg.Backends.VLLM.Docker == nil {
|
|
||||||
cfg.Backends.VLLM.Docker = &DockerSettings{}
|
|
||||||
}
|
|
||||||
cfg.Backends.VLLM.Docker.Image = vllmDockerImage
|
|
||||||
}
|
|
||||||
if vllmDockerArgs := os.Getenv("LLAMACTL_VLLM_DOCKER_ARGS"); vllmDockerArgs != "" {
|
|
||||||
if cfg.Backends.VLLM.Docker == nil {
|
|
||||||
cfg.Backends.VLLM.Docker = &DockerSettings{}
|
|
||||||
}
|
|
||||||
cfg.Backends.VLLM.Docker.Args = strings.Split(vllmDockerArgs, " ")
|
|
||||||
}
|
|
||||||
if vllmDockerEnv := os.Getenv("LLAMACTL_VLLM_DOCKER_ENV"); vllmDockerEnv != "" {
|
|
||||||
if cfg.Backends.VLLM.Docker == nil {
|
|
||||||
cfg.Backends.VLLM.Docker = &DockerSettings{}
|
|
||||||
}
|
|
||||||
if cfg.Backends.VLLM.Docker.Environment == nil {
|
|
||||||
cfg.Backends.VLLM.Docker.Environment = make(map[string]string)
|
|
||||||
}
|
|
||||||
parseEnvVars(vllmDockerEnv, cfg.Backends.VLLM.Docker.Environment)
|
|
||||||
}
|
|
||||||
if llamaEnv := os.Getenv("LLAMACTL_VLLM_RESPONSE_HEADERS"); llamaEnv != "" {
|
|
||||||
if cfg.Backends.VLLM.ResponseHeaders == nil {
|
|
||||||
cfg.Backends.VLLM.ResponseHeaders = make(map[string]string)
|
|
||||||
}
|
|
||||||
parseHeaders(llamaEnv, cfg.Backends.VLLM.ResponseHeaders)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MLX backend
|
|
||||||
if mlxCmd := os.Getenv("LLAMACTL_MLX_COMMAND"); mlxCmd != "" {
|
|
||||||
cfg.Backends.MLX.Command = mlxCmd
|
|
||||||
}
|
|
||||||
if mlxArgs := os.Getenv("LLAMACTL_MLX_ARGS"); mlxArgs != "" {
|
|
||||||
cfg.Backends.MLX.Args = strings.Split(mlxArgs, " ")
|
|
||||||
}
|
|
||||||
if mlxEnv := os.Getenv("LLAMACTL_MLX_ENV"); mlxEnv != "" {
|
|
||||||
if cfg.Backends.MLX.Environment == nil {
|
|
||||||
cfg.Backends.MLX.Environment = make(map[string]string)
|
|
||||||
}
|
|
||||||
parseEnvVars(mlxEnv, cfg.Backends.MLX.Environment)
|
|
||||||
}
|
|
||||||
if llamaEnv := os.Getenv("LLAMACTL_MLX_RESPONSE_HEADERS"); llamaEnv != "" {
|
|
||||||
if cfg.Backends.MLX.ResponseHeaders == nil {
|
|
||||||
cfg.Backends.MLX.ResponseHeaders = make(map[string]string)
|
|
||||||
}
|
|
||||||
parseHeaders(llamaEnv, cfg.Backends.MLX.ResponseHeaders)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Instance defaults
|
|
||||||
if autoRestart := os.Getenv("LLAMACTL_DEFAULT_AUTO_RESTART"); autoRestart != "" {
|
|
||||||
if b, err := strconv.ParseBool(autoRestart); err == nil {
|
|
||||||
cfg.Instances.DefaultAutoRestart = b
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if maxRestarts := os.Getenv("LLAMACTL_DEFAULT_MAX_RESTARTS"); maxRestarts != "" {
|
|
||||||
if m, err := strconv.Atoi(maxRestarts); err == nil {
|
|
||||||
cfg.Instances.DefaultMaxRestarts = m
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if restartDelay := os.Getenv("LLAMACTL_DEFAULT_RESTART_DELAY"); restartDelay != "" {
|
|
||||||
if seconds, err := strconv.Atoi(restartDelay); err == nil {
|
|
||||||
cfg.Instances.DefaultRestartDelay = seconds
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if onDemandStart := os.Getenv("LLAMACTL_DEFAULT_ON_DEMAND_START"); onDemandStart != "" {
|
|
||||||
if b, err := strconv.ParseBool(onDemandStart); err == nil {
|
|
||||||
cfg.Instances.DefaultOnDemandStart = b
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if onDemandTimeout := os.Getenv("LLAMACTL_ON_DEMAND_START_TIMEOUT"); onDemandTimeout != "" {
|
|
||||||
if seconds, err := strconv.Atoi(onDemandTimeout); err == nil {
|
|
||||||
cfg.Instances.OnDemandStartTimeout = seconds
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if timeoutCheckInterval := os.Getenv("LLAMACTL_TIMEOUT_CHECK_INTERVAL"); timeoutCheckInterval != "" {
|
|
||||||
if minutes, err := strconv.Atoi(timeoutCheckInterval); err == nil {
|
|
||||||
cfg.Instances.TimeoutCheckInterval = minutes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Auth config
|
|
||||||
if requireInferenceAuth := os.Getenv("LLAMACTL_REQUIRE_INFERENCE_AUTH"); requireInferenceAuth != "" {
|
|
||||||
if b, err := strconv.ParseBool(requireInferenceAuth); err == nil {
|
|
||||||
cfg.Auth.RequireInferenceAuth = b
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if inferenceKeys := os.Getenv("LLAMACTL_INFERENCE_KEYS"); inferenceKeys != "" {
|
|
||||||
cfg.Auth.InferenceKeys = strings.Split(inferenceKeys, ",")
|
|
||||||
}
|
|
||||||
if requireManagementAuth := os.Getenv("LLAMACTL_REQUIRE_MANAGEMENT_AUTH"); requireManagementAuth != "" {
|
|
||||||
if b, err := strconv.ParseBool(requireManagementAuth); err == nil {
|
|
||||||
cfg.Auth.RequireManagementAuth = b
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if managementKeys := os.Getenv("LLAMACTL_MANAGEMENT_KEYS"); managementKeys != "" {
|
|
||||||
cfg.Auth.ManagementKeys = strings.Split(managementKeys, ",")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Local node config
|
|
||||||
if localNode := os.Getenv("LLAMACTL_LOCAL_NODE"); localNode != "" {
|
|
||||||
cfg.LocalNode = localNode
|
|
||||||
}
|
|
||||||
|
|
||||||
// Database config
|
|
||||||
if dbPath := os.Getenv("LLAMACTL_DATABASE_PATH"); dbPath != "" {
|
|
||||||
cfg.Database.Path = dbPath
|
|
||||||
}
|
|
||||||
if maxOpenConns := os.Getenv("LLAMACTL_DATABASE_MAX_OPEN_CONNECTIONS"); maxOpenConns != "" {
|
|
||||||
if m, err := strconv.Atoi(maxOpenConns); err == nil {
|
|
||||||
cfg.Database.MaxOpenConnections = m
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if maxIdleConns := os.Getenv("LLAMACTL_DATABASE_MAX_IDLE_CONNECTIONS"); maxIdleConns != "" {
|
|
||||||
if m, err := strconv.Atoi(maxIdleConns); err == nil {
|
|
||||||
cfg.Database.MaxIdleConnections = m
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if connMaxLifetime := os.Getenv("LLAMACTL_DATABASE_CONN_MAX_LIFETIME"); connMaxLifetime != "" {
|
|
||||||
if d, err := time.ParseDuration(connMaxLifetime); err == nil {
|
|
||||||
cfg.Database.ConnMaxLifetime = d
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParsePortRange parses port range from string formats like "8000-9000" or "8000,9000"
|
|
||||||
func ParsePortRange(s string) [2]int {
|
|
||||||
var parts []string
|
|
||||||
|
|
||||||
// Try both separators
|
|
||||||
if strings.Contains(s, "-") {
|
|
||||||
parts = strings.Split(s, "-")
|
|
||||||
} else if strings.Contains(s, ",") {
|
|
||||||
parts = strings.Split(s, ",")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the two parts
|
|
||||||
if len(parts) == 2 {
|
|
||||||
start, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
|
|
||||||
end, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
|
|
||||||
if err1 == nil && err2 == nil {
|
|
||||||
return [2]int{start, end}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [2]int{0, 0} // Invalid format
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseEnvVars parses environment variables in format "KEY1=value1,KEY2=value2"
|
|
||||||
// and populates the provided environment map
|
|
||||||
func parseEnvVars(envString string, envMap map[string]string) {
|
|
||||||
if envString == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, envPair := range strings.Split(envString, ",") {
|
|
||||||
if parts := strings.SplitN(strings.TrimSpace(envPair), "=", 2); len(parts) == 2 {
|
|
||||||
envMap[parts[0]] = parts[1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseHeaders parses HTTP headers in format "KEY1=value1;KEY2=value2"
|
|
||||||
// and populates the provided environment map
|
|
||||||
func parseHeaders(envString string, envMap map[string]string) {
|
|
||||||
if envString == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, envPair := range strings.Split(envString, ";") {
|
|
||||||
if parts := strings.SplitN(strings.TrimSpace(envPair), "=", 2); len(parts) == 2 {
|
|
||||||
envMap[parts[0]] = parts[1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getDefaultDataDirectory returns platform-specific default data directory
|
|
||||||
func getDefaultDataDirectory() string {
|
|
||||||
switch runtime.GOOS {
|
|
||||||
case "windows":
|
|
||||||
// Try PROGRAMDATA first (system-wide), fallback to LOCALAPPDATA (user)
|
|
||||||
if programData := os.Getenv("PROGRAMDATA"); programData != "" {
|
|
||||||
return filepath.Join(programData, "llamactl")
|
|
||||||
}
|
|
||||||
if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" {
|
|
||||||
return filepath.Join(localAppData, "llamactl")
|
|
||||||
}
|
|
||||||
return "C:\\ProgramData\\llamactl" // Final fallback
|
|
||||||
|
|
||||||
case "darwin":
|
|
||||||
// For macOS, use user's Application Support directory
|
|
||||||
if homeDir, _ := os.UserHomeDir(); homeDir != "" {
|
|
||||||
return filepath.Join(homeDir, "Library", "Application Support", "llamactl")
|
|
||||||
}
|
|
||||||
return "/usr/local/var/llamactl" // Fallback
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Linux and other Unix-like systems
|
|
||||||
if homeDir, _ := os.UserHomeDir(); homeDir != "" {
|
|
||||||
return filepath.Join(homeDir, ".local", "share", "llamactl")
|
|
||||||
}
|
|
||||||
return "/var/lib/llamactl" // Final fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getDefaultConfigLocations returns platform-specific config file locations
|
|
||||||
func getDefaultConfigLocations() []string {
|
|
||||||
var locations []string
|
|
||||||
// Use ./llamactl.yaml and ./config.yaml as the default config file
|
|
||||||
locations = append(locations, "llamactl.yaml")
|
|
||||||
locations = append(locations, "config.yaml")
|
|
||||||
|
|
||||||
homeDir, _ := os.UserHomeDir()
|
|
||||||
|
|
||||||
switch runtime.GOOS {
|
|
||||||
case "windows":
|
|
||||||
// Windows: Use APPDATA if available, else user home, fallback to ProgramData
|
|
||||||
if appData := os.Getenv("APPDATA"); appData != "" {
|
|
||||||
locations = append(locations, filepath.Join(appData, "llamactl", "config.yaml"))
|
|
||||||
} else if homeDir != "" {
|
|
||||||
locations = append(locations, filepath.Join(homeDir, "llamactl", "config.yaml"))
|
|
||||||
}
|
|
||||||
locations = append(locations, filepath.Join(os.Getenv("PROGRAMDATA"), "llamactl", "config.yaml"))
|
|
||||||
|
|
||||||
case "darwin":
|
|
||||||
// macOS: Use Application Support in user home, fallback to /Library/Application Support
|
|
||||||
if homeDir != "" {
|
|
||||||
locations = append(locations, filepath.Join(homeDir, "Library", "Application Support", "llamactl", "config.yaml"))
|
|
||||||
}
|
|
||||||
locations = append(locations, "/Library/Application Support/llamactl/config.yaml")
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Linux/Unix: Use ~/.config/llamactl/config.yaml, fallback to /etc/llamactl/config.yaml
|
|
||||||
if homeDir != "" {
|
|
||||||
locations = append(locations, filepath.Join(homeDir, ".config", "llamactl", "config.yaml"))
|
|
||||||
}
|
|
||||||
locations = append(locations, "/etc/llamactl/config.yaml")
|
|
||||||
}
|
|
||||||
|
|
||||||
return locations
|
|
||||||
}
|
|
||||||
|
|
||||||
// SanitizedCopy returns a copy of the AppConfig with sensitive information removed
|
// SanitizedCopy returns a copy of the AppConfig with sensitive information removed
|
||||||
func (cfg *AppConfig) SanitizedCopy() (AppConfig, error) {
|
func (cfg *AppConfig) SanitizedCopy() (AppConfig, error) {
|
||||||
// Deep copy via JSON marshal/unmarshal to avoid concurrent map access
|
// Deep copy via JSON marshal/unmarshal to avoid concurrent map access
|
||||||
@@ -674,7 +89,6 @@ func (cfg *AppConfig) SanitizedCopy() (AppConfig, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clear sensitive information
|
// Clear sensitive information
|
||||||
sanitized.Auth.InferenceKeys = []string{}
|
|
||||||
sanitized.Auth.ManagementKeys = []string{}
|
sanitized.Auth.ManagementKeys = []string{}
|
||||||
|
|
||||||
// Clear API keys from nodes
|
// Clear API keys from nodes
|
||||||
|
|||||||
@@ -41,9 +41,6 @@ func TestLoadConfig_Defaults(t *testing.T) {
|
|||||||
t.Fatalf("Failed to get user home directory: %v", err)
|
t.Fatalf("Failed to get user home directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Instances.InstancesDir != filepath.Join(homedir, ".local", "share", "llamactl", "instances") {
|
|
||||||
t.Errorf("Expected default instances directory '%s', got %q", filepath.Join(homedir, ".local", "share", "llamactl", "instances"), cfg.Instances.InstancesDir)
|
|
||||||
}
|
|
||||||
if cfg.Instances.LogsDir != filepath.Join(homedir, ".local", "share", "llamactl", "logs") {
|
if cfg.Instances.LogsDir != filepath.Join(homedir, ".local", "share", "llamactl", "logs") {
|
||||||
t.Errorf("Expected default logs directory '%s', got %q", filepath.Join(homedir, ".local", "share", "llamactl", "logs"), cfg.Instances.LogsDir)
|
t.Errorf("Expected default logs directory '%s', got %q", filepath.Join(homedir, ".local", "share", "llamactl", "logs"), cfg.Instances.LogsDir)
|
||||||
}
|
}
|
||||||
@@ -78,8 +75,8 @@ server:
|
|||||||
port: 9090
|
port: 9090
|
||||||
instances:
|
instances:
|
||||||
port_range: [7000, 8000]
|
port_range: [7000, 8000]
|
||||||
logs_dir: "/custom/logs"
|
|
||||||
max_instances: 5
|
max_instances: 5
|
||||||
|
logs_dir: "/custom/logs"
|
||||||
llama_executable: "/usr/bin/llama-server"
|
llama_executable: "/usr/bin/llama-server"
|
||||||
default_auto_restart: false
|
default_auto_restart: false
|
||||||
default_max_restarts: 10
|
default_max_restarts: 10
|
||||||
@@ -219,7 +216,6 @@ instances:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func TestParsePortRange(t *testing.T) {
|
func TestParsePortRange(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -248,7 +244,6 @@ func TestParsePortRange(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func TestGetBackendSettings_NewStructuredConfig(t *testing.T) {
|
func TestGetBackendSettings_NewStructuredConfig(t *testing.T) {
|
||||||
bc := &config.BackendConfig{
|
bc := &config.BackendConfig{
|
||||||
LlamaCpp: config.BackendSettings{
|
LlamaCpp: config.BackendSettings{
|
||||||
@@ -305,7 +300,6 @@ func TestGetBackendSettings_NewStructuredConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func TestLoadConfig_BackendEnvironmentVariables(t *testing.T) {
|
func TestLoadConfig_BackendEnvironmentVariables(t *testing.T) {
|
||||||
// Test that backend environment variables work correctly
|
// Test that backend environment variables work correctly
|
||||||
envVars := map[string]string{
|
envVars := map[string]string{
|
||||||
@@ -375,7 +369,6 @@ func TestLoadConfig_BackendEnvironmentVariables(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func TestLoadConfig_LocalNode(t *testing.T) {
|
func TestLoadConfig_LocalNode(t *testing.T) {
|
||||||
t.Run("default local node", func(t *testing.T) {
|
t.Run("default local node", func(t *testing.T) {
|
||||||
cfg, err := config.LoadConfig("nonexistent-file.yaml")
|
cfg, err := config.LoadConfig("nonexistent-file.yaml")
|
||||||
|
|||||||
150
pkg/config/defaults.go
Normal file
150
pkg/config/defaults.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getDefaultConfig(dataDir string) AppConfig {
|
||||||
|
return AppConfig{
|
||||||
|
Server: ServerConfig{
|
||||||
|
Host: "0.0.0.0",
|
||||||
|
Port: 8080,
|
||||||
|
AllowedOrigins: []string{"*"}, // Default to allow all origins
|
||||||
|
AllowedHeaders: []string{"*"}, // Default to allow all headers
|
||||||
|
EnableSwagger: false,
|
||||||
|
},
|
||||||
|
LocalNode: "main",
|
||||||
|
Nodes: map[string]NodeConfig{},
|
||||||
|
DataDir: dataDir,
|
||||||
|
Backends: BackendConfig{
|
||||||
|
LlamaCpp: BackendSettings{
|
||||||
|
Command: "llama-server",
|
||||||
|
Args: []string{},
|
||||||
|
Environment: map[string]string{},
|
||||||
|
Docker: &DockerSettings{
|
||||||
|
Enabled: false,
|
||||||
|
Image: "ghcr.io/ggml-org/llama.cpp:server",
|
||||||
|
Args: []string{
|
||||||
|
"run", "--rm", "--network", "host", "--gpus", "all",
|
||||||
|
"-v", filepath.Join(dataDir, "llama.cpp") + ":/root/.cache/llama.cpp"},
|
||||||
|
Environment: map[string]string{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
VLLM: BackendSettings{
|
||||||
|
Command: "vllm",
|
||||||
|
Args: []string{"serve"},
|
||||||
|
Docker: &DockerSettings{
|
||||||
|
Enabled: false,
|
||||||
|
Image: "vllm/vllm-openai:latest",
|
||||||
|
Args: []string{
|
||||||
|
"run", "--rm", "--network", "host", "--gpus", "all", "--shm-size", "1g",
|
||||||
|
"-v", filepath.Join(dataDir, "huggingface") + ":/root/.cache/huggingface",
|
||||||
|
},
|
||||||
|
Environment: map[string]string{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MLX: BackendSettings{
|
||||||
|
Command: "mlx_lm.server",
|
||||||
|
Args: []string{},
|
||||||
|
// No Docker section for MLX - not supported
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Instances: InstancesConfig{
|
||||||
|
PortRange: [2]int{8000, 9000},
|
||||||
|
AutoCreateDirs: true,
|
||||||
|
MaxInstances: -1, // -1 means unlimited
|
||||||
|
MaxRunningInstances: -1, // -1 means unlimited
|
||||||
|
EnableLRUEviction: true,
|
||||||
|
DefaultAutoRestart: true,
|
||||||
|
DefaultMaxRestarts: 3,
|
||||||
|
DefaultRestartDelay: 5,
|
||||||
|
DefaultOnDemandStart: true,
|
||||||
|
OnDemandStartTimeout: 120, // 2 minutes
|
||||||
|
TimeoutCheckInterval: 5, // Check timeouts every 5 minutes
|
||||||
|
LogsDir: "", // Will be set to data_dir/logs if empty
|
||||||
|
LogRotationEnabled: true,
|
||||||
|
LogRotationMaxSize: 100,
|
||||||
|
LogRotationCompress: false,
|
||||||
|
},
|
||||||
|
Database: DatabaseConfig{
|
||||||
|
Path: "", // Will be set to data_dir/llamactl.db if empty
|
||||||
|
MaxOpenConnections: 25,
|
||||||
|
MaxIdleConnections: 5,
|
||||||
|
ConnMaxLifetime: 5 * time.Minute,
|
||||||
|
},
|
||||||
|
Auth: AuthConfig{
|
||||||
|
RequireInferenceAuth: true,
|
||||||
|
RequireManagementAuth: true,
|
||||||
|
ManagementKeys: []string{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDefaultDataDir returns platform-specific default data directory
|
||||||
|
func getDefaultDataDir() string {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
// Try PROGRAMDATA first (system-wide), fallback to LOCALAPPDATA (user)
|
||||||
|
if programData := os.Getenv("PROGRAMDATA"); programData != "" {
|
||||||
|
return filepath.Join(programData, "llamactl")
|
||||||
|
}
|
||||||
|
if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" {
|
||||||
|
return filepath.Join(localAppData, "llamactl")
|
||||||
|
}
|
||||||
|
return "C:\\ProgramData\\llamactl" // Final fallback
|
||||||
|
|
||||||
|
case "darwin":
|
||||||
|
// For macOS, use user's Application Support directory
|
||||||
|
if homeDir, _ := os.UserHomeDir(); homeDir != "" {
|
||||||
|
return filepath.Join(homeDir, "Library", "Application Support", "llamactl")
|
||||||
|
}
|
||||||
|
return "/usr/local/var/llamactl" // Fallback
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Linux and other Unix-like systems
|
||||||
|
if homeDir, _ := os.UserHomeDir(); homeDir != "" {
|
||||||
|
return filepath.Join(homeDir, ".local", "share", "llamactl")
|
||||||
|
}
|
||||||
|
return "/var/lib/llamactl" // Final fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDefaultConfigLocations returns platform-specific config file locations
|
||||||
|
func getDefaultConfigLocations() []string {
|
||||||
|
var locations []string
|
||||||
|
// Use ./llamactl.yaml and ./config.yaml as the default config file
|
||||||
|
locations = append(locations, "llamactl.yaml")
|
||||||
|
locations = append(locations, "config.yaml")
|
||||||
|
|
||||||
|
homeDir, _ := os.UserHomeDir()
|
||||||
|
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
// Windows: Use APPDATA if available, else user home, fallback to ProgramData
|
||||||
|
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||||
|
locations = append(locations, filepath.Join(appData, "llamactl", "config.yaml"))
|
||||||
|
} else if homeDir != "" {
|
||||||
|
locations = append(locations, filepath.Join(homeDir, "llamactl", "config.yaml"))
|
||||||
|
}
|
||||||
|
locations = append(locations, filepath.Join(os.Getenv("PROGRAMDATA"), "llamactl", "config.yaml"))
|
||||||
|
|
||||||
|
case "darwin":
|
||||||
|
// macOS: Use Application Support in user home, fallback to /Library/Application Support
|
||||||
|
if homeDir != "" {
|
||||||
|
locations = append(locations, filepath.Join(homeDir, "Library", "Application Support", "llamactl", "config.yaml"))
|
||||||
|
}
|
||||||
|
locations = append(locations, "/Library/Application Support/llamactl/config.yaml")
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Linux/Unix: Use ~/.config/llamactl/config.yaml, fallback to /etc/llamactl/config.yaml
|
||||||
|
if homeDir != "" {
|
||||||
|
locations = append(locations, filepath.Join(homeDir, ".config", "llamactl", "config.yaml"))
|
||||||
|
}
|
||||||
|
locations = append(locations, "/etc/llamactl/config.yaml")
|
||||||
|
}
|
||||||
|
|
||||||
|
return locations
|
||||||
|
}
|
||||||
319
pkg/config/env.go
Normal file
319
pkg/config/env.go
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// loadEnvVars overrides config with environment variables
|
||||||
|
func loadEnvVars(cfg *AppConfig) {
|
||||||
|
// Server config
|
||||||
|
if host := os.Getenv("LLAMACTL_HOST"); host != "" {
|
||||||
|
cfg.Server.Host = host
|
||||||
|
}
|
||||||
|
if port := os.Getenv("LLAMACTL_PORT"); port != "" {
|
||||||
|
if p, err := strconv.Atoi(port); err == nil {
|
||||||
|
cfg.Server.Port = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allowedOrigins := os.Getenv("LLAMACTL_ALLOWED_ORIGINS"); allowedOrigins != "" {
|
||||||
|
cfg.Server.AllowedOrigins = strings.Split(allowedOrigins, ",")
|
||||||
|
}
|
||||||
|
if enableSwagger := os.Getenv("LLAMACTL_ENABLE_SWAGGER"); enableSwagger != "" {
|
||||||
|
if b, err := strconv.ParseBool(enableSwagger); err == nil {
|
||||||
|
cfg.Server.EnableSwagger = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data config
|
||||||
|
if dataDir := os.Getenv("LLAMACTL_DATA_DIRECTORY"); dataDir != "" {
|
||||||
|
cfg.DataDir = dataDir
|
||||||
|
}
|
||||||
|
if logsDir := os.Getenv("LLAMACTL_LOGS_DIR"); logsDir != "" {
|
||||||
|
cfg.Instances.LogsDir = logsDir
|
||||||
|
}
|
||||||
|
if autoCreate := os.Getenv("LLAMACTL_AUTO_CREATE_DATA_DIR"); autoCreate != "" {
|
||||||
|
if b, err := strconv.ParseBool(autoCreate); err == nil {
|
||||||
|
cfg.Instances.AutoCreateDirs = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instance config
|
||||||
|
if portRange := os.Getenv("LLAMACTL_INSTANCE_PORT_RANGE"); portRange != "" {
|
||||||
|
if ports := ParsePortRange(portRange); ports != [2]int{0, 0} {
|
||||||
|
cfg.Instances.PortRange = ports
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if maxInstances := os.Getenv("LLAMACTL_MAX_INSTANCES"); maxInstances != "" {
|
||||||
|
if m, err := strconv.Atoi(maxInstances); err == nil {
|
||||||
|
cfg.Instances.MaxInstances = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if maxRunning := os.Getenv("LLAMACTL_MAX_RUNNING_INSTANCES"); maxRunning != "" {
|
||||||
|
if m, err := strconv.Atoi(maxRunning); err == nil {
|
||||||
|
cfg.Instances.MaxRunningInstances = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if enableLRUEviction := os.Getenv("LLAMACTL_ENABLE_LRU_EVICTION"); enableLRUEviction != "" {
|
||||||
|
if b, err := strconv.ParseBool(enableLRUEviction); err == nil {
|
||||||
|
cfg.Instances.EnableLRUEviction = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Backend config
|
||||||
|
// LlamaCpp backend
|
||||||
|
if llamaCmd := os.Getenv("LLAMACTL_LLAMACPP_COMMAND"); llamaCmd != "" {
|
||||||
|
cfg.Backends.LlamaCpp.Command = llamaCmd
|
||||||
|
}
|
||||||
|
if llamaArgs := os.Getenv("LLAMACTL_LLAMACPP_ARGS"); llamaArgs != "" {
|
||||||
|
cfg.Backends.LlamaCpp.Args = strings.Split(llamaArgs, " ")
|
||||||
|
}
|
||||||
|
if llamaEnv := os.Getenv("LLAMACTL_LLAMACPP_ENV"); llamaEnv != "" {
|
||||||
|
if cfg.Backends.LlamaCpp.Environment == nil {
|
||||||
|
cfg.Backends.LlamaCpp.Environment = make(map[string]string)
|
||||||
|
}
|
||||||
|
parseEnvVars(llamaEnv, cfg.Backends.LlamaCpp.Environment)
|
||||||
|
}
|
||||||
|
if llamaDockerEnabled := os.Getenv("LLAMACTL_LLAMACPP_DOCKER_ENABLED"); llamaDockerEnabled != "" {
|
||||||
|
if b, err := strconv.ParseBool(llamaDockerEnabled); err == nil {
|
||||||
|
if cfg.Backends.LlamaCpp.Docker == nil {
|
||||||
|
cfg.Backends.LlamaCpp.Docker = &DockerSettings{}
|
||||||
|
}
|
||||||
|
cfg.Backends.LlamaCpp.Docker.Enabled = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if llamaDockerImage := os.Getenv("LLAMACTL_LLAMACPP_DOCKER_IMAGE"); llamaDockerImage != "" {
|
||||||
|
if cfg.Backends.LlamaCpp.Docker == nil {
|
||||||
|
cfg.Backends.LlamaCpp.Docker = &DockerSettings{}
|
||||||
|
}
|
||||||
|
cfg.Backends.LlamaCpp.Docker.Image = llamaDockerImage
|
||||||
|
}
|
||||||
|
if llamaDockerArgs := os.Getenv("LLAMACTL_LLAMACPP_DOCKER_ARGS"); llamaDockerArgs != "" {
|
||||||
|
if cfg.Backends.LlamaCpp.Docker == nil {
|
||||||
|
cfg.Backends.LlamaCpp.Docker = &DockerSettings{}
|
||||||
|
}
|
||||||
|
cfg.Backends.LlamaCpp.Docker.Args = strings.Split(llamaDockerArgs, " ")
|
||||||
|
}
|
||||||
|
if llamaDockerEnv := os.Getenv("LLAMACTL_LLAMACPP_DOCKER_ENV"); llamaDockerEnv != "" {
|
||||||
|
if cfg.Backends.LlamaCpp.Docker == nil {
|
||||||
|
cfg.Backends.LlamaCpp.Docker = &DockerSettings{}
|
||||||
|
}
|
||||||
|
if cfg.Backends.LlamaCpp.Docker.Environment == nil {
|
||||||
|
cfg.Backends.LlamaCpp.Docker.Environment = make(map[string]string)
|
||||||
|
}
|
||||||
|
parseEnvVars(llamaDockerEnv, cfg.Backends.LlamaCpp.Docker.Environment)
|
||||||
|
}
|
||||||
|
if llamaEnv := os.Getenv("LLAMACTL_LLAMACPP_RESPONSE_HEADERS"); llamaEnv != "" {
|
||||||
|
if cfg.Backends.LlamaCpp.ResponseHeaders == nil {
|
||||||
|
cfg.Backends.LlamaCpp.ResponseHeaders = make(map[string]string)
|
||||||
|
}
|
||||||
|
parseHeaders(llamaEnv, cfg.Backends.LlamaCpp.ResponseHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
// vLLM backend
|
||||||
|
if vllmCmd := os.Getenv("LLAMACTL_VLLM_COMMAND"); vllmCmd != "" {
|
||||||
|
cfg.Backends.VLLM.Command = vllmCmd
|
||||||
|
}
|
||||||
|
if vllmArgs := os.Getenv("LLAMACTL_VLLM_ARGS"); vllmArgs != "" {
|
||||||
|
cfg.Backends.VLLM.Args = strings.Split(vllmArgs, " ")
|
||||||
|
}
|
||||||
|
if vllmEnv := os.Getenv("LLAMACTL_VLLM_ENV"); vllmEnv != "" {
|
||||||
|
if cfg.Backends.VLLM.Environment == nil {
|
||||||
|
cfg.Backends.VLLM.Environment = make(map[string]string)
|
||||||
|
}
|
||||||
|
parseEnvVars(vllmEnv, cfg.Backends.VLLM.Environment)
|
||||||
|
}
|
||||||
|
if vllmDockerEnabled := os.Getenv("LLAMACTL_VLLM_DOCKER_ENABLED"); vllmDockerEnabled != "" {
|
||||||
|
if b, err := strconv.ParseBool(vllmDockerEnabled); err == nil {
|
||||||
|
if cfg.Backends.VLLM.Docker == nil {
|
||||||
|
cfg.Backends.VLLM.Docker = &DockerSettings{}
|
||||||
|
}
|
||||||
|
cfg.Backends.VLLM.Docker.Enabled = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if vllmDockerImage := os.Getenv("LLAMACTL_VLLM_DOCKER_IMAGE"); vllmDockerImage != "" {
|
||||||
|
if cfg.Backends.VLLM.Docker == nil {
|
||||||
|
cfg.Backends.VLLM.Docker = &DockerSettings{}
|
||||||
|
}
|
||||||
|
cfg.Backends.VLLM.Docker.Image = vllmDockerImage
|
||||||
|
}
|
||||||
|
if vllmDockerArgs := os.Getenv("LLAMACTL_VLLM_DOCKER_ARGS"); vllmDockerArgs != "" {
|
||||||
|
if cfg.Backends.VLLM.Docker == nil {
|
||||||
|
cfg.Backends.VLLM.Docker = &DockerSettings{}
|
||||||
|
}
|
||||||
|
cfg.Backends.VLLM.Docker.Args = strings.Split(vllmDockerArgs, " ")
|
||||||
|
}
|
||||||
|
if vllmDockerEnv := os.Getenv("LLAMACTL_VLLM_DOCKER_ENV"); vllmDockerEnv != "" {
|
||||||
|
if cfg.Backends.VLLM.Docker == nil {
|
||||||
|
cfg.Backends.VLLM.Docker = &DockerSettings{}
|
||||||
|
}
|
||||||
|
if cfg.Backends.VLLM.Docker.Environment == nil {
|
||||||
|
cfg.Backends.VLLM.Docker.Environment = make(map[string]string)
|
||||||
|
}
|
||||||
|
parseEnvVars(vllmDockerEnv, cfg.Backends.VLLM.Docker.Environment)
|
||||||
|
}
|
||||||
|
if llamaEnv := os.Getenv("LLAMACTL_VLLM_RESPONSE_HEADERS"); llamaEnv != "" {
|
||||||
|
if cfg.Backends.VLLM.ResponseHeaders == nil {
|
||||||
|
cfg.Backends.VLLM.ResponseHeaders = make(map[string]string)
|
||||||
|
}
|
||||||
|
parseHeaders(llamaEnv, cfg.Backends.VLLM.ResponseHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MLX backend
|
||||||
|
if mlxCmd := os.Getenv("LLAMACTL_MLX_COMMAND"); mlxCmd != "" {
|
||||||
|
cfg.Backends.MLX.Command = mlxCmd
|
||||||
|
}
|
||||||
|
if mlxArgs := os.Getenv("LLAMACTL_MLX_ARGS"); mlxArgs != "" {
|
||||||
|
cfg.Backends.MLX.Args = strings.Split(mlxArgs, " ")
|
||||||
|
}
|
||||||
|
if mlxEnv := os.Getenv("LLAMACTL_MLX_ENV"); mlxEnv != "" {
|
||||||
|
if cfg.Backends.MLX.Environment == nil {
|
||||||
|
cfg.Backends.MLX.Environment = make(map[string]string)
|
||||||
|
}
|
||||||
|
parseEnvVars(mlxEnv, cfg.Backends.MLX.Environment)
|
||||||
|
}
|
||||||
|
if llamaEnv := os.Getenv("LLAMACTL_MLX_RESPONSE_HEADERS"); llamaEnv != "" {
|
||||||
|
if cfg.Backends.MLX.ResponseHeaders == nil {
|
||||||
|
cfg.Backends.MLX.ResponseHeaders = make(map[string]string)
|
||||||
|
}
|
||||||
|
parseHeaders(llamaEnv, cfg.Backends.MLX.ResponseHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instance defaults
|
||||||
|
if autoRestart := os.Getenv("LLAMACTL_DEFAULT_AUTO_RESTART"); autoRestart != "" {
|
||||||
|
if b, err := strconv.ParseBool(autoRestart); err == nil {
|
||||||
|
cfg.Instances.DefaultAutoRestart = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if maxRestarts := os.Getenv("LLAMACTL_DEFAULT_MAX_RESTARTS"); maxRestarts != "" {
|
||||||
|
if m, err := strconv.Atoi(maxRestarts); err == nil {
|
||||||
|
cfg.Instances.DefaultMaxRestarts = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if restartDelay := os.Getenv("LLAMACTL_DEFAULT_RESTART_DELAY"); restartDelay != "" {
|
||||||
|
if seconds, err := strconv.Atoi(restartDelay); err == nil {
|
||||||
|
cfg.Instances.DefaultRestartDelay = seconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if onDemandStart := os.Getenv("LLAMACTL_DEFAULT_ON_DEMAND_START"); onDemandStart != "" {
|
||||||
|
if b, err := strconv.ParseBool(onDemandStart); err == nil {
|
||||||
|
cfg.Instances.DefaultOnDemandStart = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if onDemandTimeout := os.Getenv("LLAMACTL_ON_DEMAND_START_TIMEOUT"); onDemandTimeout != "" {
|
||||||
|
if seconds, err := strconv.Atoi(onDemandTimeout); err == nil {
|
||||||
|
cfg.Instances.OnDemandStartTimeout = seconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if timeoutCheckInterval := os.Getenv("LLAMACTL_TIMEOUT_CHECK_INTERVAL"); timeoutCheckInterval != "" {
|
||||||
|
if minutes, err := strconv.Atoi(timeoutCheckInterval); err == nil {
|
||||||
|
cfg.Instances.TimeoutCheckInterval = minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Auth config
|
||||||
|
if requireInferenceAuth := os.Getenv("LLAMACTL_REQUIRE_INFERENCE_AUTH"); requireInferenceAuth != "" {
|
||||||
|
if b, err := strconv.ParseBool(requireInferenceAuth); err == nil {
|
||||||
|
cfg.Auth.RequireInferenceAuth = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if requireManagementAuth := os.Getenv("LLAMACTL_REQUIRE_MANAGEMENT_AUTH"); requireManagementAuth != "" {
|
||||||
|
if b, err := strconv.ParseBool(requireManagementAuth); err == nil {
|
||||||
|
cfg.Auth.RequireManagementAuth = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if managementKeys := os.Getenv("LLAMACTL_MANAGEMENT_KEYS"); managementKeys != "" {
|
||||||
|
cfg.Auth.ManagementKeys = strings.Split(managementKeys, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local node config
|
||||||
|
if localNode := os.Getenv("LLAMACTL_LOCAL_NODE"); localNode != "" {
|
||||||
|
cfg.LocalNode = localNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database config
|
||||||
|
if dbPath := os.Getenv("LLAMACTL_DATABASE_PATH"); dbPath != "" {
|
||||||
|
cfg.Database.Path = dbPath
|
||||||
|
}
|
||||||
|
if maxOpenConns := os.Getenv("LLAMACTL_DATABASE_MAX_OPEN_CONNECTIONS"); maxOpenConns != "" {
|
||||||
|
if m, err := strconv.Atoi(maxOpenConns); err == nil {
|
||||||
|
cfg.Database.MaxOpenConnections = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if maxIdleConns := os.Getenv("LLAMACTL_DATABASE_MAX_IDLE_CONNECTIONS"); maxIdleConns != "" {
|
||||||
|
if m, err := strconv.Atoi(maxIdleConns); err == nil {
|
||||||
|
cfg.Database.MaxIdleConnections = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if connMaxLifetime := os.Getenv("LLAMACTL_DATABASE_CONN_MAX_LIFETIME"); connMaxLifetime != "" {
|
||||||
|
if d, err := time.ParseDuration(connMaxLifetime); err == nil {
|
||||||
|
cfg.Database.ConnMaxLifetime = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log rotation config
|
||||||
|
if logRotationEnabled := os.Getenv("LLAMACTL_LOG_ROTATION_ENABLED"); logRotationEnabled != "" {
|
||||||
|
if b, err := strconv.ParseBool(logRotationEnabled); err == nil {
|
||||||
|
cfg.Instances.LogRotationEnabled = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if logRotationMaxSize := os.Getenv("LLAMACTL_LOG_ROTATION_MAX_SIZE"); logRotationMaxSize != "" {
|
||||||
|
if m, err := strconv.Atoi(logRotationMaxSize); err == nil {
|
||||||
|
cfg.Instances.LogRotationMaxSize = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if logRotationCompress := os.Getenv("LLAMACTL_LOG_ROTATION_COMPRESS"); logRotationCompress != "" {
|
||||||
|
if b, err := strconv.ParseBool(logRotationCompress); err == nil {
|
||||||
|
cfg.Instances.LogRotationCompress = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePortRange parses port range from string formats like "8000-9000" or "8000,9000"
|
||||||
|
func ParsePortRange(s string) [2]int {
|
||||||
|
var parts []string
|
||||||
|
|
||||||
|
// Try both separators
|
||||||
|
if strings.Contains(s, "-") {
|
||||||
|
parts = strings.Split(s, "-")
|
||||||
|
} else if strings.Contains(s, ",") {
|
||||||
|
parts = strings.Split(s, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the two parts
|
||||||
|
if len(parts) == 2 {
|
||||||
|
start, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||||
|
end, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||||
|
if err1 == nil && err2 == nil {
|
||||||
|
return [2]int{start, end}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [2]int{0, 0} // Invalid format
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseEnvVars parses environment variables in format "KEY1=value1,KEY2=value2"
|
||||||
|
// and populates the provided environment map
|
||||||
|
func parseEnvVars(envString string, envMap map[string]string) {
|
||||||
|
if envString == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, envPair := range strings.Split(envString, ",") {
|
||||||
|
if parts := strings.SplitN(strings.TrimSpace(envPair), "=", 2); len(parts) == 2 {
|
||||||
|
envMap[parts[0]] = parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHeaders parses HTTP headers in format "KEY1=value1;KEY2=value2"
|
||||||
|
// and populates the provided environment map
|
||||||
|
func parseHeaders(envString string, envMap map[string]string) {
|
||||||
|
if envString == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, envPair := range strings.Split(envString, ";") {
|
||||||
|
if parts := strings.SplitN(strings.TrimSpace(envPair), "=", 2); len(parts) == 2 {
|
||||||
|
envMap[parts[0]] = parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
143
pkg/config/types.go
Normal file
143
pkg/config/types.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// BackendSettings contains structured backend configuration
|
||||||
|
type BackendSettings struct {
|
||||||
|
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" 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" 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" json:"server"`
|
||||||
|
Backends BackendConfig `yaml:"backends" json:"backends"`
|
||||||
|
Instances InstancesConfig `yaml:"instances" json:"instances"`
|
||||||
|
Database DatabaseConfig `yaml:"database" json:"database"`
|
||||||
|
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"`
|
||||||
|
|
||||||
|
// Directory where all llamactl data will be stored (database, instances, logs, etc.)
|
||||||
|
DataDir string `yaml:"data_dir" json:"data_dir"`
|
||||||
|
|
||||||
|
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" json:"host"`
|
||||||
|
|
||||||
|
// Server port to bind to
|
||||||
|
Port int `yaml:"port" json:"port"`
|
||||||
|
|
||||||
|
// Allowed origins for CORS (e.g., "http://localhost:3000")
|
||||||
|
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" json:"allowed_headers"`
|
||||||
|
|
||||||
|
// Enable Swagger UI for API documentation
|
||||||
|
EnableSwagger bool `yaml:"enable_swagger" json:"enable_swagger"`
|
||||||
|
|
||||||
|
// Response headers to send with responses
|
||||||
|
ResponseHeaders map[string]string `yaml:"response_headers,omitempty" json:"response_headers,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DatabaseConfig contains database configuration settings
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
// Database file path (relative to the top-level data_dir or absolute)
|
||||||
|
Path string `yaml:"path" json:"path"`
|
||||||
|
|
||||||
|
// Connection settings
|
||||||
|
MaxOpenConnections int `yaml:"max_open_connections" json:"max_open_connections"`
|
||||||
|
MaxIdleConnections int `yaml:"max_idle_connections" json:"max_idle_connections"`
|
||||||
|
ConnMaxLifetime time.Duration `yaml:"connection_max_lifetime" json:"connection_max_lifetime" swaggertype:"string" example:"1h"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstancesConfig contains instance management configuration
|
||||||
|
type InstancesConfig struct {
|
||||||
|
// Port range for instances (e.g., 8000,9000)
|
||||||
|
PortRange [2]int `yaml:"port_range" json:"port_range"`
|
||||||
|
|
||||||
|
// Automatically create the data directory if it doesn't exist
|
||||||
|
AutoCreateDirs bool `yaml:"auto_create_dirs" json:"auto_create_dirs"`
|
||||||
|
|
||||||
|
// Maximum number of instances that can be created
|
||||||
|
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" json:"max_running_instances,omitempty"`
|
||||||
|
|
||||||
|
// Enable LRU eviction for instance logs
|
||||||
|
EnableLRUEviction bool `yaml:"enable_lru_eviction" json:"enable_lru_eviction"`
|
||||||
|
|
||||||
|
// Default auto-restart setting for new instances
|
||||||
|
DefaultAutoRestart bool `yaml:"default_auto_restart" json:"default_auto_restart"`
|
||||||
|
|
||||||
|
// Default max restarts for new instances
|
||||||
|
DefaultMaxRestarts int `yaml:"default_max_restarts" json:"default_max_restarts"`
|
||||||
|
|
||||||
|
// Default restart delay for new instances (in seconds)
|
||||||
|
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" 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" json:"on_demand_start_timeout,omitempty"`
|
||||||
|
|
||||||
|
// Interval for checking instance timeouts (in minutes)
|
||||||
|
TimeoutCheckInterval int `yaml:"timeout_check_interval" json:"timeout_check_interval"`
|
||||||
|
|
||||||
|
// Logs directory override (relative to data_dir if not absolute)
|
||||||
|
LogsDir string `yaml:"logs_dir" json:"logs_dir"`
|
||||||
|
|
||||||
|
// Log rotation enabled
|
||||||
|
LogRotationEnabled bool `yaml:"log_rotation_enabled" default:"true"`
|
||||||
|
|
||||||
|
// Maximum log file size in MB before rotation
|
||||||
|
LogRotationMaxSize int `yaml:"log_rotation_max_size" default:"100"`
|
||||||
|
|
||||||
|
// Whether to compress rotated log files
|
||||||
|
LogRotationCompress bool `yaml:"log_rotation_compress" default:"false"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthConfig contains authentication settings
|
||||||
|
type AuthConfig struct {
|
||||||
|
|
||||||
|
// Require authentication for OpenAI compatible inference endpoints
|
||||||
|
RequireInferenceAuth bool `yaml:"require_inference_auth" json:"require_inference_auth"`
|
||||||
|
|
||||||
|
// Require authentication for management endpoints
|
||||||
|
RequireManagementAuth bool `yaml:"require_management_auth" json:"require_management_auth"`
|
||||||
|
|
||||||
|
// List of keys for management endpoints
|
||||||
|
ManagementKeys []string `yaml:"management_keys" json:"management_keys"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NodeConfig struct {
|
||||||
|
Address string `yaml:"address" json:"address"`
|
||||||
|
APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"`
|
||||||
|
}
|
||||||
211
pkg/database/apikeys.go
Normal file
211
pkg/database/apikeys.go
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"llamactl/pkg/auth"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateKey inserts a new API key with permissions (transactional)
|
||||||
|
func (db *sqliteDB) CreateKey(ctx context.Context, key *auth.APIKey, permissions []auth.KeyPermission) error {
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// Insert the API key
|
||||||
|
query := `
|
||||||
|
INSERT INTO api_keys (key_hash, name, user_id, permission_mode, expires_at, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`
|
||||||
|
|
||||||
|
var expiresAt sql.NullInt64
|
||||||
|
if key.ExpiresAt != nil {
|
||||||
|
expiresAt = sql.NullInt64{Int64: *key.ExpiresAt, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := tx.ExecContext(ctx, query,
|
||||||
|
key.KeyHash, key.Name, key.UserID, key.PermissionMode,
|
||||||
|
expiresAt, key.CreatedAt, key.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to insert API key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keyID, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get last insert ID: %w", err)
|
||||||
|
}
|
||||||
|
key.ID = int(keyID)
|
||||||
|
|
||||||
|
// Insert permissions if per-instance mode
|
||||||
|
if key.PermissionMode == auth.PermissionModePerInstance {
|
||||||
|
for _, perm := range permissions {
|
||||||
|
query := `
|
||||||
|
INSERT INTO key_permissions (key_id, instance_id)
|
||||||
|
VALUES (?, ?)
|
||||||
|
`
|
||||||
|
_, err := tx.ExecContext(ctx, query, key.ID, perm.InstanceID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to insert permission for instance %d: %w", perm.InstanceID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetKeyByID retrieves an API key by ID
|
||||||
|
func (db *sqliteDB) GetKeyByID(ctx context.Context, id int) (*auth.APIKey, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, key_hash, name, user_id, permission_mode, expires_at, created_at, updated_at, last_used_at
|
||||||
|
FROM api_keys
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
var key auth.APIKey
|
||||||
|
var expiresAt sql.NullInt64
|
||||||
|
var lastUsedAt sql.NullInt64
|
||||||
|
|
||||||
|
err := db.QueryRowContext(ctx, query, id).Scan(
|
||||||
|
&key.ID, &key.KeyHash, &key.Name, &key.UserID, &key.PermissionMode,
|
||||||
|
&expiresAt, &key.CreatedAt, &key.UpdatedAt, &lastUsedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, fmt.Errorf("API key not found")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to query API key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if expiresAt.Valid {
|
||||||
|
key.ExpiresAt = &expiresAt.Int64
|
||||||
|
}
|
||||||
|
if lastUsedAt.Valid {
|
||||||
|
key.LastUsedAt = &lastUsedAt.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
return &key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserKeys retrieves all API keys for a user
|
||||||
|
func (db *sqliteDB) GetUserKeys(ctx context.Context, userID string) ([]*auth.APIKey, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, key_hash, name, user_id, permission_mode, expires_at, created_at, updated_at, last_used_at
|
||||||
|
FROM api_keys
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(ctx, query, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query API keys: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var keys []*auth.APIKey
|
||||||
|
for rows.Next() {
|
||||||
|
var key auth.APIKey
|
||||||
|
var expiresAt sql.NullInt64
|
||||||
|
var lastUsedAt sql.NullInt64
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&key.ID, &key.KeyHash, &key.Name, &key.UserID, &key.PermissionMode,
|
||||||
|
&expiresAt, &key.CreatedAt, &key.UpdatedAt, &lastUsedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan API key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if expiresAt.Valid {
|
||||||
|
key.ExpiresAt = &expiresAt.Int64
|
||||||
|
}
|
||||||
|
if lastUsedAt.Valid {
|
||||||
|
key.LastUsedAt = &lastUsedAt.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
keys = append(keys, &key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActiveKeys retrieves all non-expired API keys
|
||||||
|
func (db *sqliteDB) GetActiveKeys(ctx context.Context) ([]*auth.APIKey, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, key_hash, name, user_id, permission_mode, expires_at, created_at, updated_at, last_used_at
|
||||||
|
FROM api_keys
|
||||||
|
WHERE expires_at IS NULL OR expires_at > ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
rows, err := db.QueryContext(ctx, query, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query active API keys: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var keys []*auth.APIKey
|
||||||
|
for rows.Next() {
|
||||||
|
var key auth.APIKey
|
||||||
|
var expiresAt sql.NullInt64
|
||||||
|
var lastUsedAt sql.NullInt64
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&key.ID, &key.KeyHash, &key.Name, &key.UserID, &key.PermissionMode,
|
||||||
|
&expiresAt, &key.CreatedAt, &key.UpdatedAt, &lastUsedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan API key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if expiresAt.Valid {
|
||||||
|
key.ExpiresAt = &expiresAt.Int64
|
||||||
|
}
|
||||||
|
if lastUsedAt.Valid {
|
||||||
|
key.LastUsedAt = &lastUsedAt.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
keys = append(keys, &key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteKey removes an API key (cascades to permissions)
|
||||||
|
func (db *sqliteDB) DeleteKey(ctx context.Context, id int) error {
|
||||||
|
query := `DELETE FROM api_keys WHERE id = ?`
|
||||||
|
|
||||||
|
result, err := db.ExecContext(ctx, query, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete API key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get rows affected: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rowsAffected == 0 {
|
||||||
|
return fmt.Errorf("API key not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TouchKey updates the last_used_at timestamp
|
||||||
|
func (db *sqliteDB) TouchKey(ctx context.Context, id int) error {
|
||||||
|
query := `UPDATE api_keys SET last_used_at = ?, updated_at = ? WHERE id = ?`
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
_, err := db.ExecContext(ctx, query, now, now, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update last used timestamp: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"llamactl/pkg/auth"
|
||||||
"llamactl/pkg/instance"
|
"llamactl/pkg/instance"
|
||||||
"log"
|
"log"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -11,14 +13,26 @@ import (
|
|||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DB defines the interface for instance persistence operations
|
// InstanceStore defines interface for instance persistence operations
|
||||||
type DB interface {
|
type InstanceStore interface {
|
||||||
Save(inst *instance.Instance) error
|
Save(inst *instance.Instance) error
|
||||||
Delete(name string) error
|
Delete(name string) error
|
||||||
LoadAll() ([]*instance.Instance, error)
|
LoadAll() ([]*instance.Instance, error)
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AuthStore defines the interface for authentication operations
|
||||||
|
type AuthStore interface {
|
||||||
|
CreateKey(ctx context.Context, key *auth.APIKey, permissions []auth.KeyPermission) error
|
||||||
|
GetUserKeys(ctx context.Context, userID string) ([]*auth.APIKey, error)
|
||||||
|
GetActiveKeys(ctx context.Context) ([]*auth.APIKey, error)
|
||||||
|
GetKeyByID(ctx context.Context, id int) (*auth.APIKey, error)
|
||||||
|
DeleteKey(ctx context.Context, id int) error
|
||||||
|
TouchKey(ctx context.Context, id int) error
|
||||||
|
GetPermissions(ctx context.Context, keyID int) ([]auth.KeyPermission, error)
|
||||||
|
HasPermission(ctx context.Context, keyID, instanceID int) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
// Config contains database configuration settings
|
// Config contains database configuration settings
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// Database file path (relative to data_dir or absolute)
|
// Database file path (relative to data_dir or absolute)
|
||||||
@@ -30,13 +44,13 @@ type Config struct {
|
|||||||
ConnMaxLifetime time.Duration
|
ConnMaxLifetime time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// sqliteDB wraps the database connection with configuration
|
// sqliteDB wraps database connection with configuration
|
||||||
type sqliteDB struct {
|
type sqliteDB struct {
|
||||||
*sql.DB
|
*sql.DB
|
||||||
config *Config
|
config *Config
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open creates a new database connection with the provided configuration
|
// Open creates a new database connection with provided configuration
|
||||||
func Open(config *Config) (*sqliteDB, error) {
|
func Open(config *Config) (*sqliteDB, error) {
|
||||||
if config == nil {
|
if config == nil {
|
||||||
return nil, fmt.Errorf("database config cannot be nil")
|
return nil, fmt.Errorf("database config cannot be nil")
|
||||||
@@ -46,10 +60,10 @@ func Open(config *Config) (*sqliteDB, error) {
|
|||||||
return nil, fmt.Errorf("database path cannot be empty")
|
return nil, fmt.Errorf("database path cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure the database directory exists
|
// Ensure that database directory exists
|
||||||
dbDir := filepath.Dir(config.Path)
|
dbDir := filepath.Dir(config.Path)
|
||||||
if dbDir != "." && dbDir != "/" {
|
if dbDir != "." && dbDir != "/" {
|
||||||
// Directory will be created by the manager if auto_create_dirs is enabled
|
// Directory will be created by manager if auto_create_dirs is enabled
|
||||||
log.Printf("Database will be created at: %s", config.Path)
|
log.Printf("Database will be created at: %s", config.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,16 +103,22 @@ func Open(config *Config) (*sqliteDB, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close closes the database connection
|
// Close closes database connection
|
||||||
func (db *sqliteDB) Close() error {
|
func (db *sqliteDB) Close() error {
|
||||||
if db.DB != nil {
|
if db.DB != nil {
|
||||||
log.Println("Closing database connection")
|
log.Println("Closing database connection")
|
||||||
|
|
||||||
|
// Checkpoint WAL to merge changes back to main database file
|
||||||
|
if _, err := db.DB.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
|
||||||
|
log.Printf("Warning: Failed to checkpoint WAL: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
return db.DB.Close()
|
return db.DB.Close()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HealthCheck verifies the database is accessible
|
// HealthCheck verifies that database is accessible
|
||||||
func (db *sqliteDB) HealthCheck() error {
|
func (db *sqliteDB) HealthCheck() error {
|
||||||
if db.DB == nil {
|
if db.DB == nil {
|
||||||
return fmt.Errorf("database connection is nil")
|
return fmt.Errorf("database connection is nil")
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func (db *sqliteDB) Create(ctx context.Context, inst *instance.Instance) error {
|
|||||||
) VALUES (?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
|
|
||||||
_, err = db.DB.ExecContext(ctx, query,
|
result, err := db.DB.ExecContext(ctx, query,
|
||||||
row.Name, row.Status, row.CreatedAt, row.UpdatedAt, row.OptionsJSON, row.OwnerUserID,
|
row.Name, row.Status, row.CreatedAt, row.UpdatedAt, row.OptionsJSON, row.OwnerUserID,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,6 +53,14 @@ func (db *sqliteDB) Create(ctx context.Context, inst *instance.Instance) error {
|
|||||||
return fmt.Errorf("failed to insert instance: %w", err)
|
return fmt.Errorf("failed to insert instance: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the auto-generated ID and set it on the instance
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get last insert ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
inst.ID = int(id)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +271,7 @@ func (db *sqliteDB) rowToInstance(row *instanceRow) (*instance.Instance, error)
|
|||||||
|
|
||||||
// Build complete instance JSON with all fields
|
// Build complete instance JSON with all fields
|
||||||
instanceJSON, err := json.Marshal(map[string]any{
|
instanceJSON, err := json.Marshal(map[string]any{
|
||||||
|
"id": row.ID,
|
||||||
"name": row.Name,
|
"name": row.Name,
|
||||||
"created": row.CreatedAt,
|
"created": row.CreatedAt,
|
||||||
"status": row.Status,
|
"status": row.Status,
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
-- Drop indexes first
|
-- Drop API key related indexes and tables first
|
||||||
DROP INDEX IF EXISTS idx_instances_backend_type;
|
DROP INDEX IF EXISTS idx_key_permissions_instance_id;
|
||||||
|
DROP INDEX IF EXISTS idx_api_keys_expires_at;
|
||||||
|
DROP INDEX IF EXISTS idx_api_keys_user_id;
|
||||||
|
DROP TABLE IF EXISTS key_permissions;
|
||||||
|
DROP TABLE IF EXISTS api_keys;
|
||||||
|
|
||||||
|
-- Drop instance related indexes and tables
|
||||||
DROP INDEX IF EXISTS idx_instances_status;
|
DROP INDEX IF EXISTS idx_instances_status;
|
||||||
DROP INDEX IF EXISTS idx_instances_name;
|
DROP INDEX IF EXISTS idx_instances_name;
|
||||||
|
|
||||||
-- Drop tables
|
|
||||||
DROP TABLE IF EXISTS instances;
|
DROP TABLE IF EXISTS instances;
|
||||||
|
|||||||
@@ -25,3 +25,36 @@ CREATE TABLE IF NOT EXISTS instances (
|
|||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_instances_name ON instances(name);
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_instances_name ON instances(name);
|
||||||
CREATE INDEX IF NOT EXISTS idx_instances_status ON instances(status);
|
CREATE INDEX IF NOT EXISTS idx_instances_status ON instances(status);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- API Keys Table: Database-backed inference API keys
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
key_hash TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
permission_mode TEXT NOT NULL CHECK(permission_mode IN ('allow_all', 'per_instance')) DEFAULT 'per_instance',
|
||||||
|
expires_at INTEGER NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
last_used_at INTEGER NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Key Permissions Table: Per-instance permissions for API keys
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS key_permissions (
|
||||||
|
key_id INTEGER NOT NULL,
|
||||||
|
instance_id INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (key_id, instance_id),
|
||||||
|
FOREIGN KEY (key_id) REFERENCES api_keys (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (instance_id) REFERENCES instances (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Indexes for API keys and permissions
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_expires_at ON api_keys(expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_key_permissions_instance_id ON key_permissions(instance_id);
|
||||||
|
|||||||
57
pkg/database/permissions.go
Normal file
57
pkg/database/permissions.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"llamactl/pkg/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetPermissions retrieves all permissions for a key
|
||||||
|
func (db *sqliteDB) GetPermissions(ctx context.Context, keyID int) ([]auth.KeyPermission, error) {
|
||||||
|
query := `
|
||||||
|
SELECT key_id, instance_id
|
||||||
|
FROM key_permissions
|
||||||
|
WHERE key_id = ?
|
||||||
|
ORDER BY instance_id
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(ctx, query, keyID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query key permissions: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var permissions []auth.KeyPermission
|
||||||
|
for rows.Next() {
|
||||||
|
var perm auth.KeyPermission
|
||||||
|
err := rows.Scan(&perm.KeyID, &perm.InstanceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan key permission: %w", err)
|
||||||
|
}
|
||||||
|
permissions = append(permissions, perm)
|
||||||
|
}
|
||||||
|
|
||||||
|
return permissions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasPermission checks if key has inference permission for instance
|
||||||
|
func (db *sqliteDB) HasPermission(ctx context.Context, keyID, instanceID int) (bool, error) {
|
||||||
|
query := `
|
||||||
|
SELECT 1
|
||||||
|
FROM key_permissions
|
||||||
|
WHERE key_id = ? AND instance_id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
var exists int
|
||||||
|
err := db.QueryRowContext(ctx, query, keyID, instanceID).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
// No permission record found, deny access
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("failed to check key permission: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
@@ -3,16 +3,19 @@ package instance
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"llamactl/pkg/config"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"llamactl/pkg/backends"
|
||||||
|
"llamactl/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Instance represents a running instance of the llama server
|
// Instance represents a running instance of llama server
|
||||||
type Instance struct {
|
type Instance struct {
|
||||||
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Created int64 `json:"created,omitempty"` // Unix timestamp when the instance was created
|
Created int64 `json:"created,omitempty"` // Unix timestamp when instance was created
|
||||||
|
|
||||||
// Global configuration
|
// Global configuration
|
||||||
globalInstanceSettings *config.InstancesConfig
|
globalInstanceSettings *config.InstancesConfig
|
||||||
@@ -48,6 +51,7 @@ func New(name string, globalConfig *config.AppConfig, opts *Options, onStatusCha
|
|||||||
options := newOptions(opts)
|
options := newOptions(opts)
|
||||||
|
|
||||||
instance := &Instance{
|
instance := &Instance{
|
||||||
|
ID: 0, // Will be set by database
|
||||||
Name: name,
|
Name: name,
|
||||||
options: options,
|
options: options,
|
||||||
globalInstanceSettings: globalInstanceSettings,
|
globalInstanceSettings: globalInstanceSettings,
|
||||||
@@ -66,7 +70,16 @@ func New(name string, globalConfig *config.AppConfig, opts *Options, onStatusCha
|
|||||||
|
|
||||||
// Only create logger, proxy, and process for local instances
|
// Only create logger, proxy, and process for local instances
|
||||||
if !instance.IsRemote() {
|
if !instance.IsRemote() {
|
||||||
instance.logger = newLogger(name, globalInstanceSettings.LogsDir)
|
logRotationConfig := &LogRotationConfig{
|
||||||
|
Enabled: globalInstanceSettings.LogRotationEnabled,
|
||||||
|
MaxSize: globalInstanceSettings.LogRotationMaxSize,
|
||||||
|
Compress: globalInstanceSettings.LogRotationCompress,
|
||||||
|
}
|
||||||
|
instance.logger = newLogger(
|
||||||
|
name,
|
||||||
|
globalInstanceSettings.LogsDir,
|
||||||
|
logRotationConfig,
|
||||||
|
)
|
||||||
instance.process = newProcess(instance)
|
instance.process = newProcess(instance)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +118,14 @@ func (i *Instance) WaitForHealthy(timeout int) error {
|
|||||||
return i.process.waitForHealthy(timeout)
|
return i.process.waitForHealthy(timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (i *Instance) GetBackendType() backends.BackendType {
|
||||||
|
opts := i.GetOptions()
|
||||||
|
if opts == nil {
|
||||||
|
return backends.BackendTypeUnknown
|
||||||
|
}
|
||||||
|
return opts.BackendOptions.BackendType
|
||||||
|
}
|
||||||
|
|
||||||
// GetOptions returns the current options
|
// GetOptions returns the current options
|
||||||
func (i *Instance) GetOptions() *Options {
|
func (i *Instance) GetOptions() *Options {
|
||||||
if i.options == nil {
|
if i.options == nil {
|
||||||
@@ -279,11 +300,13 @@ func (i *Instance) buildEnvironment() map[string]string {
|
|||||||
// MarshalJSON implements json.Marshaler for Instance
|
// MarshalJSON implements json.Marshaler for Instance
|
||||||
func (i *Instance) MarshalJSON() ([]byte, error) {
|
func (i *Instance) MarshalJSON() ([]byte, error) {
|
||||||
return json.Marshal(&struct {
|
return json.Marshal(&struct {
|
||||||
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status *status `json:"status"`
|
Status *status `json:"status"`
|
||||||
Created int64 `json:"created,omitempty"`
|
Created int64 `json:"created,omitempty"`
|
||||||
Options *options `json:"options,omitempty"`
|
Options *options `json:"options,omitempty"`
|
||||||
}{
|
}{
|
||||||
|
ID: i.ID,
|
||||||
Name: i.Name,
|
Name: i.Name,
|
||||||
Status: i.status,
|
Status: i.status,
|
||||||
Created: i.Created,
|
Created: i.Created,
|
||||||
@@ -295,6 +318,7 @@ func (i *Instance) MarshalJSON() ([]byte, error) {
|
|||||||
func (i *Instance) UnmarshalJSON(data []byte) error {
|
func (i *Instance) UnmarshalJSON(data []byte) error {
|
||||||
// Explicitly deserialize to match MarshalJSON format
|
// Explicitly deserialize to match MarshalJSON format
|
||||||
aux := &struct {
|
aux := &struct {
|
||||||
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status *status `json:"status"`
|
Status *status `json:"status"`
|
||||||
Created int64 `json:"created,omitempty"`
|
Created int64 `json:"created,omitempty"`
|
||||||
@@ -306,6 +330,7 @@ func (i *Instance) UnmarshalJSON(data []byte) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set the fields
|
// Set the fields
|
||||||
|
i.ID = aux.ID
|
||||||
i.Name = aux.Name
|
i.Name = aux.Name
|
||||||
i.Created = aux.Created
|
i.Created = aux.Created
|
||||||
i.status = aux.Status
|
i.status = aux.Status
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ func TestNewInstance(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Instances: config.InstancesConfig{
|
Instances: config.InstancesConfig{
|
||||||
LogsDir: "/tmp/test",
|
|
||||||
DefaultAutoRestart: true,
|
DefaultAutoRestart: true,
|
||||||
|
LogsDir: "/tmp/test",
|
||||||
DefaultMaxRestarts: 3,
|
DefaultMaxRestarts: 3,
|
||||||
DefaultRestartDelay: 5,
|
DefaultRestartDelay: 5,
|
||||||
},
|
},
|
||||||
@@ -120,8 +120,8 @@ func TestSetOptions(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Instances: config.InstancesConfig{
|
Instances: config.InstancesConfig{
|
||||||
LogsDir: "/tmp/test",
|
|
||||||
DefaultAutoRestart: true,
|
DefaultAutoRestart: true,
|
||||||
|
LogsDir: "/tmp/test",
|
||||||
DefaultMaxRestarts: 3,
|
DefaultMaxRestarts: 3,
|
||||||
DefaultRestartDelay: 5,
|
DefaultRestartDelay: 5,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,66 +7,117 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
timber "github.com/DeRuina/timberjack"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// LogRotationConfig contains log rotation settings for instances
|
||||||
|
type LogRotationConfig struct {
|
||||||
|
Enabled bool
|
||||||
|
MaxSize int
|
||||||
|
Compress bool
|
||||||
|
}
|
||||||
|
|
||||||
type logger struct {
|
type logger struct {
|
||||||
name string
|
name string
|
||||||
logDir string
|
logDir string
|
||||||
logFile atomic.Pointer[os.File]
|
logFile *timber.Logger
|
||||||
logFilePath string
|
logFilePath string
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
cfg *LogRotationConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func newLogger(name string, logDir string) *logger {
|
func newLogger(name, logDir string, cfg *LogRotationConfig) *logger {
|
||||||
return &logger{
|
return &logger{
|
||||||
name: name,
|
name: name,
|
||||||
logDir: logDir,
|
logDir: logDir,
|
||||||
|
cfg: cfg,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// create creates and opens the log files for stdout and stderr
|
func (l *logger) create() error {
|
||||||
func (i *logger) create() error {
|
l.mu.Lock()
|
||||||
i.mu.Lock()
|
defer l.mu.Unlock()
|
||||||
defer i.mu.Unlock()
|
|
||||||
|
|
||||||
if i.logDir == "" {
|
if l.logDir == "" {
|
||||||
return fmt.Errorf("logDir is empty for instance %s", i.name)
|
return fmt.Errorf("logDir empty for instance %s", l.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up instance logs
|
if err := os.MkdirAll(l.logDir, 0755); err != nil {
|
||||||
logPath := i.logDir + "/" + i.name + ".log"
|
|
||||||
|
|
||||||
i.logFilePath = logPath
|
|
||||||
if err := os.MkdirAll(i.logDir, 0755); err != nil {
|
|
||||||
return fmt.Errorf("failed to create log directory: %w", err)
|
return fmt.Errorf("failed to create log directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
logPath := fmt.Sprintf("%s/%s.log", l.logDir, l.name)
|
||||||
if err != nil {
|
l.logFilePath = logPath
|
||||||
return fmt.Errorf("failed to create stdout log file: %w", err)
|
|
||||||
|
// Build the timber logger
|
||||||
|
t := &timber.Logger{
|
||||||
|
Filename: logPath,
|
||||||
|
MaxSize: l.cfg.MaxSize,
|
||||||
|
MaxBackups: 0, // No limit on backups
|
||||||
|
// Compression: "gzip" if Compress is true, else "none"
|
||||||
|
Compression: func() string {
|
||||||
|
if l.cfg.Compress {
|
||||||
|
return "gzip"
|
||||||
|
}
|
||||||
|
return "none"
|
||||||
|
}(),
|
||||||
|
FileMode: 0644,
|
||||||
|
LocalTime: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
i.logFile.Store(logFile)
|
// If rotation is disabled, set MaxSize to 0 so no rotation occurs
|
||||||
|
if !l.cfg.Enabled {
|
||||||
|
t.MaxSize = 0
|
||||||
|
}
|
||||||
|
|
||||||
// Write a startup marker to both files
|
l.logFile = t
|
||||||
timestamp := time.Now().Format("2006-01-02 15:04:05")
|
|
||||||
fmt.Fprintf(logFile, "\n=== Instance %s started at %s ===\n", i.name, timestamp)
|
// Write a startup marker
|
||||||
|
ts := time.Now().Format("2006-01-02 15:04:05")
|
||||||
|
fmt.Fprintf(t, "\n=== Instance %s started at %s ===\n", l.name, ts)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getLogs retrieves the last n lines of logs from the instance
|
func (l *logger) readOutput(rc io.ReadCloser) {
|
||||||
func (i *logger) getLogs(num_lines int) (string, error) {
|
defer rc.Close()
|
||||||
i.mu.RLock()
|
scanner := bufio.NewScanner(rc)
|
||||||
defer i.mu.RUnlock()
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if lg := l.logFile; lg != nil {
|
||||||
|
fmt.Fprintln(lg, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if i.logFilePath == "" {
|
func (l *logger) close() {
|
||||||
return "", fmt.Errorf("log file not created for instance %s", i.name)
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
lg := l.logFile
|
||||||
|
if lg == nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := os.Open(i.logFilePath)
|
ts := time.Now().Format("2006-01-02 15:04:05")
|
||||||
|
fmt.Fprintf(lg, "=== Instance %s stopped at %s ===\n\n", l.name, ts)
|
||||||
|
|
||||||
|
_ = lg.Close()
|
||||||
|
l.logFile = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLogs retrieves the last n lines of logs from the instance
|
||||||
|
func (l *logger) getLogs(num_lines int) (string, error) {
|
||||||
|
l.mu.RLock()
|
||||||
|
defer l.mu.RUnlock()
|
||||||
|
|
||||||
|
if l.logFilePath == "" {
|
||||||
|
return "", fmt.Errorf("log file not created for instance %s", l.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(l.logFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to open log file: %w", err)
|
return "", fmt.Errorf("failed to open log file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -97,31 +148,3 @@ func (i *logger) getLogs(num_lines int) (string, error) {
|
|||||||
|
|
||||||
return strings.Join(lines[start:], "\n"), nil
|
return strings.Join(lines[start:], "\n"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// close closes the log files
|
|
||||||
func (i *logger) close() {
|
|
||||||
i.mu.Lock()
|
|
||||||
defer i.mu.Unlock()
|
|
||||||
|
|
||||||
logFile := i.logFile.Swap(nil)
|
|
||||||
if logFile != nil {
|
|
||||||
timestamp := time.Now().Format("2006-01-02 15:04:05")
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// readOutput reads from the given reader and writes lines to the log file
|
|
||||||
func (i *logger) readOutput(reader io.ReadCloser) {
|
|
||||||
defer reader.Close()
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(reader)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
// Use atomic load to avoid lock contention on every line
|
|
||||||
if logFile := i.logFile.Load(); logFile != nil {
|
|
||||||
fmt.Fprintln(logFile, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ type InstanceManager interface {
|
|||||||
UpdateInstance(name string, options *instance.Options) (*instance.Instance, error)
|
UpdateInstance(name string, options *instance.Options) (*instance.Instance, error)
|
||||||
DeleteInstance(name string) error
|
DeleteInstance(name string) error
|
||||||
StartInstance(name string) (*instance.Instance, error)
|
StartInstance(name string) (*instance.Instance, error)
|
||||||
IsMaxRunningInstancesReached() bool
|
AtMaxRunning() bool
|
||||||
StopInstance(name string) (*instance.Instance, error)
|
StopInstance(name string) (*instance.Instance, error)
|
||||||
EvictLRUInstance() error
|
EvictLRUInstance() error
|
||||||
RestartInstance(name string) (*instance.Instance, error)
|
RestartInstance(name string) (*instance.Instance, error)
|
||||||
@@ -31,7 +31,7 @@ type instanceManager struct {
|
|||||||
// Components (each with own synchronization)
|
// Components (each with own synchronization)
|
||||||
registry *instanceRegistry
|
registry *instanceRegistry
|
||||||
ports *portAllocator
|
ports *portAllocator
|
||||||
db database.DB
|
db database.InstanceStore
|
||||||
remote *remoteManager
|
remote *remoteManager
|
||||||
lifecycle *lifecycleManager
|
lifecycle *lifecycleManager
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ type instanceManager struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new instance of InstanceManager with dependency injection.
|
// New creates a new instance of InstanceManager with dependency injection.
|
||||||
func New(globalConfig *config.AppConfig, db database.DB) InstanceManager {
|
func New(globalConfig *config.AppConfig, db database.InstanceStore) InstanceManager {
|
||||||
|
|
||||||
if globalConfig.Instances.TimeoutCheckInterval <= 0 {
|
if globalConfig.Instances.TimeoutCheckInterval <= 0 {
|
||||||
globalConfig.Instances.TimeoutCheckInterval = 5 // Default to 5 minutes if not set
|
globalConfig.Instances.TimeoutCheckInterval = 5 // Default to 5 minutes if not set
|
||||||
@@ -114,11 +114,6 @@ func (im *instanceManager) Shutdown() {
|
|||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
fmt.Println("All instances stopped.")
|
fmt.Println("All instances stopped.")
|
||||||
|
|
||||||
// 4. Close database connection
|
|
||||||
if err := im.db.Close(); err != nil {
|
|
||||||
log.Printf("Error closing database: %v\n", err)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +176,7 @@ func (im *instanceManager) loadInstance(persistedInst *instance.Instance) error
|
|||||||
inst := instance.New(name, im.globalConfig, options, statusCallback)
|
inst := instance.New(name, im.globalConfig, options, statusCallback)
|
||||||
|
|
||||||
// Restore persisted fields that NewInstance doesn't set
|
// Restore persisted fields that NewInstance doesn't set
|
||||||
|
inst.ID = persistedInst.ID
|
||||||
inst.Created = persistedInst.Created
|
inst.Created = persistedInst.Created
|
||||||
inst.SetStatus(persistedInst.GetStatus())
|
inst.SetStatus(persistedInst.GetStatus())
|
||||||
|
|
||||||
|
|||||||
@@ -202,12 +202,11 @@ func createTestAppConfig(instancesDir string) *config.AppConfig {
|
|||||||
},
|
},
|
||||||
Instances: config.InstancesConfig{
|
Instances: config.InstancesConfig{
|
||||||
PortRange: [2]int{8000, 9000},
|
PortRange: [2]int{8000, 9000},
|
||||||
InstancesDir: instancesDir,
|
|
||||||
LogsDir: instancesDir,
|
|
||||||
MaxInstances: 10,
|
MaxInstances: 10,
|
||||||
MaxRunningInstances: 10,
|
MaxRunningInstances: 10,
|
||||||
DefaultAutoRestart: true,
|
DefaultAutoRestart: true,
|
||||||
DefaultMaxRestarts: 3,
|
DefaultMaxRestarts: 3,
|
||||||
|
LogsDir: instancesDir,
|
||||||
DefaultRestartDelay: 5,
|
DefaultRestartDelay: 5,
|
||||||
TimeoutCheckInterval: 5,
|
TimeoutCheckInterval: 5,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ func (im *instanceManager) ListInstances() ([]*instance.Instance, error) {
|
|||||||
if node := im.getNodeForInstance(inst); node != nil {
|
if node := im.getNodeForInstance(inst); node != nil {
|
||||||
remoteInst, err := im.remote.getInstance(ctx, node, inst.Name)
|
remoteInst, err := im.remote.getInstance(ctx, node, inst.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Log error but continue with stale data
|
|
||||||
// Don't fail the entire list operation due to one remote failure
|
// Don't fail the entire list operation due to one remote failure
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -384,7 +383,7 @@ func (im *instanceManager) StartInstance(name string) (*instance.Instance, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check max running instances limit for local instances only
|
// Check max running instances limit for local instances only
|
||||||
if im.IsMaxRunningInstancesReached() {
|
if im.AtMaxRunning() {
|
||||||
return nil, MaxRunningInstancesError(fmt.Errorf("maximum number of running instances (%d) reached", im.globalConfig.Instances.MaxRunningInstances))
|
return nil, MaxRunningInstancesError(fmt.Errorf("maximum number of running instances (%d) reached", im.globalConfig.Instances.MaxRunningInstances))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,7 +399,7 @@ func (im *instanceManager) StartInstance(name string) (*instance.Instance, error
|
|||||||
return inst, nil
|
return inst, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (im *instanceManager) IsMaxRunningInstancesReached() bool {
|
func (im *instanceManager) AtMaxRunning() bool {
|
||||||
if im.globalConfig.Instances.MaxRunningInstances == -1 {
|
if im.globalConfig.Instances.MaxRunningInstances == -1 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ func TestCreateInstance_FailsWithDuplicateName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateInstance_FailsWhenMaxInstancesReached(t *testing.T) {
|
func TestCreateInstance_FailsWhenMaxInstancesReached(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
|
||||||
appConfig := &config.AppConfig{
|
appConfig := &config.AppConfig{
|
||||||
Backends: config.BackendConfig{
|
Backends: config.BackendConfig{
|
||||||
LlamaCpp: config.BackendSettings{
|
LlamaCpp: config.BackendSettings{
|
||||||
@@ -47,7 +46,6 @@ func TestCreateInstance_FailsWhenMaxInstancesReached(t *testing.T) {
|
|||||||
},
|
},
|
||||||
Instances: config.InstancesConfig{
|
Instances: config.InstancesConfig{
|
||||||
PortRange: [2]int{8000, 9000},
|
PortRange: [2]int{8000, 9000},
|
||||||
InstancesDir: tempDir,
|
|
||||||
MaxInstances: 1, // Very low limit for testing
|
MaxInstances: 1, // Very low limit for testing
|
||||||
TimeoutCheckInterval: 5,
|
TimeoutCheckInterval: 5,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"llamactl/pkg/config"
|
"llamactl/pkg/config"
|
||||||
|
"llamactl/pkg/database"
|
||||||
"llamactl/pkg/instance"
|
"llamactl/pkg/instance"
|
||||||
"llamactl/pkg/manager"
|
"llamactl/pkg/manager"
|
||||||
"llamactl/pkg/validation"
|
"llamactl/pkg/validation"
|
||||||
@@ -52,20 +53,25 @@ type Handler struct {
|
|||||||
InstanceManager manager.InstanceManager
|
InstanceManager manager.InstanceManager
|
||||||
cfg config.AppConfig
|
cfg config.AppConfig
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
authStore database.AuthStore
|
||||||
|
authMiddleware *APIAuthMiddleware
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a new Handler instance with the provided instance manager and configuration
|
// NewHandler creates a new Handler instance with the provided instance manager and configuration
|
||||||
func NewHandler(im manager.InstanceManager, cfg config.AppConfig) *Handler {
|
func NewHandler(im manager.InstanceManager, cfg config.AppConfig, authStore database.AuthStore) *Handler {
|
||||||
return &Handler{
|
handler := &Handler{
|
||||||
InstanceManager: im,
|
InstanceManager: im,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
},
|
},
|
||||||
|
authStore: authStore,
|
||||||
}
|
}
|
||||||
|
handler.authMiddleware = NewAPIAuthMiddleware(cfg.Auth, authStore)
|
||||||
|
return handler
|
||||||
}
|
}
|
||||||
|
|
||||||
// getInstance retrieves an instance by name from the request query parameters
|
// getInstance retrieves an instance by name from request query parameters
|
||||||
func (h *Handler) getInstance(r *http.Request) (*instance.Instance, error) {
|
func (h *Handler) getInstance(r *http.Request) (*instance.Instance, error) {
|
||||||
name := chi.URLParam(r, "name")
|
name := chi.URLParam(r, "name")
|
||||||
validatedName, err := validation.ValidateInstanceName(name)
|
validatedName, err := validation.ValidateInstanceName(name)
|
||||||
@@ -81,7 +87,7 @@ func (h *Handler) getInstance(r *http.Request) (*instance.Instance, error) {
|
|||||||
return inst, nil
|
return inst, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureInstanceRunning ensures the instance is running by starting it if on-demand start is enabled
|
// ensureInstanceRunning ensures that an instance is running by starting it if on-demand start is enabled
|
||||||
// It handles LRU eviction when the maximum number of running instances is reached
|
// It handles LRU eviction when the maximum number of running instances is reached
|
||||||
func (h *Handler) ensureInstanceRunning(inst *instance.Instance) error {
|
func (h *Handler) ensureInstanceRunning(inst *instance.Instance) error {
|
||||||
options := inst.GetOptions()
|
options := inst.GetOptions()
|
||||||
@@ -90,7 +96,7 @@ func (h *Handler) ensureInstanceRunning(inst *instance.Instance) error {
|
|||||||
return fmt.Errorf("instance is not running and on-demand start is not enabled")
|
return fmt.Errorf("instance is not running and on-demand start is not enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
if h.InstanceManager.IsMaxRunningInstancesReached() {
|
if h.InstanceManager.AtMaxRunning() {
|
||||||
if h.cfg.Instances.EnableLRUEviction {
|
if h.cfg.Instances.EnableLRUEviction {
|
||||||
err := h.InstanceManager.EvictLRUInstance()
|
err := h.InstanceManager.EvictLRUInstance()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
354
pkg/server/handlers_auth.go
Normal file
354
pkg/server/handlers_auth.go
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"llamactl/pkg/auth"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateKeyRequest represents the request body for creating a new API key.
|
||||||
|
type CreateKeyRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
PermissionMode auth.PermissionMode `json:"permission_mode"`
|
||||||
|
ExpiresAt *int64 `json:"expires_at,omitempty"`
|
||||||
|
InstanceIDs []int `json:"instance_ids,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateKeyResponse represents the response returned when creating a new API key.
|
||||||
|
type CreateKeyResponse struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
PermissionMode auth.PermissionMode `json:"permission_mode"`
|
||||||
|
ExpiresAt *int64 `json:"expires_at"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
LastUsedAt *int64 `json:"last_used_at"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyResponse represents an API key in responses for list and get operations.
|
||||||
|
type KeyResponse struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
PermissionMode auth.PermissionMode `json:"permission_mode"`
|
||||||
|
ExpiresAt *int64 `json:"expires_at"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
LastUsedAt *int64 `json:"last_used_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyPermissionResponse represents the permissions for an API key on a specific instance.
|
||||||
|
type KeyPermissionResponse struct {
|
||||||
|
InstanceID int `json:"instance_id"`
|
||||||
|
InstanceName string `json:"instance_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateKey godoc
|
||||||
|
// @Summary Create a new API key
|
||||||
|
// @Description Creates a new API key with the specified permissions and returns the plain-text key (only shown once)
|
||||||
|
// @Tags Keys
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param key body CreateKeyRequest true "API key configuration"
|
||||||
|
// @Success 201 {object} CreateKeyResponse "Created API key with plain-text key"
|
||||||
|
// @Failure 400 {string} string "Invalid request body or validation error"
|
||||||
|
// @Failure 500 {string} string "Internal Server Error"
|
||||||
|
// @Router /api/v1/auth/keys [post]
|
||||||
|
func (h *Handler) CreateKey() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req CreateKeyRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_json", "Invalid JSON in request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if req.Name == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_name", "Name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Name) > 100 {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_name", "Name must be 100 characters or less")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.PermissionMode != auth.PermissionModeAllowAll && req.PermissionMode != auth.PermissionModePerInstance {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_permission_mode", "Permission mode must be 'allow_all' or 'per_instance'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.PermissionMode == auth.PermissionModePerInstance && len(req.InstanceIDs) == 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing_permissions", "Instance IDs required when permission mode is 'per_instance'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ExpiresAt != nil && *req.ExpiresAt <= time.Now().Unix() {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_expires_at", "Expiration time must be in future")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate instance IDs exist
|
||||||
|
if req.PermissionMode == auth.PermissionModePerInstance {
|
||||||
|
instances, err := h.InstanceManager.ListInstances()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "fetch_instances_failed", fmt.Sprintf("Failed to fetch instances: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
instanceIDMap := make(map[int]bool)
|
||||||
|
for _, inst := range instances {
|
||||||
|
instanceIDMap[inst.ID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, instanceID := range req.InstanceIDs {
|
||||||
|
if !instanceIDMap[instanceID] {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_instance_id", fmt.Sprintf("Instance ID %d does not exist", instanceID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate plain-text key
|
||||||
|
plainTextKey, err := auth.GenerateKey("llamactl")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "key_generation_failed", "Failed to generate API key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash key
|
||||||
|
keyHash, err := auth.HashKey(plainTextKey)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "key_hashing_failed", "Failed to hash API key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create APIKey struct
|
||||||
|
now := time.Now().Unix()
|
||||||
|
apiKey := &auth.APIKey{
|
||||||
|
KeyHash: keyHash,
|
||||||
|
Name: req.Name,
|
||||||
|
UserID: "system",
|
||||||
|
PermissionMode: req.PermissionMode,
|
||||||
|
ExpiresAt: req.ExpiresAt,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert InstanceIDs to KeyPermissions
|
||||||
|
var keyPermissions []auth.KeyPermission
|
||||||
|
for _, instanceID := range req.InstanceIDs {
|
||||||
|
keyPermissions = append(keyPermissions, auth.KeyPermission{
|
||||||
|
KeyID: 0, // Will be set by database after key creation
|
||||||
|
InstanceID: instanceID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create in database
|
||||||
|
err = h.authStore.CreateKey(r.Context(), apiKey, keyPermissions)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "creation_failed", fmt.Sprintf("Failed to create API key: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return response with plain-text key (only shown once)
|
||||||
|
response := CreateKeyResponse{
|
||||||
|
ID: apiKey.ID,
|
||||||
|
Name: apiKey.Name,
|
||||||
|
UserID: apiKey.UserID,
|
||||||
|
PermissionMode: apiKey.PermissionMode,
|
||||||
|
ExpiresAt: apiKey.ExpiresAt,
|
||||||
|
CreatedAt: apiKey.CreatedAt,
|
||||||
|
UpdatedAt: apiKey.UpdatedAt,
|
||||||
|
LastUsedAt: apiKey.LastUsedAt,
|
||||||
|
Key: plainTextKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListKeys godoc
|
||||||
|
// @Summary List all API keys
|
||||||
|
// @Description Returns a list of all API keys for the system user (excludes key hash and plain-text key)
|
||||||
|
// @Tags Keys
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} KeyResponse "List of API keys"
|
||||||
|
// @Failure 500 {string} string "Internal Server Error"
|
||||||
|
// @Router /api/v1/auth/keys [get]
|
||||||
|
func (h *Handler) ListKeys() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
keys, err := h.authStore.GetUserKeys(r.Context(), "system")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "fetch_failed", fmt.Sprintf("Failed to fetch API keys: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove key_hash from all keys
|
||||||
|
response := make([]KeyResponse, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
response = append(response, KeyResponse{
|
||||||
|
ID: key.ID,
|
||||||
|
Name: key.Name,
|
||||||
|
UserID: key.UserID,
|
||||||
|
PermissionMode: key.PermissionMode,
|
||||||
|
ExpiresAt: key.ExpiresAt,
|
||||||
|
CreatedAt: key.CreatedAt,
|
||||||
|
UpdatedAt: key.UpdatedAt,
|
||||||
|
LastUsedAt: key.LastUsedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetKey godoc
|
||||||
|
// @Summary Get details of a specific API key
|
||||||
|
// @Description Returns details for a specific API key by ID (excludes key hash and plain-text key)
|
||||||
|
// @Tags Keys
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int true "Key ID"
|
||||||
|
// @Success 200 {object} KeyResponse "API key details"
|
||||||
|
// @Failure 400 {string} string "Invalid key ID"
|
||||||
|
// @Failure 404 {string} string "API key not found"
|
||||||
|
// @Failure 500 {string} string "Internal Server Error"
|
||||||
|
// @Router /api/v1/auth/keys/{id} [get]
|
||||||
|
func (h *Handler) GetKey() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := chi.URLParam(r, "id")
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_id", "Invalid key ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := h.authStore.GetKeyByID(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "API key not found" {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "API key not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, "fetch_failed", fmt.Sprintf("Failed to fetch API key: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove key_hash from response
|
||||||
|
response := KeyResponse{
|
||||||
|
ID: key.ID,
|
||||||
|
Name: key.Name,
|
||||||
|
UserID: key.UserID,
|
||||||
|
PermissionMode: key.PermissionMode,
|
||||||
|
ExpiresAt: key.ExpiresAt,
|
||||||
|
CreatedAt: key.CreatedAt,
|
||||||
|
UpdatedAt: key.UpdatedAt,
|
||||||
|
LastUsedAt: key.LastUsedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteKey godoc
|
||||||
|
// @Summary Delete an API key
|
||||||
|
// @Description Deletes an API key by ID
|
||||||
|
// @Tags Keys
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Param id path int true "Key ID"
|
||||||
|
// @Success 204 "API key deleted successfully"
|
||||||
|
// @Failure 400 {string} string "Invalid key ID"
|
||||||
|
// @Failure 404 {string} string "API key not found"
|
||||||
|
// @Failure 500 {string} string "Internal Server Error"
|
||||||
|
// @Router /api/v1/auth/keys/{id} [delete]
|
||||||
|
func (h *Handler) DeleteKey() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := chi.URLParam(r, "id")
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_id", "Invalid key ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.authStore.DeleteKey(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "API key not found" {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "API key not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, "deletion_failed", fmt.Sprintf("Failed to delete API key: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetKeyPermissions godoc
|
||||||
|
// @Summary Get API key permissions
|
||||||
|
// @Description Returns the instance-level permissions for a specific API key (includes instance names)
|
||||||
|
// @Tags Keys
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int true "Key ID"
|
||||||
|
// @Success 200 {array} KeyPermissionResponse "List of key permissions"
|
||||||
|
// @Failure 400 {string} string "Invalid key ID"
|
||||||
|
// @Failure 404 {string} string "API key not found"
|
||||||
|
// @Failure 500 {string} string "Internal Server Error"
|
||||||
|
// @Router /api/v1/auth/keys/{id}/permissions [get]
|
||||||
|
func (h *Handler) GetKeyPermissions() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := chi.URLParam(r, "id")
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_id", "Invalid key ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify key exists
|
||||||
|
_, err = h.authStore.GetKeyByID(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "API key not found" {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "API key not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, "fetch_failed", fmt.Sprintf("Failed to fetch API key: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
permissions, err := h.authStore.GetPermissions(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "fetch_failed", fmt.Sprintf("Failed to fetch permissions: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get instance names for the permissions
|
||||||
|
instances, err := h.InstanceManager.ListInstances()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "fetch_instances_failed", fmt.Sprintf("Failed to fetch instances: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
instanceNameMap := make(map[int]string)
|
||||||
|
for _, inst := range instances {
|
||||||
|
instanceNameMap[inst.ID] = inst.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
response := make([]KeyPermissionResponse, 0, len(permissions))
|
||||||
|
for _, perm := range permissions {
|
||||||
|
response = append(response, KeyPermissionResponse{
|
||||||
|
InstanceID: perm.InstanceID,
|
||||||
|
InstanceName: instanceNameMap[perm.InstanceID],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,6 +109,12 @@ func (h *Handler) LlamaCppProxy() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check instance permissions
|
||||||
|
if err := h.authMiddleware.CheckInstancePermission(r.Context(), inst.ID); err != nil {
|
||||||
|
writeError(w, http.StatusForbidden, "permission_denied", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Check if instance is shutting down before autostart logic
|
// Check if instance is shutting down before autostart logic
|
||||||
if inst.GetStatus() == instance.ShuttingDown {
|
if inst.GetStatus() == instance.ShuttingDown {
|
||||||
writeError(w, http.StatusServiceUnavailable, "instance_shutting_down", "Instance is shutting down")
|
writeError(w, http.StatusServiceUnavailable, "instance_shutting_down", "Instance is shutting down")
|
||||||
@@ -300,3 +306,158 @@ func (h *Handler) LlamaServerVersionHandler() http.HandlerFunc {
|
|||||||
func (h *Handler) LlamaServerListDevicesHandler() http.HandlerFunc {
|
func (h *Handler) LlamaServerListDevicesHandler() http.HandlerFunc {
|
||||||
return h.executeLlamaServerCommand("--list-devices", "Failed to list devices")
|
return h.executeLlamaServerCommand("--list-devices", "Failed to list devices")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LlamaCppListModels godoc
|
||||||
|
// @Summary List models in a llama.cpp instance
|
||||||
|
// @Description Returns a list of models available in the specified llama.cpp instance
|
||||||
|
// @Tags Llama.cpp
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Produces json
|
||||||
|
// @Param name path string true "Instance Name"
|
||||||
|
// @Success 200 {object} map[string]any "Models list response"
|
||||||
|
// @Failure 400 {string} string "Invalid instance"
|
||||||
|
// @Failure 500 {string} string "Internal Server Error"
|
||||||
|
// @Router /api/v1/llama-cpp/{name}/models [get]
|
||||||
|
func (h *Handler) LlamaCppListModels() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
inst, err := h.validateLlamaCppInstance(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid instance", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check instance permissions
|
||||||
|
if err := h.authMiddleware.CheckInstancePermission(r.Context(), inst.ID); err != nil {
|
||||||
|
writeError(w, http.StatusForbidden, "permission_denied", err.Error())
|
||||||
|
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 {
|
||||||
|
writeError(w, http.StatusInternalServerError, "instance start failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modify request path to /models for proxying
|
||||||
|
r.URL.Path = "/models"
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LlamaCppLoadModel godoc
|
||||||
|
// @Summary Load a model in a llama.cpp instance
|
||||||
|
// @Description Loads the specified model in the given llama.cpp instance
|
||||||
|
// @Tags Llama.cpp
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Produces json
|
||||||
|
// @Param name path string true "Instance Name"
|
||||||
|
// @Param model path string true "Model Name"
|
||||||
|
// @Success 200 {object} map[string]string "Success message"
|
||||||
|
// @Failure 400 {string} string "Invalid request"
|
||||||
|
// @Failure 500 {string} string "Internal Server Error"
|
||||||
|
// @Router /api/v1/llama-cpp/{name}/models/{model}/load [post]
|
||||||
|
func (h *Handler) LlamaCppLoadModel() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
inst, err := h.validateLlamaCppInstance(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid instance", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check instance permissions
|
||||||
|
if err := h.authMiddleware.CheckInstancePermission(r.Context(), inst.ID); err != nil {
|
||||||
|
writeError(w, http.StatusForbidden, "permission_denied", err.Error())
|
||||||
|
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 {
|
||||||
|
writeError(w, http.StatusInternalServerError, "instance start failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modify request path to /models/load for proxying
|
||||||
|
r.URL.Path = "/models/load"
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LlamaCppUnloadModel godoc
|
||||||
|
// @Summary Unload a model in a llama.cpp instance
|
||||||
|
// @Description Unloads the specified model in the given llama.cpp instance
|
||||||
|
// @Tags Llama.cpp
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Produces json
|
||||||
|
// @Param name path string true "Instance Name"
|
||||||
|
// @Param model path string true "Model Name"
|
||||||
|
// @Success 200 {object} map[string]string "Success message"
|
||||||
|
// @Failure 400 {string} string "Invalid request"
|
||||||
|
// @Failure 500 {string} string "Internal Server Error"
|
||||||
|
// @Router /api/v1/llama-cpp/{name}/models/{model}/unload [post]
|
||||||
|
func (h *Handler) LlamaCppUnloadModel() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
inst, err := h.validateLlamaCppInstance(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid instance", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check instance permissions
|
||||||
|
if err := h.authMiddleware.CheckInstancePermission(r.Context(), inst.ID); err != nil {
|
||||||
|
writeError(w, http.StatusForbidden, "permission_denied", err.Error())
|
||||||
|
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 {
|
||||||
|
writeError(w, http.StatusInternalServerError, "instance start failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modify request path to /models/unload for proxying
|
||||||
|
r.URL.Path = "/models/unload"
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -327,6 +327,12 @@ func (h *Handler) InstanceProxy() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check instance permissions
|
||||||
|
if err := h.authMiddleware.CheckInstancePermission(r.Context(), inst.ID); err != nil {
|
||||||
|
writeError(w, http.StatusForbidden, "permission_denied", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if !inst.IsRunning() {
|
if !inst.IsRunning() {
|
||||||
writeError(w, http.StatusServiceUnavailable, "instance_not_running", "Instance is not running")
|
writeError(w, http.StatusServiceUnavailable, "instance_not_running", "Instance is not running")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ package server
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"llamactl/pkg/backends"
|
||||||
"llamactl/pkg/instance"
|
"llamactl/pkg/instance"
|
||||||
"llamactl/pkg/validation"
|
"llamactl/pkg/validation"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OpenAIListInstancesResponse represents the response structure for listing instances (models) in OpenAI-compatible format
|
// OpenAIListInstancesResponse represents the response structure for listing instances (models) in OpenAI-compatible format
|
||||||
@@ -23,6 +26,53 @@ type OpenAIInstance struct {
|
|||||||
OwnedBy string `json:"owned_by"`
|
OwnedBy string `json:"owned_by"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LlamaCppModel represents a model available in a llama.cpp instance
|
||||||
|
type LlamaCppModel struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
OwnedBy string `json:"owned_by"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
InCache bool `json:"in_cache"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Status LlamaCppModelStatus `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LlamaCppModelStatus represents the status of a model in a llama.cpp instance
|
||||||
|
type LlamaCppModelStatus struct {
|
||||||
|
Value string `json:"value"` // "loaded" | "loading" | "unloaded"
|
||||||
|
Args []string `json:"args"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchLlamaCppModels fetches models from a llama.cpp instance using the proxy
|
||||||
|
func fetchLlamaCppModels(inst *instance.Instance) ([]LlamaCppModel, error) {
|
||||||
|
// Create a request to the instance's /models endpoint
|
||||||
|
req, err := http.NewRequest("GET", fmt.Sprintf("http://%s:%d/models", inst.GetHost(), inst.GetPort()), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a custom response writer to capture the response
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Data []LlamaCppModel `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
// OpenAIListInstances godoc
|
// OpenAIListInstances godoc
|
||||||
// @Summary List instances in OpenAI-compatible format
|
// @Summary List instances in OpenAI-compatible format
|
||||||
// @Description Returns a list of instances in a format compatible with OpenAI API
|
// @Description Returns a list of instances in a format compatible with OpenAI API
|
||||||
@@ -40,14 +90,41 @@ func (h *Handler) OpenAIListInstances() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
openaiInstances := make([]OpenAIInstance, len(instances))
|
var openaiInstances []OpenAIInstance
|
||||||
for i, inst := range instances {
|
|
||||||
openaiInstances[i] = OpenAIInstance{
|
// For each llama.cpp instance, try to fetch models and add them as separate entries
|
||||||
|
for _, inst := range instances {
|
||||||
|
|
||||||
|
if inst.GetBackendType() == backends.BackendTypeLlamaCpp && inst.IsRunning() {
|
||||||
|
// Try to fetch models from the instance
|
||||||
|
models, err := fetchLlamaCppModels(inst)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to fetch models from instance %s: %v", inst.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, model := range models {
|
||||||
|
openaiInstances = append(openaiInstances, OpenAIInstance{
|
||||||
|
ID: inst.Name + "/" + model.ID,
|
||||||
|
Object: "model",
|
||||||
|
Created: inst.Created,
|
||||||
|
OwnedBy: inst.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(models) > 1 {
|
||||||
|
// Skip adding the instance name if multiple models are present
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add instance name as single entry (for non-llama.cpp or if model fetch failed)
|
||||||
|
openaiInstances = append(openaiInstances, OpenAIInstance{
|
||||||
ID: inst.Name,
|
ID: inst.Name,
|
||||||
Object: "model",
|
Object: "model",
|
||||||
Created: inst.Created,
|
Created: inst.Created,
|
||||||
OwnedBy: "llamactl",
|
OwnedBy: "llamactl",
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
openaiResponse := OpenAIListInstancesResponse{
|
openaiResponse := OpenAIListInstancesResponse{
|
||||||
@@ -87,14 +164,28 @@ func (h *Handler) OpenAIProxy() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
modelName, ok := requestBody["model"].(string)
|
reqModelName, ok := requestBody["model"].(string)
|
||||||
if !ok || modelName == "" {
|
if !ok || reqModelName == "" {
|
||||||
writeError(w, http.StatusBadRequest, "invalid_request", "Instance name is required")
|
writeError(w, http.StatusBadRequest, "invalid_request", "Model name is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse instance name and model name from <instance_name>/<model_name> format
|
||||||
|
var instanceName string
|
||||||
|
var modelName string
|
||||||
|
|
||||||
|
// Check if model name contains "/"
|
||||||
|
if idx := strings.Index(reqModelName, "/"); idx != -1 {
|
||||||
|
// Split into instance and model parts
|
||||||
|
instanceName = reqModelName[:idx]
|
||||||
|
modelName = reqModelName[idx+1:]
|
||||||
|
} else {
|
||||||
|
instanceName = reqModelName
|
||||||
|
modelName = reqModelName
|
||||||
|
}
|
||||||
|
|
||||||
// Validate instance name at the entry point
|
// Validate instance name at the entry point
|
||||||
validatedName, err := validation.ValidateInstanceName(modelName)
|
validatedName, err := validation.ValidateInstanceName(instanceName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid_instance_name", err.Error())
|
writeError(w, http.StatusBadRequest, "invalid_instance_name", err.Error())
|
||||||
return
|
return
|
||||||
@@ -107,12 +198,23 @@ func (h *Handler) OpenAIProxy() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check instance permissions
|
||||||
|
if err := h.authMiddleware.CheckInstancePermission(r.Context(), inst.ID); err != nil {
|
||||||
|
writeError(w, http.StatusForbidden, "permission_denied", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Check if instance is shutting down before autostart logic
|
// Check if instance is shutting down before autostart logic
|
||||||
if inst.GetStatus() == instance.ShuttingDown {
|
if inst.GetStatus() == instance.ShuttingDown {
|
||||||
writeError(w, http.StatusServiceUnavailable, "instance_shutting_down", "Instance is shutting down")
|
writeError(w, http.StatusServiceUnavailable, "instance_shutting_down", "Instance is shutting down")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if inst.IsRemote() {
|
||||||
|
// Don't replace model name for remote instances
|
||||||
|
modelName = reqModelName
|
||||||
|
}
|
||||||
|
|
||||||
if !inst.IsRemote() && !inst.IsRunning() {
|
if !inst.IsRemote() && !inst.IsRunning() {
|
||||||
err := h.ensureInstanceRunning(inst)
|
err := h.ensureInstanceRunning(inst)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -121,6 +223,16 @@ func (h *Handler) OpenAIProxy() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the request body with just the model name
|
||||||
|
requestBody["model"] = modelName
|
||||||
|
|
||||||
|
// Re-marshal the updated body
|
||||||
|
bodyBytes, err = json.Marshal(requestBody)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "marshal_error", "Failed to update request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Recreate the request body from the bytes we read
|
// Recreate the request body from the bytes we read
|
||||||
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||||
r.ContentLength = int64(len(bodyBytes))
|
r.ContentLength = int64(len(bodyBytes))
|
||||||
|
|||||||
@@ -1,107 +1,76 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"context"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"llamactl/pkg/auth"
|
||||||
"llamactl/pkg/config"
|
"llamactl/pkg/config"
|
||||||
|
"llamactl/pkg/database"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type KeyType int
|
// contextKey is a custom type for context keys to avoid collisions
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
KeyTypeInference KeyType = iota
|
apiKeyContextKey contextKey = "apiKey"
|
||||||
KeyTypeManagement
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type APIAuthMiddleware struct {
|
type APIAuthMiddleware struct {
|
||||||
|
authStore database.AuthStore
|
||||||
requireInferenceAuth bool
|
requireInferenceAuth bool
|
||||||
inferenceKeys map[string]bool
|
|
||||||
requireManagementAuth bool
|
requireManagementAuth bool
|
||||||
managementKeys map[string]bool
|
managementKeys map[string]bool // Config-based management keys
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAPIAuthMiddleware creates a new APIAuthMiddleware with the given configuration
|
// NewAPIAuthMiddleware creates a new APIAuthMiddleware with the given configuration
|
||||||
func NewAPIAuthMiddleware(authCfg config.AuthConfig) *APIAuthMiddleware {
|
func NewAPIAuthMiddleware(authCfg config.AuthConfig, authStore database.AuthStore) *APIAuthMiddleware {
|
||||||
|
// Load management keys from config into managementKeys map
|
||||||
|
managementKeys := make(map[string]bool)
|
||||||
|
for _, key := range authCfg.ManagementKeys {
|
||||||
|
managementKeys[key] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle legacy auto-generation for management keys if none provided and auth is required
|
||||||
var generated bool = false
|
var generated bool = false
|
||||||
|
|
||||||
inferenceAPIKeys := make(map[string]bool)
|
|
||||||
managementAPIKeys := make(map[string]bool)
|
|
||||||
|
|
||||||
const banner = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
const banner = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
|
||||||
if authCfg.RequireManagementAuth && len(authCfg.ManagementKeys) == 0 {
|
if authCfg.RequireManagementAuth && len(authCfg.ManagementKeys) == 0 {
|
||||||
key := generateAPIKey(KeyTypeManagement)
|
key, err := auth.GenerateKey("llamactl-mgmt")
|
||||||
managementAPIKeys[key] = true
|
if err != nil {
|
||||||
|
log.Printf("Warning: Failed to generate management key: %v", err)
|
||||||
|
// Fallback to PID-based key for safety
|
||||||
|
key = fmt.Sprintf("sk-management-fallback-%d", os.Getpid())
|
||||||
|
}
|
||||||
|
managementKeys[key] = true
|
||||||
generated = true
|
generated = true
|
||||||
fmt.Printf("%s\n⚠️ MANAGEMENT AUTHENTICATION REQUIRED\n%s\n", banner, banner)
|
fmt.Printf("%s\n⚠️ MANAGEMENT AUTHENTICATION REQUIRED\n%s\n", banner, banner)
|
||||||
fmt.Printf("🔑 Generated Management API Key:\n\n %s\n\n", key)
|
fmt.Printf("🔑 Generated Management API Key:\n\n %s\n\n", key)
|
||||||
}
|
}
|
||||||
for _, key := range authCfg.ManagementKeys {
|
|
||||||
managementAPIKeys[key] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if authCfg.RequireInferenceAuth && len(authCfg.InferenceKeys) == 0 {
|
|
||||||
key := generateAPIKey(KeyTypeInference)
|
|
||||||
inferenceAPIKeys[key] = true
|
|
||||||
generated = true
|
|
||||||
fmt.Printf("%s\n⚠️ INFERENCE AUTHENTICATION REQUIRED\n%s\n", banner, banner)
|
|
||||||
fmt.Printf("🔑 Generated Inference API Key:\n\n %s\n\n", key)
|
|
||||||
}
|
|
||||||
for _, key := range authCfg.InferenceKeys {
|
|
||||||
inferenceAPIKeys[key] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if generated {
|
if generated {
|
||||||
fmt.Printf("%s\n⚠️ IMPORTANT\n%s\n", banner, banner)
|
fmt.Printf("%s\n⚠️ IMPORTANT\n%s\n", banner, banner)
|
||||||
fmt.Println("• These keys are auto-generated and will change on restart")
|
fmt.Println("• This key is auto-generated and will change on restart")
|
||||||
fmt.Println("• For production, add explicit keys to your configuration")
|
fmt.Println("• For production, add explicit keys to your configuration")
|
||||||
fmt.Println("• Copy these keys before they disappear from the terminal")
|
fmt.Println("• Copy this key before it disappears from the terminal")
|
||||||
fmt.Println(banner)
|
fmt.Println(banner)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &APIAuthMiddleware{
|
return &APIAuthMiddleware{
|
||||||
|
authStore: authStore,
|
||||||
requireInferenceAuth: authCfg.RequireInferenceAuth,
|
requireInferenceAuth: authCfg.RequireInferenceAuth,
|
||||||
inferenceKeys: inferenceAPIKeys,
|
|
||||||
requireManagementAuth: authCfg.RequireManagementAuth,
|
requireManagementAuth: authCfg.RequireManagementAuth,
|
||||||
managementKeys: managementAPIKeys,
|
managementKeys: managementKeys,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateAPIKey creates a cryptographically secure API key
|
// InferenceAuthMiddleware returns middleware for inference endpoints
|
||||||
func generateAPIKey(keyType KeyType) string {
|
func (a *APIAuthMiddleware) InferenceAuthMiddleware() func(http.Handler) http.Handler {
|
||||||
// Generate 32 random bytes (256 bits)
|
|
||||||
randomBytes := make([]byte, 32)
|
|
||||||
|
|
||||||
var prefix string
|
|
||||||
|
|
||||||
switch keyType {
|
|
||||||
case KeyTypeInference:
|
|
||||||
prefix = "sk-inference"
|
|
||||||
case KeyTypeManagement:
|
|
||||||
prefix = "sk-management"
|
|
||||||
default:
|
|
||||||
prefix = "sk-unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := rand.Read(randomBytes); err != nil {
|
|
||||||
log.Printf("Warning: Failed to generate secure random key, using fallback")
|
|
||||||
// Fallback to a less secure method if crypto/rand fails
|
|
||||||
return fmt.Sprintf("%s-fallback-%d", prefix, os.Getpid())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to hex and add prefix
|
|
||||||
return fmt.Sprintf("%s-%s", prefix, hex.EncodeToString(randomBytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuthMiddleware returns a middleware that checks API keys for the given key type
|
|
||||||
func (a *APIAuthMiddleware) AuthMiddleware(keyType KeyType) func(http.Handler) http.Handler {
|
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == "OPTIONS" {
|
if r.Method == "OPTIONS" {
|
||||||
@@ -109,24 +78,74 @@ func (a *APIAuthMiddleware) AuthMiddleware(keyType KeyType) func(http.Handler) h
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract API key from request
|
||||||
apiKey := a.extractAPIKey(r)
|
apiKey := a.extractAPIKey(r)
|
||||||
if apiKey == "" {
|
if apiKey == "" {
|
||||||
a.unauthorized(w, "Missing API key")
|
a.unauthorized(w, "Missing API key")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var isValid bool
|
// Try database authentication first
|
||||||
switch keyType {
|
var foundKey *auth.APIKey
|
||||||
case KeyTypeInference:
|
if a.requireInferenceAuth && a.authStore != nil {
|
||||||
// Management keys also work for OpenAI endpoints (higher privilege)
|
activeKeys, err := a.authStore.GetActiveKeys(r.Context())
|
||||||
isValid = a.isValidKey(apiKey, KeyTypeInference) || a.isValidKey(apiKey, KeyTypeManagement)
|
if err != nil {
|
||||||
case KeyTypeManagement:
|
log.Printf("Failed to get active inference keys: %v", err)
|
||||||
isValid = a.isValidKey(apiKey, KeyTypeManagement)
|
// Continue to management key fallback
|
||||||
default:
|
} else {
|
||||||
isValid = false
|
for _, key := range activeKeys {
|
||||||
|
if auth.VerifyKey(apiKey, key.KeyHash) {
|
||||||
|
foundKey = key
|
||||||
|
// Async update last_used_at
|
||||||
|
go func(keyID int) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := a.authStore.TouchKey(ctx, keyID); err != nil {
|
||||||
|
log.Printf("Failed to update last used timestamp for key %d: %v", keyID, err)
|
||||||
|
}
|
||||||
|
}(key.ID)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isValid {
|
// If no database key found, try management key authentication (config-based)
|
||||||
|
if foundKey == nil {
|
||||||
|
if !a.isValidManagementKey(apiKey) {
|
||||||
|
a.unauthorized(w, "Invalid API key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Management key was used, continue without adding APIKey to context
|
||||||
|
} else {
|
||||||
|
// Add APIKey to context for permission checking
|
||||||
|
ctx := context.WithValue(r.Context(), apiKeyContextKey, foundKey)
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManagementAuthMiddleware returns middleware for management endpoints
|
||||||
|
func (a *APIAuthMiddleware) ManagementAuthMiddleware() func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract API key from request
|
||||||
|
apiKey := a.extractAPIKey(r)
|
||||||
|
if apiKey == "" {
|
||||||
|
a.unauthorized(w, "Missing API key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if key exists in managementKeys map using constant-time comparison
|
||||||
|
if !a.isValidManagementKey(apiKey) {
|
||||||
a.unauthorized(w, "Invalid API key")
|
a.unauthorized(w, "Invalid API key")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -136,6 +155,33 @@ func (a *APIAuthMiddleware) AuthMiddleware(keyType KeyType) func(http.Handler) h
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckInstancePermission checks if the authenticated key has permission for the instance
|
||||||
|
func (a *APIAuthMiddleware) CheckInstancePermission(ctx context.Context, instanceID int) error {
|
||||||
|
// Extract APIKey from context
|
||||||
|
apiKey, ok := ctx.Value(apiKeyContextKey).(*auth.APIKey)
|
||||||
|
if !ok {
|
||||||
|
// APIKey is nil, management key was used, allow all
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If permission_mode == "allow_all", allow all
|
||||||
|
if apiKey.PermissionMode == auth.PermissionModeAllowAll {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check per-instance permissions
|
||||||
|
canInfer, err := a.authStore.HasPermission(ctx, apiKey.ID, instanceID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check permission: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !canInfer {
|
||||||
|
return fmt.Errorf("permission denied: key does not have access to this instance")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// extractAPIKey extracts the API key from the request
|
// extractAPIKey extracts the API key from the request
|
||||||
func (a *APIAuthMiddleware) extractAPIKey(r *http.Request) string {
|
func (a *APIAuthMiddleware) extractAPIKey(r *http.Request) string {
|
||||||
// Check Authorization header: "Bearer sk-..."
|
// Check Authorization header: "Bearer sk-..."
|
||||||
@@ -158,20 +204,9 @@ func (a *APIAuthMiddleware) extractAPIKey(r *http.Request) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// isValidKey checks if the provided API key is valid for the given key type
|
// isValidManagementKey checks if the provided API key is a valid management key
|
||||||
func (a *APIAuthMiddleware) isValidKey(providedKey string, keyType KeyType) bool {
|
func (a *APIAuthMiddleware) isValidManagementKey(providedKey string) bool {
|
||||||
var validKeys map[string]bool
|
for validKey := range a.managementKeys {
|
||||||
|
|
||||||
switch keyType {
|
|
||||||
case KeyTypeInference:
|
|
||||||
validKeys = a.inferenceKeys
|
|
||||||
case KeyTypeManagement:
|
|
||||||
validKeys = a.managementKeys
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
for validKey := range validKeys {
|
|
||||||
if len(providedKey) == len(validKey) &&
|
if len(providedKey) == len(validKey) &&
|
||||||
subtle.ConstantTimeCompare([]byte(providedKey), []byte(validKey)) == 1 {
|
subtle.ConstantTimeCompare([]byte(providedKey), []byte(validKey)) == 1 {
|
||||||
return true
|
return true
|
||||||
@@ -187,3 +222,11 @@ func (a *APIAuthMiddleware) unauthorized(w http.ResponseWriter, message string)
|
|||||||
response := fmt.Sprintf(`{"error": {"message": "%s", "type": "authentication_error"}}`, message)
|
response := fmt.Sprintf(`{"error": {"message": "%s", "type": "authentication_error"}}`, message)
|
||||||
w.Write([]byte(response))
|
w.Write([]byte(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// forbidden sends a forbidden response
|
||||||
|
func (a *APIAuthMiddleware) forbidden(w http.ResponseWriter, message string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
response := fmt.Sprintf(`{"error": {"message": "%s", "type": "permission_denied"}}`, message)
|
||||||
|
w.Write([]byte(response))
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,107 +9,44 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAuthMiddleware(t *testing.T) {
|
func TestInferenceAuthMiddleware(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
keyType server.KeyType
|
|
||||||
inferenceKeys []string
|
|
||||||
managementKeys []string
|
managementKeys []string
|
||||||
requestKey string
|
requestKey string
|
||||||
method string
|
method string
|
||||||
expectedStatus int
|
expectedStatus int
|
||||||
}{
|
}{
|
||||||
// Valid key tests
|
|
||||||
{
|
{
|
||||||
name: "valid inference key for inference",
|
name: "valid management key for inference",
|
||||||
keyType: server.KeyTypeInference,
|
|
||||||
inferenceKeys: []string{"sk-inference-valid123"},
|
|
||||||
requestKey: "sk-inference-valid123",
|
|
||||||
method: "GET",
|
|
||||||
expectedStatus: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "valid management key for inference", // Management keys work for inference
|
|
||||||
keyType: server.KeyTypeInference,
|
|
||||||
managementKeys: []string{"sk-management-admin123"},
|
managementKeys: []string{"sk-management-admin123"},
|
||||||
requestKey: "sk-management-admin123",
|
requestKey: "sk-management-admin123",
|
||||||
method: "GET",
|
method: "GET",
|
||||||
expectedStatus: http.StatusOK,
|
expectedStatus: http.StatusOK,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "valid management key for management",
|
name: "invalid key",
|
||||||
keyType: server.KeyTypeManagement,
|
|
||||||
managementKeys: []string{"sk-management-admin123"},
|
|
||||||
requestKey: "sk-management-admin123",
|
|
||||||
method: "GET",
|
|
||||||
expectedStatus: http.StatusOK,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Invalid key tests
|
|
||||||
{
|
|
||||||
name: "inference key for management should fail",
|
|
||||||
keyType: server.KeyTypeManagement,
|
|
||||||
inferenceKeys: []string{"sk-inference-user123"},
|
|
||||||
requestKey: "sk-inference-user123",
|
|
||||||
method: "GET",
|
|
||||||
expectedStatus: http.StatusUnauthorized,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid inference key",
|
|
||||||
keyType: server.KeyTypeInference,
|
|
||||||
inferenceKeys: []string{"sk-inference-valid123"},
|
|
||||||
requestKey: "sk-inference-invalid",
|
|
||||||
method: "GET",
|
|
||||||
expectedStatus: http.StatusUnauthorized,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing inference key",
|
|
||||||
keyType: server.KeyTypeInference,
|
|
||||||
inferenceKeys: []string{"sk-inference-valid123"},
|
|
||||||
requestKey: "",
|
|
||||||
method: "GET",
|
|
||||||
expectedStatus: http.StatusUnauthorized,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid management key",
|
|
||||||
keyType: server.KeyTypeManagement,
|
|
||||||
managementKeys: []string{"sk-management-valid123"},
|
managementKeys: []string{"sk-management-valid123"},
|
||||||
requestKey: "sk-management-invalid",
|
requestKey: "sk-management-invalid",
|
||||||
method: "GET",
|
method: "GET",
|
||||||
expectedStatus: http.StatusUnauthorized,
|
expectedStatus: http.StatusUnauthorized,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing management key",
|
name: "missing key",
|
||||||
keyType: server.KeyTypeManagement,
|
|
||||||
managementKeys: []string{"sk-management-valid123"},
|
managementKeys: []string{"sk-management-valid123"},
|
||||||
requestKey: "",
|
requestKey: "",
|
||||||
method: "GET",
|
method: "GET",
|
||||||
expectedStatus: http.StatusUnauthorized,
|
expectedStatus: http.StatusUnauthorized,
|
||||||
},
|
},
|
||||||
|
|
||||||
// OPTIONS requests should always pass
|
|
||||||
{
|
{
|
||||||
name: "OPTIONS request bypasses inference auth",
|
name: "OPTIONS request bypasses auth",
|
||||||
keyType: server.KeyTypeInference,
|
|
||||||
inferenceKeys: []string{"sk-inference-valid123"},
|
|
||||||
requestKey: "",
|
|
||||||
method: "OPTIONS",
|
|
||||||
expectedStatus: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "OPTIONS request bypasses management auth",
|
|
||||||
keyType: server.KeyTypeManagement,
|
|
||||||
managementKeys: []string{"sk-management-valid123"},
|
managementKeys: []string{"sk-management-valid123"},
|
||||||
requestKey: "",
|
requestKey: "",
|
||||||
method: "OPTIONS",
|
method: "OPTIONS",
|
||||||
expectedStatus: http.StatusOK,
|
expectedStatus: http.StatusOK,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Cross-key-type validation
|
|
||||||
{
|
{
|
||||||
name: "management key works for inference endpoint",
|
name: "management key works for inference endpoint",
|
||||||
keyType: server.KeyTypeInference,
|
|
||||||
inferenceKeys: []string{},
|
|
||||||
managementKeys: []string{"sk-management-admin"},
|
managementKeys: []string{"sk-management-admin"},
|
||||||
requestKey: "sk-management-admin",
|
requestKey: "sk-management-admin",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -120,10 +57,10 @@ func TestAuthMiddleware(t *testing.T) {
|
|||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
cfg := config.AuthConfig{
|
cfg := config.AuthConfig{
|
||||||
InferenceKeys: tt.inferenceKeys,
|
RequireInferenceAuth: true,
|
||||||
ManagementKeys: tt.managementKeys,
|
ManagementKeys: tt.managementKeys,
|
||||||
}
|
}
|
||||||
middleware := server.NewAPIAuthMiddleware(cfg)
|
middleware := server.NewAPIAuthMiddleware(cfg, nil)
|
||||||
|
|
||||||
// Create test request
|
// Create test request
|
||||||
req := httptest.NewRequest(tt.method, "/test", nil)
|
req := httptest.NewRequest(tt.method, "/test", nil)
|
||||||
@@ -131,24 +68,17 @@ func TestAuthMiddleware(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+tt.requestKey)
|
req.Header.Set("Authorization", "Bearer "+tt.requestKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create test handler using the appropriate middleware
|
// Create test handler
|
||||||
var handler http.Handler
|
handler := middleware.InferenceAuthMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if tt.keyType == server.KeyTypeInference {
|
w.WriteHeader(http.StatusOK)
|
||||||
handler = middleware.AuthMiddleware(server.KeyTypeInference)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
}))
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
} else {
|
|
||||||
handler = middleware.AuthMiddleware(server.KeyTypeManagement)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute request
|
// Execute request
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(recorder, req)
|
handler.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
if recorder.Code != tt.expectedStatus {
|
if recorder.Code != tt.expectedStatus {
|
||||||
t.Errorf("AuthMiddleware() status = %v, expected %v", recorder.Code, tt.expectedStatus)
|
t.Errorf("InferenceAuthMiddleware() status = %v, expected %v", recorder.Code, tt.expectedStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check that unauthorized responses have proper format
|
// Check that unauthorized responses have proper format
|
||||||
@@ -167,178 +97,171 @@ func TestAuthMiddleware(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateAPIKey(t *testing.T) {
|
func TestManagementAuthMiddleware(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
keyType server.KeyType
|
managementKeys []string
|
||||||
}{
|
requestKey string
|
||||||
{"inference key generation", server.KeyTypeInference},
|
method string
|
||||||
{"management key generation", server.KeyTypeManagement},
|
expectedStatus int
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Test auto-generation by creating config that will trigger it
|
|
||||||
var config config.AuthConfig
|
|
||||||
if tt.keyType == server.KeyTypeInference {
|
|
||||||
config.RequireInferenceAuth = true
|
|
||||||
config.InferenceKeys = []string{} // Empty to trigger generation
|
|
||||||
} else {
|
|
||||||
config.RequireManagementAuth = true
|
|
||||||
config.ManagementKeys = []string{} // Empty to trigger generation
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create middleware - this should trigger key generation
|
|
||||||
middleware := server.NewAPIAuthMiddleware(config)
|
|
||||||
|
|
||||||
// Test that auth is required (meaning a key was generated)
|
|
||||||
req := httptest.NewRequest("GET", "/", nil)
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
|
|
||||||
var handler http.Handler
|
|
||||||
if tt.keyType == server.KeyTypeInference {
|
|
||||||
handler = middleware.AuthMiddleware(server.KeyTypeInference)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
} else {
|
|
||||||
handler = middleware.AuthMiddleware(server.KeyTypeManagement)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
handler.ServeHTTP(recorder, req)
|
|
||||||
|
|
||||||
// Should be unauthorized without a key (proving that a key was generated and auth is working)
|
|
||||||
if recorder.Code != http.StatusUnauthorized {
|
|
||||||
t.Errorf("Expected unauthorized without key, got status %v", recorder.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test uniqueness by creating another middleware instance
|
|
||||||
middleware2 := server.NewAPIAuthMiddleware(config)
|
|
||||||
|
|
||||||
req2 := httptest.NewRequest("GET", "/", nil)
|
|
||||||
recorder2 := httptest.NewRecorder()
|
|
||||||
|
|
||||||
if tt.keyType == server.KeyTypeInference {
|
|
||||||
handler2 := middleware2.AuthMiddleware(server.KeyTypeInference)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
handler2.ServeHTTP(recorder2, req2)
|
|
||||||
} else {
|
|
||||||
handler2 := middleware2.AuthMiddleware(server.KeyTypeManagement)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
handler2.ServeHTTP(recorder2, req2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both should require auth (proving keys were generated for both instances)
|
|
||||||
if recorder2.Code != http.StatusUnauthorized {
|
|
||||||
t.Errorf("Expected unauthorized for second middleware without key, got status %v", recorder2.Code)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAutoGeneration(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
requireInference bool
|
|
||||||
requireManagement bool
|
|
||||||
providedInference []string
|
|
||||||
providedManagement []string
|
|
||||||
shouldGenerateInf bool // Whether inference key should be generated
|
|
||||||
shouldGenerateMgmt bool // Whether management key should be generated
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "inference auth required, keys provided - no generation",
|
name: "valid management key",
|
||||||
requireInference: true,
|
managementKeys: []string{"sk-management-admin123"},
|
||||||
requireManagement: false,
|
requestKey: "sk-management-admin123",
|
||||||
providedInference: []string{"sk-inference-provided"},
|
method: "GET",
|
||||||
providedManagement: []string{},
|
expectedStatus: http.StatusOK,
|
||||||
shouldGenerateInf: false,
|
|
||||||
shouldGenerateMgmt: false,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "inference auth required, no keys - should auto-generate",
|
name: "invalid management key",
|
||||||
requireInference: true,
|
managementKeys: []string{"sk-management-valid123"},
|
||||||
requireManagement: false,
|
requestKey: "sk-management-invalid",
|
||||||
providedInference: []string{},
|
method: "GET",
|
||||||
providedManagement: []string{},
|
expectedStatus: http.StatusUnauthorized,
|
||||||
shouldGenerateInf: true,
|
|
||||||
shouldGenerateMgmt: false,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "management auth required, keys provided - no generation",
|
name: "missing management key",
|
||||||
requireInference: false,
|
managementKeys: []string{"sk-management-valid123"},
|
||||||
requireManagement: true,
|
requestKey: "",
|
||||||
providedInference: []string{},
|
method: "GET",
|
||||||
providedManagement: []string{"sk-management-provided"},
|
expectedStatus: http.StatusUnauthorized,
|
||||||
shouldGenerateInf: false,
|
|
||||||
shouldGenerateMgmt: false,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "management auth required, no keys - should auto-generate",
|
name: "OPTIONS request bypasses management auth",
|
||||||
requireInference: false,
|
managementKeys: []string{"sk-management-valid123"},
|
||||||
requireManagement: true,
|
requestKey: "",
|
||||||
providedInference: []string{},
|
method: "OPTIONS",
|
||||||
providedManagement: []string{},
|
expectedStatus: http.StatusOK,
|
||||||
shouldGenerateInf: false,
|
},
|
||||||
shouldGenerateMgmt: true,
|
}
|
||||||
},
|
|
||||||
{
|
for _, tt := range tests {
|
||||||
name: "both required, both provided - no generation",
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
requireInference: true,
|
cfg := config.AuthConfig{
|
||||||
requireManagement: true,
|
RequireManagementAuth: true,
|
||||||
providedInference: []string{"sk-inference-provided"},
|
ManagementKeys: tt.managementKeys,
|
||||||
providedManagement: []string{"sk-management-provided"},
|
}
|
||||||
shouldGenerateInf: false,
|
middleware := server.NewAPIAuthMiddleware(cfg, nil)
|
||||||
shouldGenerateMgmt: false,
|
|
||||||
},
|
// Create test request
|
||||||
{
|
req := httptest.NewRequest(tt.method, "/test", nil)
|
||||||
name: "both required, none provided - should auto-generate both",
|
if tt.requestKey != "" {
|
||||||
requireInference: true,
|
req.Header.Set("Authorization", "Bearer "+tt.requestKey)
|
||||||
requireManagement: true,
|
}
|
||||||
providedInference: []string{},
|
|
||||||
providedManagement: []string{},
|
// Create test handler
|
||||||
shouldGenerateInf: true,
|
handler := middleware.ManagementAuthMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
shouldGenerateMgmt: true,
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Execute request
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
if recorder.Code != tt.expectedStatus {
|
||||||
|
t.Errorf("ManagementAuthMiddleware() status = %v, expected %v", recorder.Code, tt.expectedStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that unauthorized responses have proper format
|
||||||
|
if recorder.Code == http.StatusUnauthorized {
|
||||||
|
contentType := recorder.Header().Get("Content-Type")
|
||||||
|
if contentType != "application/json" {
|
||||||
|
t.Errorf("Unauthorized response Content-Type = %v, expected application/json", contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := recorder.Body.String()
|
||||||
|
if !strings.Contains(body, `"type": "authentication_error"`) {
|
||||||
|
t.Errorf("Unauthorized response missing proper error type: %v", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagementKeyAutoGeneration(t *testing.T) {
|
||||||
|
// Test auto-generation for management keys
|
||||||
|
config := config.AuthConfig{
|
||||||
|
RequireManagementAuth: true,
|
||||||
|
ManagementKeys: []string{}, // Empty to trigger generation
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create middleware - this should trigger key generation
|
||||||
|
middleware := server.NewAPIAuthMiddleware(config, nil)
|
||||||
|
|
||||||
|
// Test that auth is required (meaning a key was generated)
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler := middleware.ManagementAuthMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
handler.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
// Should be unauthorized without a key (proving that a key was generated and auth is working)
|
||||||
|
if recorder.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("Expected unauthorized without key, got status %v", recorder.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test uniqueness by creating another middleware instance
|
||||||
|
middleware2 := server.NewAPIAuthMiddleware(config, nil)
|
||||||
|
|
||||||
|
req2 := httptest.NewRequest("GET", "/", nil)
|
||||||
|
recorder2 := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler2 := middleware2.ManagementAuthMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
handler2.ServeHTTP(recorder2, req2)
|
||||||
|
|
||||||
|
// Both should require auth (proving keys were generated for both instances)
|
||||||
|
if recorder2.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("Expected unauthorized for second middleware without key, got status %v", recorder2.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoGenerationScenarios(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
requireManagement bool
|
||||||
|
providedManagement []string
|
||||||
|
shouldGenerate bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "management auth required, keys provided - no generation",
|
||||||
|
requireManagement: true,
|
||||||
|
providedManagement: []string{"sk-management-provided"},
|
||||||
|
shouldGenerate: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "management auth required, no keys - should auto-generate",
|
||||||
|
requireManagement: true,
|
||||||
|
providedManagement: []string{},
|
||||||
|
shouldGenerate: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "management auth not required - no generation",
|
||||||
|
requireManagement: false,
|
||||||
|
providedManagement: []string{},
|
||||||
|
shouldGenerate: false,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
cfg := config.AuthConfig{
|
cfg := config.AuthConfig{
|
||||||
RequireInferenceAuth: tt.requireInference,
|
|
||||||
RequireManagementAuth: tt.requireManagement,
|
RequireManagementAuth: tt.requireManagement,
|
||||||
InferenceKeys: tt.providedInference,
|
|
||||||
ManagementKeys: tt.providedManagement,
|
ManagementKeys: tt.providedManagement,
|
||||||
}
|
}
|
||||||
|
|
||||||
middleware := server.NewAPIAuthMiddleware(cfg)
|
middleware := server.NewAPIAuthMiddleware(cfg, nil)
|
||||||
|
|
||||||
// Test inference behavior if inference auth is required
|
|
||||||
if tt.requireInference {
|
|
||||||
req := httptest.NewRequest("GET", "/v1/models", nil)
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := middleware.AuthMiddleware(server.KeyTypeInference)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
|
|
||||||
handler.ServeHTTP(recorder, req)
|
|
||||||
|
|
||||||
// Should always be unauthorized without a key (since middleware assumes auth is required)
|
|
||||||
if recorder.Code != http.StatusUnauthorized {
|
|
||||||
t.Errorf("Expected unauthorized for inference without key, got status %v", recorder.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test management behavior if management auth is required
|
// Test management behavior if management auth is required
|
||||||
if tt.requireManagement {
|
if tt.requireManagement {
|
||||||
req := httptest.NewRequest("GET", "/api/v1/instances", nil)
|
req := httptest.NewRequest("GET", "/api/v1/instances", nil)
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
handler := middleware.AuthMiddleware(server.KeyTypeManagement)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := middleware.ManagementAuthMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ func SetupRouter(handler *Handler) *chi.Mux {
|
|||||||
MaxAge: 300,
|
MaxAge: 300,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Add API authentication middleware
|
|
||||||
authMiddleware := NewAPIAuthMiddleware(handler.cfg.Auth)
|
|
||||||
|
|
||||||
if handler.cfg.Server.EnableSwagger {
|
if handler.cfg.Server.EnableSwagger {
|
||||||
r.Get("/swagger/*", httpSwagger.Handler(
|
r.Get("/swagger/*", httpSwagger.Handler(
|
||||||
httpSwagger.URL("/swagger/doc.json"),
|
httpSwagger.URL("/swagger/doc.json"),
|
||||||
@@ -38,14 +35,25 @@ func SetupRouter(handler *Handler) *chi.Mux {
|
|||||||
// Define routes
|
// Define routes
|
||||||
r.Route("/api/v1", func(r chi.Router) {
|
r.Route("/api/v1", func(r chi.Router) {
|
||||||
|
|
||||||
if authMiddleware != nil && handler.cfg.Auth.RequireManagementAuth {
|
if handler.authMiddleware != nil && handler.cfg.Auth.RequireManagementAuth {
|
||||||
r.Use(authMiddleware.AuthMiddleware(KeyTypeManagement))
|
r.Use(handler.authMiddleware.ManagementAuthMiddleware())
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Get("/version", handler.VersionHandler())
|
r.Get("/version", handler.VersionHandler())
|
||||||
|
|
||||||
r.Get("/config", handler.ConfigHandler())
|
r.Get("/config", handler.ConfigHandler())
|
||||||
|
|
||||||
|
// API key management endpoints
|
||||||
|
r.Route("/auth", func(r chi.Router) {
|
||||||
|
r.Route("/keys", func(r chi.Router) {
|
||||||
|
r.Post("/", handler.CreateKey()) // Create API key
|
||||||
|
r.Get("/", handler.ListKeys()) // List API keys
|
||||||
|
r.Get("/{id}", handler.GetKey()) // Get API key details
|
||||||
|
r.Delete("/{id}", handler.DeleteKey()) // Delete API key
|
||||||
|
r.Get("/{id}/permissions", handler.GetKeyPermissions()) // Get key permissions
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Backend-specific endpoints
|
// Backend-specific endpoints
|
||||||
r.Route("/backends", func(r chi.Router) {
|
r.Route("/backends", func(r chi.Router) {
|
||||||
r.Route("/llama-cpp", func(r chi.Router) {
|
r.Route("/llama-cpp", func(r chi.Router) {
|
||||||
@@ -62,12 +70,19 @@ func SetupRouter(handler *Handler) *chi.Mux {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Llama.cpp instance-specific endpoints
|
||||||
|
r.Route("/llama-cpp/{name}", func(r chi.Router) {
|
||||||
|
r.Get("/models", handler.LlamaCppListModels())
|
||||||
|
r.Post("/models/{model}/load", handler.LlamaCppLoadModel())
|
||||||
|
r.Post("/models/{model}/unload", handler.LlamaCppUnloadModel())
|
||||||
|
})
|
||||||
|
|
||||||
// Node management endpoints
|
// Node management endpoints
|
||||||
r.Route("/nodes", func(r chi.Router) {
|
r.Route("/nodes", func(r chi.Router) {
|
||||||
r.Get("/", handler.ListNodes()) // List all nodes
|
r.Get("/", handler.ListNodes()) // List all nodes
|
||||||
|
|
||||||
r.Route("/{name}", func(r chi.Router) {
|
r.Route("/{name}", func(r chi.Router) {
|
||||||
r.Get("/", handler.GetNode())
|
r.Get("/", handler.GetNode()) // Get node details
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -94,13 +109,13 @@ func SetupRouter(handler *Handler) *chi.Mux {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Route(("/v1"), func(r chi.Router) {
|
r.Route("/v1", func(r chi.Router) {
|
||||||
|
|
||||||
if authMiddleware != nil && handler.cfg.Auth.RequireInferenceAuth {
|
if handler.authMiddleware != nil && handler.cfg.Auth.RequireInferenceAuth {
|
||||||
r.Use(authMiddleware.AuthMiddleware(KeyTypeInference))
|
r.Use(handler.authMiddleware.InferenceAuthMiddleware())
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Get(("/models"), handler.OpenAIListInstances()) // List instances in OpenAI-compatible format
|
r.Get("/models", handler.OpenAIListInstances()) // List instances in OpenAI-compatible format
|
||||||
|
|
||||||
// OpenAI-compatible proxy endpoint
|
// OpenAI-compatible proxy endpoint
|
||||||
// Handles all POST requests to /v1/*, including:
|
// Handles all POST requests to /v1/*, including:
|
||||||
@@ -124,11 +139,11 @@ func SetupRouter(handler *Handler) *chi.Mux {
|
|||||||
// Private Routes
|
// Private Routes
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
|
|
||||||
if authMiddleware != nil && handler.cfg.Auth.RequireInferenceAuth {
|
if handler.authMiddleware != nil && handler.cfg.Auth.RequireInferenceAuth {
|
||||||
r.Use(authMiddleware.AuthMiddleware(KeyTypeInference))
|
r.Use(handler.authMiddleware.InferenceAuthMiddleware())
|
||||||
}
|
}
|
||||||
|
|
||||||
// This handler auto start the server if it's not running
|
// This handler auto starts the server if it's not running
|
||||||
llamaCppHandler := handler.LlamaCppProxy()
|
llamaCppHandler := handler.LlamaCppProxy()
|
||||||
|
|
||||||
// llama.cpp server specific proxy endpoints
|
// llama.cpp server specific proxy endpoints
|
||||||
|
|||||||
135
test_client.py
Normal file
135
test_client.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Simple Python script to interact with local LLM server's OpenAI-compatible API
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Local LLM server configuration
|
||||||
|
BASE_URL = "http://localhost:8080"
|
||||||
|
API_KEY = None
|
||||||
|
MODEL_NAME = None
|
||||||
|
|
||||||
|
def get_models():
|
||||||
|
"""Fetch available models from /v1/models endpoint"""
|
||||||
|
headers = {}
|
||||||
|
if API_KEY:
|
||||||
|
headers["Authorization"] = f"Bearer {API_KEY}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(f"{BASE_URL}/v1/models", headers=headers, timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()["data"]
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error fetching models: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def send_message(message):
|
||||||
|
"""
|
||||||
|
Send a message to local LLM server API
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message (str): The message to send
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The AI response or error message
|
||||||
|
"""
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
if API_KEY:
|
||||||
|
headers["Authorization"] = f"Bearer {API_KEY}"
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"model": MODEL_NAME,
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": message
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"temperature": 0.7,
|
||||||
|
"max_tokens": 1000,
|
||||||
|
"stream": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(f"{BASE_URL}/v1/chat/completions", headers=headers, json=data, timeout=60)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
def interactive_mode():
|
||||||
|
"""Run in interactive mode for continuous conversation"""
|
||||||
|
global BASE_URL, API_KEY, MODEL_NAME
|
||||||
|
|
||||||
|
# Get base URL
|
||||||
|
url_input = input(f"Base URL [{BASE_URL}]: ").strip()
|
||||||
|
if url_input:
|
||||||
|
BASE_URL = url_input
|
||||||
|
|
||||||
|
# Get API key (optional)
|
||||||
|
key_input = input("API key (optional): ").strip()
|
||||||
|
if key_input:
|
||||||
|
API_KEY = key_input
|
||||||
|
|
||||||
|
# Fetch and select model
|
||||||
|
models = get_models()
|
||||||
|
if not models:
|
||||||
|
print("No models available. Exiting.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("\nAvailable models:")
|
||||||
|
for i, m in enumerate(models, 1):
|
||||||
|
print(f"{i}. {m['id']}")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
selection = int(input("\nSelect model: "))
|
||||||
|
if 1 <= selection <= len(models):
|
||||||
|
MODEL_NAME = models[selection - 1]["id"]
|
||||||
|
break
|
||||||
|
print(f"Please enter a number between 1 and {len(models)}")
|
||||||
|
except ValueError:
|
||||||
|
print("Please enter a valid number")
|
||||||
|
|
||||||
|
print(f"\nUsing model: {MODEL_NAME}")
|
||||||
|
print("Type 'quit' or 'exit' to stop")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
user_input = input("\nYou: ").strip()
|
||||||
|
|
||||||
|
if user_input.lower() in ['quit', 'exit', 'q']:
|
||||||
|
print("Goodbye!")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not user_input:
|
||||||
|
continue
|
||||||
|
|
||||||
|
print("AI: ", end="", flush=True)
|
||||||
|
response = send_message(user_input)
|
||||||
|
print(response)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nGoodbye!")
|
||||||
|
break
|
||||||
|
except EOFError:
|
||||||
|
print("\nGoodbye!")
|
||||||
|
break
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main function"""
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
# Single message mode
|
||||||
|
message = " ".join(sys.argv[1:])
|
||||||
|
response = send_message(message)
|
||||||
|
print(response)
|
||||||
|
else:
|
||||||
|
# Interactive mode
|
||||||
|
interactive_mode()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
727
webui/package-lock.json
generated
727
webui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -18,28 +18,29 @@
|
|||||||
"lint:fix": "eslint . --ext .ts,.tsx --fix"
|
"lint:fix": "eslint . --ext .ts,.tsx --fix"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.555.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"lucide-react": "^0.562.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/eslint__js": "^9.14.0",
|
"@types/node": "^25.0.2",
|
||||||
"@types/node": "^24.10.1",
|
|
||||||
"@types/react": "^19.2.4",
|
"@types/react": "^19.2.4",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
@@ -48,11 +49,11 @@
|
|||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"jsdom": "^27.2.0",
|
"jsdom": "^27.3.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.48.0",
|
"typescript-eslint": "^8.50.0",
|
||||||
"vite": "^7.2.2",
|
"vite": "^7.3.0",
|
||||||
"vitest": "^4.0.8"
|
"vitest": "^4.0.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import InstanceList from "@/components/InstanceList";
|
|||||||
import InstanceDialog from "@/components/InstanceDialog";
|
import InstanceDialog from "@/components/InstanceDialog";
|
||||||
import LoginDialog from "@/components/LoginDialog";
|
import LoginDialog from "@/components/LoginDialog";
|
||||||
import SystemInfoDialog from "./components/SystemInfoDialog";
|
import SystemInfoDialog from "./components/SystemInfoDialog";
|
||||||
|
import SettingsDialog from "./components/settings/SettingsDialog";
|
||||||
import { type CreateInstanceOptions, type Instance } from "@/types/instance";
|
import { type CreateInstanceOptions, type Instance } from "@/types/instance";
|
||||||
import { useInstances } from "@/contexts/InstancesContext";
|
import { useInstances } from "@/contexts/InstancesContext";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
@@ -14,6 +15,7 @@ function App() {
|
|||||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
||||||
const [isInstanceModalOpen, setIsInstanceModalOpen] = useState(false);
|
const [isInstanceModalOpen, setIsInstanceModalOpen] = useState(false);
|
||||||
const [isSystemInfoModalOpen, setIsSystemInfoModalOpen] = useState(false);
|
const [isSystemInfoModalOpen, setIsSystemInfoModalOpen] = useState(false);
|
||||||
|
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
|
||||||
const [editingInstance, setEditingInstance] = useState<Instance | undefined>(
|
const [editingInstance, setEditingInstance] = useState<Instance | undefined>(
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
@@ -41,6 +43,10 @@ function App() {
|
|||||||
setIsSystemInfoModalOpen(true);
|
setIsSystemInfoModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleShowSettings = () => {
|
||||||
|
setIsSettingsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
// Show loading spinner while checking auth
|
// Show loading spinner while checking auth
|
||||||
if (authLoading) {
|
if (authLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -70,7 +76,11 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<Header onCreateInstance={handleCreateInstance} onShowSystemInfo={handleShowSystemInfo} />
|
<Header
|
||||||
|
onCreateInstance={handleCreateInstance}
|
||||||
|
onShowSystemInfo={handleShowSystemInfo}
|
||||||
|
onShowSettings={handleShowSettings}
|
||||||
|
/>
|
||||||
<main className="container mx-auto max-w-4xl px-4 py-8">
|
<main className="container mx-auto max-w-4xl px-4 py-8">
|
||||||
<InstanceList editInstance={handleEditInstance} />
|
<InstanceList editInstance={handleEditInstance} />
|
||||||
</main>
|
</main>
|
||||||
@@ -86,7 +96,12 @@ function App() {
|
|||||||
open={isSystemInfoModalOpen}
|
open={isSystemInfoModalOpen}
|
||||||
onOpenChange={setIsSystemInfoModalOpen}
|
onOpenChange={setIsSystemInfoModalOpen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SettingsDialog
|
||||||
|
open={isSettingsModalOpen}
|
||||||
|
onOpenChange={setIsSettingsModalOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ function renderApp() {
|
|||||||
|
|
||||||
describe('App Component - Critical Business Logic Only', () => {
|
describe('App Component - Critical Business Logic Only', () => {
|
||||||
const mockInstances: Instance[] = [
|
const mockInstances: Instance[] = [
|
||||||
{ name: 'test-instance-1', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model1.gguf' } } },
|
{ id: 1, name: 'test-instance-1', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model1.gguf' } } },
|
||||||
{ name: 'test-instance-2', status: 'running', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model2.gguf' } } }
|
{ id: 2, name: 'test-instance-2', status: 'running', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model2.gguf' } } }
|
||||||
]
|
]
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -109,6 +109,7 @@ describe('App Component - Critical Business Logic Only', () => {
|
|||||||
it('creates new instance with correct API call and updates UI', async () => {
|
it('creates new instance with correct API call and updates UI', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const newInstance: Instance = {
|
const newInstance: Instance = {
|
||||||
|
id: 3,
|
||||||
name: 'new-test-instance',
|
name: 'new-test-instance',
|
||||||
status: 'stopped',
|
status: 'stopped',
|
||||||
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'new-model.gguf' } }
|
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'new-model.gguf' } }
|
||||||
@@ -151,6 +152,7 @@ describe('App Component - Critical Business Logic Only', () => {
|
|||||||
it('updates existing instance with correct API call', async () => {
|
it('updates existing instance with correct API call', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const updatedInstance: Instance = {
|
const updatedInstance: Instance = {
|
||||||
|
id: 1,
|
||||||
name: 'test-instance-1',
|
name: 'test-instance-1',
|
||||||
status: 'stopped',
|
status: 'stopped',
|
||||||
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'updated-model.gguf' } }
|
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'updated-model.gguf' } }
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { HelpCircle, LogOut, Moon, Sun } from "lucide-react";
|
import { HelpCircle, LogOut, Moon, Settings, Sun } from "lucide-react";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
onCreateInstance: () => void;
|
onCreateInstance: () => void;
|
||||||
onShowSystemInfo: () => void;
|
onShowSystemInfo: () => void;
|
||||||
|
onShowSettings: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) {
|
function Header({ onCreateInstance, onShowSystemInfo, onShowSettings }: HeaderProps) {
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
@@ -41,6 +42,16 @@ function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) {
|
|||||||
{theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
|
{theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={onShowSettings}
|
||||||
|
data-testid="settings-button"
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
// ui/src/components/InstanceCard.tsx
|
// ui/src/components/InstanceCard.tsx
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import type { Instance } from "@/types/instance";
|
import type { Instance } from "@/types/instance";
|
||||||
import { Edit, FileText, Play, Square, Trash2, MoreHorizontal, Download } from "lucide-react";
|
import { Edit, FileText, Play, Square, Trash2, MoreHorizontal, Download, Boxes } from "lucide-react";
|
||||||
import LogsDialog from "@/components/LogDialog";
|
import LogsDialog from "@/components/LogDialog";
|
||||||
|
import ModelsDialog from "@/components/ModelsDialog";
|
||||||
import HealthBadge from "@/components/HealthBadge";
|
import HealthBadge from "@/components/HealthBadge";
|
||||||
import BackendBadge from "@/components/BackendBadge";
|
import BackendBadge from "@/components/BackendBadge";
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useInstanceHealth } from "@/hooks/useInstanceHealth";
|
import { useInstanceHealth } from "@/hooks/useInstanceHealth";
|
||||||
import { instancesApi } from "@/lib/api";
|
import { instancesApi, llamaCppApi, type Model } from "@/lib/api";
|
||||||
|
|
||||||
interface InstanceCardProps {
|
interface InstanceCardProps {
|
||||||
instance: Instance;
|
instance: Instance;
|
||||||
@@ -26,9 +28,35 @@ function InstanceCard({
|
|||||||
editInstance,
|
editInstance,
|
||||||
}: InstanceCardProps) {
|
}: InstanceCardProps) {
|
||||||
const [isLogsOpen, setIsLogsOpen] = useState(false);
|
const [isLogsOpen, setIsLogsOpen] = useState(false);
|
||||||
|
const [isModelsOpen, setIsModelsOpen] = useState(false);
|
||||||
const [showAllActions, setShowAllActions] = useState(false);
|
const [showAllActions, setShowAllActions] = useState(false);
|
||||||
|
const [models, setModels] = useState<Model[]>([]);
|
||||||
const health = useInstanceHealth(instance.name, instance.status);
|
const health = useInstanceHealth(instance.name, instance.status);
|
||||||
|
|
||||||
|
const running = instance.status === "running";
|
||||||
|
const isLlamaCpp = instance.options?.backend_type === "llama_cpp";
|
||||||
|
|
||||||
|
// Fetch models for llama.cpp instances
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLlamaCpp || !running) {
|
||||||
|
setModels([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const fetchedModels = await llamaCppApi.getModels(instance.name);
|
||||||
|
setModels(fetchedModels);
|
||||||
|
} catch {
|
||||||
|
setModels([]);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [instance.name, isLlamaCpp, running]);
|
||||||
|
|
||||||
|
// Calculate model counts
|
||||||
|
const totalModels = models.length;
|
||||||
|
const loadedModels = models.filter(m => m.status.value === "loaded").length;
|
||||||
|
|
||||||
const handleStart = () => {
|
const handleStart = () => {
|
||||||
startInstance(instance.name);
|
startInstance(instance.name);
|
||||||
};
|
};
|
||||||
@@ -53,6 +81,10 @@ function InstanceCard({
|
|||||||
setIsLogsOpen(true);
|
setIsLogsOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleModels = () => {
|
||||||
|
setIsModelsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
@@ -83,8 +115,6 @@ function InstanceCard({
|
|||||||
})();
|
})();
|
||||||
};
|
};
|
||||||
|
|
||||||
const running = instance.status === "running";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card className="hover:shadow-md transition-shadow">
|
<Card className="hover:shadow-md transition-shadow">
|
||||||
@@ -99,6 +129,12 @@ function InstanceCard({
|
|||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<BackendBadge backend={instance.options?.backend_type} docker={instance.options?.docker_enabled} />
|
<BackendBadge backend={instance.options?.backend_type} docker={instance.options?.docker_enabled} />
|
||||||
{running && <HealthBadge health={health} />}
|
{running && <HealthBadge health={health} />}
|
||||||
|
{isLlamaCpp && running && totalModels > 0 && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
<Boxes className="h-3 w-3 mr-1" />
|
||||||
|
{loadedModels}/{totalModels} models
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -149,26 +185,37 @@ function InstanceCard({
|
|||||||
|
|
||||||
{/* Secondary actions - collapsible */}
|
{/* Secondary actions - collapsible */}
|
||||||
{showAllActions && (
|
{showAllActions && (
|
||||||
<div className="flex items-center gap-2 pt-2 border-t border-border">
|
<div className="flex items-center gap-2 pt-2 border-t border-border flex-wrap">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleLogs}
|
onClick={handleLogs}
|
||||||
title="View logs"
|
title="View logs"
|
||||||
data-testid="view-logs-button"
|
data-testid="view-logs-button"
|
||||||
className="flex-1"
|
|
||||||
>
|
>
|
||||||
<FileText className="h-4 w-4 mr-1" />
|
<FileText className="h-4 w-4 mr-1" />
|
||||||
Logs
|
Logs
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{isLlamaCpp && totalModels > 1 && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleModels}
|
||||||
|
title="Manage models"
|
||||||
|
data-testid="manage-models-button"
|
||||||
|
>
|
||||||
|
<Boxes className="h-4 w-4 mr-1" />
|
||||||
|
Models
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleExport}
|
onClick={handleExport}
|
||||||
title="Export instance"
|
title="Export instance"
|
||||||
data-testid="export-instance-button"
|
data-testid="export-instance-button"
|
||||||
className="flex-1"
|
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4 mr-1" />
|
<Download className="h-4 w-4 mr-1" />
|
||||||
Export
|
Export
|
||||||
@@ -195,6 +242,13 @@ function InstanceCard({
|
|||||||
instanceName={instance.name}
|
instanceName={instance.name}
|
||||||
isRunning={running}
|
isRunning={running}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ModelsDialog
|
||||||
|
open={isModelsOpen}
|
||||||
|
onOpenChange={setIsModelsOpen}
|
||||||
|
instanceName={instance.name}
|
||||||
|
isRunning={running}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
303
webui/src/components/ModelsDialog.tsx
Normal file
303
webui/src/components/ModelsDialog.tsx
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { llamaCppApi } from '@/lib/api'
|
||||||
|
import { RefreshCw, Loader2, AlertCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
interface ModelsDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
instanceName: string
|
||||||
|
isRunning: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Model {
|
||||||
|
id: string
|
||||||
|
object: string
|
||||||
|
owned_by: string
|
||||||
|
created: number
|
||||||
|
in_cache: boolean
|
||||||
|
path: string
|
||||||
|
status: {
|
||||||
|
value: string // "loaded" | "loading" | "unloaded"
|
||||||
|
args: string[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatusIcon: React.FC<{ status: string }> = ({ status }) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'loaded':
|
||||||
|
return (
|
||||||
|
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||||
|
)
|
||||||
|
case 'loading':
|
||||||
|
return (
|
||||||
|
<Loader2
|
||||||
|
className="h-3 w-3 animate-spin text-yellow-500"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 'unloaded':
|
||||||
|
return (
|
||||||
|
<div className="h-2 w-2 rounded-full bg-gray-400" />
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModelsDialog: React.FC<ModelsDialogProps> = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
instanceName,
|
||||||
|
isRunning,
|
||||||
|
}) => {
|
||||||
|
const [models, setModels] = useState<Model[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loadingModels, setLoadingModels] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// Fetch models function
|
||||||
|
const fetchModels = React.useCallback(async () => {
|
||||||
|
if (!instanceName || !isRunning) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await llamaCppApi.getModels(instanceName)
|
||||||
|
setModels(response)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch models')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [instanceName, isRunning])
|
||||||
|
|
||||||
|
// Fetch models when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !isRunning) return
|
||||||
|
|
||||||
|
// Initial fetch
|
||||||
|
void fetchModels()
|
||||||
|
}, [open, isRunning, fetchModels])
|
||||||
|
|
||||||
|
// Auto-refresh only when models are loading
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !isRunning) return
|
||||||
|
|
||||||
|
// Check if any model is in loading state
|
||||||
|
const hasLoadingModel = models.some(m => m.status.value === 'loading')
|
||||||
|
|
||||||
|
if (!hasLoadingModel) return
|
||||||
|
|
||||||
|
// Poll every 2 seconds when there's a loading model
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
void fetchModels()
|
||||||
|
}, 2000)
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [open, isRunning, models, fetchModels])
|
||||||
|
|
||||||
|
// Load model
|
||||||
|
const loadModel = async (modelName: string) => {
|
||||||
|
setLoadingModels((prev) => new Set(prev).add(modelName))
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await llamaCppApi.loadModel(instanceName, modelName)
|
||||||
|
// Wait a bit for the backend to process the load
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500))
|
||||||
|
// Refresh models list after loading
|
||||||
|
await fetchModels()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load model')
|
||||||
|
} finally {
|
||||||
|
setLoadingModels((prev) => {
|
||||||
|
const newSet = new Set(prev)
|
||||||
|
newSet.delete(modelName)
|
||||||
|
return newSet
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unload model
|
||||||
|
const unloadModel = async (modelName: string) => {
|
||||||
|
setLoadingModels((prev) => new Set(prev).add(modelName))
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await llamaCppApi.unloadModel(instanceName, modelName)
|
||||||
|
// Wait a bit for the backend to process the unload
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500))
|
||||||
|
// Refresh models list after unloading
|
||||||
|
await fetchModels()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to unload model')
|
||||||
|
} finally {
|
||||||
|
setLoadingModels((prev) => {
|
||||||
|
const newSet = new Set(prev)
|
||||||
|
newSet.delete(modelName)
|
||||||
|
return newSet
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-4xl max-w-[calc(100%-2rem)] max-h-[80vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
Models: {instanceName}
|
||||||
|
<Badge variant={isRunning ? 'default' : 'secondary'}>
|
||||||
|
{isRunning ? 'Running' : 'Stopped'}
|
||||||
|
</Badge>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Manage models in this llama.cpp instance
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void fetchModels()}
|
||||||
|
disabled={loading || !isRunning}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Error Display */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||||
|
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||||
|
<span className="text-sm text-destructive">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Models Table */}
|
||||||
|
<div className="flex-1 flex flex-col min-h-0 overflow-auto">
|
||||||
|
{!isRunning ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
Instance is not running
|
||||||
|
</div>
|
||||||
|
) : loading && models.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-muted-foreground">
|
||||||
|
Loading models...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : models.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
No models found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Model</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{models.map((model) => {
|
||||||
|
const isLoading = loadingModels.has(model.id)
|
||||||
|
const isModelLoading = model.status.value === 'loading'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={model.id}>
|
||||||
|
<TableCell className="font-mono text-sm">
|
||||||
|
{model.id}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusIcon status={model.status.value} />
|
||||||
|
<span className="text-sm capitalize">
|
||||||
|
{model.status.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{model.status.value === 'loaded' ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => { void unloadModel(model.id) }}
|
||||||
|
disabled={!isRunning || isLoading || isModelLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin mr-1" />
|
||||||
|
Unloading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Unload'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : model.status.value === 'unloaded' ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
onClick={() => { void loadModel(model.id) }}
|
||||||
|
disabled={!isRunning || isLoading || isModelLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin mr-1" />
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Load'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" variant="ghost" disabled>
|
||||||
|
Loading...
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-refresh indicator - only shown when models are loading */}
|
||||||
|
{isRunning && models.some(m => m.status.value === 'loading') && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<div className="w-2 h-2 bg-yellow-500 rounded-full animate-pulse"></div>
|
||||||
|
Auto-refreshing while models are loading
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ModelsDialog
|
||||||
@@ -21,12 +21,14 @@ describe('InstanceCard - Instance Actions and State', () => {
|
|||||||
const mockEditInstance = vi.fn()
|
const mockEditInstance = vi.fn()
|
||||||
|
|
||||||
const stoppedInstance: Instance = {
|
const stoppedInstance: Instance = {
|
||||||
|
id: 1,
|
||||||
name: 'test-instance',
|
name: 'test-instance',
|
||||||
status: 'stopped',
|
status: 'stopped',
|
||||||
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'test-model.gguf' } }
|
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'test-model.gguf' } }
|
||||||
}
|
}
|
||||||
|
|
||||||
const runningInstance: Instance = {
|
const runningInstance: Instance = {
|
||||||
|
id: 2,
|
||||||
name: 'running-instance',
|
name: 'running-instance',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'running-model.gguf' } }
|
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'running-model.gguf' } }
|
||||||
@@ -342,6 +344,7 @@ afterEach(() => {
|
|||||||
describe('Error Edge Cases', () => {
|
describe('Error Edge Cases', () => {
|
||||||
it('handles instance with minimal data', () => {
|
it('handles instance with minimal data', () => {
|
||||||
const minimalInstance: Instance = {
|
const minimalInstance: Instance = {
|
||||||
|
id: 3,
|
||||||
name: 'minimal',
|
name: 'minimal',
|
||||||
status: 'stopped',
|
status: 'stopped',
|
||||||
options: {}
|
options: {}
|
||||||
@@ -364,6 +367,7 @@ afterEach(() => {
|
|||||||
|
|
||||||
it('handles instance with undefined options', () => {
|
it('handles instance with undefined options', () => {
|
||||||
const instanceWithoutOptions: Instance = {
|
const instanceWithoutOptions: Instance = {
|
||||||
|
id: 4,
|
||||||
name: 'no-options',
|
name: 'no-options',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
options: undefined
|
options: undefined
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import userEvent from '@testing-library/user-event'
|
|||||||
import InstanceList from '@/components/InstanceList'
|
import InstanceList from '@/components/InstanceList'
|
||||||
import { InstancesProvider } from '@/contexts/InstancesContext'
|
import { InstancesProvider } from '@/contexts/InstancesContext'
|
||||||
import { instancesApi } from '@/lib/api'
|
import { instancesApi } from '@/lib/api'
|
||||||
import type { Instance } from '@/types/instance'
|
import { BackendType, type Instance } from '@/types/instance'
|
||||||
import { BackendType } from '@/types/instance'
|
|
||||||
import { AuthProvider } from '@/contexts/AuthContext'
|
import { AuthProvider } from '@/contexts/AuthContext'
|
||||||
|
|
||||||
// Mock the API
|
// Mock the API
|
||||||
@@ -59,9 +58,9 @@ describe('InstanceList - State Management and UI Logic', () => {
|
|||||||
const mockEditInstance = vi.fn()
|
const mockEditInstance = vi.fn()
|
||||||
|
|
||||||
const mockInstances: Instance[] = [
|
const mockInstances: Instance[] = [
|
||||||
{ name: 'instance-1', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model1.gguf' } } },
|
{ id: 1, name: 'instance-1', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model1.gguf' } } },
|
||||||
{ name: 'instance-2', status: 'running', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model2.gguf' } } },
|
{ id: 1, name: 'instance-2', status: 'running', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model2.gguf' } } },
|
||||||
{ name: 'instance-3', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model3.gguf' } } }
|
{ id: 1, name: 'instance-3', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model3.gguf' } } }
|
||||||
]
|
]
|
||||||
|
|
||||||
const DUMMY_API_KEY = 'test-api-key-123'
|
const DUMMY_API_KEY = 'test-api-key-123'
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ afterEach(() => {
|
|||||||
|
|
||||||
describe('Edit Mode', () => {
|
describe('Edit Mode', () => {
|
||||||
const mockInstance: Instance = {
|
const mockInstance: Instance = {
|
||||||
|
id: 1,
|
||||||
name: 'existing-instance',
|
name: 'existing-instance',
|
||||||
status: 'stopped',
|
status: 'stopped',
|
||||||
options: {
|
options: {
|
||||||
|
|||||||
238
webui/src/components/apikeys/CreateApiKeyDialog.tsx
Normal file
238
webui/src/components/apikeys/CreateApiKeyDialog.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { apiKeysApi } from "@/lib/api";
|
||||||
|
import { PermissionMode, type CreateKeyRequest } from "@/types/apiKey";
|
||||||
|
import { useInstances } from "@/contexts/InstancesContext";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
|
interface CreateApiKeyDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onKeyCreated: (plainTextKey: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateApiKeyDialog({ open, onOpenChange, onKeyCreated }: CreateApiKeyDialogProps) {
|
||||||
|
const { instances } = useInstances();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [permissionMode, setPermissionMode] = useState<PermissionMode>(PermissionMode.AllowAll);
|
||||||
|
const [expiresAt, setExpiresAt] = useState<string>("");
|
||||||
|
const [instancePermissions, setInstancePermissions] = useState<Record<number, boolean>>({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const formatDisplayDate = (dateString: string) => {
|
||||||
|
if (!dateString) return null;
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return format(date, "d MMMM yyyy");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError("Name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.length > 100) {
|
||||||
|
setError("Name must be 100 characters or less");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissionMode === PermissionMode.PerInstance) {
|
||||||
|
const hasAnyPermission = Object.values(instancePermissions).some(v => v);
|
||||||
|
if (!hasAnyPermission) {
|
||||||
|
setError("At least one instance permission is required for per-instance mode");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build request
|
||||||
|
const instanceIds: number[] = [];
|
||||||
|
if (permissionMode === PermissionMode.PerInstance) {
|
||||||
|
Object.entries(instancePermissions).forEach(([instanceId, hasPermission]) => {
|
||||||
|
if (hasPermission) {
|
||||||
|
instanceIds.push(parseInt(instanceId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: CreateKeyRequest = {
|
||||||
|
name: name.trim(),
|
||||||
|
permission_mode: permissionMode,
|
||||||
|
instance_ids: instanceIds,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add expiration if provided
|
||||||
|
if (expiresAt) {
|
||||||
|
const expirationDate = new Date(expiresAt);
|
||||||
|
const now = new Date();
|
||||||
|
if (expirationDate <= now) {
|
||||||
|
setError("Expiration date must be in the future");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
request.expires_at = Math.floor(expirationDate.getTime() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await apiKeysApi.create(request);
|
||||||
|
onKeyCreated(response.key);
|
||||||
|
// Reset form
|
||||||
|
setName("");
|
||||||
|
setPermissionMode(PermissionMode.AllowAll);
|
||||||
|
setExpiresAt("");
|
||||||
|
setInstancePermissions({});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to create API key");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstancePermissionChange = (instanceId: number, checked: boolean) => {
|
||||||
|
setInstancePermissions(prev => ({
|
||||||
|
...prev,
|
||||||
|
[instanceId]: checked,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create API Key</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="My API Key"
|
||||||
|
maxLength={100}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>Permission Mode</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={permissionMode}
|
||||||
|
onValueChange={(value) => setPermissionMode(value as PermissionMode)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value={PermissionMode.AllowAll} id="allow-all" />
|
||||||
|
<Label htmlFor="allow-all" className="font-normal cursor-pointer">
|
||||||
|
Full Access
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value={PermissionMode.PerInstance} id="per-instance" />
|
||||||
|
<Label htmlFor="per-instance" className="font-normal cursor-pointer">
|
||||||
|
Per-Instance Access
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
{permissionMode === PermissionMode.AllowAll && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This key will have access to all instances
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{permissionMode === PermissionMode.PerInstance && (
|
||||||
|
<div className="space-y-2 border rounded-lg p-4">
|
||||||
|
<Label className="text-sm font-semibold">Instance Permissions</Label>
|
||||||
|
{instances.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No instances available</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{instances.map((instance, index) => {
|
||||||
|
const isChecked = !!instancePermissions[instance.id];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${instance.name}-${index}`}
|
||||||
|
className="flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={`instance-${instance.id}`}
|
||||||
|
checked={isChecked}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
handleInstancePermissionChange(instance.id, checked as boolean);
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`instance-${instance.id}`}
|
||||||
|
className="font-normal cursor-pointer flex-1"
|
||||||
|
>
|
||||||
|
{instance.name}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="expires-at">Expiration Date (Optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="expires-at"
|
||||||
|
type="date"
|
||||||
|
value={expiresAt}
|
||||||
|
onChange={(e) => setExpiresAt(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
{expiresAt && formatDisplayDate(expiresAt) && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Expires on {formatDisplayDate(expiresAt)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateApiKeyDialog;
|
||||||
@@ -59,7 +59,7 @@ const KeyValueInput: React.FC<KeyValueInputProps> = ({
|
|||||||
// Reset to single empty row if value is explicitly undefined/null
|
// Reset to single empty row if value is explicitly undefined/null
|
||||||
setPairs([{ key: '', value: '' }])
|
setPairs([{ key: '', value: '' }])
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [value])
|
}, [value])
|
||||||
|
|
||||||
// Update parent component when pairs change
|
// Update parent component when pairs change
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import NumberInput from '@/components/form/NumberInput'
|
|||||||
|
|
||||||
interface AutoRestartConfigurationProps {
|
interface AutoRestartConfigurationProps {
|
||||||
formData: CreateInstanceOptions
|
formData: CreateInstanceOptions
|
||||||
onChange: (key: keyof CreateInstanceOptions, value: any) => void
|
onChange: <K extends keyof CreateInstanceOptions>(key: K, value: CreateInstanceOptions[K]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const AutoRestartConfiguration: React.FC<AutoRestartConfigurationProps> = ({
|
const AutoRestartConfiguration: React.FC<AutoRestartConfigurationProps> = ({
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import type { CreateInstanceOptions } from '@/types/instance'
|
|||||||
import { getBasicBackendFields, getAdvancedBackendFields } from '@/lib/zodFormUtils'
|
import { getBasicBackendFields, getAdvancedBackendFields } from '@/lib/zodFormUtils'
|
||||||
import BackendFormField from '@/components/BackendFormField'
|
import BackendFormField from '@/components/BackendFormField'
|
||||||
|
|
||||||
|
type BackendFieldValue = string | number | boolean | string[] | Record<string, string> | undefined
|
||||||
|
|
||||||
interface BackendConfigurationProps {
|
interface BackendConfigurationProps {
|
||||||
formData: CreateInstanceOptions
|
formData: CreateInstanceOptions
|
||||||
onBackendFieldChange: (key: string, value: any) => void
|
onBackendFieldChange: (key: string, value: BackendFieldValue) => void
|
||||||
showAdvanced?: boolean
|
showAdvanced?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +28,7 @@ const BackendConfiguration: React.FC<BackendConfigurationProps> = ({
|
|||||||
<BackendFormField
|
<BackendFormField
|
||||||
key={fieldKey}
|
key={fieldKey}
|
||||||
fieldKey={fieldKey}
|
fieldKey={fieldKey}
|
||||||
value={(formData.backend_options as any)?.[fieldKey]}
|
value={(formData.backend_options as Record<string, BackendFieldValue> | undefined)?.[fieldKey]}
|
||||||
onChange={onBackendFieldChange}
|
onChange={onBackendFieldChange}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -41,7 +43,7 @@ const BackendConfiguration: React.FC<BackendConfigurationProps> = ({
|
|||||||
<BackendFormField
|
<BackendFormField
|
||||||
key={fieldKey}
|
key={fieldKey}
|
||||||
fieldKey={fieldKey}
|
fieldKey={fieldKey}
|
||||||
value={(formData.backend_options as any)?.[fieldKey]}
|
value={(formData.backend_options as Record<string, BackendFieldValue> | undefined)?.[fieldKey]}
|
||||||
onChange={onBackendFieldChange}
|
onChange={onBackendFieldChange}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -53,7 +55,7 @@ const BackendConfiguration: React.FC<BackendConfigurationProps> = ({
|
|||||||
<BackendFormField
|
<BackendFormField
|
||||||
key="extra_args"
|
key="extra_args"
|
||||||
fieldKey="extra_args"
|
fieldKey="extra_args"
|
||||||
value={(formData.backend_options as any)?.extra_args}
|
value={(formData.backend_options as Record<string, BackendFieldValue> | undefined)?.extra_args}
|
||||||
onChange={onBackendFieldChange}
|
onChange={onBackendFieldChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
270
webui/src/components/settings/ApiKeysSection.tsx
Normal file
270
webui/src/components/settings/ApiKeysSection.tsx
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import { useEffect, useState, Fragment } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Trash2, Copy, Check, X, ChevronDown, ChevronRight } from "lucide-react";
|
||||||
|
import { apiKeysApi } from "@/lib/api";
|
||||||
|
import { type ApiKey, type KeyPermissionResponse, PermissionMode } from "@/types/apiKey";
|
||||||
|
import CreateApiKeyDialog from "@/components/apikeys/CreateApiKeyDialog";
|
||||||
|
import { format, formatDistanceToNow } from "date-fns";
|
||||||
|
|
||||||
|
function ApiKeysSection() {
|
||||||
|
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [expandedRowId, setExpandedRowId] = useState<number | null>(null);
|
||||||
|
const [newKeyPlainText, setNewKeyPlainText] = useState<string | null>(null);
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
|
const [copiedKey, setCopiedKey] = useState(false);
|
||||||
|
const [permissions, setPermissions] = useState<Record<number, KeyPermissionResponse[]>>({});
|
||||||
|
const [loadingPermissions, setLoadingPermissions] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchKeys();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchKeys = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await apiKeysApi.list();
|
||||||
|
setKeys(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load API keys");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPermissions = async (keyId: number) => {
|
||||||
|
if (permissions[keyId]) return;
|
||||||
|
|
||||||
|
setLoadingPermissions({ ...loadingPermissions, [keyId]: true });
|
||||||
|
try {
|
||||||
|
const data = await apiKeysApi.getPermissions(keyId);
|
||||||
|
setPermissions({ ...permissions, [keyId]: data });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load permissions:", err);
|
||||||
|
} finally {
|
||||||
|
setLoadingPermissions({ ...loadingPermissions, [keyId]: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyCreated = (plainTextKey: string) => {
|
||||||
|
setNewKeyPlainText(plainTextKey);
|
||||||
|
void fetchKeys();
|
||||||
|
setCreateDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissSuccessBanner = () => {
|
||||||
|
setNewKeyPlainText(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyKey = async () => {
|
||||||
|
if (newKeyPlainText) {
|
||||||
|
await navigator.clipboard.writeText(newKeyPlainText);
|
||||||
|
setCopiedKey(true);
|
||||||
|
setTimeout(() => setCopiedKey(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteKey = async (id: number, name: string) => {
|
||||||
|
if (!confirm(`Are you sure you want to delete the key '${name}'?\n\nThis action cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiKeysApi.delete(id);
|
||||||
|
void fetchKeys();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : "Failed to delete API key");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowClick = (key: ApiKey) => {
|
||||||
|
if (expandedRowId === key.id) {
|
||||||
|
setExpandedRowId(null);
|
||||||
|
} else {
|
||||||
|
setExpandedRowId(key.id);
|
||||||
|
if (key.permission_mode === PermissionMode.PerInstance) {
|
||||||
|
void fetchPermissions(key.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (timestamp: number) => {
|
||||||
|
return format(new Date(timestamp * 1000), "MMM d, yyyy");
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLastUsed = (timestamp: number | null) => {
|
||||||
|
if (!timestamp) return "Never";
|
||||||
|
return formatDistanceToNow(new Date(timestamp * 1000), { addSuffix: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isExpired = (expiresAt: number | null) => {
|
||||||
|
if (!expiresAt) return false;
|
||||||
|
return expiresAt * 1000 < Date.now();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">API Keys</h3>
|
||||||
|
<Button onClick={() => setCreateDialogOpen(true)}>Create API Key</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{newKeyPlainText && (
|
||||||
|
<Alert className="bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-900">
|
||||||
|
<AlertDescription className="space-y-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-green-900 dark:text-green-100">API key created successfully</p>
|
||||||
|
<p className="text-sm text-green-800 dark:text-green-200 mt-1">
|
||||||
|
Make sure to copy this key now. You won't be able to see it again!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={dismissSuccessBanner}
|
||||||
|
className="h-6 w-6"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 p-3 bg-white dark:bg-gray-900 border border-green-300 dark:border-green-800 rounded font-mono text-sm break-all">
|
||||||
|
{newKeyPlainText}
|
||||||
|
</code>
|
||||||
|
<Button onClick={() => void handleCopyKey()} variant="outline" size="sm">
|
||||||
|
{copiedKey ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="h-16 bg-muted animate-pulse rounded" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : keys.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
No API keys yet. Create your first key to get started.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-3 font-semibold text-sm">Name</th>
|
||||||
|
<th className="text-left p-3 font-semibold text-sm">Permissions</th>
|
||||||
|
<th className="text-left p-3 font-semibold text-sm">Created</th>
|
||||||
|
<th className="text-left p-3 font-semibold text-sm">Expires</th>
|
||||||
|
<th className="text-left p-3 font-semibold text-sm">Last Accessed</th>
|
||||||
|
<th className="text-left p-3 font-semibold text-sm">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{keys.map((key) => (
|
||||||
|
<Fragment key={key.id}>
|
||||||
|
<tr
|
||||||
|
className="border-t hover:bg-muted/50 cursor-pointer"
|
||||||
|
onClick={() => handleRowClick(key)}
|
||||||
|
>
|
||||||
|
<td className="p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{expandedRowId === key.id ? (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{key.name}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
{key.permission_mode === PermissionMode.AllowAll ? (
|
||||||
|
<Badge variant="default">Full Access</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">Limited Access</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-sm text-muted-foreground">{formatDate(key.created_at)}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
{key.expires_at ? (
|
||||||
|
isExpired(key.expires_at) ? (
|
||||||
|
<Badge variant="destructive">Expired</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">{formatDate(key.expires_at)}</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">Never</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-sm text-muted-foreground">{formatLastUsed(key.last_used_at)}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void handleDeleteKey(key.id, key.name);
|
||||||
|
}}
|
||||||
|
title="Delete key"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{expandedRowId === key.id && (
|
||||||
|
<tr key={`${key.id}-expanded`} className="border-t bg-muted/30">
|
||||||
|
<td colSpan={6} className="p-4">
|
||||||
|
{key.permission_mode === PermissionMode.AllowAll ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This key has full access to all instances
|
||||||
|
</p>
|
||||||
|
) : loadingPermissions[key.id] ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading permissions...</p>
|
||||||
|
) : permissions[key.id] ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-semibold">Allowed Instances:</p>
|
||||||
|
<ul className="text-sm space-y-1">
|
||||||
|
{permissions[key.id].map((perm) => (
|
||||||
|
<li key={perm.instance_id} className="flex items-center gap-2">
|
||||||
|
<Check className="h-3 w-3 text-green-600" />
|
||||||
|
{perm.instance_name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No permissions data</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CreateApiKeyDialog
|
||||||
|
open={createDialogOpen}
|
||||||
|
onOpenChange={setCreateDialogOpen}
|
||||||
|
onKeyCreated={handleKeyCreated}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ApiKeysSection;
|
||||||
25
webui/src/components/settings/SettingsDialog.tsx
Normal file
25
webui/src/components/settings/SettingsDialog.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import ApiKeysSection from "./ApiKeysSection";
|
||||||
|
|
||||||
|
interface SettingsDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-5xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Settings</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Manage your application settings and API keys.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<ApiKeysSection />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SettingsDialog;
|
||||||
66
webui/src/components/ui/alert.tsx
Normal file
66
webui/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -9,14 +9,13 @@ const buttonVariants = cva(
|
|||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
outline:
|
outline:
|
||||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
ghost:
|
ghost:
|
||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
@@ -26,6 +25,8 @@ const buttonVariants = cva(
|
|||||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
icon: "size-9",
|
icon: "size-9",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
<div
|
<div
|
||||||
data-slot="card-header"
|
data-slot="card-header"
|
||||||
className={cn(
|
className={cn(
|
||||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ function Checkbox({
|
|||||||
>
|
>
|
||||||
<CheckboxPrimitive.Indicator
|
<CheckboxPrimitive.Indicator
|
||||||
data-slot="checkbox-indicator"
|
data-slot="checkbox-indicator"
|
||||||
className="flex items-center justify-center text-current transition-none"
|
className="grid place-content-center text-current transition-none"
|
||||||
>
|
>
|
||||||
<CheckIcon className="size-3.5" />
|
<CheckIcon className="size-3.5" />
|
||||||
</CheckboxPrimitive.Indicator>
|
</CheckboxPrimitive.Indicator>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
className
|
className
|
||||||
|
|||||||
43
webui/src/components/ui/radio-group.tsx
Normal file
43
webui/src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||||
|
import { CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function RadioGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
data-slot="radio-group"
|
||||||
|
className={cn("grid gap-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioGroupItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
data-slot="radio-group-item"
|
||||||
|
className={cn(
|
||||||
|
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator
|
||||||
|
data-slot="radio-group-indicator"
|
||||||
|
className="relative flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
||||||
117
webui/src/components/ui/table.tsx
Normal file
117
webui/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Table = React.forwardRef<
|
||||||
|
HTMLTableElement,
|
||||||
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
Table.displayName = "Table"
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||||
|
))
|
||||||
|
TableHeader.displayName = "TableHeader"
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableBody.displayName = "TableBody"
|
||||||
|
|
||||||
|
const TableFooter = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tfoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableFooter.displayName = "TableFooter"
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableRow.displayName = "TableRow"
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableHead.displayName = "TableHead"
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCell.displayName = "TableCell"
|
||||||
|
|
||||||
|
const TableCaption = React.forwardRef<
|
||||||
|
HTMLTableCaptionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<caption
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCaption.displayName = "TableCaption"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
@@ -3,8 +3,7 @@ import { render, screen, waitFor } from "@testing-library/react";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { InstancesProvider, useInstances } from "@/contexts/InstancesContext";
|
import { InstancesProvider, useInstances } from "@/contexts/InstancesContext";
|
||||||
import { instancesApi } from "@/lib/api";
|
import { instancesApi } from "@/lib/api";
|
||||||
import type { Instance } from "@/types/instance";
|
import { BackendType, type Instance } from "@/types/instance";
|
||||||
import { BackendType } from "@/types/instance";
|
|
||||||
import { AuthProvider } from "../AuthContext";
|
import { AuthProvider } from "../AuthContext";
|
||||||
|
|
||||||
// Mock the API module
|
// Mock the API module
|
||||||
@@ -71,37 +70,37 @@ function TestComponent() {
|
|||||||
|
|
||||||
{/* Action buttons for testing with specific instances */}
|
{/* Action buttons for testing with specific instances */}
|
||||||
<button
|
<button
|
||||||
onClick={() => createInstance("new-instance", { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "test.gguf" } })}
|
onClick={() => void createInstance("new-instance", { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "test.gguf" } })}
|
||||||
data-testid="create-instance"
|
data-testid="create-instance"
|
||||||
>
|
>
|
||||||
Create Instance
|
Create Instance
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => updateInstance("instance1", { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "updated.gguf" } })}
|
onClick={() => void updateInstance("instance1", { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "updated.gguf" } })}
|
||||||
data-testid="update-instance"
|
data-testid="update-instance"
|
||||||
>
|
>
|
||||||
Update Instance
|
Update Instance
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => startInstance("instance2")}
|
onClick={() => void startInstance("instance2")}
|
||||||
data-testid="start-instance"
|
data-testid="start-instance"
|
||||||
>
|
>
|
||||||
Start Instance2
|
Start Instance2
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => stopInstance("instance1")}
|
onClick={() => void stopInstance("instance1")}
|
||||||
data-testid="stop-instance"
|
data-testid="stop-instance"
|
||||||
>
|
>
|
||||||
Stop Instance1
|
Stop Instance1
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => restartInstance("instance1")}
|
onClick={() => void restartInstance("instance1")}
|
||||||
data-testid="restart-instance"
|
data-testid="restart-instance"
|
||||||
>
|
>
|
||||||
Restart Instance1
|
Restart Instance1
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => deleteInstance("instance2")}
|
onClick={() => void deleteInstance("instance2")}
|
||||||
data-testid="delete-instance"
|
data-testid="delete-instance"
|
||||||
>
|
>
|
||||||
Delete Instance2
|
Delete Instance2
|
||||||
@@ -123,8 +122,8 @@ function renderWithProvider(children: ReactNode) {
|
|||||||
|
|
||||||
describe("InstancesContext", () => {
|
describe("InstancesContext", () => {
|
||||||
const mockInstances: Instance[] = [
|
const mockInstances: Instance[] = [
|
||||||
{ name: "instance1", status: "running", options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "model1.gguf" } } },
|
{ id: 1, name: "instance1", status: "running", options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "model1.gguf" } } },
|
||||||
{ name: "instance2", status: "stopped", options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "model2.gguf" } } },
|
{ id: 2, name: "instance2", status: "stopped", options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "model2.gguf" } } },
|
||||||
];
|
];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -181,6 +180,7 @@ describe("InstancesContext", () => {
|
|||||||
describe("Create Instance", () => {
|
describe("Create Instance", () => {
|
||||||
it("creates instance and adds it to state", async () => {
|
it("creates instance and adds it to state", async () => {
|
||||||
const newInstance: Instance = {
|
const newInstance: Instance = {
|
||||||
|
id: 3,
|
||||||
name: "new-instance",
|
name: "new-instance",
|
||||||
status: "stopped",
|
status: "stopped",
|
||||||
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "test.gguf" } },
|
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "test.gguf" } },
|
||||||
@@ -238,6 +238,7 @@ describe("InstancesContext", () => {
|
|||||||
describe("Update Instance", () => {
|
describe("Update Instance", () => {
|
||||||
it("updates instance and maintains it in state", async () => {
|
it("updates instance and maintains it in state", async () => {
|
||||||
const updatedInstance: Instance = {
|
const updatedInstance: Instance = {
|
||||||
|
id: 1,
|
||||||
name: "instance1",
|
name: "instance1",
|
||||||
status: "running",
|
status: "running",
|
||||||
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "updated.gguf" } },
|
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "updated.gguf" } },
|
||||||
@@ -408,6 +409,7 @@ describe("InstancesContext", () => {
|
|||||||
it("maintains consistent state during multiple operations", async () => {
|
it("maintains consistent state during multiple operations", async () => {
|
||||||
// Test that operations don't interfere with each other
|
// Test that operations don't interfere with each other
|
||||||
const newInstance: Instance = {
|
const newInstance: Instance = {
|
||||||
|
id: 3,
|
||||||
name: "new-instance",
|
name: "new-instance",
|
||||||
status: "stopped",
|
status: "stopped",
|
||||||
options: {},
|
options: {},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { CreateInstanceOptions, Instance } from "@/types/instance";
|
import type { CreateInstanceOptions, Instance } from "@/types/instance";
|
||||||
import type { AppConfig } from "@/types/config";
|
import type { AppConfig } from "@/types/config";
|
||||||
|
import type { ApiKey, CreateKeyRequest, CreateKeyResponse, KeyPermissionResponse } from "@/types/apiKey";
|
||||||
import { handleApiError } from "./errorUtils";
|
import { handleApiError } from "./errorUtils";
|
||||||
|
|
||||||
// Adding baseURI as a prefix to support being served behind a subpath
|
// Adding baseURI as a prefix to support being served behind a subpath
|
||||||
@@ -178,3 +179,79 @@ export const instancesApi = {
|
|||||||
// GET /instances/{name}/proxy/health
|
// GET /instances/{name}/proxy/health
|
||||||
getHealth: (name: string) => apiCall<Record<string, unknown>>(`/instances/${encodeURIComponent(name)}/proxy/health`),
|
getHealth: (name: string) => apiCall<Record<string, unknown>>(`/instances/${encodeURIComponent(name)}/proxy/health`),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// API Keys API functions
|
||||||
|
export const apiKeysApi = {
|
||||||
|
// GET /auth/keys
|
||||||
|
list: () => apiCall<ApiKey[]>("/auth/keys"),
|
||||||
|
|
||||||
|
// GET /auth/keys/{id}
|
||||||
|
get: (id: number) => apiCall<ApiKey>(`/auth/keys/${id}`),
|
||||||
|
|
||||||
|
// POST /auth/keys
|
||||||
|
create: (request: CreateKeyRequest) =>
|
||||||
|
apiCall<CreateKeyResponse>("/auth/keys", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// DELETE /auth/keys/{id}
|
||||||
|
delete: (id: number) =>
|
||||||
|
apiCall<void>(`/auth/keys/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
}),
|
||||||
|
|
||||||
|
// GET /auth/keys/{id}/permissions
|
||||||
|
getPermissions: (id: number) =>
|
||||||
|
apiCall<KeyPermissionResponse[]>(`/auth/keys/${id}/permissions`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Llama.cpp model management types
|
||||||
|
export interface Model {
|
||||||
|
id: string;
|
||||||
|
object: string;
|
||||||
|
owned_by: string;
|
||||||
|
created: number;
|
||||||
|
in_cache: boolean;
|
||||||
|
path: string;
|
||||||
|
status: {
|
||||||
|
value: string; // "loaded" | "loading" | "unloaded"
|
||||||
|
args: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelsListResponse {
|
||||||
|
object: string;
|
||||||
|
data: Model[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Llama.cpp model management API functions
|
||||||
|
export const llamaCppApi = {
|
||||||
|
// GET /llama-cpp/{name}/models
|
||||||
|
getModels: async (instanceName: string): Promise<Model[]> => {
|
||||||
|
const response = await apiCall<ModelsListResponse>(
|
||||||
|
`/llama-cpp/${encodeURIComponent(instanceName)}/models`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// POST /llama-cpp/{name}/models/{model}/load
|
||||||
|
loadModel: (instanceName: string, modelName: string) =>
|
||||||
|
apiCall<{ success: boolean }>(
|
||||||
|
`/llama-cpp/${encodeURIComponent(instanceName)}/models/${encodeURIComponent(modelName)}/load`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ model: modelName }),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
|
||||||
|
// POST /llama-cpp/{name}/models/{model}/unload
|
||||||
|
unloadModel: (instanceName: string, modelName: string) =>
|
||||||
|
apiCall<{ success: boolean }>(
|
||||||
|
`/llama-cpp/${encodeURIComponent(instanceName)}/models/${encodeURIComponent(modelName)}/unload`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ model: modelName }),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|||||||
@@ -156,11 +156,14 @@ class HealthService {
|
|||||||
this.callbacks.set(instanceName, new Set())
|
this.callbacks.set(instanceName, new Set())
|
||||||
}
|
}
|
||||||
|
|
||||||
this.callbacks.get(instanceName)!.add(callback)
|
const callbacks = this.callbacks.get(instanceName)
|
||||||
|
if (callbacks) {
|
||||||
|
callbacks.add(callback)
|
||||||
|
|
||||||
// Start health checking if this is the first subscriber
|
// Start health checking if this is the first subscriber
|
||||||
if (this.callbacks.get(instanceName)!.size === 1) {
|
if (callbacks.size === 1) {
|
||||||
this.startHealthCheck(instanceName)
|
this.startHealthCheck(instanceName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return unsubscribe function
|
// Return unsubscribe function
|
||||||
@@ -214,22 +217,24 @@ class HealthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start new interval with appropriate timing
|
// Start new interval with appropriate timing
|
||||||
const interval = setInterval(async () => {
|
const interval = setInterval(() => {
|
||||||
try {
|
void (async () => {
|
||||||
const health = await this.performHealthCheck(instanceName)
|
try {
|
||||||
this.notifyCallbacks(instanceName, health)
|
const health = await this.performHealthCheck(instanceName)
|
||||||
|
this.notifyCallbacks(instanceName, health)
|
||||||
|
|
||||||
// Check if state changed and adjust interval
|
// Check if state changed and adjust interval
|
||||||
const previousState = this.lastHealthState.get(instanceName)
|
const previousState = this.lastHealthState.get(instanceName)
|
||||||
this.lastHealthState.set(instanceName, health.state)
|
this.lastHealthState.set(instanceName, health.state)
|
||||||
|
|
||||||
if (previousState !== health.state) {
|
if (previousState !== health.state) {
|
||||||
this.adjustPollingInterval(instanceName, health.state)
|
this.adjustPollingInterval(instanceName, health.state)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Health check failed for ${instanceName}:`, error)
|
||||||
|
// Continue polling even on error
|
||||||
}
|
}
|
||||||
} catch (error) {
|
})()
|
||||||
console.error(`Health check failed for ${instanceName}:`, error)
|
|
||||||
// Continue polling even on error
|
|
||||||
}
|
|
||||||
}, pollInterval)
|
}, pollInterval)
|
||||||
|
|
||||||
this.intervals.set(instanceName, interval)
|
this.intervals.set(instanceName, interval)
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import './index.css'
|
|||||||
import { AuthProvider } from './contexts/AuthContext'
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
import { ConfigProvider } from './contexts/ConfigContext'
|
import { ConfigProvider } from './contexts/ConfigContext'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
const rootElement = document.getElementById('root')
|
||||||
|
if (!rootElement) throw new Error('Failed to find the root element')
|
||||||
|
|
||||||
|
ReactDOM.createRoot(rootElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<ConfigProvider>
|
<ConfigProvider>
|
||||||
|
|||||||
@@ -1,10 +1,44 @@
|
|||||||
import '@testing-library/jest-dom'
|
import '@testing-library/jest-dom'
|
||||||
import { afterEach, vi } from 'vitest'
|
import { afterEach, beforeEach } from 'vitest'
|
||||||
|
|
||||||
// Mock fetch globally since your app uses fetch
|
// Create a working localStorage implementation for tests
|
||||||
global.fetch = vi.fn()
|
// This ensures localStorage works in both CLI and VSCode test runner
|
||||||
|
class LocalStorageMock implements Storage {
|
||||||
|
private store: Map<string, string> = new Map()
|
||||||
|
|
||||||
|
get length(): number {
|
||||||
|
return this.store.size
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.store.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
getItem(key: string): string | null {
|
||||||
|
return this.store.get(key) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
key(index: number): string | null {
|
||||||
|
return Array.from(this.store.keys())[index] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
removeItem(key: string): void {
|
||||||
|
this.store.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
setItem(key: string, value: string): void {
|
||||||
|
this.store.set(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace global localStorage
|
||||||
|
global.localStorage = new LocalStorageMock()
|
||||||
|
|
||||||
|
// Clean up before each test
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
// Clean up after each test
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks()
|
localStorage.clear()
|
||||||
})
|
})
|
||||||
31
webui/src/types/apiKey.ts
Normal file
31
webui/src/types/apiKey.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export enum PermissionMode {
|
||||||
|
AllowAll = "allow_all",
|
||||||
|
PerInstance = "per_instance"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiKey {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
user_id: string
|
||||||
|
permission_mode: PermissionMode
|
||||||
|
expires_at: number | null
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
last_used_at: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateKeyRequest {
|
||||||
|
name: string
|
||||||
|
permission_mode: PermissionMode
|
||||||
|
expires_at?: number
|
||||||
|
instance_ids: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateKeyResponse extends ApiKey {
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyPermissionResponse {
|
||||||
|
instance_id: number
|
||||||
|
instance_name: string
|
||||||
|
}
|
||||||
@@ -30,7 +30,6 @@ export interface ServerConfig {
|
|||||||
|
|
||||||
export interface InstancesConfig {
|
export interface InstancesConfig {
|
||||||
port_range: [number, number]
|
port_range: [number, number]
|
||||||
configs_dir: string
|
|
||||||
logs_dir: string
|
logs_dir: string
|
||||||
auto_create_dirs: boolean
|
auto_create_dirs: boolean
|
||||||
max_instances: number
|
max_instances: number
|
||||||
@@ -53,7 +52,6 @@ export interface DatabaseConfig {
|
|||||||
|
|
||||||
export interface AuthConfig {
|
export interface AuthConfig {
|
||||||
require_inference_auth: boolean
|
require_inference_auth: boolean
|
||||||
inference_keys: string[] // Will be empty in sanitized response
|
|
||||||
require_management_auth: boolean
|
require_management_auth: boolean
|
||||||
management_keys: string[] // Will be empty in sanitized response
|
management_keys: string[] // Will be empty in sanitized response
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface HealthStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Instance {
|
export interface Instance {
|
||||||
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
status: InstanceStatus;
|
status: InstanceStatus;
|
||||||
options?: CreateInstanceOptions;
|
options?: CreateInstanceOptions;
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowSyntheticDefaultImports": true
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts"]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user