Enhance command parsing in ParseLlamaCommand

This commit is contained in:
2025-09-15 21:29:46 +02:00
parent 323056096c
commit e7b06341c3
2 changed files with 342 additions and 23 deletions

View File

@@ -3,32 +3,31 @@ package llamacpp
import (
"encoding/json"
"fmt"
"path/filepath"
"regexp"
"strconv"
"strings"
)
// ParseLlamaCommand parses a llama-server command string into LlamaServerOptions
// Supports multiple formats:
// 1. Full command: "llama-server --model file.gguf"
// 2. Full path: "/usr/local/bin/llama-server --model file.gguf"
// 3. Args only: "--model file.gguf --gpu-layers 32"
// 4. Multiline commands with backslashes
func ParseLlamaCommand(command string) (*LlamaServerOptions, error) {
// 1. Validate command starts with llama-server
trimmed := strings.TrimSpace(command)
// 1. Normalize the command - handle multiline with backslashes
trimmed := normalizeMultilineCommand(command)
if trimmed == "" {
return nil, fmt.Errorf("command cannot be empty")
}
// Check if command starts with llama-server (case-insensitive)
lowerCommand := strings.ToLower(trimmed)
if !strings.HasPrefix(lowerCommand, "llama-server") {
return nil, fmt.Errorf("command must start with 'llama-server'")
// 2. Extract arguments from command
args, err := extractArgumentsFromCommand(trimmed)
if err != nil {
return nil, err
}
// 2. Extract arguments (everything after llama-server)
parts := strings.Fields(trimmed)
if len(parts) < 1 {
return nil, fmt.Errorf("invalid command format")
}
args := parts[1:] // Skip binary name
// 3. Parse arguments into map
options := make(map[string]any)
i := 0
@@ -79,7 +78,8 @@ func ParseLlamaCommand(command string) (*LlamaServerOptions, error) {
flagName := strings.ReplaceAll(flag, "-", "_")
// Check if next arg is a value (not a flag)
if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
// Special case: allow negative numbers as values
if i+1 < len(args) && !isFlag(args[i+1]) {
value := parseValue(args[i+1])
// Handle array flags by checking if flag already exists
@@ -129,7 +129,7 @@ func parseValue(value string) any {
return false
}
// Try to parse as integer
// Try to parse as integer (handle negative numbers)
if intVal, err := strconv.Atoi(value); err == nil {
return intVal
}
@@ -139,6 +139,122 @@ func parseValue(value string) any {
return floatVal
}
// Default to string
return value
// Default to string (remove quotes if present)
return strings.Trim(value, `""`)
}
// normalizeMultilineCommand handles multiline commands with backslashes
func normalizeMultilineCommand(command string) string {
// Handle escaped newlines (backslash followed by newline)
re := regexp.MustCompile(`\\\s*\n\s*`)
normalized := re.ReplaceAllString(command, " ")
// Clean up extra whitespace
re = regexp.MustCompile(`\s+`)
normalized = re.ReplaceAllString(normalized, " ")
return strings.TrimSpace(normalized)
}
// extractArgumentsFromCommand extracts arguments from various command formats
func extractArgumentsFromCommand(command string) ([]string, error) {
// Split command into tokens respecting quotes
tokens, err := splitCommandTokens(command)
if err != nil {
return nil, err
}
if len(tokens) == 0 {
return nil, fmt.Errorf("no command tokens found")
}
// Check if first token looks like an executable
firstToken := tokens[0]
// Case 1: Full path to executable (contains path separator or ends with llama-server)
if strings.Contains(firstToken, string(filepath.Separator)) ||
strings.HasSuffix(filepath.Base(firstToken), "llama-server") {
return tokens[1:], nil // Return everything except the executable
}
// Case 2: Just "llama-server" command
if strings.ToLower(firstToken) == "llama-server" {
return tokens[1:], nil // Return everything except the command
}
// Case 3: Arguments only (starts with a flag)
if strings.HasPrefix(firstToken, "-") {
return tokens, nil // Return all tokens as arguments
}
// Case 4: Unknown format - might be a different executable name
// Be permissive and assume it's the executable
return tokens[1:], nil
}
// splitCommandTokens splits a command string into tokens, respecting quotes
func splitCommandTokens(command string) ([]string, error) {
var tokens []string
var current strings.Builder
inQuotes := false
quoteChar := byte(0)
escaped := false
for i := 0; i < len(command); i++ {
c := command[i]
if escaped {
current.WriteByte(c)
escaped = false
continue
}
if c == '\\' {
escaped = true
current.WriteByte(c)
continue
}
if !inQuotes && (c == '"' || c == '\'') {
inQuotes = true
quoteChar = c
current.WriteByte(c)
} else if inQuotes && c == quoteChar {
inQuotes = false
quoteChar = 0
current.WriteByte(c)
} else if !inQuotes && (c == ' ' || c == '\t') {
if current.Len() > 0 {
tokens = append(tokens, current.String())
current.Reset()
}
} else {
current.WriteByte(c)
}
}
if inQuotes {
return nil, fmt.Errorf("unterminated quoted string")
}
if current.Len() > 0 {
tokens = append(tokens, current.String())
}
return tokens, nil
}
// isFlag determines if a string is a command line flag or a value
// Handles the special case where negative numbers should be treated as values, not flags
func isFlag(arg string) bool {
if !strings.HasPrefix(arg, "-") {
return false
}
// Special case: if it's a negative number, treat it as a value
if _, err := strconv.ParseFloat(arg, 64); err == nil {
return false
}
return true
}