Merge pull request #101 from lordmathis/feat/api-key-mgmt

feat: Add inference api key management
This commit is contained in:
2025-12-08 18:49:49 +01:00
committed by GitHub
58 changed files with 3492 additions and 565 deletions

1
.vscode/launch.json vendored
View File

@@ -14,6 +14,7 @@
"GO_ENV": "development",
"LLAMACTL_CONFIG_PATH": "${workspaceFolder}/llamactl.dev.yaml"
},
"console": "integratedTerminal",
}
]
}

View File

@@ -1,6 +1,7 @@
package main
import (
"context"
"fmt"
"llamactl/pkg/config"
"llamactl/pkg/database"
@@ -11,6 +12,7 @@ import (
"os"
"os/signal"
"syscall"
"time"
)
// version is set at build time using -ldflags "-X main.version=1.0.0"
@@ -48,7 +50,7 @@ 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 {
// Create the main data directory
if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
@@ -91,7 +93,7 @@ func main() {
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)
@@ -116,14 +118,23 @@ func main() {
<-stop
fmt.Println("Shutting down server...")
if err := server.Close(); err != nil {
// Create shutdown context with timeout
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
// Shutdown HTTP server gracefully
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("Error shutting down server: %v\n", err)
} else {
fmt.Println("Server shut down gracefully.")
}
// Wait for all instances to stop
// Stop all instances and cleanup
instanceManager.Shutdown()
if err := db.Close(); err != nil {
log.Printf("Error closing database: %v\n", err)
}
fmt.Println("Exiting llamactl.")
}

View File

@@ -13,7 +13,8 @@ import (
// migrateFromJSON migrates instances from JSON files to SQLite database
// This is a one-time migration that runs on first startup with existing JSON files.
func migrateFromJSON(cfg *config.AppConfig, db database.DB) error {
// Migrated files are moved to a migrated subdirectory to avoid re-importing.
func migrateFromJSON(cfg *config.AppConfig, db database.InstanceStore) error {
instancesDir := cfg.Instances.InstancesDir
if instancesDir == "" {
return nil // No instances directory configured
@@ -24,16 +25,6 @@ func migrateFromJSON(cfg *config.AppConfig, db database.DB) error {
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 {
@@ -46,6 +37,12 @@ func migrateFromJSON(cfg *config.AppConfig, db database.DB) error {
log.Printf("Migrating %d instances from JSON to SQLite...", len(files))
// Create migrated directory
migratedDir := filepath.Join(instancesDir, "migrated")
if err := os.MkdirAll(migratedDir, 0755); err != nil {
return fmt.Errorf("failed to create migrated directory: %w", err)
}
// Migrate each JSON file
var migrated int
for _, file := range files {
@@ -53,30 +50,24 @@ func migrateFromJSON(cfg *config.AppConfig, db database.DB) error {
log.Printf("Failed to migrate %s: %v", file, err)
continue
}
// Move the file to the migrated directory
destPath := filepath.Join(migratedDir, filepath.Base(file))
if err := os.Rename(file, destPath); err != nil {
log.Printf("Warning: Failed to move %s to migrated directory: %v", file, err)
// Don't fail the migration if we can't move the file
}
migrated++
}
log.Printf("Successfully migrated %d/%d instances to SQLite", migrated, len(files))
// Archive old JSON files
if migrated > 0 {
archiveDir := filepath.Join(instancesDir, "json_archive")
if err := os.MkdirAll(archiveDir, 0755); err == nil {
for _, file := range files {
newPath := filepath.Join(archiveDir, filepath.Base(file))
if err := os.Rename(file, newPath); err != nil {
log.Printf("Failed to archive %s: %v", file, err)
}
}
log.Printf("Archived old JSON files to %s", archiveDir)
}
}
return nil
}
// migrateJSONFile migrates a single JSON file to the database
func migrateJSONFile(filename string, db database.DB) error {
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)

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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

9
go.mod
View File

@@ -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
)
@@ -16,16 +19,12 @@ require (
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/spec v0.21.0 // indirect
github.com/go-openapi/swag v0.23.1 // indirect
github.com/golang-migrate/migrate/v4 v4.19.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/swaggo/files v1.0.1 // indirect
go.uber.org/atomic v1.7.0 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/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
)

33
go.sum
View File

@@ -1,9 +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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/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=
@@ -16,35 +14,26 @@ 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.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y=
github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/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/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
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/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.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/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/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
@@ -54,27 +43,21 @@ github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4
github.com/swaggo/swag v1.16.5 h1:nMf2fEV1TetMTJb4XzD0Lz7jFfKJmJKGTygEey8NSxM=
github.com/swaggo/swag v1.16.5/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-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=
@@ -83,6 +66,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-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=
@@ -93,8 +78,6 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-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=

73
pkg/auth/hash.go Normal file
View 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
View 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
}

View File

@@ -85,7 +85,7 @@ type DatabaseConfig struct {
// 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"`
ConnMaxLifetime time.Duration `yaml:"connection_max_lifetime" json:"connection_max_lifetime" swaggertype:"string" example:"1h"`
}
// InstancesConfig contains instance management configuration
@@ -93,7 +93,6 @@ type InstancesConfig struct {
// Port range for instances (e.g., 8000,9000)
PortRange [2]int `yaml:"port_range" json:"port_range"`
// Instance config directory override (relative to data_dir if not absolute)
InstancesDir string `yaml:"configs_dir" json:"configs_dir"`
@@ -248,9 +247,18 @@ func LoadConfig(configPath string) (AppConfig, error) {
// 3. Override with environment variables
loadEnvVars(&cfg)
// 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.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.DataDir, "logs")

211
pkg/database/apikeys.go Normal file
View 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
}

View File

@@ -1,8 +1,10 @@
package database
import (
"context"
"database/sql"
"fmt"
"llamactl/pkg/auth"
"llamactl/pkg/instance"
"log"
"path/filepath"
@@ -11,14 +13,26 @@ import (
_ "github.com/mattn/go-sqlite3"
)
// DB defines the interface for instance persistence operations
type DB interface {
// 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)
@@ -30,13 +44,13 @@ type Config struct {
ConnMaxLifetime time.Duration
}
// sqliteDB wraps the database connection with configuration
// sqliteDB wraps database connection with configuration
type sqliteDB struct {
*sql.DB
config *Config
}
// Open creates a new database connection with the provided configuration
// Open creates a new database connection with provided configuration
func Open(config *Config) (*sqliteDB, error) {
if config == nil {
return nil, fmt.Errorf("database config cannot be nil")
@@ -46,10 +60,10 @@ func Open(config *Config) (*sqliteDB, error) {
return nil, fmt.Errorf("database path cannot be empty")
}
// Ensure the database directory exists
// Ensure that database directory exists
dbDir := filepath.Dir(config.Path)
if dbDir != "." && dbDir != "/" {
// Directory will be created by the manager if auto_create_dirs is enabled
// Directory will be created by manager if auto_create_dirs is enabled
log.Printf("Database will be created at: %s", config.Path)
}
@@ -89,16 +103,22 @@ func Open(config *Config) (*sqliteDB, error) {
}, nil
}
// Close closes the database connection
// Close closes database connection
func (db *sqliteDB) Close() error {
if db.DB != nil {
log.Println("Closing database connection")
// Checkpoint WAL to merge changes back to main database file
if _, err := db.DB.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
log.Printf("Warning: Failed to checkpoint WAL: %v", err)
}
return db.DB.Close()
}
return nil
}
// HealthCheck verifies the database is accessible
// HealthCheck verifies that database is accessible
func (db *sqliteDB) HealthCheck() error {
if db.DB == nil {
return fmt.Errorf("database connection is nil")

View File

@@ -45,7 +45,7 @@ func (db *sqliteDB) Create(ctx context.Context, inst *instance.Instance) error {
) VALUES (?, ?, ?, ?, ?, ?)
`
_, err = db.DB.ExecContext(ctx, query,
result, err := db.DB.ExecContext(ctx, query,
row.Name, row.Status, row.CreatedAt, row.UpdatedAt, row.OptionsJSON, row.OwnerUserID,
)
@@ -53,6 +53,14 @@ func (db *sqliteDB) Create(ctx context.Context, inst *instance.Instance) error {
return fmt.Errorf("failed to insert instance: %w", err)
}
// Get the auto-generated ID and set it on the instance
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get last insert ID: %w", err)
}
inst.ID = int(id)
return nil
}
@@ -263,6 +271,7 @@ func (db *sqliteDB) rowToInstance(row *instanceRow) (*instance.Instance, error)
// Build complete instance JSON with all fields
instanceJSON, err := json.Marshal(map[string]any{
"id": row.ID,
"name": row.Name,
"created": row.CreatedAt,
"status": row.Status,

View File

@@ -1,7 +1,11 @@
-- Drop indexes first
DROP INDEX IF EXISTS idx_instances_backend_type;
-- 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 tables
DROP TABLE IF EXISTS instances;

View File

@@ -25,3 +25,36 @@ CREATE TABLE IF NOT EXISTS instances (
-- -----------------------------------------------------------------------------
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);

View 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
}

View File

@@ -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

View File

@@ -31,7 +31,7 @@ type instanceManager struct {
// Components (each with own synchronization)
registry *instanceRegistry
ports *portAllocator
db database.DB
db database.InstanceStore
remote *remoteManager
lifecycle *lifecycleManager
@@ -44,7 +44,7 @@ type instanceManager struct {
}
// New creates a new instance of InstanceManager with dependency injection.
func New(globalConfig *config.AppConfig, db database.DB) InstanceManager {
func New(globalConfig *config.AppConfig, db database.InstanceStore) InstanceManager {
if globalConfig.Instances.TimeoutCheckInterval <= 0 {
globalConfig.Instances.TimeoutCheckInterval = 5 // Default to 5 minutes if not set
@@ -114,11 +114,6 @@ 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)
}
})
}
@@ -181,6 +176,7 @@ func (im *instanceManager) loadInstance(persistedInst *instance.Instance) error
inst := instance.New(name, im.globalConfig, options, statusCallback)
// Restore persisted fields that NewInstance doesn't set
inst.ID = persistedInst.ID
inst.Created = persistedInst.Created
inst.SetStatus(persistedInst.GetStatus())

View File

@@ -37,7 +37,6 @@ func (im *instanceManager) ListInstances() ([]*instance.Instance, error) {
if node := im.getNodeForInstance(inst); node != nil {
remoteInst, err := im.remote.getInstance(ctx, node, inst.Name)
if err != nil {
// Log error but continue with stale data
// Don't fail the entire list operation due to one remote failure
continue
}

View File

@@ -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()

354
pkg/server/handlers_auth.go Normal file
View File

@@ -0,0 +1,354 @@
package server
import (
"encoding/json"
"fmt"
"llamactl/pkg/auth"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
)
// CreateKeyRequest represents the request body for creating a new API key.
type CreateKeyRequest struct {
Name string `json:"name"`
PermissionMode auth.PermissionMode `json:"permission_mode"`
ExpiresAt *int64 `json:"expires_at,omitempty"`
InstanceIDs []int `json:"instance_ids,omitempty"`
}
// CreateKeyResponse represents the response returned when creating a new API key.
type CreateKeyResponse struct {
ID int `json:"id"`
Name string `json:"name"`
UserID string `json:"user_id"`
PermissionMode auth.PermissionMode `json:"permission_mode"`
ExpiresAt *int64 `json:"expires_at"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
LastUsedAt *int64 `json:"last_used_at"`
Key string `json:"key"`
}
// KeyResponse represents an API key in responses for list and get operations.
type KeyResponse struct {
ID int `json:"id"`
Name string `json:"name"`
UserID string `json:"user_id"`
PermissionMode auth.PermissionMode `json:"permission_mode"`
ExpiresAt *int64 `json:"expires_at"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
LastUsedAt *int64 `json:"last_used_at"`
}
// KeyPermissionResponse represents the permissions for an API key on a specific instance.
type KeyPermissionResponse struct {
InstanceID int `json:"instance_id"`
InstanceName string `json:"instance_name"`
}
// CreateKey godoc
// @Summary Create a new API key
// @Description Creates a new API key with the specified permissions and returns the plain-text key (only shown once)
// @Tags Keys
// @Accept json
// @Produce json
// @Param key body CreateKeyRequest true "API key configuration"
// @Success 201 {object} CreateKeyResponse "Created API key with plain-text key"
// @Failure 400 {string} string "Invalid request body or validation error"
// @Failure 500 {string} string "Internal Server Error"
// @Router /api/v1/auth/keys [post]
func (h *Handler) CreateKey() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req CreateKeyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", "Invalid JSON in request body")
return
}
// Validate request
if req.Name == "" {
writeError(w, http.StatusBadRequest, "invalid_name", "Name is required")
return
}
if len(req.Name) > 100 {
writeError(w, http.StatusBadRequest, "invalid_name", "Name must be 100 characters or less")
return
}
if req.PermissionMode != auth.PermissionModeAllowAll && req.PermissionMode != auth.PermissionModePerInstance {
writeError(w, http.StatusBadRequest, "invalid_permission_mode", "Permission mode must be 'allow_all' or 'per_instance'")
return
}
if req.PermissionMode == auth.PermissionModePerInstance && len(req.InstanceIDs) == 0 {
writeError(w, http.StatusBadRequest, "missing_permissions", "Instance IDs required when permission mode is 'per_instance'")
return
}
if req.ExpiresAt != nil && *req.ExpiresAt <= time.Now().Unix() {
writeError(w, http.StatusBadRequest, "invalid_expires_at", "Expiration time must be in future")
return
}
// Validate instance IDs exist
if req.PermissionMode == auth.PermissionModePerInstance {
instances, err := h.InstanceManager.ListInstances()
if err != nil {
writeError(w, http.StatusInternalServerError, "fetch_instances_failed", fmt.Sprintf("Failed to fetch instances: %v", err))
return
}
instanceIDMap := make(map[int]bool)
for _, inst := range instances {
instanceIDMap[inst.ID] = true
}
for _, instanceID := range req.InstanceIDs {
if !instanceIDMap[instanceID] {
writeError(w, http.StatusBadRequest, "invalid_instance_id", fmt.Sprintf("Instance ID %d does not exist", instanceID))
return
}
}
}
// Generate plain-text key
plainTextKey, err := auth.GenerateKey("llamactl")
if err != nil {
writeError(w, http.StatusInternalServerError, "key_generation_failed", "Failed to generate API key")
return
}
// Hash key
keyHash, err := auth.HashKey(plainTextKey)
if err != nil {
writeError(w, http.StatusInternalServerError, "key_hashing_failed", "Failed to hash API key")
return
}
// Create APIKey struct
now := time.Now().Unix()
apiKey := &auth.APIKey{
KeyHash: keyHash,
Name: req.Name,
UserID: "system",
PermissionMode: req.PermissionMode,
ExpiresAt: req.ExpiresAt,
CreatedAt: now,
UpdatedAt: now,
}
// Convert InstanceIDs to KeyPermissions
var keyPermissions []auth.KeyPermission
for _, instanceID := range req.InstanceIDs {
keyPermissions = append(keyPermissions, auth.KeyPermission{
KeyID: 0, // Will be set by database after key creation
InstanceID: instanceID,
})
}
// Create in database
err = h.authStore.CreateKey(r.Context(), apiKey, keyPermissions)
if err != nil {
writeError(w, http.StatusInternalServerError, "creation_failed", fmt.Sprintf("Failed to create API key: %v", err))
return
}
// Return response with plain-text key (only shown once)
response := CreateKeyResponse{
ID: apiKey.ID,
Name: apiKey.Name,
UserID: apiKey.UserID,
PermissionMode: apiKey.PermissionMode,
ExpiresAt: apiKey.ExpiresAt,
CreatedAt: apiKey.CreatedAt,
UpdatedAt: apiKey.UpdatedAt,
LastUsedAt: apiKey.LastUsedAt,
Key: plainTextKey,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(response)
}
}
// ListKeys godoc
// @Summary List all API keys
// @Description Returns a list of all API keys for the system user (excludes key hash and plain-text key)
// @Tags Keys
// @Security ApiKeyAuth
// @Produce json
// @Success 200 {array} KeyResponse "List of API keys"
// @Failure 500 {string} string "Internal Server Error"
// @Router /api/v1/auth/keys [get]
func (h *Handler) ListKeys() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
keys, err := h.authStore.GetUserKeys(r.Context(), "system")
if err != nil {
writeError(w, http.StatusInternalServerError, "fetch_failed", fmt.Sprintf("Failed to fetch API keys: %v", err))
return
}
// Remove key_hash from all keys
response := make([]KeyResponse, 0, len(keys))
for _, key := range keys {
response = append(response, KeyResponse{
ID: key.ID,
Name: key.Name,
UserID: key.UserID,
PermissionMode: key.PermissionMode,
ExpiresAt: key.ExpiresAt,
CreatedAt: key.CreatedAt,
UpdatedAt: key.UpdatedAt,
LastUsedAt: key.LastUsedAt,
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
}
// GetKey godoc
// @Summary Get details of a specific API key
// @Description Returns details for a specific API key by ID (excludes key hash and plain-text key)
// @Tags Keys
// @Security ApiKeyAuth
// @Produce json
// @Param id path int true "Key ID"
// @Success 200 {object} KeyResponse "API key details"
// @Failure 400 {string} string "Invalid key ID"
// @Failure 404 {string} string "API key not found"
// @Failure 500 {string} string "Internal Server Error"
// @Router /api/v1/auth/keys/{id} [get]
func (h *Handler) GetKey() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id", "Invalid key ID")
return
}
key, err := h.authStore.GetKeyByID(r.Context(), id)
if err != nil {
if err.Error() == "API key not found" {
writeError(w, http.StatusNotFound, "not_found", "API key not found")
return
}
writeError(w, http.StatusInternalServerError, "fetch_failed", fmt.Sprintf("Failed to fetch API key: %v", err))
return
}
// Remove key_hash from response
response := KeyResponse{
ID: key.ID,
Name: key.Name,
UserID: key.UserID,
PermissionMode: key.PermissionMode,
ExpiresAt: key.ExpiresAt,
CreatedAt: key.CreatedAt,
UpdatedAt: key.UpdatedAt,
LastUsedAt: key.LastUsedAt,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
}
// DeleteKey godoc
// @Summary Delete an API key
// @Description Deletes an API key by ID
// @Tags Keys
// @Security ApiKeyAuth
// @Param id path int true "Key ID"
// @Success 204 "API key deleted successfully"
// @Failure 400 {string} string "Invalid key ID"
// @Failure 404 {string} string "API key not found"
// @Failure 500 {string} string "Internal Server Error"
// @Router /api/v1/auth/keys/{id} [delete]
func (h *Handler) DeleteKey() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id", "Invalid key ID")
return
}
err = h.authStore.DeleteKey(r.Context(), id)
if err != nil {
if err.Error() == "API key not found" {
writeError(w, http.StatusNotFound, "not_found", "API key not found")
return
}
writeError(w, http.StatusInternalServerError, "deletion_failed", fmt.Sprintf("Failed to delete API key: %v", err))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// GetKeyPermissions godoc
// @Summary Get API key permissions
// @Description Returns the instance-level permissions for a specific API key (includes instance names)
// @Tags Keys
// @Security ApiKeyAuth
// @Produce json
// @Param id path int true "Key ID"
// @Success 200 {array} KeyPermissionResponse "List of key permissions"
// @Failure 400 {string} string "Invalid key ID"
// @Failure 404 {string} string "API key not found"
// @Failure 500 {string} string "Internal Server Error"
// @Router /api/v1/auth/keys/{id}/permissions [get]
func (h *Handler) GetKeyPermissions() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id", "Invalid key ID")
return
}
// Verify key exists
_, err = h.authStore.GetKeyByID(r.Context(), id)
if err != nil {
if err.Error() == "API key not found" {
writeError(w, http.StatusNotFound, "not_found", "API key not found")
return
}
writeError(w, http.StatusInternalServerError, "fetch_failed", fmt.Sprintf("Failed to fetch API key: %v", err))
return
}
permissions, err := h.authStore.GetPermissions(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "fetch_failed", fmt.Sprintf("Failed to fetch permissions: %v", err))
return
}
// Get instance names for the permissions
instances, err := h.InstanceManager.ListInstances()
if err != nil {
writeError(w, http.StatusInternalServerError, "fetch_instances_failed", fmt.Sprintf("Failed to fetch instances: %v", err))
return
}
instanceNameMap := make(map[int]string)
for _, inst := range instances {
instanceNameMap[inst.ID] = inst.Name
}
response := make([]KeyPermissionResponse, 0, len(permissions))
for _, perm := range permissions {
response = append(response, KeyPermissionResponse{
InstanceID: perm.InstanceID,
InstanceName: instanceNameMap[perm.InstanceID],
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
}

View File

@@ -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")

View File

@@ -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

View File

@@ -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")

View File

@@ -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))
}

View File

@@ -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
}

View File

@@ -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

136
test_client.py Normal file
View File

@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""
Simple Python script to interact with local LLM server's OpenAI-compatible API
"""
import requests
import json
import sys
# Local LLM server configuration
BASE_URL = "http://localhost:8080"
API_KEY = None
MODEL_NAME = None
def get_models():
"""Fetch available models from /v1/models endpoint"""
headers = {}
if API_KEY:
headers["Authorization"] = f"Bearer {API_KEY}"
try:
response = requests.get(f"{BASE_URL}/v1/models", headers=headers, timeout=10)
response.raise_for_status()
return response.json()["data"]
except Exception as e:
print(f"Error fetching models: {e}")
return []
def send_message(message):
"""
Send a message to local LLM server API
Args:
message (str): The message to send
Returns:
str: The AI response or error message
"""
headers = {
"Content-Type": "application/json",
}
if API_KEY:
headers["Authorization"] = f"Bearer {API_KEY}"
data = {
"model": MODEL_NAME,
"messages": [
{
"role": "user",
"content": message
}
],
"temperature": 0.7,
"max_tokens": 1000,
"stream": False,
}
response = requests.post(f"{BASE_URL}/v1/chat/completions", headers=headers, json=data, timeout=60)
response.raise_for_status()
return response.json()["choices"][0]["message"]["content"]
def interactive_mode():
"""Run in interactive mode for continuous conversation"""
global BASE_URL, API_KEY, MODEL_NAME
# Get base URL
url_input = input(f"Base URL [{BASE_URL}]: ").strip()
if url_input:
BASE_URL = url_input
# Get API key (optional)
key_input = input("API key (optional): ").strip()
if key_input:
API_KEY = key_input
# Fetch and select model
models = get_models()
if not models:
print("No models available. Exiting.")
return
print("\nAvailable models:")
for i, m in enumerate(models, 1):
print(f"{i}. {m['id']}")
while True:
try:
selection = int(input("\nSelect model: "))
if 1 <= selection <= len(models):
MODEL_NAME = models[selection - 1]["id"]
break
print(f"Please enter a number between 1 and {len(models)}")
except ValueError:
print("Please enter a valid number")
print(f"\nUsing model: {MODEL_NAME}")
print("Type 'quit' or 'exit' to stop")
print("-" * 40)
while True:
try:
user_input = input("\nYou: ").strip()
if user_input.lower() in ['quit', 'exit', 'q']:
print("Goodbye!")
break
if not user_input:
continue
print("AI: ", end="", flush=True)
response = send_message(user_input)
print(response)
except KeyboardInterrupt:
print("\nGoodbye!")
break
except EOFError:
print("\nGoodbye!")
break
def main():
"""Main function"""
if len(sys.argv) > 1:
# Single message mode
message = " ".join(sys.argv[1:])
response = send_message(message)
print(response)
else:
# Interactive mode
interactive_mode()
if __name__ == "__main__":
main()

307
webui/package-lock.json generated
View File

@@ -9,13 +9,15 @@
"version": "0.0.0",
"license": "MIT",
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/vite": "^4.1.11",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.555.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -29,7 +31,6 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/eslint__js": "^9.14.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.4",
"@types/react-dom": "^19.2.3",
@@ -160,7 +161,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -510,7 +510,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -554,7 +553,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1251,21 +1249,21 @@
"license": "MIT"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
"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-checkbox": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.2.tgz",
"integrity": "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA==",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
"integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-presence": "1.1.4",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
@@ -1286,6 +1284,50 @@
}
}
},
"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-collection/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",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"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-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
@@ -1317,20 +1359,20 @@
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz",
"integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==",
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.10",
"@radix-ui/react-focus-guards": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.4",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
@@ -1352,13 +1394,46 @@
}
}
},
"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",
"integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==",
"node_modules/@radix-ui/react-dialog/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",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2"
},
"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-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.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
@@ -1380,9 +1455,9 @@
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz",
"integrity": "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
@@ -1438,12 +1513,35 @@
}
},
"node_modules/@radix-ui/react-label": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz",
"integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==",
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz",
"integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
"@radix-ui/react-primitive": "2.1.4"
},
"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-label/node_modules/@radix-ui/react-primitive": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.4"
},
"peerDependencies": {
"@types/react": "*",
@@ -1485,9 +1583,9 @@
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz",
"integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==",
"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",
@@ -1531,7 +1629,7 @@
}
}
},
"node_modules/@radix-ui/react-slot": {
"node_modules/@radix-ui/react-primitive/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",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
@@ -1549,6 +1647,87 @@
}
}
},
"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-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-slot": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"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-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
@@ -2352,7 +2531,8 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -2417,17 +2597,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/eslint__js": {
"version": "9.14.0",
"resolved": "https://registry.npmjs.org/@types/eslint__js/-/eslint__js-9.14.0.tgz",
"integrity": "sha512-s0jepCjOJWB/GKcuba4jISaVpBudw3ClXJ3fUK4tugChUMQsp6kSwuA8Dcx6wFd/JsJqcY8n4rEpa5RTHs5ypA==",
"deprecated": "This is a stub types definition. @eslint/js provides its own type definitions, so you do not need this installed.",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint/js": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2447,7 +2616,6 @@
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -2458,7 +2626,6 @@
"integrity": "sha512-tBFxBp9Nfyy5rsmefN+WXc1JeW/j2BpBHFdLZbEVfs9wn3E3NRFxwV0pJg8M1qQAexFpvz73hJXFofV0ZAu92A==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -2469,7 +2636,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -2520,7 +2686,6 @@
"integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.48.0",
"@typescript-eslint/types": "8.48.0",
@@ -2869,7 +3034,6 @@
"integrity": "sha512-F9jI5rSstNknPlTlPN2gcc4gpbaagowuRzw/OJzl368dvPun668Q182S8Q8P9PITgGCl5LAKXpzuue106eM4wA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/utils": "4.0.8",
"fflate": "^0.8.2",
@@ -2906,7 +3070,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2957,6 +3120,7 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -3228,7 +3392,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173",
@@ -3540,6 +3703,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",
@@ -3651,7 +3824,8 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/dunder-proto": {
"version": "1.0.1",
@@ -3955,7 +4129,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5181,7 +5354,6 @@
"integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.23",
"@asamuzakjp/dom-selector": "^6.7.4",
@@ -5592,6 +5764,7 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -5977,7 +6150,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -6039,6 +6211,7 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -6054,6 +6227,7 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -6095,7 +6269,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -6105,7 +6278,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -6118,7 +6290,8 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/react-refresh": {
"version": "0.18.0",
@@ -7072,7 +7245,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7220,7 +7392,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -7296,7 +7467,6 @@
"integrity": "sha512-urzu3NCEV0Qa0Y2PwvBtRgmNtxhj5t5ULw7cuKhIHh3OrkKTLlut0lnBOv9qe5OvbkMH2g38G7KPDCTpIytBVg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.8",
"@vitest/mocker": "4.0.8",
@@ -7625,7 +7795,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -18,13 +18,15 @@
"lint:fix": "eslint . --ext .ts,.tsx --fix"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/vite": "^4.1.11",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.555.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -38,7 +40,6 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/eslint__js": "^9.14.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.4",
"@types/react-dom": "^19.2.3",

View File

@@ -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>

View File

@@ -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' } }

View File

@@ -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"

View File

@@ -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

View File

@@ -4,8 +4,7 @@ import userEvent from '@testing-library/user-event'
import InstanceList from '@/components/InstanceList'
import { InstancesProvider } from '@/contexts/InstancesContext'
import { instancesApi } from '@/lib/api'
import type { Instance } from '@/types/instance'
import { BackendType } from '@/types/instance'
import { BackendType, type Instance } from '@/types/instance'
import { AuthProvider } from '@/contexts/AuthContext'
// Mock the API
@@ -59,9 +58,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'

View File

@@ -153,6 +153,7 @@ afterEach(() => {
describe('Edit Mode', () => {
const mockInstance: Instance = {
id: 1,
name: 'existing-instance',
status: 'stopped',
options: {

View File

@@ -0,0 +1,238 @@
import { useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Checkbox } from "@/components/ui/checkbox";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Loader2 } from "lucide-react";
import { apiKeysApi } from "@/lib/api";
import { PermissionMode, type CreateKeyRequest } from "@/types/apiKey";
import { useInstances } from "@/contexts/InstancesContext";
import { format } from "date-fns";
interface CreateApiKeyDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onKeyCreated: (plainTextKey: string) => void;
}
function CreateApiKeyDialog({ open, onOpenChange, onKeyCreated }: CreateApiKeyDialogProps) {
const { instances } = useInstances();
const [name, setName] = useState("");
const [permissionMode, setPermissionMode] = useState<PermissionMode>(PermissionMode.AllowAll);
const [expiresAt, setExpiresAt] = useState<string>("");
const [instancePermissions, setInstancePermissions] = useState<Record<number, boolean>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const formatDisplayDate = (dateString: string) => {
if (!dateString) return null;
try {
const date = new Date(dateString);
return format(date, "d MMMM yyyy");
} catch {
return null;
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// Validation
if (!name.trim()) {
setError("Name is required");
return;
}
if (name.length > 100) {
setError("Name must be 100 characters or less");
return;
}
if (permissionMode === PermissionMode.PerInstance) {
const hasAnyPermission = Object.values(instancePermissions).some(v => v);
if (!hasAnyPermission) {
setError("At least one instance permission is required for per-instance mode");
return;
}
}
// Build request
const instanceIds: number[] = [];
if (permissionMode === PermissionMode.PerInstance) {
Object.entries(instancePermissions).forEach(([instanceId, hasPermission]) => {
if (hasPermission) {
instanceIds.push(parseInt(instanceId));
}
});
}
const request: CreateKeyRequest = {
name: name.trim(),
permission_mode: permissionMode,
instance_ids: instanceIds,
};
// Add expiration if provided
if (expiresAt) {
const expirationDate = new Date(expiresAt);
const now = new Date();
if (expirationDate <= now) {
setError("Expiration date must be in the future");
return;
}
request.expires_at = Math.floor(expirationDate.getTime() / 1000);
}
setLoading(true);
try {
const response = await apiKeysApi.create(request);
onKeyCreated(response.key);
// Reset form
setName("");
setPermissionMode(PermissionMode.AllowAll);
setExpiresAt("");
setInstancePermissions({});
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create API key");
} finally {
setLoading(false);
}
};
const handleInstancePermissionChange = (instanceId: number, checked: boolean) => {
setInstancePermissions(prev => ({
...prev,
[instanceId]: checked,
}));
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Create API Key</DialogTitle>
</DialogHeader>
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My API Key"
maxLength={100}
disabled={loading}
/>
</div>
<div className="space-y-3">
<Label>Permission Mode</Label>
<RadioGroup
value={permissionMode}
onValueChange={(value) => setPermissionMode(value as PermissionMode)}
disabled={loading}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value={PermissionMode.AllowAll} id="allow-all" />
<Label htmlFor="allow-all" className="font-normal cursor-pointer">
Full Access
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value={PermissionMode.PerInstance} id="per-instance" />
<Label htmlFor="per-instance" className="font-normal cursor-pointer">
Per-Instance Access
</Label>
</div>
</RadioGroup>
{permissionMode === PermissionMode.AllowAll && (
<p className="text-sm text-muted-foreground">
This key will have access to all instances
</p>
)}
{permissionMode === PermissionMode.PerInstance && (
<div className="space-y-2 border rounded-lg p-4">
<Label className="text-sm font-semibold">Instance Permissions</Label>
{instances.length === 0 ? (
<p className="text-sm text-muted-foreground">No instances available</p>
) : (
<div className="space-y-2">
{instances.map((instance, index) => {
const isChecked = !!instancePermissions[instance.id];
return (
<div
key={`${instance.name}-${index}`}
className="flex items-center space-x-2"
>
<Checkbox
id={`instance-${instance.id}`}
checked={isChecked}
onCheckedChange={(checked) => {
handleInstancePermissionChange(instance.id, checked as boolean);
}}
disabled={loading}
/>
<Label
htmlFor={`instance-${instance.id}`}
className="font-normal cursor-pointer flex-1"
>
{instance.name}
</Label>
</div>
);
})}
</div>
)}
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="expires-at">Expiration Date (Optional)</Label>
<Input
id="expires-at"
type="date"
value={expiresAt}
onChange={(e) => setExpiresAt(e.target.value)}
disabled={loading}
/>
{expiresAt && formatDisplayDate(expiresAt) && (
<p className="text-sm text-muted-foreground">
Expires on {formatDisplayDate(expiresAt)}
</p>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export default CreateApiKeyDialog;

View File

@@ -59,7 +59,7 @@ const KeyValueInput: React.FC<KeyValueInputProps> = ({
// Reset to single empty row if value is explicitly undefined/null
setPairs([{ key: '', value: '' }])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value])
// Update parent component when pairs change

View File

@@ -5,7 +5,7 @@ import NumberInput from '@/components/form/NumberInput'
interface AutoRestartConfigurationProps {
formData: CreateInstanceOptions
onChange: (key: keyof CreateInstanceOptions, value: any) => void
onChange: <K extends keyof CreateInstanceOptions>(key: K, value: CreateInstanceOptions[K]) => void
}
const AutoRestartConfiguration: React.FC<AutoRestartConfigurationProps> = ({

View File

@@ -3,9 +3,11 @@ import type { CreateInstanceOptions } from '@/types/instance'
import { getBasicBackendFields, getAdvancedBackendFields } from '@/lib/zodFormUtils'
import BackendFormField from '@/components/BackendFormField'
type BackendFieldValue = string | number | boolean | string[] | Record<string, string> | undefined
interface BackendConfigurationProps {
formData: CreateInstanceOptions
onBackendFieldChange: (key: string, value: any) => void
onBackendFieldChange: (key: string, value: BackendFieldValue) => void
showAdvanced?: boolean
}
@@ -26,7 +28,7 @@ const BackendConfiguration: React.FC<BackendConfigurationProps> = ({
<BackendFormField
key={fieldKey}
fieldKey={fieldKey}
value={(formData.backend_options as any)?.[fieldKey]}
value={(formData.backend_options as Record<string, BackendFieldValue> | undefined)?.[fieldKey]}
onChange={onBackendFieldChange}
/>
))}
@@ -41,7 +43,7 @@ const BackendConfiguration: React.FC<BackendConfigurationProps> = ({
<BackendFormField
key={fieldKey}
fieldKey={fieldKey}
value={(formData.backend_options as any)?.[fieldKey]}
value={(formData.backend_options as Record<string, BackendFieldValue> | undefined)?.[fieldKey]}
onChange={onBackendFieldChange}
/>
))}
@@ -53,7 +55,7 @@ const BackendConfiguration: React.FC<BackendConfigurationProps> = ({
<BackendFormField
key="extra_args"
fieldKey="extra_args"
value={(formData.backend_options as any)?.extra_args}
value={(formData.backend_options as Record<string, BackendFieldValue> | undefined)?.extra_args}
onChange={onBackendFieldChange}
/>
</div>

View File

@@ -0,0 +1,270 @@
import { useEffect, useState, Fragment } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Trash2, Copy, Check, X, ChevronDown, ChevronRight } from "lucide-react";
import { apiKeysApi } from "@/lib/api";
import { type ApiKey, type KeyPermissionResponse, PermissionMode } from "@/types/apiKey";
import CreateApiKeyDialog from "@/components/apikeys/CreateApiKeyDialog";
import { format, formatDistanceToNow } from "date-fns";
function ApiKeysSection() {
const [keys, setKeys] = useState<ApiKey[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [expandedRowId, setExpandedRowId] = useState<number | null>(null);
const [newKeyPlainText, setNewKeyPlainText] = useState<string | null>(null);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [copiedKey, setCopiedKey] = useState(false);
const [permissions, setPermissions] = useState<Record<number, KeyPermissionResponse[]>>({});
const [loadingPermissions, setLoadingPermissions] = useState<Record<number, boolean>>({});
useEffect(() => {
void fetchKeys();
}, []);
const fetchKeys = async () => {
setLoading(true);
setError(null);
try {
const data = await apiKeysApi.list();
setKeys(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load API keys");
} finally {
setLoading(false);
}
};
const fetchPermissions = async (keyId: number) => {
if (permissions[keyId]) return;
setLoadingPermissions({ ...loadingPermissions, [keyId]: true });
try {
const data = await apiKeysApi.getPermissions(keyId);
setPermissions({ ...permissions, [keyId]: data });
} catch (err) {
console.error("Failed to load permissions:", err);
} finally {
setLoadingPermissions({ ...loadingPermissions, [keyId]: false });
}
};
const handleKeyCreated = (plainTextKey: string) => {
setNewKeyPlainText(plainTextKey);
void fetchKeys();
setCreateDialogOpen(false);
};
const dismissSuccessBanner = () => {
setNewKeyPlainText(null);
};
const handleCopyKey = async () => {
if (newKeyPlainText) {
await navigator.clipboard.writeText(newKeyPlainText);
setCopiedKey(true);
setTimeout(() => setCopiedKey(false), 2000);
}
};
const handleDeleteKey = async (id: number, name: string) => {
if (!confirm(`Are you sure you want to delete the key '${name}'?\n\nThis action cannot be undone.`)) {
return;
}
try {
await apiKeysApi.delete(id);
void fetchKeys();
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to delete API key");
}
};
const handleRowClick = (key: ApiKey) => {
if (expandedRowId === key.id) {
setExpandedRowId(null);
} else {
setExpandedRowId(key.id);
if (key.permission_mode === PermissionMode.PerInstance) {
void fetchPermissions(key.id);
}
}
};
const formatDate = (timestamp: number) => {
return format(new Date(timestamp * 1000), "MMM d, yyyy");
};
const formatLastUsed = (timestamp: number | null) => {
if (!timestamp) return "Never";
return formatDistanceToNow(new Date(timestamp * 1000), { addSuffix: true });
};
const isExpired = (expiresAt: number | null) => {
if (!expiresAt) return false;
return expiresAt * 1000 < Date.now();
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">API Keys</h3>
<Button onClick={() => setCreateDialogOpen(true)}>Create API Key</Button>
</div>
{newKeyPlainText && (
<Alert className="bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-900">
<AlertDescription className="space-y-3">
<div className="flex items-start justify-between">
<div>
<p className="font-semibold text-green-900 dark:text-green-100">API key created successfully</p>
<p className="text-sm text-green-800 dark:text-green-200 mt-1">
Make sure to copy this key now. You won't be able to see it again!
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={dismissSuccessBanner}
className="h-6 w-6"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center gap-2">
<code className="flex-1 p-3 bg-white dark:bg-gray-900 border border-green-300 dark:border-green-800 rounded font-mono text-sm break-all">
{newKeyPlainText}
</code>
<Button onClick={() => void handleCopyKey()} variant="outline" size="sm">
{copiedKey ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</AlertDescription>
</Alert>
)}
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{loading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-16 bg-muted animate-pulse rounded" />
))}
</div>
) : keys.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
No API keys yet. Create your first key to get started.
</div>
) : (
<div className="border rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-muted">
<tr>
<th className="text-left p-3 font-semibold text-sm">Name</th>
<th className="text-left p-3 font-semibold text-sm">Permissions</th>
<th className="text-left p-3 font-semibold text-sm">Created</th>
<th className="text-left p-3 font-semibold text-sm">Expires</th>
<th className="text-left p-3 font-semibold text-sm">Last Accessed</th>
<th className="text-left p-3 font-semibold text-sm">Actions</th>
</tr>
</thead>
<tbody>
{keys.map((key) => (
<Fragment key={key.id}>
<tr
className="border-t hover:bg-muted/50 cursor-pointer"
onClick={() => handleRowClick(key)}
>
<td className="p-3">
<div className="flex items-center gap-2">
{expandedRowId === key.id ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
{key.name}
</div>
</td>
<td className="p-3">
{key.permission_mode === PermissionMode.AllowAll ? (
<Badge variant="default">Full Access</Badge>
) : (
<Badge variant="secondary">Limited Access</Badge>
)}
</td>
<td className="p-3 text-sm text-muted-foreground">{formatDate(key.created_at)}</td>
<td className="p-3">
{key.expires_at ? (
isExpired(key.expires_at) ? (
<Badge variant="destructive">Expired</Badge>
) : (
<span className="text-sm text-muted-foreground">{formatDate(key.expires_at)}</span>
)
) : (
<span className="text-sm text-muted-foreground">Never</span>
)}
</td>
<td className="p-3 text-sm text-muted-foreground">{formatLastUsed(key.last_used_at)}</td>
<td className="p-3">
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
void handleDeleteKey(key.id, key.name);
}}
title="Delete key"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</td>
</tr>
{expandedRowId === key.id && (
<tr key={`${key.id}-expanded`} className="border-t bg-muted/30">
<td colSpan={6} className="p-4">
{key.permission_mode === PermissionMode.AllowAll ? (
<p className="text-sm text-muted-foreground">
This key has full access to all instances
</p>
) : loadingPermissions[key.id] ? (
<p className="text-sm text-muted-foreground">Loading permissions...</p>
) : permissions[key.id] ? (
<div className="space-y-2">
<p className="text-sm font-semibold">Allowed Instances:</p>
<ul className="text-sm space-y-1">
{permissions[key.id].map((perm) => (
<li key={perm.instance_id} className="flex items-center gap-2">
<Check className="h-3 w-3 text-green-600" />
{perm.instance_name}
</li>
))}
</ul>
</div>
) : (
<p className="text-sm text-muted-foreground">No permissions data</p>
)}
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
)}
<CreateApiKeyDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onKeyCreated={handleKeyCreated}
/>
</div>
);
}
export default ApiKeysSection;

View File

@@ -0,0 +1,25 @@
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import ApiKeysSection from "./ApiKeysSection";
interface SettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Settings</DialogTitle>
<DialogDescription>
Manage your application settings and API keys.
</DialogDescription>
</DialogHeader>
<ApiKeysSection />
</DialogContent>
</Dialog>
);
}
export default SettingsDialog;

View File

@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {

View File

@@ -9,14 +9,13 @@ const buttonVariants = cva(
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
@@ -26,6 +25,8 @@ const buttonVariants = cva(
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {

View File

@@ -20,7 +20,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}

View File

@@ -19,7 +19,7 @@ function Checkbox({
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>

View File

@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className

View File

@@ -0,0 +1,43 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View File

@@ -3,8 +3,7 @@ import { render, screen, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { InstancesProvider, useInstances } from "@/contexts/InstancesContext";
import { instancesApi } from "@/lib/api";
import type { Instance } from "@/types/instance";
import { BackendType } from "@/types/instance";
import { BackendType, type Instance } from "@/types/instance";
import { AuthProvider } from "../AuthContext";
// Mock the API module
@@ -71,37 +70,37 @@ function TestComponent() {
{/* Action buttons for testing with specific instances */}
<button
onClick={() => createInstance("new-instance", { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "test.gguf" } })}
onClick={() => void createInstance("new-instance", { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "test.gguf" } })}
data-testid="create-instance"
>
Create Instance
</button>
<button
onClick={() => updateInstance("instance1", { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "updated.gguf" } })}
onClick={() => void updateInstance("instance1", { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "updated.gguf" } })}
data-testid="update-instance"
>
Update Instance
</button>
<button
onClick={() => startInstance("instance2")}
onClick={() => void startInstance("instance2")}
data-testid="start-instance"
>
Start Instance2
</button>
<button
onClick={() => stopInstance("instance1")}
onClick={() => void stopInstance("instance1")}
data-testid="stop-instance"
>
Stop Instance1
</button>
<button
onClick={() => restartInstance("instance1")}
onClick={() => void restartInstance("instance1")}
data-testid="restart-instance"
>
Restart Instance1
</button>
<button
onClick={() => deleteInstance("instance2")}
onClick={() => void deleteInstance("instance2")}
data-testid="delete-instance"
>
Delete Instance2
@@ -123,8 +122,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 +180,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 +238,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 +409,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: {},

View File

@@ -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`),
};

View File

@@ -156,11 +156,14 @@ class HealthService {
this.callbacks.set(instanceName, new Set())
}
this.callbacks.get(instanceName)!.add(callback)
const callbacks = this.callbacks.get(instanceName)
if (callbacks) {
callbacks.add(callback)
// Start health checking if this is the first subscriber
if (this.callbacks.get(instanceName)!.size === 1) {
this.startHealthCheck(instanceName)
// Start health checking if this is the first subscriber
if (callbacks.size === 1) {
this.startHealthCheck(instanceName)
}
}
// Return unsubscribe function
@@ -214,22 +217,24 @@ class HealthService {
}
// Start new interval with appropriate timing
const interval = setInterval(async () => {
try {
const health = await this.performHealthCheck(instanceName)
this.notifyCallbacks(instanceName, health)
const interval = setInterval(() => {
void (async () => {
try {
const health = await this.performHealthCheck(instanceName)
this.notifyCallbacks(instanceName, health)
// Check if state changed and adjust interval
const previousState = this.lastHealthState.get(instanceName)
this.lastHealthState.set(instanceName, health.state)
// Check if state changed and adjust interval
const previousState = this.lastHealthState.get(instanceName)
this.lastHealthState.set(instanceName, health.state)
if (previousState !== health.state) {
this.adjustPollingInterval(instanceName, health.state)
if (previousState !== health.state) {
this.adjustPollingInterval(instanceName, health.state)
}
} catch (error) {
console.error(`Health check failed for ${instanceName}:`, error)
// Continue polling even on error
}
} catch (error) {
console.error(`Health check failed for ${instanceName}:`, error)
// Continue polling even on error
}
})()
}, pollInterval)
this.intervals.set(instanceName, interval)

View File

@@ -6,7 +6,10 @@ import './index.css'
import { AuthProvider } from './contexts/AuthContext'
import { ConfigProvider } from './contexts/ConfigContext'
ReactDOM.createRoot(document.getElementById('root')!).render(
const rootElement = document.getElementById('root')
if (!rootElement) throw new Error('Failed to find the root element')
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<AuthProvider>
<ConfigProvider>

View File

@@ -1,10 +1,44 @@
import '@testing-library/jest-dom'
import { afterEach, vi } from 'vitest'
import { afterEach, beforeEach } from 'vitest'
// Mock fetch globally since your app uses fetch
global.fetch = vi.fn()
// Create a working localStorage implementation for tests
// This ensures localStorage works in both CLI and VSCode test runner
class LocalStorageMock implements Storage {
private store: Map<string, string> = new Map()
get length(): number {
return this.store.size
}
clear(): void {
this.store.clear()
}
getItem(key: string): string | null {
return this.store.get(key) ?? null
}
key(index: number): string | null {
return Array.from(this.store.keys())[index] ?? null
}
removeItem(key: string): void {
this.store.delete(key)
}
setItem(key: string, value: string): void {
this.store.set(key, value)
}
}
// Replace global localStorage
global.localStorage = new LocalStorageMock()
// Clean up before each test
beforeEach(() => {
localStorage.clear()
})
// Clean up after each test
afterEach(() => {
vi.clearAllMocks()
localStorage.clear()
})

31
webui/src/types/apiKey.ts Normal file
View File

@@ -0,0 +1,31 @@
export enum PermissionMode {
AllowAll = "allow_all",
PerInstance = "per_instance"
}
export interface ApiKey {
id: number
name: string
user_id: string
permission_mode: PermissionMode
expires_at: number | null
created_at: number
updated_at: number
last_used_at: number | null
}
export interface CreateKeyRequest {
name: string
permission_mode: PermissionMode
expires_at?: number
instance_ids: number[]
}
export interface CreateKeyResponse extends ApiKey {
key: string
}
export interface KeyPermissionResponse {
instance_id: number
instance_name: string
}

View File

@@ -24,6 +24,7 @@ export interface HealthStatus {
}
export interface Instance {
id: number;
name: string;
status: InstanceStatus;
options?: CreateInstanceOptions;

View File

@@ -4,7 +4,8 @@
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
"allowSyntheticDefaultImports": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}