mirror of
https://github.com/lordmathis/llamactl.git
synced 2025-12-23 09:34:23 +00:00
Compare commits
28 Commits
v0.12.0
...
99927160c2
| Author | SHA1 | Date | |
|---|---|---|---|
| 99927160c2 | |||
| c37c1b8161 | |||
| 80d5d44a0b | |||
| 2d0acc60f2 | |||
| a1b6f0c1b0 | |||
| 991ce3c678 | |||
| d9c666a245 | |||
| 85cf712b03 | |||
| 5ccf493e04 | |||
| 9eee42c673 | |||
| 470f90076f | |||
| 3fd597638b | |||
| 645aa63186 | |||
| 7c05fd278c | |||
| 00114caa00 | |||
| 7272aa26ec | |||
| fec989fee2 | |||
| 0c11365d7e | |||
|
|
bb88fb2bb2 | ||
| 6d049be13e | |||
|
|
bb0d4863d8 | ||
| 22a747c318 | |||
| ceef48a125 | |||
| db1347a709 | |||
|
|
e4027722d7 | ||
|
|
8218c042c8 | ||
| efed0f543b | |||
|
|
aa0508eb9b |
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@@ -9,7 +9,7 @@
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}/cmd/server/main.go",
|
||||
"program": "${workspaceFolder}/cmd/server",
|
||||
"env": {
|
||||
"GO_ENV": "development",
|
||||
"LLAMACTL_CONFIG_PATH": "${workspaceFolder}/llamactl.dev.yaml"
|
||||
|
||||
35
README.md
35
README.md
@@ -179,21 +179,28 @@ backends:
|
||||
args: []
|
||||
environment: {} # Environment variables for the backend process
|
||||
|
||||
data_dir: ~/.local/share/llamactl # Main data directory (database, instances, logs), default varies by OS
|
||||
|
||||
instances:
|
||||
port_range: [8000, 9000] # Port range for instances
|
||||
data_dir: ~/.local/share/llamactl # Data directory (platform-specific, see below)
|
||||
configs_dir: ~/.local/share/llamactl/instances # Instance configs directory
|
||||
logs_dir: ~/.local/share/llamactl/logs # Logs directory
|
||||
auto_create_dirs: true # Auto-create data/config/logs dirs if missing
|
||||
max_instances: -1 # Max instances (-1 = unlimited)
|
||||
max_running_instances: -1 # Max running instances (-1 = unlimited)
|
||||
enable_lru_eviction: true # Enable LRU eviction for idle instances
|
||||
default_auto_restart: true # Auto-restart new instances by default
|
||||
default_max_restarts: 3 # Max restarts for new instances
|
||||
default_restart_delay: 5 # Restart delay (seconds) for new instances
|
||||
default_on_demand_start: true # Default on-demand start setting
|
||||
on_demand_start_timeout: 120 # Default on-demand start timeout in seconds
|
||||
timeout_check_interval: 5 # Idle instance timeout check in minutes
|
||||
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)
|
||||
auto_create_dirs: true # Auto-create data/config/logs dirs if missing
|
||||
max_instances: -1 # Max instances (-1 = unlimited)
|
||||
max_running_instances: -1 # Max running instances (-1 = unlimited)
|
||||
enable_lru_eviction: true # Enable LRU eviction for idle instances
|
||||
default_auto_restart: true # Auto-restart new instances by default
|
||||
default_max_restarts: 3 # Max restarts for new instances
|
||||
default_restart_delay: 5 # Restart delay (seconds) for new instances
|
||||
default_on_demand_start: true # Default on-demand start setting
|
||||
on_demand_start_timeout: 120 # Default on-demand start timeout in seconds
|
||||
timeout_check_interval: 5 # Idle instance timeout check in minutes
|
||||
|
||||
database:
|
||||
path: ~/.local/share/llamactl/llamactl.db # Database file path (platform dependent)
|
||||
max_open_connections: 25 # Maximum open database connections
|
||||
max_idle_connections: 5 # Maximum idle database connections
|
||||
connection_max_lifetime: 5m # Connection max lifetime
|
||||
|
||||
auth:
|
||||
require_inference_auth: true # Require auth for inference endpoints
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"llamactl/pkg/config"
|
||||
"llamactl/pkg/database"
|
||||
"llamactl/pkg/manager"
|
||||
"llamactl/pkg/server"
|
||||
"log"
|
||||
@@ -47,22 +48,50 @@ func main() {
|
||||
cfg.CommitHash = commitHash
|
||||
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 err := os.MkdirAll(cfg.Instances.InstancesDir, 0755); err != nil {
|
||||
log.Printf("Error creating config directory %s: %v\nPersistence will not be available.", cfg.Instances.InstancesDir, err)
|
||||
// Create the main data directory
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the instance manager
|
||||
instanceManager := manager.New(&cfg)
|
||||
// Initialize database
|
||||
db, err := database.Open(&database.Config{
|
||||
Path: cfg.Database.Path,
|
||||
MaxOpenConnections: cfg.Database.MaxOpenConnections,
|
||||
MaxIdleConnections: cfg.Database.MaxIdleConnections,
|
||||
ConnMaxLifetime: cfg.Database.ConnMaxLifetime,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open database: %v", err)
|
||||
}
|
||||
|
||||
// Run database migrations
|
||||
if err := database.RunMigrations(db); err != nil {
|
||||
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
|
||||
instanceManager := manager.New(&cfg, db)
|
||||
|
||||
// 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
|
||||
r := server.SetupRouter(handler)
|
||||
|
||||
82
cmd/server/migrate_json.go
Normal file
82
cmd/server/migrate_json.go
Normal file
@@ -0,0 +1,82 @@
|
||||
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.InstanceStore) 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))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateJSONFile migrates a single JSON file to the database
|
||||
func migrateJSONFile(filename string, db database.InstanceStore) 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
|
||||
}
|
||||
@@ -49,21 +49,28 @@ backends:
|
||||
environment: {} # Environment variables for the backend process
|
||||
response_headers: {} # Additional response headers to send with responses
|
||||
|
||||
data_dir: ~/.local/share/llamactl # Main data directory (database, instances, logs), default varies by OS
|
||||
|
||||
instances:
|
||||
port_range: [8000, 9000] # Port range for instances
|
||||
data_dir: ~/.local/share/llamactl # Data directory (platform-specific, see below)
|
||||
configs_dir: ~/.local/share/llamactl/instances # Instance configs directory
|
||||
logs_dir: ~/.local/share/llamactl/logs # Logs directory
|
||||
auto_create_dirs: true # Auto-create data/config/logs dirs if missing
|
||||
max_instances: -1 # Max instances (-1 = unlimited)
|
||||
max_running_instances: -1 # Max running instances (-1 = unlimited)
|
||||
enable_lru_eviction: true # Enable LRU eviction for idle instances
|
||||
default_auto_restart: true # Auto-restart new instances by default
|
||||
default_max_restarts: 3 # Max restarts for new instances
|
||||
default_restart_delay: 5 # Restart delay (seconds) for new instances
|
||||
default_on_demand_start: true # Default on-demand start setting
|
||||
on_demand_start_timeout: 120 # Default on-demand start timeout in seconds
|
||||
timeout_check_interval: 5 # Idle instance timeout check in minutes
|
||||
port_range: [8000, 9000] # Port range for instances
|
||||
configs_dir: data_dir/instances # Instance configs directory
|
||||
logs_dir: data_dir/logs # Logs directory
|
||||
auto_create_dirs: true # Auto-create data/config/logs dirs if missing
|
||||
max_instances: -1 # Max instances (-1 = unlimited)
|
||||
max_running_instances: -1 # Max running instances (-1 = unlimited)
|
||||
enable_lru_eviction: true # Enable LRU eviction for idle instances
|
||||
default_auto_restart: true # Auto-restart new instances by default
|
||||
default_max_restarts: 3 # Max restarts for new instances
|
||||
default_restart_delay: 5 # Restart delay (seconds) for new instances
|
||||
default_on_demand_start: true # Default on-demand start setting
|
||||
on_demand_start_timeout: 120 # Default on-demand start timeout in seconds
|
||||
timeout_check_interval: 5 # Idle instance timeout check in minutes
|
||||
|
||||
database:
|
||||
path: data_dir/llamactl.db # Database file path
|
||||
max_open_connections: 25 # Maximum open database connections
|
||||
max_idle_connections: 5 # Maximum idle database connections
|
||||
connection_max_lifetime: 5m # Connection max lifetime
|
||||
|
||||
auth:
|
||||
require_inference_auth: true # Require auth for inference endpoints
|
||||
@@ -193,32 +200,44 @@ backends:
|
||||
- `LLAMACTL_MLX_ENV` - Environment variables in format "KEY1=value1,KEY2=value2"
|
||||
- `LLAMACTL_MLX_RESPONSE_HEADERS` - Response headers in format "KEY1=value1;KEY2=value2"
|
||||
|
||||
### Data Directory Configuration
|
||||
|
||||
```yaml
|
||||
data_dir: "~/.local/share/llamactl" # Main data directory for database, instances, and logs (default varies by OS)
|
||||
```
|
||||
|
||||
**Environment Variables:**
|
||||
- `LLAMACTL_DATA_DIRECTORY` - Main data directory path
|
||||
|
||||
**Default Data Directory by Platform:**
|
||||
- **Linux**: `~/.local/share/llamactl`
|
||||
- **macOS**: `~/Library/Application Support/llamactl`
|
||||
- **Windows**: `%LOCALAPPDATA%\llamactl` or `%PROGRAMDATA%\llamactl`
|
||||
|
||||
### Instance Configuration
|
||||
|
||||
```yaml
|
||||
instances:
|
||||
port_range: [8000, 9000] # Port range for instances (default: [8000, 9000])
|
||||
data_dir: "~/.local/share/llamactl" # Directory for all llamactl data (default varies by OS)
|
||||
configs_dir: "~/.local/share/llamactl/instances" # Directory for instance configs (default: data_dir/instances)
|
||||
logs_dir: "~/.local/share/llamactl/logs" # Directory for instance logs (default: data_dir/logs)
|
||||
auto_create_dirs: true # Automatically create data/config/logs directories (default: true)
|
||||
max_instances: -1 # Maximum instances (-1 = unlimited)
|
||||
max_running_instances: -1 # Maximum running instances (-1 = unlimited)
|
||||
enable_lru_eviction: true # Enable LRU eviction for idle instances
|
||||
default_auto_restart: true # Default auto-restart setting
|
||||
default_max_restarts: 3 # Default maximum restart attempts
|
||||
default_restart_delay: 5 # Default restart delay in seconds
|
||||
default_on_demand_start: true # Default on-demand start setting
|
||||
on_demand_start_timeout: 120 # Default on-demand start timeout in seconds
|
||||
timeout_check_interval: 5 # Default instance timeout check interval in minutes
|
||||
port_range: [8000, 9000] # Port range for instances (default: [8000, 9000])
|
||||
configs_dir: "instances" # Directory for instance configs, default: data_dir/instances
|
||||
logs_dir: "logs" # Directory for instance logs, default: data_dir/logs
|
||||
auto_create_dirs: true # Automatically create data/config/logs directories (default: true)
|
||||
max_instances: -1 # Maximum instances (-1 = unlimited)
|
||||
max_running_instances: -1 # Maximum running instances (-1 = unlimited)
|
||||
enable_lru_eviction: true # Enable LRU eviction for idle instances
|
||||
default_auto_restart: true # Default auto-restart setting
|
||||
default_max_restarts: 3 # Default maximum restart attempts
|
||||
default_restart_delay: 5 # Default restart delay in seconds
|
||||
default_on_demand_start: true # Default on-demand start setting
|
||||
on_demand_start_timeout: 120 # Default on-demand start timeout in seconds
|
||||
timeout_check_interval: 5 # Default instance timeout check interval in minutes
|
||||
```
|
||||
|
||||
**Environment Variables:**
|
||||
- `LLAMACTL_INSTANCE_PORT_RANGE` - Port range (format: "8000-9000" or "8000,9000")
|
||||
- `LLAMACTL_DATA_DIRECTORY` - Data directory path
|
||||
- `LLAMACTL_INSTANCES_DIR` - Instance configs directory path
|
||||
- `LLAMACTL_LOGS_DIR` - Log directory path
|
||||
- `LLAMACTL_AUTO_CREATE_DATA_DIR` - Auto-create data/config/logs directories (true/false)
|
||||
**Environment Variables:**
|
||||
- `LLAMACTL_INSTANCE_PORT_RANGE` - Port range (format: "8000-9000" or "8000,9000")
|
||||
- `LLAMACTL_INSTANCES_DIR` - Instance configs directory path
|
||||
- `LLAMACTL_LOGS_DIR` - Log directory path
|
||||
- `LLAMACTL_AUTO_CREATE_DATA_DIR` - Auto-create data/config/logs directories (true/false)
|
||||
- `LLAMACTL_MAX_INSTANCES` - Maximum number of instances
|
||||
- `LLAMACTL_MAX_RUNNING_INSTANCES` - Maximum number of running instances
|
||||
- `LLAMACTL_ENABLE_LRU_EVICTION` - Enable LRU eviction for idle instances
|
||||
@@ -226,8 +245,24 @@ instances:
|
||||
- `LLAMACTL_DEFAULT_MAX_RESTARTS` - Default maximum restarts
|
||||
- `LLAMACTL_DEFAULT_RESTART_DELAY` - Default restart delay in seconds
|
||||
- `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_TIMEOUT_CHECK_INTERVAL` - Default instance timeout check interval in minutes
|
||||
- `LLAMACTL_ON_DEMAND_START_TIMEOUT` - Default on-demand start timeout in seconds
|
||||
- `LLAMACTL_TIMEOUT_CHECK_INTERVAL` - Default instance timeout check interval in minutes
|
||||
|
||||
### Database Configuration
|
||||
|
||||
```yaml
|
||||
database:
|
||||
path: "llamactl.db" # Database file path, default: data_dir/llamactl.db
|
||||
max_open_connections: 25 # Maximum open database connections (default: 25)
|
||||
max_idle_connections: 5 # Maximum idle database connections (default: 5)
|
||||
connection_max_lifetime: 5m # Connection max lifetime (default: 5m)
|
||||
```
|
||||
|
||||
**Environment Variables:**
|
||||
- `LLAMACTL_DATABASE_PATH` - Database file path (relative to data_dir or absolute)
|
||||
- `LLAMACTL_DATABASE_MAX_OPEN_CONNECTIONS` - Maximum open database connections
|
||||
- `LLAMACTL_DATABASE_MAX_IDLE_CONNECTIONS` - Maximum idle database connections
|
||||
- `LLAMACTL_DATABASE_CONN_MAX_LIFETIME` - Connection max lifetime (e.g., "5m", "1h")
|
||||
|
||||
### Authentication Configuration
|
||||
|
||||
|
||||
399
docs/docs.go
399
docs/docs.go
@@ -19,6 +19,235 @@ const docTemplate = `{
|
||||
"host": "{{.Host}}",
|
||||
"basePath": "{{.BasePath}}",
|
||||
"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": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -1503,6 +1732,17 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"auth.PermissionMode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"allow_all",
|
||||
"per_instance"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"PermissionModeAllowAll",
|
||||
"PermissionModePerInstance"
|
||||
]
|
||||
},
|
||||
"config.AppConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1518,6 +1758,13 @@ const docTemplate = `{
|
||||
"commit_hash": {
|
||||
"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": {
|
||||
"$ref": "#/definitions/config.InstancesConfig"
|
||||
},
|
||||
@@ -1608,6 +1855,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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1639,11 +1906,7 @@ const docTemplate = `{
|
||||
"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.)",
|
||||
"description": "Instance config directory override (relative to data_dir if not absolute)",
|
||||
"type": "string"
|
||||
},
|
||||
"default_auto_restart": {
|
||||
@@ -1667,7 +1930,7 @@ const docTemplate = `{
|
||||
"type": "boolean"
|
||||
},
|
||||
"logs_dir": {
|
||||
"description": "Logs directory override",
|
||||
"description": "Logs directory override (relative to data_dir if not absolute)",
|
||||
"type": "string"
|
||||
},
|
||||
"max_instances": {
|
||||
@@ -1748,7 +2011,10 @@ const docTemplate = `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"description": "Unix timestamp when the instance was created",
|
||||
"description": "Unix timestamp when instance was created",
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
@@ -1794,6 +2060,125 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"server.CreateKeyRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expiresAt": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"instancePermissions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/server.InstancePermission"
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"permissionMode": {
|
||||
"$ref": "#/definitions/auth.PermissionMode"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server.CreateKeyResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"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.InstancePermission": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"can_infer": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"can_view_logs": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"instance_id": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server.KeyPermissionResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"can_infer": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"can_view_logs": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"instance_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"instance_name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server.KeyResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -12,6 +12,235 @@
|
||||
},
|
||||
"basePath": "/api/v1",
|
||||
"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": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -1496,6 +1725,17 @@
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"auth.PermissionMode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"allow_all",
|
||||
"per_instance"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"PermissionModeAllowAll",
|
||||
"PermissionModePerInstance"
|
||||
]
|
||||
},
|
||||
"config.AppConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1511,6 +1751,13 @@
|
||||
"commit_hash": {
|
||||
"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": {
|
||||
"$ref": "#/definitions/config.InstancesConfig"
|
||||
},
|
||||
@@ -1601,6 +1848,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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1632,11 +1899,7 @@
|
||||
"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.)",
|
||||
"description": "Instance config directory override (relative to data_dir if not absolute)",
|
||||
"type": "string"
|
||||
},
|
||||
"default_auto_restart": {
|
||||
@@ -1660,7 +1923,7 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"logs_dir": {
|
||||
"description": "Logs directory override",
|
||||
"description": "Logs directory override (relative to data_dir if not absolute)",
|
||||
"type": "string"
|
||||
},
|
||||
"max_instances": {
|
||||
@@ -1741,7 +2004,10 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"description": "Unix timestamp when the instance was created",
|
||||
"description": "Unix timestamp when instance was created",
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
@@ -1787,6 +2053,125 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"server.CreateKeyRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expiresAt": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"instancePermissions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/server.InstancePermission"
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"permissionMode": {
|
||||
"$ref": "#/definitions/auth.PermissionMode"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server.CreateKeyResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"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.InstancePermission": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"can_infer": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"can_view_logs": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"instance_id": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server.KeyPermissionResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"can_infer": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"can_view_logs": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"instance_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"instance_name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server.KeyResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
basePath: /api/v1
|
||||
definitions:
|
||||
auth.PermissionMode:
|
||||
enum:
|
||||
- allow_all
|
||||
- per_instance
|
||||
type: string
|
||||
x-enum-varnames:
|
||||
- PermissionModeAllowAll
|
||||
- PermissionModePerInstance
|
||||
config.AppConfig:
|
||||
properties:
|
||||
auth:
|
||||
@@ -10,6 +18,12 @@ definitions:
|
||||
type: string
|
||||
commit_hash:
|
||||
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:
|
||||
$ref: '#/definitions/config.InstancesConfig'
|
||||
local_node:
|
||||
@@ -70,6 +84,20 @@ definitions:
|
||||
type: string
|
||||
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:
|
||||
properties:
|
||||
args:
|
||||
@@ -91,11 +119,8 @@ definitions:
|
||||
description: Automatically create the data directory if it doesn't exist
|
||||
type: boolean
|
||||
configs_dir:
|
||||
description: Instance config directory override
|
||||
type: string
|
||||
data_dir:
|
||||
description: Directory where all llamactl data will be stored (instances.json,
|
||||
logs, etc.)
|
||||
description: Instance config directory override (relative to data_dir if not
|
||||
absolute)
|
||||
type: string
|
||||
default_auto_restart:
|
||||
description: Default auto-restart setting for new instances
|
||||
@@ -113,7 +138,7 @@ definitions:
|
||||
description: Enable LRU eviction for instance logs
|
||||
type: boolean
|
||||
logs_dir:
|
||||
description: Logs directory override
|
||||
description: Logs directory override (relative to data_dir if not absolute)
|
||||
type: string
|
||||
max_instances:
|
||||
description: Maximum number of instances that can be created
|
||||
@@ -171,7 +196,9 @@ definitions:
|
||||
instance.Instance:
|
||||
properties:
|
||||
created:
|
||||
description: Unix timestamp when the instance was created
|
||||
description: Unix timestamp when instance was created
|
||||
type: integer
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
@@ -203,6 +230,84 @@ definitions:
|
||||
description: seconds
|
||||
type: integer
|
||||
type: object
|
||||
server.CreateKeyRequest:
|
||||
properties:
|
||||
expiresAt:
|
||||
format: int64
|
||||
type: integer
|
||||
instancePermissions:
|
||||
items:
|
||||
$ref: '#/definitions/server.InstancePermission'
|
||||
type: array
|
||||
name:
|
||||
type: string
|
||||
permissionMode:
|
||||
$ref: '#/definitions/auth.PermissionMode'
|
||||
type: object
|
||||
server.CreateKeyResponse:
|
||||
properties:
|
||||
created_at:
|
||||
type: integer
|
||||
enabled:
|
||||
type: boolean
|
||||
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.InstancePermission:
|
||||
properties:
|
||||
can_infer:
|
||||
type: boolean
|
||||
can_view_logs:
|
||||
type: boolean
|
||||
instance_id:
|
||||
type: integer
|
||||
type: object
|
||||
server.KeyPermissionResponse:
|
||||
properties:
|
||||
can_infer:
|
||||
type: boolean
|
||||
can_view_logs:
|
||||
type: boolean
|
||||
instance_id:
|
||||
type: integer
|
||||
instance_name:
|
||||
type: string
|
||||
type: object
|
||||
server.KeyResponse:
|
||||
properties:
|
||||
created_at:
|
||||
type: integer
|
||||
enabled:
|
||||
type: boolean
|
||||
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:
|
||||
properties:
|
||||
address:
|
||||
@@ -242,6 +347,156 @@ info:
|
||||
title: llamactl API
|
||||
version: "1.0"
|
||||
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:
|
||||
get:
|
||||
description: Returns a list of available devices for the llama server
|
||||
|
||||
12
go.mod
12
go.mod
@@ -5,8 +5,11 @@ go 1.24.5
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.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/swag v1.16.5
|
||||
golang.org/x/crypto v0.45.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -19,8 +22,9 @@ require (
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/swaggo/files v1.0.1 // indirect
|
||||
golang.org/x/mod v0.26.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/tools v0.35.0 // indirect
|
||||
golang.org/x/mod v0.29.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/tools v0.38.0 // indirect
|
||||
)
|
||||
|
||||
34
go.sum
34
go.sum
@@ -1,7 +1,7 @@
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
@@ -14,6 +14,8 @@ 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/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
|
||||
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
|
||||
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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
@@ -22,10 +24,14 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
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/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/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
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/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/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/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
@@ -39,25 +45,29 @@ github.com/swaggo/swag v1.16.5/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
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.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
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/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-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.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/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-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/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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -68,8 +78,8 @@ 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-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.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/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
|
||||
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
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
@@ -39,15 +40,20 @@ type BackendConfig struct {
|
||||
|
||||
// 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"`
|
||||
Auth AuthConfig `yaml:"auth" json:"auth"`
|
||||
LocalNode string `yaml:"local_node,omitempty" json:"local_node,omitempty"`
|
||||
Nodes map[string]NodeConfig `yaml:"nodes,omitempty" json:"nodes,omitempty"`
|
||||
Version string `yaml:"-" json:"version"`
|
||||
CommitHash string `yaml:"-" json:"commit_hash"`
|
||||
BuildTime string `yaml:"-" json:"build_time"`
|
||||
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
|
||||
@@ -71,18 +77,26 @@ type ServerConfig struct {
|
||||
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"`
|
||||
|
||||
// Directory where all llamactl data will be stored (instances.json, logs, etc.)
|
||||
DataDir string `yaml:"data_dir" json:"data_dir"`
|
||||
|
||||
// Instance config directory override
|
||||
// Instance config directory override (relative to data_dir if not absolute)
|
||||
InstancesDir string `yaml:"configs_dir" json:"configs_dir"`
|
||||
|
||||
// Logs directory override
|
||||
// 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
|
||||
@@ -143,6 +157,8 @@ type NodeConfig struct {
|
||||
// 3. Environment variables
|
||||
func LoadConfig(configPath string) (AppConfig, error) {
|
||||
// 1. Start with defaults
|
||||
defaultDataDir := getDefaultDataDirectory()
|
||||
|
||||
cfg := AppConfig{
|
||||
Server: ServerConfig{
|
||||
Host: "0.0.0.0",
|
||||
@@ -153,6 +169,7 @@ func LoadConfig(configPath string) (AppConfig, error) {
|
||||
},
|
||||
LocalNode: "main",
|
||||
Nodes: map[string]NodeConfig{},
|
||||
DataDir: defaultDataDir,
|
||||
Backends: BackendConfig{
|
||||
LlamaCpp: BackendSettings{
|
||||
Command: "llama-server",
|
||||
@@ -163,7 +180,7 @@ func LoadConfig(configPath string) (AppConfig, error) {
|
||||
Image: "ghcr.io/ggml-org/llama.cpp:server",
|
||||
Args: []string{
|
||||
"run", "--rm", "--network", "host", "--gpus", "all",
|
||||
"-v", filepath.Join(getDefaultDataDirectory(), "llama.cpp") + ":/root/.cache/llama.cpp"},
|
||||
"-v", filepath.Join(defaultDataDir, "llama.cpp") + ":/root/.cache/llama.cpp"},
|
||||
Environment: map[string]string{},
|
||||
},
|
||||
},
|
||||
@@ -175,7 +192,7 @@ func LoadConfig(configPath string) (AppConfig, error) {
|
||||
Image: "vllm/vllm-openai:latest",
|
||||
Args: []string{
|
||||
"run", "--rm", "--network", "host", "--gpus", "all", "--shm-size", "1g",
|
||||
"-v", filepath.Join(getDefaultDataDirectory(), "huggingface") + ":/root/.cache/huggingface",
|
||||
"-v", filepath.Join(defaultDataDir, "huggingface") + ":/root/.cache/huggingface",
|
||||
},
|
||||
Environment: map[string]string{},
|
||||
},
|
||||
@@ -188,7 +205,6 @@ func LoadConfig(configPath string) (AppConfig, error) {
|
||||
},
|
||||
Instances: InstancesConfig{
|
||||
PortRange: [2]int{8000, 9000},
|
||||
DataDir: getDefaultDataDirectory(),
|
||||
// NOTE: empty strings are set as placeholder values since InstancesDir and LogsDir
|
||||
// should be relative path to DataDir if not explicitly set.
|
||||
InstancesDir: "",
|
||||
@@ -204,6 +220,12 @@ func LoadConfig(configPath string) (AppConfig, error) {
|
||||
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{},
|
||||
@@ -225,12 +247,24 @@ func LoadConfig(configPath string) (AppConfig, error) {
|
||||
// 3. Override with environment variables
|
||||
loadEnvVars(&cfg)
|
||||
|
||||
// If InstancesDir or LogsDir is not set, set it to relative path of DataDir
|
||||
// Log warning if deprecated inference keys are present
|
||||
if len(cfg.Auth.InferenceKeys) > 0 {
|
||||
log.Println("⚠️ Config-based inference keys are no longer supported and will be ignored.")
|
||||
log.Println(" Please create inference keys in web UI or via management API.")
|
||||
}
|
||||
|
||||
// Set default directories if not specified
|
||||
if cfg.Instances.InstancesDir == "" {
|
||||
cfg.Instances.InstancesDir = filepath.Join(cfg.Instances.DataDir, "instances")
|
||||
cfg.Instances.InstancesDir = filepath.Join(cfg.DataDir, "instances")
|
||||
} else {
|
||||
// Log deprecation warning if using custom instances dir
|
||||
log.Println("⚠️ Instances directory is deprecated and will be removed in future versions. Instances are persisted in the database.")
|
||||
}
|
||||
if cfg.Instances.LogsDir == "" {
|
||||
cfg.Instances.LogsDir = filepath.Join(cfg.Instances.DataDir, "logs")
|
||||
cfg.Instances.LogsDir = filepath.Join(cfg.DataDir, "logs")
|
||||
}
|
||||
if cfg.Database.Path == "" {
|
||||
cfg.Database.Path = filepath.Join(cfg.DataDir, "llamactl.db")
|
||||
}
|
||||
|
||||
// Validate port range
|
||||
@@ -288,7 +322,7 @@ func loadEnvVars(cfg *AppConfig) {
|
||||
|
||||
// Data config
|
||||
if dataDir := os.Getenv("LLAMACTL_DATA_DIRECTORY"); dataDir != "" {
|
||||
cfg.Instances.DataDir = dataDir
|
||||
cfg.DataDir = dataDir
|
||||
}
|
||||
if instancesDir := os.Getenv("LLAMACTL_INSTANCES_DIR"); instancesDir != "" {
|
||||
cfg.Instances.InstancesDir = instancesDir
|
||||
@@ -495,6 +529,26 @@ func loadEnvVars(cfg *AppConfig) {
|
||||
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"
|
||||
|
||||
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
|
||||
}
|
||||
121
pkg/database/database.go
Normal file
121
pkg/database/database.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"llamactl/pkg/auth"
|
||||
"llamactl/pkg/instance"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// InstanceStore defines interface for instance persistence operations
|
||||
type InstanceStore interface {
|
||||
Save(inst *instance.Instance) error
|
||||
Delete(name string) error
|
||||
LoadAll() ([]*instance.Instance, 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
|
||||
type Config struct {
|
||||
// Database file path (relative to data_dir or absolute)
|
||||
Path string
|
||||
|
||||
// Connection settings
|
||||
MaxOpenConnections int
|
||||
MaxIdleConnections int
|
||||
ConnMaxLifetime time.Duration
|
||||
}
|
||||
|
||||
// sqliteDB wraps database connection with configuration
|
||||
type sqliteDB struct {
|
||||
*sql.DB
|
||||
config *Config
|
||||
}
|
||||
|
||||
// Open creates a new database connection with provided configuration
|
||||
func Open(config *Config) (*sqliteDB, error) {
|
||||
if config == nil {
|
||||
return nil, fmt.Errorf("database config cannot be nil")
|
||||
}
|
||||
|
||||
if config.Path == "" {
|
||||
return nil, fmt.Errorf("database path cannot be empty")
|
||||
}
|
||||
|
||||
// Ensure that database directory exists
|
||||
dbDir := filepath.Dir(config.Path)
|
||||
if dbDir != "." && dbDir != "/" {
|
||||
// Directory will be created by manager if auto_create_dirs is enabled
|
||||
log.Printf("Database will be created at: %s", config.Path)
|
||||
}
|
||||
|
||||
// Open SQLite database with proper options
|
||||
// - _journal_mode=WAL: Write-Ahead Logging for better concurrency
|
||||
// - _busy_timeout=5000: Wait up to 5 seconds if database is locked
|
||||
// - _foreign_keys=1: Enable foreign key constraints
|
||||
dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_busy_timeout=5000&_foreign_keys=1", config.Path)
|
||||
|
||||
sqlDB, err := sql.Open("sqlite3", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
// Configure connection pool
|
||||
if config.MaxOpenConnections > 0 {
|
||||
sqlDB.SetMaxOpenConns(config.MaxOpenConnections)
|
||||
}
|
||||
if config.MaxIdleConnections > 0 {
|
||||
sqlDB.SetMaxIdleConns(config.MaxIdleConnections)
|
||||
}
|
||||
if config.ConnMaxLifetime > 0 {
|
||||
sqlDB.SetConnMaxLifetime(config.ConnMaxLifetime)
|
||||
}
|
||||
|
||||
// Verify database connection
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
sqlDB.Close()
|
||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Database connection established: %s", config.Path)
|
||||
|
||||
return &sqliteDB{
|
||||
DB: sqlDB,
|
||||
config: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close closes database connection
|
||||
func (db *sqliteDB) Close() error {
|
||||
if db.DB != nil {
|
||||
log.Println("Closing database connection")
|
||||
return db.DB.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HealthCheck verifies that database is accessible
|
||||
func (db *sqliteDB) HealthCheck() error {
|
||||
if db.DB == nil {
|
||||
return fmt.Errorf("database connection is nil")
|
||||
}
|
||||
return db.DB.Ping()
|
||||
}
|
||||
319
pkg/database/instances.go
Normal file
319
pkg/database/instances.go
Normal file
@@ -0,0 +1,319 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"llamactl/pkg/instance"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// instanceRow represents a row in the instances table
|
||||
type instanceRow struct {
|
||||
ID int
|
||||
Name string
|
||||
Status string
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
OptionsJSON string
|
||||
OwnerUserID sql.NullString
|
||||
}
|
||||
|
||||
// Create inserts a new instance into the database
|
||||
func (db *sqliteDB) Create(ctx context.Context, inst *instance.Instance) error {
|
||||
if inst == nil {
|
||||
return fmt.Errorf("instance cannot be nil")
|
||||
}
|
||||
|
||||
opts := inst.GetOptions()
|
||||
if opts == nil {
|
||||
return fmt.Errorf("instance options cannot be nil")
|
||||
}
|
||||
|
||||
// Convert instance to database row
|
||||
row, err := db.instanceToRow(inst)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to convert instance to row: %w", err)
|
||||
}
|
||||
|
||||
// Insert into database
|
||||
query := `
|
||||
INSERT INTO instances (
|
||||
name, status, created_at, updated_at, options_json, owner_user_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
_, err = db.DB.ExecContext(ctx, query,
|
||||
row.Name, row.Status, row.CreatedAt, row.UpdatedAt, row.OptionsJSON, row.OwnerUserID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert instance: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByName retrieves an instance by name
|
||||
func (db *sqliteDB) GetByName(ctx context.Context, name string) (*instance.Instance, error) {
|
||||
query := `
|
||||
SELECT id, name, status, created_at, updated_at, options_json, owner_user_id
|
||||
FROM instances
|
||||
WHERE name = ?
|
||||
`
|
||||
|
||||
var row instanceRow
|
||||
err := db.DB.QueryRowContext(ctx, query, name).Scan(
|
||||
&row.ID, &row.Name, &row.Status, &row.CreatedAt, &row.UpdatedAt, &row.OptionsJSON, &row.OwnerUserID,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("instance not found: %s", name)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query instance: %w", err)
|
||||
}
|
||||
|
||||
return db.rowToInstance(&row)
|
||||
}
|
||||
|
||||
// GetAll retrieves all instances from the database
|
||||
func (db *sqliteDB) GetAll(ctx context.Context) ([]*instance.Instance, error) {
|
||||
query := `
|
||||
SELECT id, name, status, created_at, updated_at, options_json, owner_user_id
|
||||
FROM instances
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
||||
rows, err := db.DB.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query instances: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var instances []*instance.Instance
|
||||
for rows.Next() {
|
||||
var row instanceRow
|
||||
err := rows.Scan(
|
||||
&row.ID, &row.Name, &row.Status, &row.CreatedAt, &row.UpdatedAt, &row.OptionsJSON, &row.OwnerUserID,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Failed to scan instance row: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
inst, err := db.rowToInstance(&row)
|
||||
if err != nil {
|
||||
log.Printf("Failed to convert row to instance: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
instances = append(instances, inst)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating rows: %w", err)
|
||||
}
|
||||
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
// Update updates an existing instance
|
||||
func (db *sqliteDB) Update(ctx context.Context, inst *instance.Instance) error {
|
||||
if inst == nil {
|
||||
return fmt.Errorf("instance cannot be nil")
|
||||
}
|
||||
|
||||
opts := inst.GetOptions()
|
||||
if opts == nil {
|
||||
return fmt.Errorf("instance options cannot be nil")
|
||||
}
|
||||
|
||||
// Convert instance to database row
|
||||
row, err := db.instanceToRow(inst)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to convert instance to row: %w", err)
|
||||
}
|
||||
|
||||
// Update in database
|
||||
query := `
|
||||
UPDATE instances SET
|
||||
status = ?, updated_at = ?, options_json = ?
|
||||
WHERE name = ?
|
||||
`
|
||||
|
||||
result, err := db.DB.ExecContext(ctx, query,
|
||||
row.Status, row.UpdatedAt, row.OptionsJSON, row.Name,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update instance: %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("instance not found: %s", inst.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateStatus updates only the status of an instance (optimized operation)
|
||||
func (db *sqliteDB) UpdateStatus(ctx context.Context, name string, status instance.Status) error {
|
||||
// Convert status to string
|
||||
statusJSON, err := status.MarshalJSON()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal status: %w", err)
|
||||
}
|
||||
var statusStr string
|
||||
if err := json.Unmarshal(statusJSON, &statusStr); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal status string: %w", err)
|
||||
}
|
||||
|
||||
query := `
|
||||
UPDATE instances SET
|
||||
status = ?,
|
||||
updated_at = ?
|
||||
WHERE name = ?
|
||||
`
|
||||
|
||||
result, err := db.DB.ExecContext(ctx, query, statusStr, time.Now().Unix(), name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update instance status: %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("instance not found: %s", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteInstance removes an instance from the database
|
||||
func (db *sqliteDB) DeleteInstance(ctx context.Context, name string) error {
|
||||
query := `DELETE FROM instances WHERE name = ?`
|
||||
|
||||
result, err := db.DB.ExecContext(ctx, query, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete instance: %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("instance not found: %s", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// instanceToRow converts an Instance to a database row
|
||||
func (db *sqliteDB) instanceToRow(inst *instance.Instance) (*instanceRow, error) {
|
||||
opts := inst.GetOptions()
|
||||
if opts == nil {
|
||||
return nil, fmt.Errorf("instance options cannot be nil")
|
||||
}
|
||||
|
||||
// Marshal options to JSON using the existing MarshalJSON method
|
||||
optionsJSON, err := json.Marshal(opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal options: %w", err)
|
||||
}
|
||||
|
||||
// Convert status to string
|
||||
statusJSON, err := inst.GetStatus().MarshalJSON()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal status: %w", err)
|
||||
}
|
||||
var statusStr string
|
||||
if err := json.Unmarshal(statusJSON, &statusStr); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal status string: %w", err)
|
||||
}
|
||||
|
||||
return &instanceRow{
|
||||
Name: inst.Name,
|
||||
Status: statusStr,
|
||||
CreatedAt: inst.Created,
|
||||
UpdatedAt: time.Now().Unix(),
|
||||
OptionsJSON: string(optionsJSON),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// rowToInstance converts a database row to an Instance
|
||||
func (db *sqliteDB) rowToInstance(row *instanceRow) (*instance.Instance, error) {
|
||||
// Unmarshal options from JSON using the existing UnmarshalJSON method
|
||||
var opts instance.Options
|
||||
if err := json.Unmarshal([]byte(row.OptionsJSON), &opts); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal options: %w", err)
|
||||
}
|
||||
|
||||
// Build complete instance JSON with all fields
|
||||
instanceJSON, err := json.Marshal(map[string]any{
|
||||
"name": row.Name,
|
||||
"created": row.CreatedAt,
|
||||
"status": row.Status,
|
||||
"options": json.RawMessage(row.OptionsJSON),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal instance: %w", err)
|
||||
}
|
||||
|
||||
// Unmarshal into a complete Instance
|
||||
var inst instance.Instance
|
||||
if err := json.Unmarshal(instanceJSON, &inst); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal instance: %w", err)
|
||||
}
|
||||
|
||||
// The UnmarshalJSON doesn't handle BackendOptions and Nodes (they have json:"-" tags)
|
||||
// So we need to explicitly set the options again to ensure they're properly set
|
||||
inst.SetOptions(&opts)
|
||||
|
||||
return &inst, nil
|
||||
}
|
||||
|
||||
// Database interface implementation
|
||||
|
||||
// Save saves an instance to the database (insert or update)
|
||||
func (db *sqliteDB) Save(inst *instance.Instance) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Try to get existing instance
|
||||
existing, err := db.GetByName(ctx, inst.Name)
|
||||
if err != nil {
|
||||
// Instance doesn't exist, create it
|
||||
return db.Create(ctx, inst)
|
||||
}
|
||||
|
||||
// Instance exists, update it
|
||||
if existing != nil {
|
||||
return db.Update(ctx, inst)
|
||||
}
|
||||
|
||||
return db.Create(ctx, inst)
|
||||
}
|
||||
|
||||
// Delete removes an instance from the database
|
||||
func (db *sqliteDB) Delete(name string) error {
|
||||
ctx := context.Background()
|
||||
return db.DeleteInstance(ctx, name)
|
||||
}
|
||||
|
||||
// LoadAll loads all instances from the database
|
||||
func (db *sqliteDB) LoadAll() ([]*instance.Instance, error) {
|
||||
ctx := context.Background()
|
||||
return db.GetAll(ctx)
|
||||
}
|
||||
78
pkg/database/migrations.go
Normal file
78
pkg/database/migrations.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFiles embed.FS
|
||||
|
||||
// RunMigrations applies all pending database migrations
|
||||
func RunMigrations(db *sqliteDB) error {
|
||||
if db == nil || db.DB == nil {
|
||||
return fmt.Errorf("database connection is nil")
|
||||
}
|
||||
|
||||
// Create migration source from embedded files
|
||||
sourceDriver, err := iofs.New(migrationFiles, "migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migration source: %w", err)
|
||||
}
|
||||
|
||||
// Create database driver for migrations
|
||||
dbDriver, err := sqlite3.WithInstance(db.DB, &sqlite3.Config{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create database driver: %w", err)
|
||||
}
|
||||
|
||||
// Create migrator
|
||||
migrator, err := migrate.NewWithInstance("iofs", sourceDriver, "sqlite3", dbDriver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migrator: %w", err)
|
||||
}
|
||||
|
||||
// Get current version
|
||||
currentVersion, dirty, err := migrator.Version()
|
||||
if err != nil && err != migrate.ErrNilVersion {
|
||||
return fmt.Errorf("failed to get current migration version: %w", err)
|
||||
}
|
||||
|
||||
if dirty {
|
||||
return fmt.Errorf("database is in dirty state at version %d - manual intervention required", currentVersion)
|
||||
}
|
||||
|
||||
// Run migrations
|
||||
log.Printf("Running database migrations (current version: %v)", currentVersionString(currentVersion, err))
|
||||
|
||||
if err := migrator.Up(); err != nil {
|
||||
if err == migrate.ErrNoChange {
|
||||
log.Println("Database schema is up to date")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to run migrations: %w", err)
|
||||
}
|
||||
|
||||
// Get new version
|
||||
newVersion, _, err := migrator.Version()
|
||||
if err != nil {
|
||||
log.Printf("Migrations completed (unable to determine new version: %v)", err)
|
||||
} else {
|
||||
log.Printf("Migrations completed successfully (new version: %d)", newVersion)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// currentVersionString returns a string representation of the current version
|
||||
func currentVersionString(version uint, err error) string {
|
||||
if err == migrate.ErrNilVersion {
|
||||
return "none"
|
||||
}
|
||||
return fmt.Sprintf("%d", version)
|
||||
}
|
||||
11
pkg/database/migrations/001_initial_schema.down.sql
Normal file
11
pkg/database/migrations/001_initial_schema.down.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Drop API key related indexes and tables first
|
||||
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_name;
|
||||
DROP TABLE IF EXISTS instances;
|
||||
60
pkg/database/migrations/001_initial_schema.up.sql
Normal file
60
pkg/database/migrations/001_initial_schema.up.sql
Normal file
@@ -0,0 +1,60 @@
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Instances Table: Central configuration and state for LLM backends
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS instances (
|
||||
-- Primary identification
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
|
||||
-- Instance state
|
||||
status TEXT NOT NULL CHECK(status IN ('stopped', 'running', 'failed', 'restarting', 'shutting_down')) DEFAULT 'stopped',
|
||||
|
||||
-- Timestamps (created_at stored as Unix timestamp for compatibility with existing JSON format)
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
|
||||
-- All instance options stored as a single JSON blob
|
||||
options_json TEXT NOT NULL,
|
||||
|
||||
-- Future: OIDC user ID for ownership
|
||||
owner_user_id TEXT NULL
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Indexes for performance
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_instances_name ON instances(name);
|
||||
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
|
||||
}
|
||||
@@ -9,10 +9,11 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Instance represents a running instance of the llama server
|
||||
// Instance represents a running instance of llama server
|
||||
type Instance struct {
|
||||
ID int `json:"id"`
|
||||
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
|
||||
globalInstanceSettings *config.InstancesConfig
|
||||
@@ -48,6 +49,7 @@ func New(name string, globalConfig *config.AppConfig, opts *Options, onStatusCha
|
||||
options := newOptions(opts)
|
||||
|
||||
instance := &Instance{
|
||||
ID: 0, // Will be set by database
|
||||
Name: name,
|
||||
options: options,
|
||||
globalInstanceSettings: globalInstanceSettings,
|
||||
@@ -279,11 +281,13 @@ func (i *Instance) buildEnvironment() map[string]string {
|
||||
// MarshalJSON implements json.Marshaler for Instance
|
||||
func (i *Instance) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(&struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status *status `json:"status"`
|
||||
Created int64 `json:"created,omitempty"`
|
||||
Options *options `json:"options,omitempty"`
|
||||
}{
|
||||
ID: i.ID,
|
||||
Name: i.Name,
|
||||
Status: i.status,
|
||||
Created: i.Created,
|
||||
@@ -295,6 +299,7 @@ func (i *Instance) MarshalJSON() ([]byte, error) {
|
||||
func (i *Instance) UnmarshalJSON(data []byte) error {
|
||||
// Explicitly deserialize to match MarshalJSON format
|
||||
aux := &struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status *status `json:"status"`
|
||||
Created int64 `json:"created,omitempty"`
|
||||
@@ -306,6 +311,7 @@ func (i *Instance) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
// Set the fields
|
||||
i.ID = aux.ID
|
||||
i.Name = aux.Name
|
||||
i.Created = aux.Created
|
||||
i.status = aux.Status
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"llamactl/pkg/config"
|
||||
"llamactl/pkg/database"
|
||||
"llamactl/pkg/instance"
|
||||
"log"
|
||||
"sync"
|
||||
@@ -28,11 +29,11 @@ type InstanceManager interface {
|
||||
|
||||
type instanceManager struct {
|
||||
// Components (each with own synchronization)
|
||||
registry *instanceRegistry
|
||||
ports *portAllocator
|
||||
persistence *instancePersister
|
||||
remote *remoteManager
|
||||
lifecycle *lifecycleManager
|
||||
registry *instanceRegistry
|
||||
ports *portAllocator
|
||||
db database.InstanceStore
|
||||
remote *remoteManager
|
||||
lifecycle *lifecycleManager
|
||||
|
||||
// Configuration
|
||||
globalConfig *config.AppConfig
|
||||
@@ -42,8 +43,8 @@ type instanceManager struct {
|
||||
shutdownOnce sync.Once
|
||||
}
|
||||
|
||||
// New creates a new instance of InstanceManager.
|
||||
func New(globalConfig *config.AppConfig) InstanceManager {
|
||||
// New creates a new instance of InstanceManager with dependency injection.
|
||||
func New(globalConfig *config.AppConfig, db database.InstanceStore) InstanceManager {
|
||||
|
||||
if globalConfig.Instances.TimeoutCheckInterval <= 0 {
|
||||
globalConfig.Instances.TimeoutCheckInterval = 5 // Default to 5 minutes if not set
|
||||
@@ -56,9 +57,6 @@ func New(globalConfig *config.AppConfig) InstanceManager {
|
||||
portRange := globalConfig.Instances.PortRange
|
||||
ports := newPortAllocator(portRange[0], portRange[1])
|
||||
|
||||
// Initialize persistence
|
||||
persistence := newInstancePersister(globalConfig.Instances.InstancesDir)
|
||||
|
||||
// Initialize remote manager
|
||||
remote := newRemoteManager(globalConfig.Nodes, 30*time.Second)
|
||||
|
||||
@@ -66,7 +64,7 @@ func New(globalConfig *config.AppConfig) InstanceManager {
|
||||
im := &instanceManager{
|
||||
registry: registry,
|
||||
ports: ports,
|
||||
persistence: persistence,
|
||||
db: db,
|
||||
remote: remote,
|
||||
globalConfig: globalConfig,
|
||||
}
|
||||
@@ -86,9 +84,9 @@ func New(globalConfig *config.AppConfig) InstanceManager {
|
||||
return im
|
||||
}
|
||||
|
||||
// persistInstance saves an instance using the persistence component
|
||||
// persistInstance saves an instance using the persistence layer
|
||||
func (im *instanceManager) persistInstance(inst *instance.Instance) error {
|
||||
return im.persistence.save(inst)
|
||||
return im.db.Save(inst)
|
||||
}
|
||||
|
||||
func (im *instanceManager) Shutdown() {
|
||||
@@ -116,13 +114,18 @@ func (im *instanceManager) Shutdown() {
|
||||
}
|
||||
wg.Wait()
|
||||
fmt.Println("All instances stopped.")
|
||||
|
||||
// 4. Close database connection
|
||||
if err := im.db.Close(); err != nil {
|
||||
log.Printf("Error closing database: %v\n", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// loadInstances restores all instances from disk using the persistence component
|
||||
// loadInstances restores all instances from the persistence layer
|
||||
func (im *instanceManager) loadInstances() error {
|
||||
// Load all instances from persistence
|
||||
instances, err := im.persistence.loadAll()
|
||||
instances, err := im.db.LoadAll()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load instances: %w", err)
|
||||
}
|
||||
@@ -256,7 +259,7 @@ func (im *instanceManager) autoStartInstances() {
|
||||
}
|
||||
}
|
||||
|
||||
func (im *instanceManager) onStatusChange(name string, oldStatus, newStatus instance.Status) {
|
||||
func (im *instanceManager) onStatusChange(name string, _, newStatus instance.Status) {
|
||||
if newStatus == instance.Running {
|
||||
im.registry.markRunning(name)
|
||||
} else {
|
||||
|
||||
@@ -4,20 +4,34 @@ import (
|
||||
"fmt"
|
||||
"llamactl/pkg/backends"
|
||||
"llamactl/pkg/config"
|
||||
"llamactl/pkg/database"
|
||||
"llamactl/pkg/instance"
|
||||
"llamactl/pkg/manager"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestManager_PersistsAndLoadsInstances(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
appConfig := createTestAppConfig(tempDir)
|
||||
// Use file-based database for this test since we need to persist across connections
|
||||
appConfig.Database.Path = tempDir + "/test.db"
|
||||
|
||||
// Create instance and check file was created
|
||||
manager1 := manager.New(appConfig)
|
||||
// Create instance and check database was created
|
||||
db1, err := database.Open(&database.Config{
|
||||
Path: appConfig.Database.Path,
|
||||
MaxOpenConnections: appConfig.Database.MaxOpenConnections,
|
||||
MaxIdleConnections: appConfig.Database.MaxIdleConnections,
|
||||
ConnMaxLifetime: appConfig.Database.ConnMaxLifetime,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open database: %v", err)
|
||||
}
|
||||
if err := database.RunMigrations(db1); err != nil {
|
||||
t.Fatalf("Failed to run migrations: %v", err)
|
||||
}
|
||||
manager1 := manager.New(appConfig, db1)
|
||||
options := &instance.Options{
|
||||
BackendOptions: backends.Options{
|
||||
BackendType: backends.BackendTypeLlamaCpp,
|
||||
@@ -28,18 +42,28 @@ func TestManager_PersistsAndLoadsInstances(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
_, err := manager1.CreateInstance("test-instance", options)
|
||||
_, err = manager1.CreateInstance("test-instance", options)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateInstance failed: %v", err)
|
||||
}
|
||||
|
||||
expectedPath := filepath.Join(tempDir, "test-instance.json")
|
||||
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
|
||||
t.Errorf("Expected persistence file %s to exist", expectedPath)
|
||||
}
|
||||
// Shutdown first manager to close database connection
|
||||
manager1.Shutdown()
|
||||
|
||||
// Load instances from disk
|
||||
manager2 := manager.New(appConfig)
|
||||
// Load instances from database
|
||||
db2, err := database.Open(&database.Config{
|
||||
Path: appConfig.Database.Path,
|
||||
MaxOpenConnections: appConfig.Database.MaxOpenConnections,
|
||||
MaxIdleConnections: appConfig.Database.MaxIdleConnections,
|
||||
ConnMaxLifetime: appConfig.Database.ConnMaxLifetime,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open database: %v", err)
|
||||
}
|
||||
if err := database.RunMigrations(db2); err != nil {
|
||||
t.Fatalf("Failed to run migrations: %v", err)
|
||||
}
|
||||
manager2 := manager.New(appConfig, db2)
|
||||
instances, err := manager2.ListInstances()
|
||||
if err != nil {
|
||||
t.Fatalf("ListInstances failed: %v", err)
|
||||
@@ -50,13 +74,29 @@ func TestManager_PersistsAndLoadsInstances(t *testing.T) {
|
||||
if instances[0].Name != "test-instance" {
|
||||
t.Errorf("Expected loaded instance name 'test-instance', got %q", instances[0].Name)
|
||||
}
|
||||
|
||||
manager2.Shutdown()
|
||||
}
|
||||
|
||||
func TestDeleteInstance_RemovesPersistenceFile(t *testing.T) {
|
||||
func TestDeleteInstance_RemovesFromDatabase(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
appConfig := createTestAppConfig(tempDir)
|
||||
|
||||
mgr := manager.New(appConfig)
|
||||
db, err := database.Open(&database.Config{
|
||||
Path: appConfig.Database.Path,
|
||||
MaxOpenConnections: appConfig.Database.MaxOpenConnections,
|
||||
MaxIdleConnections: appConfig.Database.MaxIdleConnections,
|
||||
ConnMaxLifetime: appConfig.Database.ConnMaxLifetime,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open database: %v", err)
|
||||
}
|
||||
if err := database.RunMigrations(db); err != nil {
|
||||
t.Fatalf("Failed to run migrations: %v", err)
|
||||
}
|
||||
mgr := manager.New(appConfig, db)
|
||||
defer mgr.Shutdown()
|
||||
|
||||
options := &instance.Options{
|
||||
BackendOptions: backends.Options{
|
||||
BackendType: backends.BackendTypeLlamaCpp,
|
||||
@@ -67,20 +107,33 @@ func TestDeleteInstance_RemovesPersistenceFile(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
_, err := mgr.CreateInstance("test-instance", options)
|
||||
_, err = mgr.CreateInstance("test-instance", options)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateInstance failed: %v", err)
|
||||
}
|
||||
|
||||
expectedPath := filepath.Join(tempDir, "test-instance.json")
|
||||
// Verify instance exists
|
||||
instances, err := mgr.ListInstances()
|
||||
if err != nil {
|
||||
t.Fatalf("ListInstances failed: %v", err)
|
||||
}
|
||||
if len(instances) != 1 {
|
||||
t.Fatalf("Expected 1 instance, got %d", len(instances))
|
||||
}
|
||||
|
||||
// Delete instance
|
||||
err = mgr.DeleteInstance("test-instance")
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteInstance failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(expectedPath); !os.IsNotExist(err) {
|
||||
t.Error("Expected persistence file to be deleted")
|
||||
// Verify instance was deleted from database
|
||||
instances, err = mgr.ListInstances()
|
||||
if err != nil {
|
||||
t.Fatalf("ListInstances failed: %v", err)
|
||||
}
|
||||
if len(instances) != 0 {
|
||||
t.Errorf("Expected 0 instances after deletion, got %d", len(instances))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +211,12 @@ func createTestAppConfig(instancesDir string) *config.AppConfig {
|
||||
DefaultRestartDelay: 5,
|
||||
TimeoutCheckInterval: 5,
|
||||
},
|
||||
Database: config.DatabaseConfig{
|
||||
Path: ":memory:",
|
||||
MaxOpenConnections: 25,
|
||||
MaxIdleConnections: 5,
|
||||
ConnMaxLifetime: 5 * time.Minute,
|
||||
},
|
||||
LocalNode: "main",
|
||||
Nodes: map[string]config.NodeConfig{},
|
||||
}
|
||||
@@ -166,5 +225,17 @@ func createTestAppConfig(instancesDir string) *config.AppConfig {
|
||||
func createTestManager(t *testing.T) manager.InstanceManager {
|
||||
tempDir := t.TempDir()
|
||||
appConfig := createTestAppConfig(tempDir)
|
||||
return manager.New(appConfig)
|
||||
db, err := database.Open(&database.Config{
|
||||
Path: appConfig.Database.Path,
|
||||
MaxOpenConnections: appConfig.Database.MaxOpenConnections,
|
||||
MaxIdleConnections: appConfig.Database.MaxIdleConnections,
|
||||
ConnMaxLifetime: appConfig.Database.ConnMaxLifetime,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open database: %v", err)
|
||||
}
|
||||
if err := database.RunMigrations(db); err != nil {
|
||||
t.Fatalf("Failed to run migrations: %v", err)
|
||||
}
|
||||
return manager.New(appConfig, db)
|
||||
}
|
||||
|
||||
@@ -317,9 +317,9 @@ func (im *instanceManager) DeleteInstance(name string) error {
|
||||
im.remote.removeInstance(name)
|
||||
im.registry.remove(name)
|
||||
|
||||
// Delete the instance's persistence file
|
||||
if err := im.persistence.delete(name); err != nil {
|
||||
return fmt.Errorf("failed to delete config file for remote instance %s: %w", name, err)
|
||||
// Delete the instance's persistence
|
||||
if err := im.db.Delete(name); err != nil {
|
||||
return fmt.Errorf("failed to delete remote instance %s: %w", name, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -343,9 +343,9 @@ func (im *instanceManager) DeleteInstance(name string) error {
|
||||
return fmt.Errorf("failed to remove instance from registry: %w", err)
|
||||
}
|
||||
|
||||
// Delete persistence file
|
||||
if err := im.persistence.delete(name); err != nil {
|
||||
return fmt.Errorf("failed to delete config file for instance %s: %w", name, err)
|
||||
// Delete from persistence
|
||||
if err := im.db.Delete(name); err != nil {
|
||||
return fmt.Errorf("failed to delete instance from persistence %s: %w", name, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -3,10 +3,12 @@ package manager_test
|
||||
import (
|
||||
"llamactl/pkg/backends"
|
||||
"llamactl/pkg/config"
|
||||
"llamactl/pkg/database"
|
||||
"llamactl/pkg/instance"
|
||||
"llamactl/pkg/manager"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCreateInstance_FailsWithDuplicateName(t *testing.T) {
|
||||
@@ -49,10 +51,28 @@ func TestCreateInstance_FailsWhenMaxInstancesReached(t *testing.T) {
|
||||
MaxInstances: 1, // Very low limit for testing
|
||||
TimeoutCheckInterval: 5,
|
||||
},
|
||||
Database: config.DatabaseConfig{
|
||||
Path: ":memory:",
|
||||
MaxOpenConnections: 25,
|
||||
MaxIdleConnections: 5,
|
||||
ConnMaxLifetime: 5 * time.Minute,
|
||||
},
|
||||
LocalNode: "main",
|
||||
Nodes: map[string]config.NodeConfig{},
|
||||
}
|
||||
limitedManager := manager.New(appConfig)
|
||||
db, err := database.Open(&database.Config{
|
||||
Path: appConfig.Database.Path,
|
||||
MaxOpenConnections: appConfig.Database.MaxOpenConnections,
|
||||
MaxIdleConnections: appConfig.Database.MaxIdleConnections,
|
||||
ConnMaxLifetime: appConfig.Database.ConnMaxLifetime,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open database: %v", err)
|
||||
}
|
||||
if err := database.RunMigrations(db); err != nil {
|
||||
t.Fatalf("Failed to run migrations: %v", err)
|
||||
}
|
||||
limitedManager := manager.New(appConfig, db)
|
||||
|
||||
options := &instance.Options{
|
||||
BackendOptions: backends.Options{
|
||||
@@ -63,7 +83,7 @@ func TestCreateInstance_FailsWhenMaxInstancesReached(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
_, err := limitedManager.CreateInstance("instance1", options)
|
||||
_, err = limitedManager.CreateInstance("instance1", options)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateInstance 1 failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"llamactl/pkg/instance"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// instancePersister provides atomic file-based persistence with durability guarantees.
|
||||
type instancePersister struct {
|
||||
mu sync.Mutex
|
||||
instancesDir string
|
||||
}
|
||||
|
||||
// newInstancePersister creates a new instance persister.
|
||||
// If instancesDir is empty, persistence is disabled.
|
||||
func newInstancePersister(instancesDir string) *instancePersister {
|
||||
return &instancePersister{
|
||||
instancesDir: instancesDir,
|
||||
}
|
||||
}
|
||||
|
||||
// Save persists an instance to disk with atomic write
|
||||
func (p *instancePersister) save(inst *instance.Instance) error {
|
||||
if inst == nil {
|
||||
return fmt.Errorf("cannot save nil instance")
|
||||
}
|
||||
|
||||
// Validate instance name to prevent path traversal
|
||||
validatedName, err := p.validateInstanceName(inst.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
instancePath := filepath.Join(p.instancesDir, validatedName+".json")
|
||||
tempPath := instancePath + ".tmp"
|
||||
|
||||
// Serialize instance to JSON
|
||||
jsonData, err := json.MarshalIndent(inst, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal instance %s: %w", inst.Name, err)
|
||||
}
|
||||
|
||||
// Create temporary file
|
||||
tempFile, err := os.OpenFile(tempPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file for instance %s: %w", inst.Name, err)
|
||||
}
|
||||
|
||||
// Write data to temporary file
|
||||
if _, err := tempFile.Write(jsonData); err != nil {
|
||||
tempFile.Close()
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("failed to write temp file for instance %s: %w", inst.Name, err)
|
||||
}
|
||||
|
||||
// Sync to disk before rename to ensure durability
|
||||
if err := tempFile.Sync(); err != nil {
|
||||
tempFile.Close()
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("failed to sync temp file for instance %s: %w", inst.Name, err)
|
||||
}
|
||||
|
||||
// Close the file
|
||||
if err := tempFile.Close(); err != nil {
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("failed to close temp file for instance %s: %w", inst.Name, err)
|
||||
}
|
||||
|
||||
// Atomic rename (this is atomic on POSIX systems)
|
||||
if err := os.Rename(tempPath, instancePath); err != nil {
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("failed to rename temp file for instance %s: %w", inst.Name, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes an instance's persistence file from disk.
|
||||
func (p *instancePersister) delete(name string) error {
|
||||
validatedName, err := p.validateInstanceName(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
instancePath := filepath.Join(p.instancesDir, validatedName+".json")
|
||||
|
||||
if err := os.Remove(instancePath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Not an error if file doesn't exist
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to delete instance file for %s: %w", name, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAll loads all persisted instances from disk.
|
||||
// Returns a slice of instances and any errors encountered during loading.
|
||||
func (p *instancePersister) loadAll() ([]*instance.Instance, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Check if instances directory exists
|
||||
if _, err := os.Stat(p.instancesDir); os.IsNotExist(err) {
|
||||
return nil, nil // No instances directory, return empty list
|
||||
}
|
||||
|
||||
// Read all JSON files from instances directory
|
||||
files, err := os.ReadDir(p.instancesDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read instances directory: %w", err)
|
||||
}
|
||||
|
||||
instances := make([]*instance.Instance, 0)
|
||||
var loadErrors []string
|
||||
|
||||
for _, file := range files {
|
||||
if file.IsDir() || !strings.HasSuffix(file.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
|
||||
instanceName := strings.TrimSuffix(file.Name(), ".json")
|
||||
instancePath := filepath.Join(p.instancesDir, file.Name())
|
||||
|
||||
inst, err := p.loadInstanceFile(instanceName, instancePath)
|
||||
if err != nil {
|
||||
log.Printf("Failed to load instance %s: %v", instanceName, err)
|
||||
loadErrors = append(loadErrors, fmt.Sprintf("%s: %v", instanceName, err))
|
||||
continue
|
||||
}
|
||||
|
||||
instances = append(instances, inst)
|
||||
}
|
||||
|
||||
if len(loadErrors) > 0 {
|
||||
log.Printf("Loaded %d instances with %d errors", len(instances), len(loadErrors))
|
||||
} else if len(instances) > 0 {
|
||||
log.Printf("Loaded %d instances from persistence", len(instances))
|
||||
}
|
||||
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
// loadInstanceFile is an internal helper that loads a single instance file.
|
||||
// Note: This assumes the mutex is already held by the caller.
|
||||
func (p *instancePersister) loadInstanceFile(name, path string) (*instance.Instance, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read instance file: %w", err)
|
||||
}
|
||||
|
||||
var inst instance.Instance
|
||||
if err := json.Unmarshal(data, &inst); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal instance: %w", err)
|
||||
}
|
||||
|
||||
// Validate the instance name matches the filename
|
||||
if inst.Name != name {
|
||||
return nil, fmt.Errorf("instance name mismatch: file=%s, instance.Name=%s", name, inst.Name)
|
||||
}
|
||||
|
||||
return &inst, nil
|
||||
}
|
||||
|
||||
// validateInstanceName ensures the instance name is safe for filesystem operations.
|
||||
// Returns the validated name if valid, or an error if invalid.
|
||||
func (p *instancePersister) validateInstanceName(name string) (string, error) {
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("instance name cannot be empty")
|
||||
}
|
||||
|
||||
// Check for path separators and parent directory references
|
||||
// This prevents path traversal attacks
|
||||
if strings.Contains(name, "/") || strings.Contains(name, "\\") || strings.Contains(name, "..") {
|
||||
return "", fmt.Errorf("invalid instance name: %s (cannot contain path separators or '..')", name)
|
||||
}
|
||||
|
||||
// Additional check: ensure the name doesn't start with a dot (hidden files)
|
||||
// or contain any other suspicious characters
|
||||
if strings.HasPrefix(name, ".") {
|
||||
return "", fmt.Errorf("invalid instance name: %s (cannot start with '.')", name)
|
||||
}
|
||||
|
||||
return name, nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"llamactl/pkg/config"
|
||||
"llamactl/pkg/database"
|
||||
"llamactl/pkg/instance"
|
||||
"llamactl/pkg/manager"
|
||||
"llamactl/pkg/validation"
|
||||
@@ -52,20 +53,25 @@ type Handler struct {
|
||||
InstanceManager manager.InstanceManager
|
||||
cfg config.AppConfig
|
||||
httpClient *http.Client
|
||||
authStore database.AuthStore
|
||||
authMiddleware *APIAuthMiddleware
|
||||
}
|
||||
|
||||
// NewHandler creates a new Handler instance with the provided instance manager and configuration
|
||||
func NewHandler(im manager.InstanceManager, cfg config.AppConfig) *Handler {
|
||||
return &Handler{
|
||||
func NewHandler(im manager.InstanceManager, cfg config.AppConfig, authStore database.AuthStore) *Handler {
|
||||
handler := &Handler{
|
||||
InstanceManager: im,
|
||||
cfg: cfg,
|
||||
httpClient: &http.Client{
|
||||
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) {
|
||||
name := chi.URLParam(r, "name")
|
||||
validatedName, err := validation.ValidateInstanceName(name)
|
||||
@@ -81,7 +87,7 @@ func (h *Handler) getInstance(r *http.Request) (*instance.Instance, error) {
|
||||
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
|
||||
func (h *Handler) ensureInstanceRunning(inst *instance.Instance) error {
|
||||
options := inst.GetOptions()
|
||||
|
||||
359
pkg/server/handlers_auth.go
Normal file
359
pkg/server/handlers_auth.go
Normal file
@@ -0,0 +1,359 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"llamactl/pkg/auth"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// InstancePermission defines the permissions for an API key on a specific instance.
|
||||
type InstancePermission struct {
|
||||
InstanceID int `json:"instance_id"`
|
||||
}
|
||||
|
||||
// CreateKeyRequest represents the request body for creating a new API key.
|
||||
type CreateKeyRequest struct {
|
||||
Name string
|
||||
PermissionMode auth.PermissionMode
|
||||
ExpiresAt *int64
|
||||
InstancePermissions []InstancePermission
|
||||
}
|
||||
|
||||
// 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.InstancePermissions) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "missing_permissions", "Instance permissions 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 _, perm := range req.InstancePermissions {
|
||||
if !instanceIDMap[perm.InstanceID] {
|
||||
writeError(w, http.StatusBadRequest, "invalid_instance_id", fmt.Sprintf("Instance ID %d does not exist", perm.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 InstancePermissions to KeyPermissions
|
||||
var keyPermissions []auth.KeyPermission
|
||||
for _, perm := range req.InstancePermissions {
|
||||
keyPermissions = append(keyPermissions, auth.KeyPermission{
|
||||
KeyID: 0, // Will be set by database after key creation
|
||||
InstanceID: perm.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
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
@@ -327,6 +327,12 @@ func (h *Handler) InstanceProxy() http.HandlerFunc {
|
||||
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() {
|
||||
writeError(w, http.StatusServiceUnavailable, "instance_not_running", "Instance is not running")
|
||||
return
|
||||
|
||||
@@ -107,6 +107,12 @@ func (h *Handler) OpenAIProxy() http.HandlerFunc {
|
||||
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")
|
||||
|
||||
@@ -1,107 +1,76 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"llamactl/pkg/auth"
|
||||
"llamactl/pkg/config"
|
||||
"llamactl/pkg/database"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type KeyType int
|
||||
// contextKey is a custom type for context keys to avoid collisions
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
KeyTypeInference KeyType = iota
|
||||
KeyTypeManagement
|
||||
apiKeyContextKey contextKey = "apiKey"
|
||||
)
|
||||
|
||||
type APIAuthMiddleware struct {
|
||||
authStore database.AuthStore
|
||||
requireInferenceAuth bool
|
||||
inferenceKeys map[string]bool
|
||||
requireManagementAuth bool
|
||||
managementKeys map[string]bool
|
||||
managementKeys map[string]bool // Config-based management keys
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
inferenceAPIKeys := make(map[string]bool)
|
||||
managementAPIKeys := make(map[string]bool)
|
||||
|
||||
const banner = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
if authCfg.RequireManagementAuth && len(authCfg.ManagementKeys) == 0 {
|
||||
key := generateAPIKey(KeyTypeManagement)
|
||||
managementAPIKeys[key] = true
|
||||
key, err := auth.GenerateKey("llamactl-mgmt")
|
||||
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
|
||||
fmt.Printf("%s\n⚠️ MANAGEMENT AUTHENTICATION REQUIRED\n%s\n", banner, banner)
|
||||
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 {
|
||||
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("• Copy these keys before they disappear from the terminal")
|
||||
fmt.Println("• Copy this key before it disappears from the terminal")
|
||||
fmt.Println(banner)
|
||||
}
|
||||
|
||||
return &APIAuthMiddleware{
|
||||
authStore: authStore,
|
||||
requireInferenceAuth: authCfg.RequireInferenceAuth,
|
||||
inferenceKeys: inferenceAPIKeys,
|
||||
requireManagementAuth: authCfg.RequireManagementAuth,
|
||||
managementKeys: managementAPIKeys,
|
||||
managementKeys: managementKeys,
|
||||
}
|
||||
}
|
||||
|
||||
// generateAPIKey creates a cryptographically secure API key
|
||||
func generateAPIKey(keyType KeyType) string {
|
||||
// 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 {
|
||||
// InferenceAuthMiddleware returns middleware for inference endpoints
|
||||
func (a *APIAuthMiddleware) InferenceAuthMiddleware() 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" {
|
||||
@@ -109,24 +78,74 @@ func (a *APIAuthMiddleware) AuthMiddleware(keyType KeyType) func(http.Handler) h
|
||||
return
|
||||
}
|
||||
|
||||
// Extract API key from request
|
||||
apiKey := a.extractAPIKey(r)
|
||||
if apiKey == "" {
|
||||
a.unauthorized(w, "Missing API key")
|
||||
return
|
||||
}
|
||||
|
||||
var isValid bool
|
||||
switch keyType {
|
||||
case KeyTypeInference:
|
||||
// Management keys also work for OpenAI endpoints (higher privilege)
|
||||
isValid = a.isValidKey(apiKey, KeyTypeInference) || a.isValidKey(apiKey, KeyTypeManagement)
|
||||
case KeyTypeManagement:
|
||||
isValid = a.isValidKey(apiKey, KeyTypeManagement)
|
||||
default:
|
||||
isValid = false
|
||||
// Try database authentication first
|
||||
var foundKey *auth.APIKey
|
||||
if a.requireInferenceAuth && a.authStore != nil {
|
||||
activeKeys, err := a.authStore.GetActiveKeys(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("Failed to get active inference keys: %v", err)
|
||||
// Continue to management key fallback
|
||||
} else {
|
||||
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")
|
||||
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
|
||||
func (a *APIAuthMiddleware) extractAPIKey(r *http.Request) string {
|
||||
// Check Authorization header: "Bearer sk-..."
|
||||
@@ -158,20 +204,9 @@ func (a *APIAuthMiddleware) extractAPIKey(r *http.Request) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// isValidKey checks if the provided API key is valid for the given key type
|
||||
func (a *APIAuthMiddleware) isValidKey(providedKey string, keyType KeyType) bool {
|
||||
var validKeys map[string]bool
|
||||
|
||||
switch keyType {
|
||||
case KeyTypeInference:
|
||||
validKeys = a.inferenceKeys
|
||||
case KeyTypeManagement:
|
||||
validKeys = a.managementKeys
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
for validKey := range validKeys {
|
||||
// isValidManagementKey checks if the provided API key is a valid management key
|
||||
func (a *APIAuthMiddleware) isValidManagementKey(providedKey string) bool {
|
||||
for validKey := range a.managementKeys {
|
||||
if len(providedKey) == len(validKey) &&
|
||||
subtle.ConstantTimeCompare([]byte(providedKey), []byte(validKey)) == 1 {
|
||||
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)
|
||||
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"
|
||||
)
|
||||
|
||||
func TestAuthMiddleware(t *testing.T) {
|
||||
func TestInferenceAuthMiddleware(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
keyType server.KeyType
|
||||
inferenceKeys []string
|
||||
managementKeys []string
|
||||
requestKey string
|
||||
method string
|
||||
expectedStatus int
|
||||
}{
|
||||
// Valid key tests
|
||||
{
|
||||
name: "valid inference 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,
|
||||
name: "valid management key for inference",
|
||||
managementKeys: []string{"sk-management-admin123"},
|
||||
requestKey: "sk-management-admin123",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "valid management key for management",
|
||||
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,
|
||||
name: "invalid key",
|
||||
managementKeys: []string{"sk-management-valid123"},
|
||||
requestKey: "sk-management-invalid",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "missing management key",
|
||||
keyType: server.KeyTypeManagement,
|
||||
name: "missing key",
|
||||
managementKeys: []string{"sk-management-valid123"},
|
||||
requestKey: "",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
},
|
||||
|
||||
// OPTIONS requests should always pass
|
||||
{
|
||||
name: "OPTIONS request bypasses inference auth",
|
||||
keyType: server.KeyTypeInference,
|
||||
inferenceKeys: []string{"sk-inference-valid123"},
|
||||
requestKey: "",
|
||||
method: "OPTIONS",
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "OPTIONS request bypasses management auth",
|
||||
keyType: server.KeyTypeManagement,
|
||||
name: "OPTIONS request bypasses auth",
|
||||
managementKeys: []string{"sk-management-valid123"},
|
||||
requestKey: "",
|
||||
method: "OPTIONS",
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
|
||||
// Cross-key-type validation
|
||||
{
|
||||
name: "management key works for inference endpoint",
|
||||
keyType: server.KeyTypeInference,
|
||||
inferenceKeys: []string{},
|
||||
managementKeys: []string{"sk-management-admin"},
|
||||
requestKey: "sk-management-admin",
|
||||
method: "POST",
|
||||
@@ -120,10 +57,10 @@ func TestAuthMiddleware(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := config.AuthConfig{
|
||||
InferenceKeys: tt.inferenceKeys,
|
||||
ManagementKeys: tt.managementKeys,
|
||||
RequireInferenceAuth: true,
|
||||
ManagementKeys: tt.managementKeys,
|
||||
}
|
||||
middleware := server.NewAPIAuthMiddleware(cfg)
|
||||
middleware := server.NewAPIAuthMiddleware(cfg, nil)
|
||||
|
||||
// Create test request
|
||||
req := httptest.NewRequest(tt.method, "/test", nil)
|
||||
@@ -131,24 +68,17 @@ func TestAuthMiddleware(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+tt.requestKey)
|
||||
}
|
||||
|
||||
// Create test handler using the appropriate middleware
|
||||
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)
|
||||
}))
|
||||
}
|
||||
// Create test handler
|
||||
handler := middleware.InferenceAuthMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// Execute request
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, req)
|
||||
|
||||
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
|
||||
@@ -167,178 +97,171 @@ func TestAuthMiddleware(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAPIKey(t *testing.T) {
|
||||
func TestManagementAuthMiddleware(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
keyType server.KeyType
|
||||
}{
|
||||
{"inference key generation", server.KeyTypeInference},
|
||||
{"management key generation", server.KeyTypeManagement},
|
||||
}
|
||||
|
||||
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 string
|
||||
managementKeys []string
|
||||
requestKey string
|
||||
method string
|
||||
expectedStatus int
|
||||
}{
|
||||
{
|
||||
name: "inference auth required, keys provided - no generation",
|
||||
requireInference: true,
|
||||
requireManagement: false,
|
||||
providedInference: []string{"sk-inference-provided"},
|
||||
providedManagement: []string{},
|
||||
shouldGenerateInf: false,
|
||||
shouldGenerateMgmt: false,
|
||||
name: "valid management key",
|
||||
managementKeys: []string{"sk-management-admin123"},
|
||||
requestKey: "sk-management-admin123",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "inference auth required, no keys - should auto-generate",
|
||||
requireInference: true,
|
||||
requireManagement: false,
|
||||
providedInference: []string{},
|
||||
providedManagement: []string{},
|
||||
shouldGenerateInf: true,
|
||||
shouldGenerateMgmt: false,
|
||||
name: "invalid management key",
|
||||
managementKeys: []string{"sk-management-valid123"},
|
||||
requestKey: "sk-management-invalid",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "management auth required, keys provided - no generation",
|
||||
requireInference: false,
|
||||
requireManagement: true,
|
||||
providedInference: []string{},
|
||||
providedManagement: []string{"sk-management-provided"},
|
||||
shouldGenerateInf: false,
|
||||
shouldGenerateMgmt: false,
|
||||
name: "missing management key",
|
||||
managementKeys: []string{"sk-management-valid123"},
|
||||
requestKey: "",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "management auth required, no keys - should auto-generate",
|
||||
requireInference: false,
|
||||
requireManagement: true,
|
||||
providedInference: []string{},
|
||||
providedManagement: []string{},
|
||||
shouldGenerateInf: false,
|
||||
shouldGenerateMgmt: true,
|
||||
},
|
||||
{
|
||||
name: "both required, both provided - no generation",
|
||||
requireInference: true,
|
||||
requireManagement: true,
|
||||
providedInference: []string{"sk-inference-provided"},
|
||||
providedManagement: []string{"sk-management-provided"},
|
||||
shouldGenerateInf: false,
|
||||
shouldGenerateMgmt: false,
|
||||
},
|
||||
{
|
||||
name: "both required, none provided - should auto-generate both",
|
||||
requireInference: true,
|
||||
requireManagement: true,
|
||||
providedInference: []string{},
|
||||
providedManagement: []string{},
|
||||
shouldGenerateInf: true,
|
||||
shouldGenerateMgmt: true,
|
||||
name: "OPTIONS request bypasses management auth",
|
||||
managementKeys: []string{"sk-management-valid123"},
|
||||
requestKey: "",
|
||||
method: "OPTIONS",
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := config.AuthConfig{
|
||||
RequireManagementAuth: true,
|
||||
ManagementKeys: tt.managementKeys,
|
||||
}
|
||||
middleware := server.NewAPIAuthMiddleware(cfg, nil)
|
||||
|
||||
// Create test request
|
||||
req := httptest.NewRequest(tt.method, "/test", nil)
|
||||
if tt.requestKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+tt.requestKey)
|
||||
}
|
||||
|
||||
// Create test handler
|
||||
handler := middleware.ManagementAuthMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := config.AuthConfig{
|
||||
RequireInferenceAuth: tt.requireInference,
|
||||
RequireManagementAuth: tt.requireManagement,
|
||||
InferenceKeys: tt.providedInference,
|
||||
ManagementKeys: tt.providedManagement,
|
||||
}
|
||||
|
||||
middleware := server.NewAPIAuthMiddleware(cfg)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
middleware := server.NewAPIAuthMiddleware(cfg, nil)
|
||||
|
||||
// Test management behavior if management auth is required
|
||||
if tt.requireManagement {
|
||||
req := httptest.NewRequest("GET", "/api/v1/instances", nil)
|
||||
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)
|
||||
}))
|
||||
|
||||
@@ -352,3 +275,16 @@ func TestAutoGeneration(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigBasedInferenceKeysDeprecationWarning(t *testing.T) {
|
||||
// Test that config-based inference keys trigger a warning (captured in logs)
|
||||
cfg := config.AuthConfig{
|
||||
InferenceKeys: []string{"sk-inference-old"},
|
||||
}
|
||||
|
||||
// Creating middleware should log a warning, but shouldn't fail
|
||||
_ = server.NewAPIAuthMiddleware(cfg, nil)
|
||||
|
||||
// If we get here without panic, the test passes
|
||||
// The warning is logged but not returned as an error
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ func SetupRouter(handler *Handler) *chi.Mux {
|
||||
}))
|
||||
|
||||
// Add API authentication middleware
|
||||
authMiddleware := NewAPIAuthMiddleware(handler.cfg.Auth)
|
||||
authMiddleware := NewAPIAuthMiddleware(handler.cfg.Auth, handler.authStore)
|
||||
|
||||
if handler.cfg.Server.EnableSwagger {
|
||||
r.Get("/swagger/*", httpSwagger.Handler(
|
||||
@@ -39,13 +39,24 @@ func SetupRouter(handler *Handler) *chi.Mux {
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
|
||||
if authMiddleware != nil && handler.cfg.Auth.RequireManagementAuth {
|
||||
r.Use(authMiddleware.AuthMiddleware(KeyTypeManagement))
|
||||
r.Use(authMiddleware.ManagementAuthMiddleware())
|
||||
}
|
||||
|
||||
r.Get("/version", handler.VersionHandler())
|
||||
|
||||
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
|
||||
r.Route("/backends", func(r chi.Router) {
|
||||
r.Route("/llama-cpp", func(r chi.Router) {
|
||||
@@ -67,7 +78,7 @@ func SetupRouter(handler *Handler) *chi.Mux {
|
||||
r.Get("/", handler.ListNodes()) // List all nodes
|
||||
|
||||
r.Route("/{name}", func(r chi.Router) {
|
||||
r.Get("/", handler.GetNode())
|
||||
r.Get("/", handler.GetNode()) // Get node details
|
||||
})
|
||||
})
|
||||
|
||||
@@ -94,13 +105,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 {
|
||||
r.Use(authMiddleware.AuthMiddleware(KeyTypeInference))
|
||||
r.Use(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
|
||||
// Handles all POST requests to /v1/*, including:
|
||||
@@ -125,10 +136,10 @@ func SetupRouter(handler *Handler) *chi.Mux {
|
||||
r.Group(func(r chi.Router) {
|
||||
|
||||
if authMiddleware != nil && handler.cfg.Auth.RequireInferenceAuth {
|
||||
r.Use(authMiddleware.AuthMiddleware(KeyTypeInference))
|
||||
r.Use(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()
|
||||
|
||||
// llama.cpp server specific proxy endpoints
|
||||
|
||||
513
webui/package-lock.json
generated
513
webui/package-lock.json
generated
@@ -12,11 +12,13 @@
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.553.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.555.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
@@ -42,7 +44,7 @@
|
||||
"jsdom": "^27.2.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.2.2",
|
||||
"vitest": "^4.0.8"
|
||||
}
|
||||
@@ -1240,44 +1242,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "2.0.5",
|
||||
"run-parallel": "^1.1.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.stat": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
|
||||
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.walk": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
|
||||
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.scandir": "2.1.5",
|
||||
"fastq": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.29",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||
@@ -1321,6 +1285,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collection": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||
@@ -1387,6 +1377,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-direction": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
||||
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer": {
|
||||
"version": "1.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz",
|
||||
@@ -1566,6 +1571,105 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-radio-group": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz",
|
||||
"integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-roving-focus": "1.1.11",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-roving-focus": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
|
||||
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-collection": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
@@ -2508,17 +2612,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.46.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.4.tgz",
|
||||
"integrity": "sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==",
|
||||
"version": "8.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz",
|
||||
"integrity": "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.10.0",
|
||||
"@typescript-eslint/scope-manager": "8.46.4",
|
||||
"@typescript-eslint/type-utils": "8.46.4",
|
||||
"@typescript-eslint/utils": "8.46.4",
|
||||
"@typescript-eslint/visitor-keys": "8.46.4",
|
||||
"@typescript-eslint/scope-manager": "8.48.0",
|
||||
"@typescript-eslint/type-utils": "8.48.0",
|
||||
"@typescript-eslint/utils": "8.48.0",
|
||||
"@typescript-eslint/visitor-keys": "8.48.0",
|
||||
"graphemer": "^1.4.0",
|
||||
"ignore": "^7.0.0",
|
||||
"natural-compare": "^1.4.0",
|
||||
@@ -2532,7 +2636,7 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.46.4",
|
||||
"@typescript-eslint/parser": "^8.48.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
@@ -2548,16 +2652,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.46.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.4.tgz",
|
||||
"integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==",
|
||||
"version": "8.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.0.tgz",
|
||||
"integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.46.4",
|
||||
"@typescript-eslint/types": "8.46.4",
|
||||
"@typescript-eslint/typescript-estree": "8.46.4",
|
||||
"@typescript-eslint/visitor-keys": "8.46.4",
|
||||
"@typescript-eslint/scope-manager": "8.48.0",
|
||||
"@typescript-eslint/types": "8.48.0",
|
||||
"@typescript-eslint/typescript-estree": "8.48.0",
|
||||
"@typescript-eslint/visitor-keys": "8.48.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
@@ -2573,14 +2677,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.46.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.4.tgz",
|
||||
"integrity": "sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==",
|
||||
"version": "8.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.0.tgz",
|
||||
"integrity": "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.46.4",
|
||||
"@typescript-eslint/types": "^8.46.4",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.48.0",
|
||||
"@typescript-eslint/types": "^8.48.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
@@ -2595,14 +2699,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.46.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.4.tgz",
|
||||
"integrity": "sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==",
|
||||
"version": "8.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.0.tgz",
|
||||
"integrity": "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.46.4",
|
||||
"@typescript-eslint/visitor-keys": "8.46.4"
|
||||
"@typescript-eslint/types": "8.48.0",
|
||||
"@typescript-eslint/visitor-keys": "8.48.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -2613,9 +2717,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.46.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.4.tgz",
|
||||
"integrity": "sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==",
|
||||
"version": "8.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.0.tgz",
|
||||
"integrity": "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2630,15 +2734,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.46.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.4.tgz",
|
||||
"integrity": "sha512-V4QC8h3fdT5Wro6vANk6eojqfbv5bpwHuMsBcJUJkqs2z5XnYhJzyz9Y02eUmF9u3PgXEUiOt4w4KHR3P+z0PQ==",
|
||||
"version": "8.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.0.tgz",
|
||||
"integrity": "sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.46.4",
|
||||
"@typescript-eslint/typescript-estree": "8.46.4",
|
||||
"@typescript-eslint/utils": "8.46.4",
|
||||
"@typescript-eslint/types": "8.48.0",
|
||||
"@typescript-eslint/typescript-estree": "8.48.0",
|
||||
"@typescript-eslint/utils": "8.48.0",
|
||||
"debug": "^4.3.4",
|
||||
"ts-api-utils": "^2.1.0"
|
||||
},
|
||||
@@ -2655,9 +2759,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.46.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.4.tgz",
|
||||
"integrity": "sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==",
|
||||
"version": "8.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz",
|
||||
"integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2669,21 +2773,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.46.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.4.tgz",
|
||||
"integrity": "sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==",
|
||||
"version": "8.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.0.tgz",
|
||||
"integrity": "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.46.4",
|
||||
"@typescript-eslint/tsconfig-utils": "8.46.4",
|
||||
"@typescript-eslint/types": "8.46.4",
|
||||
"@typescript-eslint/visitor-keys": "8.46.4",
|
||||
"@typescript-eslint/project-service": "8.48.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.48.0",
|
||||
"@typescript-eslint/types": "8.48.0",
|
||||
"@typescript-eslint/visitor-keys": "8.48.0",
|
||||
"debug": "^4.3.4",
|
||||
"fast-glob": "^3.3.2",
|
||||
"is-glob": "^4.0.3",
|
||||
"minimatch": "^9.0.4",
|
||||
"semver": "^7.6.0",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"ts-api-utils": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -2737,16 +2840,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.46.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.4.tgz",
|
||||
"integrity": "sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==",
|
||||
"version": "8.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.0.tgz",
|
||||
"integrity": "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.7.0",
|
||||
"@typescript-eslint/scope-manager": "8.46.4",
|
||||
"@typescript-eslint/types": "8.46.4",
|
||||
"@typescript-eslint/typescript-estree": "8.46.4"
|
||||
"@typescript-eslint/scope-manager": "8.48.0",
|
||||
"@typescript-eslint/types": "8.48.0",
|
||||
"@typescript-eslint/typescript-estree": "8.48.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -2761,13 +2864,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.46.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.4.tgz",
|
||||
"integrity": "sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==",
|
||||
"version": "8.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.0.tgz",
|
||||
"integrity": "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.46.4",
|
||||
"@typescript-eslint/types": "8.48.0",
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3240,19 +3343,6 @@
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.25.1",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
|
||||
@@ -3584,6 +3674,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -4238,36 +4338,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "^2.0.2",
|
||||
"@nodelib/fs.walk": "^1.2.3",
|
||||
"glob-parent": "^5.1.2",
|
||||
"merge2": "^1.3.0",
|
||||
"micromatch": "^4.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-glob/node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
@@ -4282,16 +4352,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||
"integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -4329,19 +4389,6 @@
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||
@@ -5042,16 +5089,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-number-object": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz",
|
||||
@@ -5270,9 +5307,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5684,9 +5721,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.553.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.553.0.tgz",
|
||||
"integrity": "sha512-BRgX5zrWmNy/lkVAe0dXBgd7XQdZ3HTf+Hwe3c9WK6dqgnj9h+hxV+MDncM88xDWlCq27+TKvHGE70ViODNILw==",
|
||||
"version": "0.555.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.555.0.tgz",
|
||||
"integrity": "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
@@ -5729,43 +5766,6 @@
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
"picomatch": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch/node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/min-indent": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||
@@ -6235,27 +6235,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
@@ -6460,17 +6439,6 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"iojs": ">=1.0.0",
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.45.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.45.1.tgz",
|
||||
@@ -6510,30 +6478,6 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-array-concat": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
|
||||
@@ -7110,19 +7054,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/totalist": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||
@@ -7294,16 +7225,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.46.4",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.4.tgz",
|
||||
"integrity": "sha512-KALyxkpYV5Ix7UhvjTwJXZv76VWsHG+NjNlt/z+a17SOQSiOcBdUXdbJdyXi7RPxrBFECtFOiPwUJQusJuCqrg==",
|
||||
"version": "8.48.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.0.tgz",
|
||||
"integrity": "sha512-fcKOvQD9GUn3Xw63EgiDqhvWJ5jsyZUaekl3KVpGsDJnN46WJTe3jWxtQP9lMZm1LJNkFLlTaWAxK2vUQR+cqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.46.4",
|
||||
"@typescript-eslint/parser": "8.46.4",
|
||||
"@typescript-eslint/typescript-estree": "8.46.4",
|
||||
"@typescript-eslint/utils": "8.46.4"
|
||||
"@typescript-eslint/eslint-plugin": "8.48.0",
|
||||
"@typescript-eslint/parser": "8.48.0",
|
||||
"@typescript-eslint/typescript-estree": "8.48.0",
|
||||
"@typescript-eslint/utils": "8.48.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
|
||||
@@ -21,11 +21,13 @@
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.553.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.555.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
@@ -51,7 +53,7 @@
|
||||
"jsdom": "^27.2.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.2.2",
|
||||
"vitest": "^4.0.8"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import InstanceList from "@/components/InstanceList";
|
||||
import InstanceDialog from "@/components/InstanceDialog";
|
||||
import LoginDialog from "@/components/LoginDialog";
|
||||
import SystemInfoDialog from "./components/SystemInfoDialog";
|
||||
import SettingsDialog from "./components/settings/SettingsDialog";
|
||||
import { type CreateInstanceOptions, type Instance } from "@/types/instance";
|
||||
import { useInstances } from "@/contexts/InstancesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
@@ -14,6 +15,7 @@ function App() {
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const [isInstanceModalOpen, setIsInstanceModalOpen] = useState(false);
|
||||
const [isSystemInfoModalOpen, setIsSystemInfoModalOpen] = useState(false);
|
||||
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
|
||||
const [editingInstance, setEditingInstance] = useState<Instance | undefined>(
|
||||
undefined
|
||||
);
|
||||
@@ -41,6 +43,10 @@ function App() {
|
||||
setIsSystemInfoModalOpen(true);
|
||||
};
|
||||
|
||||
const handleShowSettings = () => {
|
||||
setIsSettingsModalOpen(true);
|
||||
};
|
||||
|
||||
// Show loading spinner while checking auth
|
||||
if (authLoading) {
|
||||
return (
|
||||
@@ -70,7 +76,11 @@ function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<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">
|
||||
<InstanceList editInstance={handleEditInstance} />
|
||||
</main>
|
||||
@@ -86,7 +96,12 @@ function App() {
|
||||
open={isSystemInfoModalOpen}
|
||||
onOpenChange={setIsSystemInfoModalOpen}
|
||||
/>
|
||||
|
||||
|
||||
<SettingsDialog
|
||||
open={isSettingsModalOpen}
|
||||
onOpenChange={setIsSettingsModalOpen}
|
||||
/>
|
||||
|
||||
<Toaster />
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -75,8 +75,8 @@ function renderApp() {
|
||||
|
||||
describe('App Component - Critical Business Logic Only', () => {
|
||||
const mockInstances: Instance[] = [
|
||||
{ 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: 1, name: 'test-instance-1', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model1.gguf' } } },
|
||||
{ id: 2, name: 'test-instance-2', status: 'running', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model2.gguf' } } }
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -109,6 +109,7 @@ describe('App Component - Critical Business Logic Only', () => {
|
||||
it('creates new instance with correct API call and updates UI', async () => {
|
||||
const user = userEvent.setup()
|
||||
const newInstance: Instance = {
|
||||
id: 3,
|
||||
name: 'new-test-instance',
|
||||
status: 'stopped',
|
||||
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 () => {
|
||||
const user = userEvent.setup()
|
||||
const updatedInstance: Instance = {
|
||||
id: 1,
|
||||
name: 'test-instance-1',
|
||||
status: 'stopped',
|
||||
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'updated-model.gguf' } }
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
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 { useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
interface HeaderProps {
|
||||
onCreateInstance: () => void;
|
||||
onShowSystemInfo: () => void;
|
||||
onShowSettings: () => void;
|
||||
}
|
||||
|
||||
function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) {
|
||||
function Header({ onCreateInstance, onShowSystemInfo, onShowSettings }: HeaderProps) {
|
||||
const { logout } = useAuth();
|
||||
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" />}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onShowSettings}
|
||||
data-testid="settings-button"
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
|
||||
@@ -21,12 +21,14 @@ describe('InstanceCard - Instance Actions and State', () => {
|
||||
const mockEditInstance = vi.fn()
|
||||
|
||||
const stoppedInstance: Instance = {
|
||||
id: 1,
|
||||
name: 'test-instance',
|
||||
status: 'stopped',
|
||||
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'test-model.gguf' } }
|
||||
}
|
||||
|
||||
const runningInstance: Instance = {
|
||||
id: 2,
|
||||
name: 'running-instance',
|
||||
status: 'running',
|
||||
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'running-model.gguf' } }
|
||||
@@ -342,6 +344,7 @@ afterEach(() => {
|
||||
describe('Error Edge Cases', () => {
|
||||
it('handles instance with minimal data', () => {
|
||||
const minimalInstance: Instance = {
|
||||
id: 3,
|
||||
name: 'minimal',
|
||||
status: 'stopped',
|
||||
options: {}
|
||||
@@ -364,6 +367,7 @@ afterEach(() => {
|
||||
|
||||
it('handles instance with undefined options', () => {
|
||||
const instanceWithoutOptions: Instance = {
|
||||
id: 4,
|
||||
name: 'no-options',
|
||||
status: 'running',
|
||||
options: undefined
|
||||
|
||||
@@ -59,9 +59,9 @@ describe('InstanceList - State Management and UI Logic', () => {
|
||||
const mockEditInstance = vi.fn()
|
||||
|
||||
const mockInstances: Instance[] = [
|
||||
{ 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' } } },
|
||||
{ name: 'instance-3', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model3.gguf' } } }
|
||||
{ id: 1, name: 'instance-1', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model1.gguf' } } },
|
||||
{ id: 1, name: 'instance-2', status: 'running', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model2.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'
|
||||
|
||||
@@ -153,6 +153,7 @@ afterEach(() => {
|
||||
|
||||
describe('Edit Mode', () => {
|
||||
const mockInstance: Instance = {
|
||||
id: 1,
|
||||
name: 'existing-instance',
|
||||
status: 'stopped',
|
||||
options: {
|
||||
|
||||
236
webui/src/components/apikeys/CreateApiKeyDialog.tsx
Normal file
236
webui/src/components/apikeys/CreateApiKeyDialog.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
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 { CreateKeyRequest, PermissionMode, InstancePermission } from "@/types/apiKey";
|
||||
import { useInstances } from "@/contexts/InstancesContext";
|
||||
import { format, addDays } 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 permissions: InstancePermission[] = [];
|
||||
if (permissionMode === PermissionMode.PerInstance) {
|
||||
Object.entries(instancePermissions).forEach(([instanceId, canInfer]) => {
|
||||
if (canInfer) {
|
||||
permissions.push({
|
||||
InstanceID: parseInt(instanceId),
|
||||
CanInfer: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const request: CreateKeyRequest = {
|
||||
Name: name.trim(),
|
||||
PermissionMode: permissionMode,
|
||||
InstancePermissions: permissions,
|
||||
};
|
||||
|
||||
// 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.ExpiresAt = 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({
|
||||
...instancePermissions,
|
||||
[instanceId]: checked,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create API Key</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} 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) => (
|
||||
<div key={instance.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`instance-${instance.id}`}
|
||||
checked={instancePermissions[instance.id] || false}
|
||||
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>
|
||||
<span className="text-sm text-muted-foreground">Can Infer</span>
|
||||
</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;
|
||||
281
webui/src/components/settings/ApiKeysSection.tsx
Normal file
281
webui/src/components/settings/ApiKeysSection.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import { useEffect, useState } 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 { ApiKey, 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(() => {
|
||||
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);
|
||||
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);
|
||||
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) {
|
||||
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={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) => (
|
||||
<>
|
||||
<tr
|
||||
key={key.id}
|
||||
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();
|
||||
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">Instance Permissions:</p>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-2">Instance Name</th>
|
||||
<th className="text-left py-2">Can Infer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{permissions[key.id].map((perm) => (
|
||||
<tr key={perm.instance_id} className="border-b">
|
||||
<td className="py-2">{perm.instance_name}</td>
|
||||
<td className="py-2">
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No permissions data</p>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreateApiKeyDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onKeyCreated={handleKeyCreated}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ApiKeysSection;
|
||||
22
webui/src/components/settings/SettingsDialog.tsx
Normal file
22
webui/src/components/settings/SettingsDialog.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Dialog, DialogContent, 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>
|
||||
</DialogHeader>
|
||||
<ApiKeysSection />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsDialog;
|
||||
59
webui/src/components/ui/alert.tsx
Normal file
59
webui/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
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 p-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
42
webui/src/components/ui/radio-group.tsx
Normal file
42
webui/src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
@@ -123,8 +123,8 @@ function renderWithProvider(children: ReactNode) {
|
||||
|
||||
describe("InstancesContext", () => {
|
||||
const mockInstances: Instance[] = [
|
||||
{ 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: 1, name: "instance1", status: "running", options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "model1.gguf" } } },
|
||||
{ id: 2, name: "instance2", status: "stopped", options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "model2.gguf" } } },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -181,6 +181,7 @@ describe("InstancesContext", () => {
|
||||
describe("Create Instance", () => {
|
||||
it("creates instance and adds it to state", async () => {
|
||||
const newInstance: Instance = {
|
||||
id: 3,
|
||||
name: "new-instance",
|
||||
status: "stopped",
|
||||
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "test.gguf" } },
|
||||
@@ -238,6 +239,7 @@ describe("InstancesContext", () => {
|
||||
describe("Update Instance", () => {
|
||||
it("updates instance and maintains it in state", async () => {
|
||||
const updatedInstance: Instance = {
|
||||
id: 1,
|
||||
name: "instance1",
|
||||
status: "running",
|
||||
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "updated.gguf" } },
|
||||
@@ -408,6 +410,7 @@ describe("InstancesContext", () => {
|
||||
it("maintains consistent state during multiple operations", async () => {
|
||||
// Test that operations don't interfere with each other
|
||||
const newInstance: Instance = {
|
||||
id: 3,
|
||||
name: "new-instance",
|
||||
status: "stopped",
|
||||
options: {},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CreateInstanceOptions, Instance } from "@/types/instance";
|
||||
import type { AppConfig } from "@/types/config";
|
||||
import type { ApiKey, CreateKeyRequest, CreateKeyResponse, KeyPermissionResponse } from "@/types/apiKey";
|
||||
import { handleApiError } from "./errorUtils";
|
||||
|
||||
// Adding baseURI as a prefix to support being served behind a subpath
|
||||
@@ -178,3 +179,29 @@ export const instancesApi = {
|
||||
// GET /instances/{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`),
|
||||
};
|
||||
|
||||
35
webui/src/types/apiKey.ts
Normal file
35
webui/src/types/apiKey.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
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
|
||||
PermissionMode: PermissionMode
|
||||
ExpiresAt?: number
|
||||
InstancePermissions: InstancePermission[]
|
||||
}
|
||||
|
||||
export interface InstancePermission {
|
||||
InstanceID: 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 {
|
||||
port_range: [number, number]
|
||||
data_dir: string
|
||||
configs_dir: string
|
||||
logs_dir: string
|
||||
auto_create_dirs: boolean
|
||||
@@ -45,6 +44,13 @@ export interface InstancesConfig {
|
||||
timeout_check_interval: number
|
||||
}
|
||||
|
||||
export interface DatabaseConfig {
|
||||
path: string
|
||||
max_open_connections: number
|
||||
max_idle_connections: number
|
||||
connection_max_lifetime: number
|
||||
}
|
||||
|
||||
export interface AuthConfig {
|
||||
require_inference_auth: boolean
|
||||
inference_keys: string[] // Will be empty in sanitized response
|
||||
@@ -61,9 +67,11 @@ export interface AppConfig {
|
||||
server: ServerConfig
|
||||
backends: BackendConfig
|
||||
instances: InstancesConfig
|
||||
database: DatabaseConfig
|
||||
auth: AuthConfig
|
||||
local_node: string
|
||||
nodes: Record<string, NodeConfig>
|
||||
data_dir: string
|
||||
version?: string
|
||||
commit_hash?: string
|
||||
build_time?: string
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface HealthStatus {
|
||||
}
|
||||
|
||||
export interface Instance {
|
||||
id: number;
|
||||
name: string;
|
||||
status: InstanceStatus;
|
||||
options?: CreateInstanceOptions;
|
||||
|
||||
Reference in New Issue
Block a user