50 Commits

Author SHA1 Message Date
19eb552dc7 Merge pull request #89 from lordmathis/dependabot/npm_and_yarn/webui/npm-development-bfaa8512fe
chore: bump the npm-development group across 1 directory with 14 updates
2025-11-13 21:40:29 +01:00
4bddea2831 Merge branch 'main' into dependabot/npm_and_yarn/webui/npm-development-bfaa8512fe 2025-11-13 21:38:39 +01:00
b878dfe8da Merge pull request #91 from lordmathis/fix/node-types
fix: Add missing @types/node to types in tsconfig.json
2025-11-13 21:38:25 +01:00
d600212cd0 Add @types/node to types in tsconfig.json 2025-11-13 21:36:21 +01:00
dependabot[bot]
5837521821 chore: bump the npm-development group across 1 directory with 14 updates
Bumps the npm-development group with 14 updates in the /webui directory:

| Package | From | To |
| --- | --- | --- |
| [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.32.0` | `9.39.1` |
| [@types/eslint__js](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/eslint__js) | `8.42.3` | `9.14.0` |
| [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) | `6.6.3` | `6.9.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `24.0.15` | `24.10.1` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `4.7.0` | `5.1.1` |
| [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `3.2.4` | `4.0.8` |
| [eslint](https://github.com/eslint/eslint) | `9.32.0` | `9.39.1` |
| [eslint-plugin-react-hooks](https://github.com/facebook/react/tree/HEAD/packages/eslint-plugin-react-hooks) | `5.2.0` | `7.0.1` |
| [jsdom](https://github.com/jsdom/jsdom) | `26.1.0` | `27.2.0` |
| [tw-animate-css](https://github.com/Wombosvideo/tw-animate-css) | `1.3.5` | `1.4.0` |
| [typescript](https://github.com/microsoft/TypeScript) | `5.8.3` | `5.9.3` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.38.0` | `8.46.4` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `7.1.11` | `7.2.2` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `3.2.4` | `4.0.8` |



Updates `@eslint/js` from 9.32.0 to 9.39.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v9.39.1/packages/js)

Updates `@types/eslint__js` from 8.42.3 to 9.14.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/eslint__js)

Updates `@testing-library/jest-dom` from 6.6.3 to 6.9.1
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v6.6.3...v6.9.1)

Updates `@types/eslint__js` from 8.42.3 to 9.14.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/eslint__js)

Updates `@types/node` from 24.0.15 to 24.10.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@vitejs/plugin-react` from 4.7.0 to 5.1.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@5.1.1/packages/plugin-react)

Updates `@vitest/ui` from 3.2.4 to 4.0.8
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.0.8/packages/ui)

Updates `eslint` from 9.32.0 to 9.39.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.32.0...v9.39.1)

Updates `eslint-plugin-react-hooks` from 5.2.0 to 7.0.1
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/packages/eslint-plugin-react-hooks/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/HEAD/packages/eslint-plugin-react-hooks)

Updates `jsdom` from 26.1.0 to 27.2.0
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Changelog](https://github.com/jsdom/jsdom/blob/main/Changelog.md)
- [Commits](https://github.com/jsdom/jsdom/compare/26.1.0...27.2.0)

Updates `tw-animate-css` from 1.3.5 to 1.4.0
- [Release notes](https://github.com/Wombosvideo/tw-animate-css/releases)
- [Commits](https://github.com/Wombosvideo/tw-animate-css/compare/v1.3.5...v1.4.0)

Updates `typescript` from 5.8.3 to 5.9.3
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release-publish.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.8.3...v5.9.3)

Updates `typescript-eslint` from 8.38.0 to 8.46.4
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.46.4/packages/typescript-eslint)

Updates `vite` from 7.1.11 to 7.2.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.2.2/packages/vite)

Updates `vitest` from 3.2.4 to 4.0.8
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.0.8/packages/vitest)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 9.39.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-development
- dependency-name: "@types/eslint__js"
  dependency-version: 9.14.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-development
- dependency-name: "@testing-library/jest-dom"
  dependency-version: 6.9.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-development
- dependency-name: "@types/eslint__js"
  dependency-version: 9.14.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-development
- dependency-name: "@types/node"
  dependency-version: 24.10.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-development
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 5.1.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-development
- dependency-name: "@vitest/ui"
  dependency-version: 4.0.8
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-development
- dependency-name: eslint
  dependency-version: 9.39.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-development
- dependency-name: eslint-plugin-react-hooks
  dependency-version: 7.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-development
- dependency-name: jsdom
  dependency-version: 27.2.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-development
- dependency-name: tw-animate-css
  dependency-version: 1.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-development
- dependency-name: typescript
  dependency-version: 5.9.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-development
- dependency-name: typescript-eslint
  dependency-version: 8.46.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-development
- dependency-name: vite
  dependency-version: 7.2.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-development
- dependency-name: vitest
  dependency-version: 4.0.8
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-13 20:19:01 +00:00
7e71ada904 Merge pull request #88 from lordmathis/dependabot/npm_and_yarn/webui/npm-production-ebddbb6ace
chore: bump the npm-production group in /webui with 5 updates
2025-11-13 21:16:36 +01:00
5335634879 Merge branch 'main' into dependabot/npm_and_yarn/webui/npm-production-ebddbb6ace 2025-11-13 21:11:33 +01:00
15d1e17454 Merge pull request #90 from lordmathis/feat/custom-args
feat: Add support for custom args
2025-11-13 21:10:45 +01:00
72b70918fa Add useEffect to sync internal state with value prop in KeyValueInput 2025-11-13 21:04:20 +01:00
11bfe75a3c Add support for extra args for command parser 2025-11-13 20:41:08 +01:00
ae5358ff65 Change FlashAttn field type to string in LlamaServerOptions 2025-11-12 23:49:34 +01:00
bff8e7d914 Refactor command line argument keys to use snake_case format in LlamaServerOptions 2025-11-12 23:46:15 +01:00
5ad076919e Add extra args test to backend 2025-11-12 23:40:07 +01:00
c022746cd8 Fix import path for EnvVarsInput component 2025-11-12 23:13:09 +01:00
8d92f9b371 Add ExtraArgs support for Llama, Mlx, and Vllm server options 2025-11-12 23:11:22 +01:00
15180a227b Add support for extra arguments in frontend 2025-11-12 22:50:15 +01:00
dependabot[bot]
0708327a16 chore: bump the npm-production group in /webui with 5 updates
Bumps the npm-production group in /webui with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `0.525.0` | `0.553.0` |
| [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.1.0` | `19.2.0` |
| [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) | `19.1.0` | `19.2.0` |
| [tailwind-merge](https://github.com/dcastil/tailwind-merge) | `3.3.1` | `3.4.0` |
| [zod](https://github.com/colinhacks/zod) | `4.0.5` | `4.1.12` |


Updates `lucide-react` from 0.525.0 to 0.553.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/0.553.0/packages/lucide-react)

Updates `react` from 19.1.0 to 19.2.0
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.0/packages/react)

Updates `react-dom` from 19.1.0 to 19.2.0
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.0/packages/react-dom)

Updates `tailwind-merge` from 3.3.1 to 3.4.0
- [Release notes](https://github.com/dcastil/tailwind-merge/releases)
- [Commits](https://github.com/dcastil/tailwind-merge/compare/v3.3.1...v3.4.0)

Updates `zod` from 4.0.5 to 4.1.12
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.0.5...v4.1.12)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 0.553.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-production
- dependency-name: react
  dependency-version: 19.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-production
- dependency-name: react-dom
  dependency-version: 19.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-production
- dependency-name: tailwind-merge
  dependency-version: 3.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-production
- dependency-name: zod
  dependency-version: 4.1.12
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-12 20:58:32 +00:00
a2740055c2 Merge pull request #87 from lordmathis/chore/update-dependabot
chore: Simplify dependabot configuration
2025-11-12 21:57:07 +01:00
0ddffaa2e6 Simplify dependabot configuration 2025-11-12 21:53:01 +01:00
9a160a5312 Merge pull request #80 from lordmathis/chore/dependabot
chore: Add dependabot configuration for Go and npm dependencies
2025-11-12 21:34:51 +01:00
8861057f11 Add dependabot configuration for Go and npm dependencies 2025-11-12 21:31:47 +01:00
34edb8a2e5 Merge pull request #78 from lordmathis/feat/inflight-requests
feat: Wait for inflight requests to finish before shutting down an instance
2025-10-30 18:08:55 +01:00
560850f86d Add shutdown state checks in HTTP handlers 2025-10-30 18:00:59 +01:00
c340439306 Add support for 'shutting_down' state in HealthBadge and health service 2025-10-29 00:09:18 +01:00
77c0e22fd0 Use instance's ServeHTTP in handlers 2025-10-29 00:01:29 +01:00
d65c5ab717 Wait for inflight requests before stopping 2025-10-29 00:00:56 +01:00
2b94244c8a Replace GetProxy with ServeHttp in instance 2025-10-29 00:00:02 +01:00
2e5644db53 Implement inflight request tracking 2025-10-28 23:59:02 +01:00
7ee22fee51 Implement shutting down status 2025-10-28 23:53:11 +01:00
e5baedb776 Merge pull request #76 from lordmathis/feat/import-export
feat: Ad support for instance import and export on frontend
2025-10-27 20:46:48 +01:00
e6205b930e Document import and export features 2025-10-27 20:44:28 +01:00
f9eb424690 Fix concurrent map write issue in MarshalJSON by initializing BackendOptions 2025-10-27 20:36:42 +01:00
5b84b64623 Fix some typescript issues 2025-10-27 20:36:31 +01:00
7813a5f2be Move import instance configuration to InstanceDialog component 2025-10-27 20:17:18 +01:00
a00c9b82a6 Add import functionality for instance configuration from JSON file 2025-10-27 20:11:22 +01:00
cbfa6bd48f Fix export functionality to exclude computed field from JSON output 2025-10-27 19:59:43 +01:00
bee0f72c10 Add export functionality to InstanceCard component 2025-10-27 19:55:07 +01:00
a5d8f541f0 Merge pull request #75 from lordmathis/fix/delete-instance
fix: Prevent restarting instance from getting deleted
2025-10-27 19:27:58 +01:00
dfcc16083c Update test configuration to use 'sh -c "sleep 999999"' command 2025-10-27 19:25:13 +01:00
6ec2919049 Fix instance start simulation in TestUpdateInstance 2025-10-27 19:14:54 +01:00
d6a6f377fc Fix logger race condition 2025-10-27 19:06:06 +01:00
cd9a71d9fc Update test configuration to use 'yes' command instead of 'sleep' 2025-10-27 18:54:20 +01:00
2c4cc5a69a Fix manager tests 2025-10-27 18:47:17 +01:00
b1fc1d2dc8 Add InstancesDir to test configuration for instance management 2025-10-27 18:38:23 +01:00
08c47a16a0 Fix operations tests 2025-10-27 18:35:16 +01:00
219db7abce Move port range validation to config 2025-10-27 18:23:49 +01:00
14131a6274 Remove redundant code 2025-10-27 18:18:25 +01:00
e65f4f1641 Remove unsupported error wrapping from log.Printf 2025-10-27 18:01:58 +01:00
5ef0654cdd Use %w for error wrapping in log messages across multiple files 2025-10-27 17:54:39 +01:00
1814772fa2 Fix instance deletion check to account for restarting status 2025-10-27 17:42:27 +01:00
54 changed files with 1801 additions and 1078 deletions

40
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
version: 2
updates:
# Go modules
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
commit-message:
prefix: "chore"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-patch"]
groups:
go-dependencies:
patterns:
- "*"
labels:
- "dependencies"
# npm dependencies for webui
- package-ecosystem: "npm"
directory: "/webui"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
commit-message:
prefix: "chore"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-patch"]
groups:
npm-production:
dependency-type: "production"
npm-development:
dependency-type: "development"
labels:
- "dependencies"

View File

@@ -5,6 +5,7 @@ import (
"llamactl/pkg/config"
"llamactl/pkg/manager"
"llamactl/pkg/server"
"log"
"net/http"
"os"
"os/signal"
@@ -38,8 +39,7 @@ func main() {
configPath := os.Getenv("LLAMACTL_CONFIG_PATH")
cfg, err := config.LoadConfig(configPath)
if err != nil {
fmt.Printf("Error loading config: %v\n", err)
fmt.Println("Using default configuration.")
log.Printf("Error loading config: %v\nUsing default configuration.", err)
}
// Set version information
@@ -50,13 +50,11 @@ func main() {
// Create the data directory if it doesn't exist
if cfg.Instances.AutoCreateDirs {
if err := os.MkdirAll(cfg.Instances.InstancesDir, 0755); err != nil {
fmt.Printf("Error creating config directory %s: %v\n", cfg.Instances.InstancesDir, err)
fmt.Println("Persistence will not be available.")
log.Printf("Error creating config directory %s: %v\nPersistence will not be available.", cfg.Instances.InstancesDir, err)
}
if err := os.MkdirAll(cfg.Instances.LogsDir, 0755); err != nil {
fmt.Printf("Error creating log directory %s: %v\n", cfg.Instances.LogsDir, err)
fmt.Println("Instance logs will not be available.")
log.Printf("Error creating log directory %s: %v\nInstance logs will not be available.", cfg.Instances.LogsDir, err)
}
}
@@ -81,7 +79,7 @@ func main() {
go func() {
fmt.Printf("Llamactl server listening on %s:%d\n", cfg.Server.Host, cfg.Server.Port)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Printf("Error starting server: %v\n", err)
log.Printf("Error starting server: %v\n", err)
}
}()
@@ -90,7 +88,7 @@ func main() {
fmt.Println("Shutting down server...")
if err := server.Close(); err != nil {
fmt.Printf("Error shutting down server: %v\n", err)
log.Printf("Error shutting down server: %v\n", err)
} else {
fmt.Println("Server shut down gracefully.")
}

View File

@@ -42,6 +42,7 @@ Each instance is displayed as a card showing:
![Create Instance Screenshot](images/create_instance.png)
1. Click the **"Create Instance"** button on the dashboard
2. *Optional*: Click **"Import"** in the dialog header to load a previously exported configuration
2. Enter a unique **Name** for your instance (only required field)
3. **Select Target Node**: Choose which node to deploy the instance to from the dropdown
4. **Choose Backend Type**:
@@ -219,6 +220,12 @@ curl -X PUT http://localhost:8080/api/v1/instances/{name} \
Configuration changes require restarting the instance to take effect.
## Export Instance
**Via Web UI**
1. Click the **"More actions"** button (three dots) on an instance card
2. Click **"Export"** to download the instance configuration as a JSON file
## View Logs
**Via Web UI**

View File

@@ -93,6 +93,8 @@ func (o *Options) MarshalJSON() ([]byte, error) {
if err != nil {
return nil, fmt.Errorf("failed to marshal backend options: %w", err)
}
// Create a new map to avoid concurrent map writes
aux.BackendOptions = make(map[string]any)
if err := json.Unmarshal(optionsData, &aux.BackendOptions); err != nil {
return nil, fmt.Errorf("failed to unmarshal backend options to map: %w", err)
}

View File

@@ -93,3 +93,22 @@ func BuildDockerCommand(backendConfig *config.BackendSettings, instanceArgs []st
return "docker", dockerArgs, nil
}
// convertExtraArgsToFlags converts map[string]string to command flags
// Empty values become boolean flags: {"flag": ""} → ["--flag"]
// Non-empty values: {"flag": "value"} → ["--flag", "value"]
func convertExtraArgsToFlags(extraArgs map[string]string) []string {
var args []string
for key, value := range extraArgs {
if value == "" {
// Boolean flag
args = append(args, "--"+key)
} else {
// Value flag
args = append(args, "--"+key, value)
}
}
return args
}

View File

@@ -5,7 +5,6 @@ import (
"fmt"
"llamactl/pkg/validation"
"reflect"
"strconv"
)
// llamaMultiValuedFlags defines flags that should be repeated for each value rather than comma-separated
@@ -41,7 +40,7 @@ type LlamaServerOptions struct {
BatchSize int `json:"batch_size,omitempty"`
UBatchSize int `json:"ubatch_size,omitempty"`
Keep int `json:"keep,omitempty"`
FlashAttn bool `json:"flash_attn,omitempty"`
FlashAttn string `json:"flash_attn,omitempty"`
NoPerf bool `json:"no_perf,omitempty"`
Escape bool `json:"escape,omitempty"`
NoEscape bool `json:"no_escape,omitempty"`
@@ -187,6 +186,10 @@ type LlamaServerOptions struct {
FIMQwen7BDefault bool `json:"fim_qwen_7b_default,omitempty"`
FIMQwen7BSpec bool `json:"fim_qwen_7b_spec,omitempty"`
FIMQwen14BSpec bool `json:"fim_qwen_14b_spec,omitempty"`
// ExtraArgs are additional command line arguments.
// Example: {"verbose": "", "log-file": "/logs/llama.log"}
ExtraArgs map[string]string `json:"extra_args,omitempty"`
}
// UnmarshalJSON implements custom JSON unmarshaling to support multiple field names
@@ -209,6 +212,15 @@ func (o *LlamaServerOptions) UnmarshalJSON(data []byte) error {
// Copy to our struct
*o = LlamaServerOptions(temp)
// Track which fields we've processed
processedFields := make(map[string]bool)
// Get all known canonical field names from struct tags
knownFields := getKnownFieldNames(o)
for field := range knownFields {
processedFields[field] = true
}
// Handle alternative field names
fieldMappings := map[string]string{
// Common params
@@ -220,7 +232,7 @@ func (o *LlamaServerOptions) UnmarshalJSON(data []byte) error {
"Crb": "cpu_range_batch", // -Crb, --cpu-range-batch lo-hi
"c": "ctx_size", // -c, --ctx-size N
"n": "predict", // -n, --predict N
"n-predict": "predict", // --n-predict N
"n_predict": "predict", // -n-predict N
"b": "batch_size", // -b, --batch-size N
"ub": "ubatch_size", // -ub, --ubatch-size N
"fa": "flash_attn", // -fa, --flash-attn
@@ -234,7 +246,7 @@ func (o *LlamaServerOptions) UnmarshalJSON(data []byte) error {
"dev": "device", // -dev, --device <dev1,dev2,..>
"ot": "override_tensor", // --override-tensor, -ot
"ngl": "gpu_layers", // -ngl, --gpu-layers, --n-gpu-layers N
"n-gpu-layers": "gpu_layers", // --n-gpu-layers N
"n_gpu_layers": "gpu_layers", // --n-gpu-layers N
"sm": "split_mode", // -sm, --split-mode
"ts": "tensor_split", // -ts, --tensor-split N0,N1,N2,...
"mg": "main_gpu", // -mg, --main-gpu INDEX
@@ -250,9 +262,9 @@ func (o *LlamaServerOptions) UnmarshalJSON(data []byte) error {
"hffv": "hf_file_v", // -hffv, --hf-file-v FILE
"hft": "hf_token", // -hft, --hf-token TOKEN
"v": "verbose", // -v, --verbose, --log-verbose
"log-verbose": "verbose", // --log-verbose
"log_verbose": "verbose", // --log-verbose
"lv": "verbosity", // -lv, --verbosity, --log-verbosity N
"log-verbosity": "verbosity", // --log-verbosity N
"log_verbosity": "verbosity", // --log-verbosity N
// Sampling params
"s": "seed", // -s, --seed SEED
@@ -269,21 +281,23 @@ func (o *LlamaServerOptions) UnmarshalJSON(data []byte) error {
"rerank": "reranking", // --reranking
"to": "timeout", // -to, --timeout N
"sps": "slot_prompt_similarity", // -sps, --slot-prompt-similarity
"draft": "draft-max", // -draft, --draft-max N
"draft-n": "draft-max", // --draft-n-max N
"draft-n-min": "draft_min", // --draft-n-min N
"draft": "draft_max", // -draft, --draft-max N
"draft_n": "draft_max", // --draft-n-max N
"draft_n_min": "draft_min", // --draft-n-min N
"cd": "ctx_size_draft", // -cd, --ctx-size-draft N
"devd": "device_draft", // -devd, --device-draft
"ngld": "gpu_layers_draft", // -ngld, --gpu-layers-draft
"n-gpu-layers-draft": "gpu_layers_draft", // --n-gpu-layers-draft N
"n_gpu_layers_draft": "gpu_layers_draft", // --n-gpu-layers-draft N
"md": "model_draft", // -md, --model-draft FNAME
"ctkd": "cache_type_k_draft", // -ctkd, --cache-type-k-draft TYPE
"ctvd": "cache_type_v_draft", // -ctvd, --cache-type-v-draft TYPE
"mv": "model_vocoder", // -mv, --model-vocoder FNAME
}
// Process alternative field names
// Process alternative field names and mark them as processed
for altName, canonicalName := range fieldMappings {
processedFields[altName] = true // Mark alternatives as known
if value, exists := raw[altName]; exists {
// Use reflection to set the field value
v := reflect.ValueOf(o).Elem()
@@ -294,36 +308,21 @@ func (o *LlamaServerOptions) UnmarshalJSON(data []byte) error {
})
if field.IsValid() && field.CanSet() {
switch field.Kind() {
case reflect.Int:
if intVal, ok := value.(float64); ok {
field.SetInt(int64(intVal))
} else if strVal, ok := value.(string); ok {
if intVal, err := strconv.Atoi(strVal); err == nil {
field.SetInt(int64(intVal))
}
}
case reflect.Float64:
if floatVal, ok := value.(float64); ok {
field.SetFloat(floatVal)
} else if strVal, ok := value.(string); ok {
if floatVal, err := strconv.ParseFloat(strVal, 64); err == nil {
field.SetFloat(floatVal)
}
}
case reflect.String:
if strVal, ok := value.(string); ok {
field.SetString(strVal)
}
case reflect.Bool:
if boolVal, ok := value.(bool); ok {
field.SetBool(boolVal)
}
}
setFieldValue(field, value)
}
}
}
// Collect unknown fields into ExtraArgs
if o.ExtraArgs == nil {
o.ExtraArgs = make(map[string]string)
}
for key, value := range raw {
if !processedFields[key] {
o.ExtraArgs[key] = fmt.Sprintf("%v", value)
}
}
return nil
}
@@ -354,6 +353,18 @@ func (o *LlamaServerOptions) Validate() error {
return validation.ValidationError(fmt.Errorf("invalid port range: %d", o.Port))
}
// Validate extra_args keys and values
for key, value := range o.ExtraArgs {
if err := validation.ValidateStringForInjection(key); err != nil {
return validation.ValidationError(fmt.Errorf("extra_args key %q: %w", key, err))
}
if value != "" {
if err := validation.ValidateStringForInjection(value); err != nil {
return validation.ValidationError(fmt.Errorf("extra_args value for %q: %w", key, err))
}
}
}
return nil
}
@@ -361,7 +372,12 @@ func (o *LlamaServerOptions) Validate() error {
func (o *LlamaServerOptions) BuildCommandArgs() []string {
// Llama uses multiple flags for arrays by default (not comma-separated)
// Use package-level llamaMultiValuedFlags variable
return BuildCommandArgs(o, llamaMultiValuedFlags)
args := BuildCommandArgs(o, llamaMultiValuedFlags)
// Append extra args at the end
args = append(args, convertExtraArgsToFlags(o.ExtraArgs)...)
return args
}
func (o *LlamaServerOptions) BuildDockerArgs() []string {

View File

@@ -33,12 +33,11 @@ func TestLlamaCppBuildCommandArgs_BooleanFields(t *testing.T) {
{
name: "multiple booleans",
options: backends.LlamaServerOptions{
Verbose: true,
FlashAttn: true,
Mlock: false,
NoMmap: true,
Verbose: true,
Mlock: false,
NoMmap: true,
},
expected: []string{"--verbose", "--flash-attn", "--no-mmap"},
expected: []string{"--verbose", "--no-mmap"},
excluded: []string{"--mlock"},
},
}
@@ -346,7 +345,7 @@ func TestParseLlamaCommand(t *testing.T) {
},
{
name: "multiple value types",
command: "llama-server --model /test/model.gguf --gpu-layers 32 --temp 0.7 --verbose --no-mmap",
command: "llama-server --model /test/model.gguf --n-gpu-layers 32 --temp 0.7 --verbose --no-mmap",
expectErr: false,
validate: func(t *testing.T, opts *backends.LlamaServerOptions) {
if opts.Model != "/test/model.gguf" {
@@ -434,3 +433,119 @@ func TestParseLlamaCommandArrays(t *testing.T) {
}
}
}
func TestLlamaCppBuildCommandArgs_ExtraArgs(t *testing.T) {
options := backends.LlamaServerOptions{
Model: "/models/test.gguf",
ExtraArgs: map[string]string{
"flash-attn": "", // boolean flag
"log-file": "/logs/test.log", // value flag
},
}
args := options.BuildCommandArgs()
// Check that extra args are present
if !testutil.Contains(args, "--flash-attn") {
t.Error("Expected --flash-attn flag not found")
}
if !testutil.Contains(args, "--log-file") || !testutil.Contains(args, "/logs/test.log") {
t.Error("Expected --log-file flag or value not found")
}
}
func TestParseLlamaCommand_ExtraArgs(t *testing.T) {
tests := []struct {
name string
command string
expectErr bool
validate func(*testing.T, *backends.LlamaServerOptions)
}{
{
name: "extra args with known fields",
command: "llama-server --model /path/to/model.gguf --gpu-layers 32 --unknown-flag value --another-bool-flag",
expectErr: false,
validate: func(t *testing.T, opts *backends.LlamaServerOptions) {
if opts.Model != "/path/to/model.gguf" {
t.Errorf("expected model '/path/to/model.gguf', got '%s'", opts.Model)
}
if opts.GPULayers != 32 {
t.Errorf("expected gpu_layers 32, got %d", opts.GPULayers)
}
if opts.ExtraArgs == nil {
t.Fatal("expected extra_args to be non-nil")
}
if val, ok := opts.ExtraArgs["unknown_flag"]; !ok || val != "value" {
t.Errorf("expected extra_args[unknown_flag]='value', got '%s'", val)
}
if val, ok := opts.ExtraArgs["another_bool_flag"]; !ok || val != "true" {
t.Errorf("expected extra_args[another_bool_flag]='true', got '%s'", val)
}
},
},
{
name: "extra args with alternative field names",
command: "llama-server -m /model.gguf -ngl 16 --custom-arg test --new-feature",
expectErr: false,
validate: func(t *testing.T, opts *backends.LlamaServerOptions) {
// Check that alternative names worked for known fields
if opts.Model != "/model.gguf" {
t.Errorf("expected model '/model.gguf', got '%s'", opts.Model)
}
if opts.GPULayers != 16 {
t.Errorf("expected gpu_layers 16, got %d", opts.GPULayers)
}
// Check that unknown args went to ExtraArgs
if opts.ExtraArgs == nil {
t.Fatal("expected extra_args to be non-nil")
}
if val, ok := opts.ExtraArgs["custom_arg"]; !ok || val != "test" {
t.Errorf("expected extra_args[custom_arg]='test', got '%s'", val)
}
if val, ok := opts.ExtraArgs["new_feature"]; !ok || val != "true" {
t.Errorf("expected extra_args[new_feature]='true', got '%s'", val)
}
},
},
{
name: "only extra args",
command: "llama-server --experimental-feature --beta-mode enabled",
expectErr: false,
validate: func(t *testing.T, opts *backends.LlamaServerOptions) {
if opts.ExtraArgs == nil {
t.Fatal("expected extra_args to be non-nil")
}
if val, ok := opts.ExtraArgs["experimental_feature"]; !ok || val != "true" {
t.Errorf("expected extra_args[experimental_feature]='true', got '%s'", val)
}
if val, ok := opts.ExtraArgs["beta_mode"]; !ok || val != "enabled" {
t.Errorf("expected extra_args[beta_mode]='enabled', got '%s'", val)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var opts backends.LlamaServerOptions
result, err := opts.ParseCommand(tt.command)
if tt.expectErr && err == nil {
t.Error("expected error but got none")
return
}
if !tt.expectErr && err != nil {
t.Errorf("unexpected error: %v", err)
return
}
if !tt.expectErr && tt.validate != nil {
llamaOpts, ok := result.(*backends.LlamaServerOptions)
if !ok {
t.Fatal("result is not *LlamaServerOptions")
}
tt.validate(t, llamaOpts)
}
})
}
}

View File

@@ -1,6 +1,7 @@
package backends
import (
"encoding/json"
"fmt"
"llamactl/pkg/validation"
)
@@ -29,6 +30,46 @@ type MlxServerOptions struct {
TopK int `json:"top_k,omitempty"`
MinP float64 `json:"min_p,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
// ExtraArgs are additional command line arguments.
// Example: {"verbose": "", "log-file": "/logs/mlx.log"}
ExtraArgs map[string]string `json:"extra_args,omitempty"`
}
// UnmarshalJSON implements custom JSON unmarshaling to collect unknown fields into ExtraArgs
func (o *MlxServerOptions) UnmarshalJSON(data []byte) error {
// First unmarshal into a map to capture all fields
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
// Create a temporary struct for standard unmarshaling
type tempOptions MlxServerOptions
temp := tempOptions{}
// Standard unmarshal first
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
// Copy to our struct
*o = MlxServerOptions(temp)
// Get all known canonical field names from struct tags
knownFields := getKnownFieldNames(o)
// Collect unknown fields into ExtraArgs
if o.ExtraArgs == nil {
o.ExtraArgs = make(map[string]string)
}
for key, value := range raw {
if !knownFields[key] {
o.ExtraArgs[key] = fmt.Sprintf("%v", value)
}
}
return nil
}
func (o *MlxServerOptions) GetPort() int {
@@ -57,13 +98,30 @@ func (o *MlxServerOptions) Validate() error {
return validation.ValidationError(fmt.Errorf("invalid port range: %d", o.Port))
}
// Validate extra_args keys and values
for key, value := range o.ExtraArgs {
if err := validation.ValidateStringForInjection(key); err != nil {
return validation.ValidationError(fmt.Errorf("extra_args key %q: %w", key, err))
}
if value != "" {
if err := validation.ValidateStringForInjection(value); err != nil {
return validation.ValidationError(fmt.Errorf("extra_args value for %q: %w", key, err))
}
}
}
return nil
}
// BuildCommandArgs converts to command line arguments
func (o *MlxServerOptions) BuildCommandArgs() []string {
multipleFlags := map[string]struct{}{} // MLX doesn't currently have []string fields
return BuildCommandArgs(o, multipleFlags)
args := BuildCommandArgs(o, multipleFlags)
// Append extra args at the end
args = append(args, convertExtraArgsToFlags(o.ExtraArgs)...)
return args
}
func (o *MlxServerOptions) BuildDockerArgs() []string {

View File

@@ -202,3 +202,75 @@ func TestMlxBuildCommandArgs_ZeroValues(t *testing.T) {
}
}
}
func TestParseMlxCommand_ExtraArgs(t *testing.T) {
tests := []struct {
name string
command string
expectErr bool
validate func(*testing.T, *backends.MlxServerOptions)
}{
{
name: "extra args with known fields",
command: "mlx_lm.server --model /path/to/model --port 8080 --unknown-flag value --new-bool-flag",
expectErr: false,
validate: func(t *testing.T, opts *backends.MlxServerOptions) {
if opts.Model != "/path/to/model" {
t.Errorf("expected model '/path/to/model', got '%s'", opts.Model)
}
if opts.Port != 8080 {
t.Errorf("expected port 8080, got %d", opts.Port)
}
if opts.ExtraArgs == nil {
t.Fatal("expected extra_args to be non-nil")
}
if val, ok := opts.ExtraArgs["unknown_flag"]; !ok || val != "value" {
t.Errorf("expected extra_args[unknown_flag]='value', got '%s'", val)
}
if val, ok := opts.ExtraArgs["new_bool_flag"]; !ok || val != "true" {
t.Errorf("expected extra_args[new_bool_flag]='true', got '%s'", val)
}
},
},
{
name: "only extra args",
command: "mlx_lm.server --experimental-feature --custom-param test",
expectErr: false,
validate: func(t *testing.T, opts *backends.MlxServerOptions) {
if opts.ExtraArgs == nil {
t.Fatal("expected extra_args to be non-nil")
}
if val, ok := opts.ExtraArgs["experimental_feature"]; !ok || val != "true" {
t.Errorf("expected extra_args[experimental_feature]='true', got '%s'", val)
}
if val, ok := opts.ExtraArgs["custom_param"]; !ok || val != "test" {
t.Errorf("expected extra_args[custom_param]='test', got '%s'", val)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var opts backends.MlxServerOptions
result, err := opts.ParseCommand(tt.command)
if tt.expectErr && err == nil {
t.Error("expected error but got none")
return
}
if !tt.expectErr && err != nil {
t.Errorf("unexpected error: %v", err)
return
}
if !tt.expectErr && tt.validate != nil {
mlxOpts, ok := result.(*backends.MlxServerOptions)
if !ok {
t.Fatal("result is not *MlxServerOptions")
}
tt.validate(t, mlxOpts)
}
})
}
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"path/filepath"
"reflect"
"regexp"
"strconv"
"strings"
@@ -211,3 +212,65 @@ func parseValue(value string) any {
// Return as string
return value
}
// setFieldValue sets a field value using reflection, handling type conversions
// Used by UnmarshalJSON implementations to handle alternative field names
func setFieldValue(field reflect.Value, value any) {
switch field.Kind() {
case reflect.Int:
if intVal, ok := value.(float64); ok {
field.SetInt(int64(intVal))
} else if strVal, ok := value.(string); ok {
if intVal, err := strconv.Atoi(strVal); err == nil {
field.SetInt(int64(intVal))
}
}
case reflect.Float64:
if floatVal, ok := value.(float64); ok {
field.SetFloat(floatVal)
} else if strVal, ok := value.(string); ok {
if floatVal, err := strconv.ParseFloat(strVal, 64); err == nil {
field.SetFloat(floatVal)
}
}
case reflect.String:
if strVal, ok := value.(string); ok {
field.SetString(strVal)
}
case reflect.Bool:
if boolVal, ok := value.(bool); ok {
field.SetBool(boolVal)
}
case reflect.Slice:
// Handle string slices
if field.Type().Elem().Kind() == reflect.String {
if slice, ok := value.([]any); ok {
strSlice := make([]string, 0, len(slice))
for _, v := range slice {
if s, ok := v.(string); ok {
strSlice = append(strSlice, s)
}
}
field.Set(reflect.ValueOf(strSlice))
}
}
}
}
// getKnownFieldNames extracts all known field names from struct json tags
// Used by UnmarshalJSON implementations to identify unknown fields for ExtraArgs
func getKnownFieldNames(v any) map[string]bool {
fields := make(map[string]bool)
t := reflect.TypeOf(v).Elem()
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
jsonTag := field.Tag.Get("json")
if jsonTag != "" && jsonTag != "-" {
// Handle "name,omitempty" format
name := strings.Split(jsonTag, ",")[0]
fields[name] = true
}
}
return fields
}

View File

@@ -1,6 +1,7 @@
package backends
import (
"encoding/json"
"fmt"
"llamactl/pkg/validation"
)
@@ -142,6 +143,46 @@ type VllmServerOptions struct {
OverridePoolingConfig string `json:"override_pooling_config,omitempty"`
OverrideNeuronConfig string `json:"override_neuron_config,omitempty"`
OverrideKVCacheALIGNSize int `json:"override_kv_cache_align_size,omitempty"`
// ExtraArgs are additional command line arguments.
// Example: {"verbose": "", "log-file": "/logs/vllm.log"}
ExtraArgs map[string]string `json:"extra_args,omitempty"`
}
// UnmarshalJSON implements custom JSON unmarshaling to collect unknown fields into ExtraArgs
func (o *VllmServerOptions) UnmarshalJSON(data []byte) error {
// First unmarshal into a map to capture all fields
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
// Create a temporary struct for standard unmarshaling
type tempOptions VllmServerOptions
temp := tempOptions{}
// Standard unmarshal first
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
// Copy to our struct
*o = VllmServerOptions(temp)
// Get all known canonical field names from struct tags
knownFields := getKnownFieldNames(o)
// Collect unknown fields into ExtraArgs
if o.ExtraArgs == nil {
o.ExtraArgs = make(map[string]string)
}
for key, value := range raw {
if !knownFields[key] {
o.ExtraArgs[key] = fmt.Sprintf("%v", value)
}
}
return nil
}
func (o *VllmServerOptions) GetPort() int {
@@ -171,6 +212,18 @@ func (o *VllmServerOptions) Validate() error {
return validation.ValidationError(fmt.Errorf("invalid port range: %d", o.Port))
}
// Validate extra_args keys and values
for key, value := range o.ExtraArgs {
if err := validation.ValidateStringForInjection(key); err != nil {
return validation.ValidationError(fmt.Errorf("extra_args key %q: %w", key, err))
}
if value != "" {
if err := validation.ValidateStringForInjection(value); err != nil {
return validation.ValidationError(fmt.Errorf("extra_args value for %q: %w", key, err))
}
}
}
return nil
}
@@ -193,6 +246,9 @@ func (o *VllmServerOptions) BuildCommandArgs() []string {
flagArgs := BuildCommandArgs(&optionsCopy, vllmMultiValuedFlags)
args = append(args, flagArgs...)
// Append extra args at the end
args = append(args, convertExtraArgsToFlags(o.ExtraArgs)...)
return args
}
@@ -203,6 +259,9 @@ func (o *VllmServerOptions) BuildDockerArgs() []string {
flagArgs := BuildCommandArgs(o, vllmMultiValuedFlags)
args = append(args, flagArgs...)
// Append extra args at the end
args = append(args, convertExtraArgsToFlags(o.ExtraArgs)...)
return args
}

View File

@@ -321,3 +321,94 @@ func TestVllmBuildCommandArgs_PositionalModel(t *testing.T) {
t.Errorf("Expected --port 8080 not found in %v", args)
}
}
func TestParseVllmCommand_ExtraArgs(t *testing.T) {
tests := []struct {
name string
command string
expectErr bool
validate func(*testing.T, *backends.VllmServerOptions)
}{
{
name: "extra args with known fields",
command: "vllm serve llama-model --tensor-parallel-size 2 --unknown-flag value --new-bool-flag",
expectErr: false,
validate: func(t *testing.T, opts *backends.VllmServerOptions) {
if opts.Model != "llama-model" {
t.Errorf("expected model 'llama-model', got '%s'", opts.Model)
}
if opts.TensorParallelSize != 2 {
t.Errorf("expected tensor_parallel_size 2, got %d", opts.TensorParallelSize)
}
if opts.ExtraArgs == nil {
t.Fatal("expected extra_args to be non-nil")
}
if val, ok := opts.ExtraArgs["unknown_flag"]; !ok || val != "value" {
t.Errorf("expected extra_args[unknown_flag]='value', got '%s'", val)
}
if val, ok := opts.ExtraArgs["new_bool_flag"]; !ok || val != "true" {
t.Errorf("expected extra_args[new_bool_flag]='true', got '%s'", val)
}
},
},
{
name: "only extra args",
command: "vllm serve model --experimental-feature --custom-param test",
expectErr: false,
validate: func(t *testing.T, opts *backends.VllmServerOptions) {
if opts.ExtraArgs == nil {
t.Fatal("expected extra_args to be non-nil")
}
if val, ok := opts.ExtraArgs["experimental_feature"]; !ok || val != "true" {
t.Errorf("expected extra_args[experimental_feature]='true', got '%s'", val)
}
if val, ok := opts.ExtraArgs["custom_param"]; !ok || val != "test" {
t.Errorf("expected extra_args[custom_param]='test', got '%s'", val)
}
},
},
{
name: "extra args without model positional",
command: "vllm serve --model my-model --new-feature enabled --beta-flag",
expectErr: false,
validate: func(t *testing.T, opts *backends.VllmServerOptions) {
if opts.Model != "my-model" {
t.Errorf("expected model 'my-model', got '%s'", opts.Model)
}
if opts.ExtraArgs == nil {
t.Fatal("expected extra_args to be non-nil")
}
if val, ok := opts.ExtraArgs["new_feature"]; !ok || val != "enabled" {
t.Errorf("expected extra_args[new_feature]='enabled', got '%s'", val)
}
if val, ok := opts.ExtraArgs["beta_flag"]; !ok || val != "true" {
t.Errorf("expected extra_args[beta_flag]='true', got '%s'", val)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var opts backends.VllmServerOptions
result, err := opts.ParseCommand(tt.command)
if tt.expectErr && err == nil {
t.Error("expected error but got none")
return
}
if !tt.expectErr && err != nil {
t.Errorf("unexpected error: %v", err)
return
}
if !tt.expectErr && tt.validate != nil {
vllmOpts, ok := result.(*backends.VllmServerOptions)
if !ok {
t.Fatal("result is not *VllmServerOptions")
}
tt.validate(t, vllmOpts)
}
})
}
}

View File

@@ -1,6 +1,7 @@
package config
import (
"fmt"
"log"
"os"
"path/filepath"
@@ -231,6 +232,11 @@ func LoadConfig(configPath string) (AppConfig, error) {
cfg.Instances.LogsDir = filepath.Join(cfg.Instances.DataDir, "logs")
}
// Validate port range
if cfg.Instances.PortRange[0] <= 0 || cfg.Instances.PortRange[1] <= 0 || cfg.Instances.PortRange[0] >= cfg.Instances.PortRange[1] {
return AppConfig{}, fmt.Errorf("invalid port range: %v", cfg.Instances.PortRange)
}
return cfg, nil
}

View File

@@ -5,7 +5,7 @@ import (
"fmt"
"llamactl/pkg/config"
"log"
"net/http/httputil"
"net/http"
"time"
)
@@ -182,15 +182,6 @@ func (i *Instance) GetPort() int {
return i.options.GetPort()
}
// GetProxy returns the reverse proxy for this instance
func (i *Instance) GetProxy() (*httputil.ReverseProxy, error) {
if i.proxy == nil {
return nil, fmt.Errorf("instance %s has no proxy component", i.Name)
}
return i.proxy.get()
}
func (i *Instance) IsRemote() bool {
opts := i.GetOptions()
if opts == nil {
@@ -242,6 +233,22 @@ func (i *Instance) ShouldTimeout() bool {
return i.proxy.shouldTimeout()
}
// GetInflightRequests returns the current number of inflight requests
func (i *Instance) GetInflightRequests() int32 {
if i.proxy == nil {
return 0
}
return i.proxy.getInflightRequests()
}
// ServeHTTP serves HTTP requests through the proxy with request tracking and shutdown handling
func (i *Instance) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
if i.proxy == nil {
return fmt.Errorf("instance %s has no proxy component", i.Name)
}
return i.proxy.serveHTTP(w, r)
}
func (i *Instance) getCommand() string {
opts := i.GetOptions()
if opts == nil {

View File

@@ -171,64 +171,6 @@ func TestSetOptions(t *testing.T) {
}
}
func TestGetProxy(t *testing.T) {
globalConfig := &config.AppConfig{
Backends: config.BackendConfig{
LlamaCpp: config.BackendSettings{
Command: "llama-server",
Args: []string{},
},
MLX: config.BackendSettings{
Command: "mlx_lm.server",
Args: []string{},
},
VLLM: config.BackendSettings{
Command: "vllm",
Args: []string{"serve"},
},
},
Instances: config.InstancesConfig{
LogsDir: "/tmp/test",
},
Nodes: map[string]config.NodeConfig{},
LocalNode: "main",
}
options := &instance.Options{
Nodes: map[string]struct{}{"main": {}},
BackendOptions: backends.Options{
BackendType: backends.BackendTypeLlamaCpp,
LlamaServerOptions: &backends.LlamaServerOptions{
Host: "localhost",
Port: 8080,
},
},
}
// Mock onStatusChange function
mockOnStatusChange := func(oldStatus, newStatus instance.Status) {}
inst := instance.New("test-instance", globalConfig, options, mockOnStatusChange)
// Get proxy for the first time
proxy1, err := inst.GetProxy()
if err != nil {
t.Fatalf("GetProxy failed: %v", err)
}
if proxy1 == nil {
t.Error("Expected proxy to be created")
}
// Get proxy again - should return cached version
proxy2, err := inst.GetProxy()
if err != nil {
t.Fatalf("GetProxy failed: %v", err)
}
if proxy1 != proxy2 {
t.Error("Expected cached proxy to be returned")
}
}
func TestMarshalJSON(t *testing.T) {
globalConfig := &config.AppConfig{
Backends: config.BackendConfig{
@@ -613,11 +555,6 @@ func TestRemoteInstanceOperations(t *testing.T) {
t.Error("Expected error when restarting remote instance")
}
// GetProxy should fail for remote instance
if _, err := inst.GetProxy(); err != nil {
t.Error("Expected no error when getting proxy for remote instance")
}
// GetLogs should fail for remote instance
if _, err := inst.GetLogs(10); err == nil {
t.Error("Expected error when getting logs for remote instance")

View File

@@ -7,13 +7,14 @@ import (
"os"
"strings"
"sync"
"sync/atomic"
"time"
)
type logger struct {
name string
logDir string
logFile *os.File
logFile atomic.Pointer[os.File]
logFilePath string
mu sync.RWMutex
}
@@ -47,11 +48,11 @@ func (i *logger) create() error {
return fmt.Errorf("failed to create stdout log file: %w", err)
}
i.logFile = logFile
i.logFile.Store(logFile)
// Write a startup marker to both files
timestamp := time.Now().Format("2006-01-02 15:04:05")
fmt.Fprintf(i.logFile, "\n=== Instance %s started at %s ===\n", i.name, timestamp)
fmt.Fprintf(logFile, "\n=== Instance %s started at %s ===\n", i.name, timestamp)
return nil
}
@@ -102,11 +103,12 @@ func (i *logger) close() {
i.mu.Lock()
defer i.mu.Unlock()
if i.logFile != nil {
logFile := i.logFile.Swap(nil)
if logFile != nil {
timestamp := time.Now().Format("2006-01-02 15:04:05")
fmt.Fprintf(i.logFile, "=== Instance %s stopped at %s ===\n\n", i.name, timestamp)
i.logFile.Close()
i.logFile = nil
fmt.Fprintf(logFile, "=== Instance %s stopped at %s ===\n\n", i.name, timestamp)
logFile.Sync() // Ensure all buffered data is written to disk
logFile.Close()
}
}
@@ -117,9 +119,9 @@ func (i *logger) readOutput(reader io.ReadCloser) {
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
line := scanner.Text()
if i.logFile != nil {
fmt.Fprintln(i.logFile, line)
i.logFile.Sync() // Ensure data is written to disk
// Use atomic load to avoid lock contention on every line
if logFile := i.logFile.Load(); logFile != nil {
fmt.Fprintln(logFile, line)
}
}
}

View File

@@ -132,14 +132,28 @@ func (p *process) stop() error {
p.restartCancel = nil
}
// Set status to stopped first to signal intentional stop
p.instance.SetStatus(Stopped)
// Set status to ShuttingDown first to reject new requests
p.instance.SetStatus(ShuttingDown)
// Get the monitor done channel before releasing the lock
monitorDone := p.monitorDone
p.mu.Unlock()
// Wait for inflight requests to complete (max 30 seconds)
log.Printf("Instance %s shutting down, waiting for inflight requests to complete...", p.instance.Name)
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
inflight := p.instance.GetInflightRequests()
if inflight == 0 {
break
}
time.Sleep(100 * time.Millisecond)
}
// Now set status to stopped to signal intentional stop
p.instance.SetStatus(Stopped)
// Stop the process with SIGINT if cmd exists
if p.cmd != nil && p.cmd.Process != nil {
if err := p.cmd.Process.Signal(syscall.SIGINT); err != nil {
@@ -156,6 +170,7 @@ func (p *process) stop() error {
select {
case <-monitorDone:
// Process exited normally
log.Printf("Instance %s shut down gracefully", p.instance.Name)
case <-time.After(30 * time.Second):
// Force kill if it doesn't exit within 30 seconds
if p.cmd != nil && p.cmd.Process != nil {

View File

@@ -37,8 +37,9 @@ type proxy struct {
proxyOnce sync.Once
proxyErr error
lastRequestTime atomic.Int64
timeProvider TimeProvider
lastRequestTime atomic.Int64
inflightRequests atomic.Int32
timeProvider TimeProvider
}
// newProxy creates a new Proxy for the given instance
@@ -153,6 +154,23 @@ func (p *proxy) build() (*httputil.ReverseProxy, error) {
return proxy, nil
}
// serveHTTP handles HTTP requests with inflight tracking
func (p *proxy) serveHTTP(w http.ResponseWriter, r *http.Request) error {
// Get the reverse proxy
reverseProxy, err := p.get()
if err != nil {
return err
}
// Track inflight requests
p.incInflightRequests()
defer p.decInflightRequests()
// Serve the request
reverseProxy.ServeHTTP(w, r)
return nil
}
// clear resets the proxy, allowing it to be recreated when options change.
func (p *proxy) clear() {
p.mu.Lock()
@@ -160,7 +178,7 @@ func (p *proxy) clear() {
p.proxy = nil
p.proxyErr = nil
p.proxyOnce = sync.Once{} // Reset Once for next GetProxy call
p.proxyOnce = sync.Once{}
}
// updateLastRequestTime updates the last request access time for the instance
@@ -199,3 +217,18 @@ func (p *proxy) shouldTimeout() bool {
func (p *proxy) setTimeProvider(tp TimeProvider) {
p.timeProvider = tp
}
// incInflightRequests increments the inflight request counter
func (p *proxy) incInflightRequests() {
p.inflightRequests.Add(1)
}
// decInflightRequests decrements the inflight request counter
func (p *proxy) decInflightRequests() {
p.inflightRequests.Add(-1)
}
// getInflightRequests returns the current number of inflight requests
func (p *proxy) getInflightRequests() int32 {
return p.inflightRequests.Load()
}

View File

@@ -14,20 +14,23 @@ const (
Running
Failed
Restarting
ShuttingDown
)
var nameToStatus = map[string]Status{
"stopped": Stopped,
"running": Running,
"failed": Failed,
"restarting": Restarting,
"stopped": Stopped,
"running": Running,
"failed": Failed,
"restarting": Restarting,
"shutting_down": ShuttingDown,
}
var statusToName = map[Status]string{
Stopped: "stopped",
Running: "running",
Failed: "failed",
Restarting: "restarting",
Stopped: "stopped",
Running: "running",
Failed: "failed",
Restarting: "restarting",
ShuttingDown: "shutting_down",
}
// Status enum JSON marshaling methods

View File

@@ -10,7 +10,7 @@ import (
)
func TestInstanceTimeoutLogic(t *testing.T) {
testManager := createTestManager()
testManager := createTestManager(t)
defer testManager.Shutdown()
idleTimeout := 1 // 1 minute
@@ -42,7 +42,7 @@ func TestInstanceTimeoutLogic(t *testing.T) {
}
func TestInstanceWithoutTimeoutNeverExpires(t *testing.T) {
testManager := createTestManager()
testManager := createTestManager(t)
defer testManager.Shutdown()
noTimeoutInst := createInstanceWithTimeout(t, testManager, "no-timeout-test", "/path/to/model.gguf", nil)
@@ -64,7 +64,7 @@ func TestInstanceWithoutTimeoutNeverExpires(t *testing.T) {
}
func TestEvictLRUInstance_Success(t *testing.T) {
manager := createTestManager()
manager := createTestManager(t)
defer manager.Shutdown()
// Create 3 instances with idle timeout enabled (value doesn't matter for LRU logic)
@@ -121,7 +121,7 @@ func TestEvictLRUInstance_Success(t *testing.T) {
}
func TestEvictLRUInstance_NoRunningInstances(t *testing.T) {
manager := createTestManager()
manager := createTestManager(t)
defer manager.Shutdown()
err := manager.EvictLRUInstance()
@@ -134,7 +134,7 @@ func TestEvictLRUInstance_NoRunningInstances(t *testing.T) {
}
func TestEvictLRUInstance_OnlyEvictsTimeoutEnabledInstances(t *testing.T) {
manager := createTestManager()
manager := createTestManager(t)
defer manager.Shutdown()
// Create mix of instances: some with timeout enabled, some disabled

View File

@@ -54,16 +54,10 @@ func New(globalConfig *config.AppConfig) InstanceManager {
// Initialize port allocator
portRange := globalConfig.Instances.PortRange
ports, err := newPortAllocator(portRange[0], portRange[1])
if err != nil {
log.Fatalf("Failed to create port allocator: %v", err)
}
ports := newPortAllocator(portRange[0], portRange[1])
// Initialize persistence
persistence, err := newInstancePersister(globalConfig.Instances.InstancesDir)
if err != nil {
log.Fatalf("Failed to create instance persister: %v", err)
}
persistence := newInstancePersister(globalConfig.Instances.InstancesDir)
// Initialize remote manager
remote := newRemoteManager(globalConfig.Nodes, 30*time.Second)
@@ -116,7 +110,7 @@ func (im *instanceManager) Shutdown() {
defer wg.Done()
fmt.Printf("Stopping instance %s...\n", inst.Name)
if err := inst.Stop(); err != nil {
fmt.Printf("Error stopping instance %s: %v\n", inst.Name, err)
log.Printf("Error stopping instance %s: %v\n", inst.Name, err)
}
}(inst)
}

View File

@@ -85,7 +85,7 @@ func TestDeleteInstance_RemovesPersistenceFile(t *testing.T) {
}
func TestConcurrentAccess(t *testing.T) {
mgr := createTestManager()
mgr := createTestManager(t)
defer mgr.Shutdown()
// Test concurrent operations
@@ -113,7 +113,7 @@ func TestConcurrentAccess(t *testing.T) {
}
// Concurrent list operations
for i := 0; i < 3; i++ {
for range 3 {
wg.Add(1)
go func() {
defer wg.Done()
@@ -134,16 +134,17 @@ func TestConcurrentAccess(t *testing.T) {
// Helper functions for test configuration
func createTestAppConfig(instancesDir string) *config.AppConfig {
// Use 'sleep' as a test command instead of 'llama-server'
// This allows tests to run in CI environments without requiring actual LLM binaries
// The sleep command will be invoked with model paths and other args, which it ignores
// Use 'sh -c "sleep 999999"' as a test command instead of 'llama-server'
// The shell ignores all additional arguments passed after the command
return &config.AppConfig{
Backends: config.BackendConfig{
LlamaCpp: config.BackendSettings{
Command: "sleep",
Command: "sh",
Args: []string{"-c", "sleep 999999"},
},
MLX: config.BackendSettings{
Command: "sleep",
Command: "sh",
Args: []string{"-c", "sleep 999999"},
},
},
Instances: config.InstancesConfig{
@@ -162,28 +163,8 @@ func createTestAppConfig(instancesDir string) *config.AppConfig {
}
}
func createTestManager() manager.InstanceManager {
appConfig := &config.AppConfig{
Backends: config.BackendConfig{
LlamaCpp: config.BackendSettings{
Command: "sleep",
},
MLX: config.BackendSettings{
Command: "sleep",
},
},
Instances: config.InstancesConfig{
PortRange: [2]int{8000, 9000},
LogsDir: "/tmp/test",
MaxInstances: 10,
MaxRunningInstances: 10,
DefaultAutoRestart: true,
DefaultMaxRestarts: 3,
DefaultRestartDelay: 5,
TimeoutCheckInterval: 5,
},
LocalNode: "main",
Nodes: map[string]config.NodeConfig{},
}
func createTestManager(t *testing.T) manager.InstanceManager {
tempDir := t.TempDir()
appConfig := createTestAppConfig(tempDir)
return manager.New(appConfig)
}

View File

@@ -330,7 +330,8 @@ func (im *instanceManager) DeleteInstance(name string) error {
lock.Lock()
defer im.unlockAndCleanup(name)
if inst.IsRunning() {
status := inst.GetStatus()
if status == instance.Running || status == instance.Restarting {
return fmt.Errorf("instance with name %s is still running, stop it before deleting", name)
}

View File

@@ -10,7 +10,7 @@ import (
)
func TestCreateInstance_FailsWithDuplicateName(t *testing.T) {
mngr := createTestManager()
mngr := createTestManager(t)
options := &instance.Options{
BackendOptions: backends.Options{
BackendType: backends.BackendTypeLlamaCpp,
@@ -36,6 +36,7 @@ func TestCreateInstance_FailsWithDuplicateName(t *testing.T) {
}
func TestCreateInstance_FailsWhenMaxInstancesReached(t *testing.T) {
tempDir := t.TempDir()
appConfig := &config.AppConfig{
Backends: config.BackendConfig{
LlamaCpp: config.BackendSettings{
@@ -44,6 +45,7 @@ func TestCreateInstance_FailsWhenMaxInstancesReached(t *testing.T) {
},
Instances: config.InstancesConfig{
PortRange: [2]int{8000, 9000},
InstancesDir: tempDir,
MaxInstances: 1, // Very low limit for testing
TimeoutCheckInterval: 5,
},
@@ -77,7 +79,7 @@ func TestCreateInstance_FailsWhenMaxInstancesReached(t *testing.T) {
}
func TestCreateInstance_FailsWithPortConflict(t *testing.T) {
manager := createTestManager()
manager := createTestManager(t)
options1 := &instance.Options{
BackendOptions: backends.Options{
@@ -115,7 +117,7 @@ func TestCreateInstance_FailsWithPortConflict(t *testing.T) {
}
func TestInstanceOperations_FailWithNonExistentInstance(t *testing.T) {
manager := createTestManager()
manager := createTestManager(t)
options := &instance.Options{
BackendOptions: backends.Options{
@@ -143,7 +145,7 @@ func TestInstanceOperations_FailWithNonExistentInstance(t *testing.T) {
}
func TestDeleteInstance_RunningInstanceFails(t *testing.T) {
mgr := createTestManager()
mgr := createTestManager(t)
defer mgr.Shutdown()
options := &instance.Options{
@@ -155,15 +157,13 @@ func TestDeleteInstance_RunningInstanceFails(t *testing.T) {
},
}
_, err := mgr.CreateInstance("test-instance", options)
inst, err := mgr.CreateInstance("test-instance", options)
if err != nil {
t.Fatalf("CreateInstance failed: %v", err)
}
_, err = mgr.StartInstance("test-instance")
if err != nil {
t.Fatalf("StartInstance failed: %v", err)
}
// Simulate starting the instance
inst.SetStatus(instance.Running)
// Should fail to delete running instance
err = mgr.DeleteInstance("test-instance")
@@ -173,7 +173,7 @@ func TestDeleteInstance_RunningInstanceFails(t *testing.T) {
}
func TestUpdateInstance(t *testing.T) {
mgr := createTestManager()
mgr := createTestManager(t)
defer mgr.Shutdown()
options := &instance.Options{
@@ -186,14 +186,14 @@ func TestUpdateInstance(t *testing.T) {
},
}
_, err := mgr.CreateInstance("test-instance", options)
inst, err := mgr.CreateInstance("test-instance", options)
if err != nil {
t.Fatalf("CreateInstance failed: %v", err)
}
_, err = mgr.StartInstance("test-instance")
if err != nil {
t.Fatalf("StartInstance failed: %v", err)
// Start the instance (will use 'yes' command from test config)
if err := inst.Start(); err != nil {
t.Fatalf("Failed to start instance: %v", err)
}
// Update running instance with new model
@@ -212,9 +212,9 @@ func TestUpdateInstance(t *testing.T) {
t.Fatalf("UpdateInstance failed: %v", err)
}
// Should still be running after update
// Should be running after update (was running before, should be restarted)
if !updated.IsRunning() {
t.Error("Instance should be running after update")
t.Errorf("Instance should be running after update, got: %v", updated.GetStatus())
}
if updated.GetOptions().BackendOptions.LlamaServerOptions.Model != "/path/to/new-model.gguf" {
@@ -223,7 +223,7 @@ func TestUpdateInstance(t *testing.T) {
}
func TestUpdateInstance_ReleasesOldPort(t *testing.T) {
mgr := createTestManager()
mgr := createTestManager(t)
defer mgr.Shutdown()
options := &instance.Options{

View File

@@ -15,35 +15,18 @@ import (
type instancePersister struct {
mu sync.Mutex
instancesDir string
enabled bool
}
// newInstancePersister creates a new instance persister.
// If instancesDir is empty, persistence is disabled.
func newInstancePersister(instancesDir string) (*instancePersister, error) {
if instancesDir == "" {
return &instancePersister{
enabled: false,
}, nil
}
// Ensure the instances directory exists
if err := os.MkdirAll(instancesDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create instances directory: %w", err)
}
func newInstancePersister(instancesDir string) *instancePersister {
return &instancePersister{
instancesDir: instancesDir,
enabled: true,
}, nil
}
}
// Save persists an instance to disk with atomic write
func (p *instancePersister) save(inst *instance.Instance) error {
if !p.enabled {
return nil
}
if inst == nil {
return fmt.Errorf("cannot save nil instance")
}
@@ -103,10 +86,6 @@ func (p *instancePersister) save(inst *instance.Instance) error {
// Delete removes an instance's persistence file from disk.
func (p *instancePersister) delete(name string) error {
if !p.enabled {
return nil
}
validatedName, err := p.validateInstanceName(name)
if err != nil {
return err
@@ -131,10 +110,6 @@ func (p *instancePersister) delete(name string) error {
// LoadAll loads all persisted instances from disk.
// Returns a slice of instances and any errors encountered during loading.
func (p *instancePersister) loadAll() ([]*instance.Instance, error) {
if !p.enabled {
return nil, nil
}
p.mu.Lock()
defer p.mu.Unlock()

View File

@@ -24,15 +24,7 @@ type portAllocator struct {
}
// newPortAllocator creates a new port allocator for the given port range.
// Returns an error if the port range is invalid.
func newPortAllocator(minPort, maxPort int) (*portAllocator, error) {
if minPort <= 0 || maxPort <= 0 {
return nil, fmt.Errorf("invalid port range: min=%d, max=%d (must be > 0)", minPort, maxPort)
}
if minPort > maxPort {
return nil, fmt.Errorf("invalid port range: min=%d > max=%d", minPort, maxPort)
}
func newPortAllocator(minPort, maxPort int) *portAllocator {
rangeSize := maxPort - minPort + 1
bitmapSize := (rangeSize + 63) / 64 // Round up to nearest uint64
@@ -42,7 +34,7 @@ func newPortAllocator(minPort, maxPort int) (*portAllocator, error) {
minPort: minPort,
maxPort: maxPort,
rangeSize: rangeSize,
}, nil
}
}
// allocate finds and allocates the first available port for the given instance.

View File

@@ -66,17 +66,16 @@ func (h *Handler) LlamaCppUIProxy() http.HandlerFunc {
return
}
proxy, err := inst.GetProxy()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to get proxy", err.Error())
return
}
if !inst.IsRemote() {
h.stripLlamaCppPrefix(r, inst.Name)
}
proxy.ServeHTTP(w, r)
// Use instance's ServeHTTP which tracks inflight requests and handles shutting down state
err = inst.ServeHTTP(w, r)
if err != nil {
// Error is already handled in ServeHTTP (response written)
return
}
}
}
@@ -110,6 +109,12 @@ func (h *Handler) LlamaCppProxy() http.HandlerFunc {
return
}
// Check if instance is shutting down before autostart logic
if inst.GetStatus() == instance.ShuttingDown {
writeError(w, http.StatusServiceUnavailable, "instance_shutting_down", "Instance is shutting down")
return
}
if !inst.IsRemote() && !inst.IsRunning() {
err := h.ensureInstanceRunning(inst)
if err != nil {
@@ -118,17 +123,16 @@ func (h *Handler) LlamaCppProxy() http.HandlerFunc {
}
}
proxy, err := inst.GetProxy()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to get proxy", err.Error())
return
}
if !inst.IsRemote() {
h.stripLlamaCppPrefix(r, inst.Name)
}
proxy.ServeHTTP(w, r)
// Use instance's ServeHTTP which tracks inflight requests and handles shutting down state
err = inst.ServeHTTP(w, r)
if err != nil {
// Error is already handled in ServeHTTP (response written)
return
}
}
}

View File

@@ -332,12 +332,6 @@ func (h *Handler) InstanceProxy() http.HandlerFunc {
return
}
proxy, err := inst.GetProxy()
if err != nil {
writeError(w, http.StatusInternalServerError, "proxy_failed", "Failed to get proxy: "+err.Error())
return
}
if !inst.IsRemote() {
// Strip the "/api/v1/instances/<name>/proxy" prefix from the request URL
prefix := fmt.Sprintf("/api/v1/instances/%s/proxy", inst.Name)
@@ -348,6 +342,11 @@ func (h *Handler) InstanceProxy() http.HandlerFunc {
r.Header.Set("X-Forwarded-Host", r.Header.Get("Host"))
r.Header.Set("X-Forwarded-Proto", "http")
proxy.ServeHTTP(w, r)
// Use instance's ServeHTTP which tracks inflight requests and handles shutting down state
err = inst.ServeHTTP(w, r)
if err != nil {
// Error is already handled in ServeHTTP (response written)
return
}
}
}

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"io"
"llamactl/pkg/instance"
"llamactl/pkg/validation"
"net/http"
)
@@ -106,6 +107,12 @@ func (h *Handler) OpenAIProxy() http.HandlerFunc {
return
}
// Check if instance is shutting down before autostart logic
if inst.GetStatus() == instance.ShuttingDown {
writeError(w, http.StatusServiceUnavailable, "instance_shutting_down", "Instance is shutting down")
return
}
if !inst.IsRemote() && !inst.IsRunning() {
err := h.ensureInstanceRunning(inst)
if err != nil {
@@ -114,16 +121,15 @@ func (h *Handler) OpenAIProxy() http.HandlerFunc {
}
}
proxy, err := inst.GetProxy()
if err != nil {
writeError(w, http.StatusInternalServerError, "proxy_failed", err.Error())
return
}
// Recreate the request body from the bytes we read
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
r.ContentLength = int64(len(bodyBytes))
proxy.ServeHTTP(w, r)
// Use instance's ServeHTTP which tracks inflight requests and handles shutting down state
err = inst.ServeHTTP(w, r)
if err != nil {
// Error is already handled in ServeHTTP (response written)
return
}
}
}

View File

@@ -1,7 +1,7 @@
package server
import (
"fmt"
"log"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
@@ -159,7 +159,7 @@ func SetupRouter(handler *Handler) *chi.Mux {
// Serve WebUI files
if err := webui.SetupWebUI(r); err != nil {
fmt.Printf("Failed to set up WebUI: %v\n", err)
log.Printf("Failed to set up WebUI: %v\n", err)
}
return r

View File

@@ -239,25 +239,3 @@ func TestValidateInstanceOptions_MultipleFieldInjection(t *testing.T) {
})
}
}
func TestValidateInstanceOptions_NonStringFields(t *testing.T) {
// Test that non-string fields don't interfere with validation
options := backends.Options{
BackendType: backends.BackendTypeLlamaCpp,
LlamaServerOptions: &backends.LlamaServerOptions{
Port: 8080,
GPULayers: 32,
CtxSize: 4096,
Temperature: 0.7,
TopK: 40,
TopP: 0.9,
Verbose: true,
FlashAttn: false,
},
}
err := options.ValidateInstanceOptions()
if err != nil {
t.Errorf("ValidateInstanceOptions with non-string fields should not error, got: %v", err)
}
}

1141
webui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,34 +25,34 @@
"@tailwindcss/vite": "^4.1.11",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.525.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"lucide-react": "^0.553.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.11",
"zod": "^4.0.5"
"zod": "^4.1.12"
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@testing-library/jest-dom": "^6.6.3",
"@eslint/js": "^9.39.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/eslint__js": "^8.42.3",
"@types/node": "^24.0.15",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.7.0",
"@vitest/ui": "^3.2.4",
"eslint": "^9.32.0",
"@types/eslint__js": "^9.14.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.4",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/ui": "^4.0.8",
"eslint": "^9.39.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.20",
"jsdom": "^26.1.0",
"tw-animate-css": "^1.3.5",
"typescript": "^5.8.3",
"typescript-eslint": "^8.38.0",
"vite": "^7.1.11",
"vitest": "^3.2.4"
"jsdom": "^27.2.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.2",
"vitest": "^4.0.8"
}
}

View File

@@ -3,22 +3,36 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import { getBackendFieldType, basicBackendFieldsConfig } from '@/lib/zodFormUtils'
import ExtraArgsInput from '@/components/form/ExtraArgsInput'
interface BackendFormFieldProps {
fieldKey: string
value: string | number | boolean | string[] | undefined
onChange: (key: string, value: string | number | boolean | string[] | undefined) => void
value: string | number | boolean | string[] | Record<string, string> | undefined
onChange: (key: string, value: string | number | boolean | string[] | Record<string, string> | undefined) => void
}
const BackendFormField: React.FC<BackendFormFieldProps> = ({ fieldKey, value, onChange }) => {
// Special handling for extra_args
if (fieldKey === 'extra_args') {
return (
<ExtraArgsInput
id={fieldKey}
label="Extra Arguments"
value={value as Record<string, string> | undefined}
onChange={(newValue) => onChange(fieldKey, newValue)}
description="Additional command line arguments to pass to the backend"
/>
)
}
// Get configuration for basic fields, or use field name for advanced fields
const config = basicBackendFieldsConfig[fieldKey as string] || { label: fieldKey }
const config = basicBackendFieldsConfig[fieldKey] || { label: fieldKey }
// Get type from Zod schema
const fieldType = getBackendFieldType(fieldKey)
const handleChange = (newValue: string | number | boolean | string[] | undefined) => {
onChange(fieldKey as string, newValue)
onChange(fieldKey, newValue)
}
const renderField = () => {

View File

@@ -21,6 +21,8 @@ const HealthBadge: React.FC<HealthBadgeProps> = ({ health }) => {
return <Loader2 className="h-3 w-3 animate-spin" />;
case "restarting":
return <Loader2 className="h-3 w-3 animate-spin" />;
case "shutting_down":
return <Loader2 className="h-3 w-3 animate-spin" />;
case "stopped":
return <Clock className="h-3 w-3" />;
case "failed":
@@ -36,6 +38,8 @@ const HealthBadge: React.FC<HealthBadgeProps> = ({ health }) => {
return "outline";
case "restarting":
return "outline";
case "shutting_down":
return "outline";
case "stopped":
return "secondary";
case "failed":
@@ -51,6 +55,8 @@ const HealthBadge: React.FC<HealthBadgeProps> = ({ health }) => {
return "Starting";
case "restarting":
return "Restarting";
case "shutting_down":
return "Shutting Down";
case "stopped":
return "Stopped";
case "failed":

View File

@@ -2,12 +2,13 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Instance } from "@/types/instance";
import { Edit, FileText, Play, Square, Trash2, MoreHorizontal } from "lucide-react";
import { Edit, FileText, Play, Square, Trash2, MoreHorizontal, Download } from "lucide-react";
import LogsDialog from "@/components/LogDialog";
import HealthBadge from "@/components/HealthBadge";
import BackendBadge from "@/components/BackendBadge";
import { useState } from "react";
import { useInstanceHealth } from "@/hooks/useInstanceHealth";
import { instancesApi } from "@/lib/api";
interface InstanceCardProps {
instance: Instance;
@@ -52,6 +53,40 @@ function InstanceCard({
setIsLogsOpen(true);
};
const handleExport = () => {
void (async () => {
try {
// Fetch the most up-to-date instance data from the backend
const instanceData = await instancesApi.get(instance.name);
// Remove docker_enabled as it's a computed field, not persisted to disk
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { docker_enabled, ...persistedData } = instanceData;
// Convert to JSON string with pretty formatting (matching backend format)
const jsonString = JSON.stringify(persistedData, null, 2);
// Create a blob and download link
const blob = new Blob([jsonString], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${instance.name}.json`;
// Trigger download
document.body.appendChild(link);
link.click();
// Cleanup
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error("Failed to export instance:", error);
alert(`Failed to export instance: ${error instanceof Error ? error.message : "Unknown error"}`);
}
})();
};
const running = instance.status === "running";
return (
@@ -131,6 +166,18 @@ function InstanceCard({
Logs
</Button>
<Button
size="sm"
variant="outline"
onClick={handleExport}
title="Export instance"
data-testid="export-instance-button"
className="flex-1"
>
<Download className="h-4 w-4 mr-1" />
Export
</Button>
<Button
size="sm"
variant="destructive"

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -9,9 +9,11 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { BackendType, type CreateInstanceOptions, type Instance } from "@/types/instance";
import type { BackendOptions } from "@/schemas/instanceOptions";
import ParseCommandDialog from "@/components/ParseCommandDialog";
import InstanceSettingsCard from "@/components/instance/InstanceSettingsCard";
import BackendConfigurationCard from "@/components/instance/BackendConfigurationCard";
import { Upload } from "lucide-react";
interface InstanceDialogProps {
open: boolean;
@@ -32,6 +34,7 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
const [formData, setFormData] = useState<CreateInstanceOptions>({});
const [nameError, setNameError] = useState("");
const [showParseDialog, setShowParseDialog] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Reset form when dialog opens/closes or when instance changes
@@ -54,31 +57,31 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
}
}, [open, instance]);
const handleFieldChange = (key: keyof CreateInstanceOptions, value: any) => {
const handleFieldChange = (key: keyof CreateInstanceOptions, value: unknown) => {
setFormData((prev) => {
// If backend_type is changing, clear backend_options
if (key === 'backend_type' && prev.backend_type !== value) {
return {
...prev,
[key]: value,
backend_type: value as CreateInstanceOptions['backend_type'],
backend_options: {}, // Clear backend options when backend type changes
};
}
return {
...prev,
[key]: value,
};
} as CreateInstanceOptions;
});
};
const handleBackendFieldChange = (key: string, value: any) => {
const handleBackendFieldChange = (key: string, value: unknown) => {
setFormData((prev) => ({
...prev,
backend_options: {
...prev.backend_options,
[key]: value,
} as any,
} as BackendOptions,
}));
};
@@ -104,11 +107,13 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
}
// Clean up undefined values to avoid sending empty fields
const cleanOptions: CreateInstanceOptions = {};
const cleanOptions: CreateInstanceOptions = {} as CreateInstanceOptions;
Object.entries(formData).forEach(([key, value]) => {
const typedKey = key as keyof CreateInstanceOptions;
if (key === 'backend_options' && value && typeof value === 'object' && !Array.isArray(value)) {
// Handle backend_options specially - clean nested object
const cleanBackendOptions: any = {};
const cleanBackendOptions: Record<string, unknown> = {};
Object.entries(value).forEach(([backendKey, backendValue]) => {
if (backendValue !== undefined && backendValue !== null && (typeof backendValue !== 'string' || backendValue.trim() !== "")) {
// Handle arrays - don't include empty arrays
@@ -121,7 +126,7 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
// Only include backend_options if it has content
if (Object.keys(cleanBackendOptions).length > 0) {
(cleanOptions as any)[key] = cleanBackendOptions;
(cleanOptions as Record<string, unknown>)[typedKey] = cleanBackendOptions as BackendOptions;
}
} else if (value !== undefined && value !== null) {
// Skip empty strings
@@ -132,7 +137,7 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
if (Array.isArray(value) && value.length === 0) {
return;
}
(cleanOptions as any)[key] = value;
(cleanOptions as Record<string, unknown>)[typedKey] = value;
}
});
@@ -153,6 +158,49 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
setShowParseDialog(false);
};
const handleImportFile = () => {
fileInputRef.current?.click();
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = e.target?.result as string;
const importedData = JSON.parse(content) as { name?: string; options?: CreateInstanceOptions };
// Validate that it's an instance export
if (!importedData.name || !importedData.options) {
alert('Invalid instance file: Missing required fields (name, options)');
return;
}
// Set the instance name (only for new instances, not editing)
if (!isEditing && typeof importedData.name === 'string') {
handleNameChange(importedData.name);
}
// Populate all the options from the imported file
if (importedData.options) {
setFormData(prev => ({
...prev,
...importedData.options,
}));
}
// Reset the file input
event.target.value = '';
} catch (error) {
console.error('Failed to parse instance file:', error);
alert(`Failed to parse instance file: ${error instanceof Error ? error.message : 'Invalid JSON'}`);
}
};
reader.readAsText(file);
};
// Save button label logic
let saveButtonLabel = "Create Instance";
@@ -168,14 +216,38 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>
{isEditing ? "Edit Instance" : "Create New Instance"}
</DialogTitle>
<DialogDescription>
{isEditing
? "Modify the instance configuration below."
: "Configure your new llama-server instance below."}
</DialogDescription>
<div className="flex items-center justify-between">
<div className="flex-1">
<DialogTitle>
{isEditing ? "Edit Instance" : "Create New Instance"}
</DialogTitle>
<DialogDescription>
{isEditing
? "Modify the instance configuration below."
: "Configure your new llama-server instance below."}
</DialogDescription>
</div>
{!isEditing && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleImportFile}
title="Import instance configuration from JSON file"
className="ml-2"
>
<Upload className="h-4 w-4 mr-2" />
Import
</Button>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileChange}
className="hidden"
/>
</DialogHeader>
<div className="flex-1 overflow-y-auto">

View File

@@ -56,9 +56,9 @@ function InstanceList({ editInstance }: InstanceListProps) {
<MemoizedInstanceCard
key={instance.name}
instance={instance}
startInstance={startInstance}
stopInstance={stopInstance}
deleteInstance={deleteInstance}
startInstance={() => { void startInstance(instance.name) }}
stopInstance={() => { void stopInstance(instance.name) }}
deleteInstance={() => { void deleteInstance(instance.name) }}
editInstance={editInstance}
/>
))}

View File

@@ -54,7 +54,7 @@ const ParseCommandDialog: React.FC<ParseCommandDialogProps> = ({
options = await backendsApi.vllm.parseCommand(command);
break;
default:
throw new Error(`Unsupported backend type: ${backendType}`);
throw new Error(`Unsupported backend type: ${String(backendType)}`);
}
onParsed(options);

View File

@@ -0,0 +1,27 @@
import React from 'react'
import KeyValueInput from './KeyValueInput'
interface EnvVarsInputProps {
id: string
label: string
value: Record<string, string> | undefined
onChange: (value: Record<string, string> | undefined) => void
description?: string
disabled?: boolean
className?: string
}
const EnvVarsInput: React.FC<EnvVarsInputProps> = (props) => {
return (
<KeyValueInput
{...props}
keyPlaceholder="Variable name"
valuePlaceholder="Variable value"
addButtonText="Add Variable"
helperText="Environment variables that will be passed to the backend process"
allowEmptyValues={false}
/>
)
}
export default EnvVarsInput

View File

@@ -1,144 +0,0 @@
import React, { useState } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { X, Plus } from 'lucide-react'
interface EnvironmentVariablesInputProps {
id: string
label: string
value: Record<string, string> | undefined
onChange: (value: Record<string, string> | undefined) => void
description?: string
disabled?: boolean
className?: string
}
interface EnvVar {
key: string
value: string
}
const EnvironmentVariablesInput: React.FC<EnvironmentVariablesInputProps> = ({
id,
label,
value,
onChange,
description,
disabled = false,
className
}) => {
// Convert the value object to an array of key-value pairs for editing
const envVarsFromValue = value
? Object.entries(value).map(([key, val]) => ({ key, value: val }))
: []
const [envVars, setEnvVars] = useState<EnvVar[]>(
envVarsFromValue.length > 0 ? envVarsFromValue : [{ key: '', value: '' }]
)
// Update parent component when env vars change
const updateParent = (newEnvVars: EnvVar[]) => {
// Filter out empty entries
const validVars = newEnvVars.filter(env => env.key.trim() !== '' && env.value.trim() !== '')
if (validVars.length === 0) {
onChange(undefined)
} else {
const envObject = validVars.reduce((acc, env) => {
acc[env.key.trim()] = env.value.trim()
return acc
}, {} as Record<string, string>)
onChange(envObject)
}
}
const handleKeyChange = (index: number, newKey: string) => {
const newEnvVars = [...envVars]
newEnvVars[index].key = newKey
setEnvVars(newEnvVars)
updateParent(newEnvVars)
}
const handleValueChange = (index: number, newValue: string) => {
const newEnvVars = [...envVars]
newEnvVars[index].value = newValue
setEnvVars(newEnvVars)
updateParent(newEnvVars)
}
const addEnvVar = () => {
const newEnvVars = [...envVars, { key: '', value: '' }]
setEnvVars(newEnvVars)
}
const removeEnvVar = (index: number) => {
if (envVars.length === 1) {
// Reset to empty if it's the last one
const newEnvVars = [{ key: '', value: '' }]
setEnvVars(newEnvVars)
updateParent(newEnvVars)
} else {
const newEnvVars = envVars.filter((_, i) => i !== index)
setEnvVars(newEnvVars)
updateParent(newEnvVars)
}
}
return (
<div className={`grid gap-2 ${className || ''}`}>
<Label htmlFor={id}>
{label}
</Label>
<div className="space-y-2">
{envVars.map((envVar, index) => (
<div key={index} className="flex gap-2 items-center">
<Input
placeholder="Variable name"
value={envVar.key}
onChange={(e) => handleKeyChange(index, e.target.value)}
disabled={disabled}
className="flex-1"
/>
<Input
placeholder="Variable value"
value={envVar.value}
onChange={(e) => handleValueChange(index, e.target.value)}
disabled={disabled}
className="flex-1"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => removeEnvVar(index)}
disabled={disabled}
className="shrink-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addEnvVar}
disabled={disabled}
className="w-fit"
>
<Plus className="h-4 w-4 mr-2" />
Add Variable
</Button>
</div>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
<p className="text-xs text-muted-foreground">
Environment variables that will be passed to the backend process
</p>
</div>
)
}
export default EnvironmentVariablesInput

View File

@@ -0,0 +1,27 @@
import React from 'react'
import KeyValueInput from './KeyValueInput'
interface ExtraArgsInputProps {
id: string
label: string
value: Record<string, string> | undefined
onChange: (value: Record<string, string> | undefined) => void
description?: string
disabled?: boolean
className?: string
}
const ExtraArgsInput: React.FC<ExtraArgsInputProps> = (props) => {
return (
<KeyValueInput
{...props}
keyPlaceholder="Flag name (without --)"
valuePlaceholder="Value (empty for boolean flags)"
addButtonText="Add Argument"
helperText="Additional command line arguments to pass to the backend. Leave value empty for boolean flags."
allowEmptyValues={true}
/>
)
}
export default ExtraArgsInput

View File

@@ -0,0 +1,171 @@
import React, { useState, useEffect } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { X, Plus } from 'lucide-react'
interface KeyValueInputProps {
id: string
label: string
value: Record<string, string> | undefined
onChange: (value: Record<string, string> | undefined) => void
description?: string
disabled?: boolean
className?: string
keyPlaceholder?: string
valuePlaceholder?: string
addButtonText?: string
helperText?: string
allowEmptyValues?: boolean // If true, entries with empty values are considered valid
}
interface KeyValuePair {
key: string
value: string
}
const KeyValueInput: React.FC<KeyValueInputProps> = ({
id,
label,
value,
onChange,
description,
disabled = false,
className,
keyPlaceholder = 'Key',
valuePlaceholder = 'Value',
addButtonText = 'Add Entry',
helperText,
allowEmptyValues = false
}) => {
// Convert the value object to an array of key-value pairs for editing
const pairsFromValue = value
? Object.entries(value).map(([key, val]) => ({ key, value: val }))
: []
const [pairs, setPairs] = useState<KeyValuePair[]>(
pairsFromValue.length > 0 ? pairsFromValue : [{ key: '', value: '' }]
)
// Sync internal state when value prop changes
useEffect(() => {
const newPairsFromValue = value
? Object.entries(value).map(([key, val]) => ({ key, value: val }))
: []
if (newPairsFromValue.length > 0) {
setPairs(newPairsFromValue)
} else if (!value) {
// Reset to single empty row if value is explicitly undefined/null
setPairs([{ key: '', value: '' }])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value])
// Update parent component when pairs change
const updateParent = (newPairs: KeyValuePair[]) => {
// Filter based on validation rules
const validPairs = allowEmptyValues
? newPairs.filter(pair => pair.key.trim() !== '')
: newPairs.filter(pair => pair.key.trim() !== '' && pair.value.trim() !== '')
if (validPairs.length === 0) {
onChange(undefined)
} else {
const pairsObject = validPairs.reduce((acc, pair) => {
acc[pair.key.trim()] = pair.value.trim()
return acc
}, {} as Record<string, string>)
onChange(pairsObject)
}
}
const handleKeyChange = (index: number, newKey: string) => {
const newPairs = [...pairs]
newPairs[index].key = newKey
setPairs(newPairs)
updateParent(newPairs)
}
const handleValueChange = (index: number, newValue: string) => {
const newPairs = [...pairs]
newPairs[index].value = newValue
setPairs(newPairs)
updateParent(newPairs)
}
const addPair = () => {
const newPairs = [...pairs, { key: '', value: '' }]
setPairs(newPairs)
}
const removePair = (index: number) => {
if (pairs.length === 1) {
// Reset to empty if it's the last one
const newPairs = [{ key: '', value: '' }]
setPairs(newPairs)
updateParent(newPairs)
} else {
const newPairs = pairs.filter((_, i) => i !== index)
setPairs(newPairs)
updateParent(newPairs)
}
}
return (
<div className={`grid gap-2 ${className || ''}`}>
<Label htmlFor={id}>
{label}
</Label>
<div className="space-y-2">
{pairs.map((pair, index) => (
<div key={index} className="flex gap-2 items-center">
<Input
placeholder={keyPlaceholder}
value={pair.key}
onChange={(e) => handleKeyChange(index, e.target.value)}
disabled={disabled}
className="flex-1"
/>
<Input
placeholder={valuePlaceholder}
value={pair.value}
onChange={(e) => handleValueChange(index, e.target.value)}
disabled={disabled}
className="flex-1"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => removePair(index)}
disabled={disabled}
className="shrink-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addPair}
disabled={disabled}
className="w-fit"
>
<Plus className="h-4 w-4 mr-2" />
{addButtonText}
</Button>
</div>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
{helperText && (
<p className="text-xs text-muted-foreground">{helperText}</p>
)}
</div>
)
}
export default KeyValueInput

View File

@@ -47,8 +47,18 @@ const BackendConfiguration: React.FC<BackendConfigurationProps> = ({
))}
</div>
)}
{/* Extra Args - Always visible as a separate section */}
<div className="space-y-4">
<BackendFormField
key="extra_args"
fieldKey="extra_args"
value={(formData.backend_options as any)?.extra_args}
onChange={onBackendFieldChange}
/>
</div>
</div>
)
}
export default BackendConfiguration
export default BackendConfiguration

View File

@@ -109,6 +109,16 @@ const BackendConfigurationCard: React.FC<BackendConfigurationCardProps> = ({
)}
</div>
)}
{/* Extra Arguments - Always visible */}
<div className="space-y-4">
<BackendFormField
key="extra_args"
fieldKey="extra_args"
value={(formData.backend_options as Record<string, unknown>)?.extra_args as Record<string, string> | undefined}
onChange={onBackendFieldChange}
/>
</div>
</CardContent>
</Card>
)

View File

@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input'
import AutoRestartConfiguration from '@/components/instance/AutoRestartConfiguration'
import NumberInput from '@/components/form/NumberInput'
import CheckboxInput from '@/components/form/CheckboxInput'
import EnvironmentVariablesInput from '@/components/form/EnvironmentVariablesInput'
import EnvVarsInput from '@/components/form/EnvVarsInput'
import SelectInput from '@/components/form/SelectInput'
import { nodesApi, type NodesMap } from '@/lib/api'
@@ -132,7 +132,7 @@ const InstanceSettingsCard: React.FC<InstanceSettingsCardProps> = ({
description="Start instance only when needed"
/>
<EnvironmentVariablesInput
<EnvVarsInput
id="environment"
label="Environment Variables"
value={formData.environment}

View File

@@ -18,7 +18,7 @@ export function useInstanceHealth(instanceName: string, instanceStatus: Instance
// Trigger health check when instance status changes to active states
useEffect(() => {
if (instanceStatus === 'running' || instanceStatus === 'restarting') {
if (instanceStatus === 'running' || instanceStatus === 'restarting' || instanceStatus === 'shutting_down') {
healthService.refreshHealth(instanceName).catch(error => {
console.error(`Failed to refresh health for ${instanceName}:`, error)
})

View File

@@ -5,11 +5,12 @@ type HealthCallback = (health: HealthStatus) => void
// Polling intervals based on health state (in milliseconds)
const POLLING_INTERVALS: Record<HealthState, number> = {
'starting': 5000, // 5 seconds - frequent during startup
'restarting': 5000, // 5 seconds - restart in progress
'ready': 60000, // 60 seconds - stable state
'stopped': 0, // No polling
'failed': 0, // No polling
'starting': 5000, // 5 seconds - frequent during startup
'restarting': 5000, // 5 seconds - restart in progress
'shutting_down': 3000, // 3 seconds - monitor shutdown progress
'ready': 60000, // 60 seconds - stable state
'stopped': 0, // No polling
'failed': 0, // No polling
}
class HealthService {
@@ -96,6 +97,7 @@ class HealthService {
case 'running': return 'starting' // Should not happen as we check HTTP for running
case 'failed': return 'failed'
case 'restarting': return 'restarting'
case 'shutting_down': return 'shutting_down'
}
}

View File

@@ -126,7 +126,7 @@ export function getAdvancedBackendFields(backendType?: string): string[] {
const fieldGetter = backendFieldGetters[normalizedType] || getAllLlamaCppFieldKeys
const basicConfig = backendFieldConfigs[normalizedType] || basicLlamaCppFieldsConfig
return fieldGetter().filter(key => !(key in basicConfig))
return fieldGetter().filter(key => !(key in basicConfig) && key !== 'extra_args')
}
// Combined backend fields config for use in BackendFormField

View File

@@ -167,6 +167,9 @@ export const LlamaCppBackendOptionsSchema = z.object({
fim_qwen_7b_default: z.boolean().optional(),
fim_qwen_7b_spec: z.boolean().optional(),
fim_qwen_14b_spec: z.boolean().optional(),
// Extra args
extra_args: z.record(z.string(), z.string()).optional(),
})
// Infer the TypeScript type from the schema

View File

@@ -25,6 +25,9 @@ export const MlxBackendOptionsSchema = z.object({
top_k: z.number().optional(),
min_p: z.number().optional(),
max_tokens: z.number().optional(),
// Extra args
extra_args: z.record(z.string(), z.string()).optional(),
})
// Infer the TypeScript type from the schema

View File

@@ -125,6 +125,9 @@ export const VllmBackendOptionsSchema = z.object({
override_pooling_config: z.string().optional(),
override_neuron_config: z.string().optional(),
override_kv_cache_align_size: z.number().optional(),
// Extra args
extra_args: z.record(z.string(), z.string()).optional(),
})
// Infer the TypeScript type from the schema

View File

@@ -11,9 +11,9 @@ export const BackendType = {
export type BackendTypeValue = typeof BackendType[keyof typeof BackendType]
export type InstanceStatus = 'running' | 'stopped' | 'failed' | 'restarting'
export type InstanceStatus = 'running' | 'stopped' | 'failed' | 'restarting' | 'shutting_down'
export type HealthState = 'stopped' | 'starting' | 'ready' | 'failed' | 'restarting'
export type HealthState = 'stopped' | 'starting' | 'ready' | 'failed' | 'restarting' | 'shutting_down'
export interface HealthStatus {
state: HealthState

View File

@@ -19,7 +19,7 @@
"paths": {
"@/*": ["./src/*"]
},
"types": ["vite/client"]
"types": ["vite/client", "@types/node"]
},
"include": ["src", "src/vite-env.d.ts"],
"references": [{ "path": "./tsconfig.node.json" }]