diff --git a/config-schema.json b/config-schema.json index 58613ced..12a72315 100644 --- a/config-schema.json +++ b/config-schema.json @@ -235,6 +235,48 @@ "type": "boolean", "default": false, "description": "If true the model will not show up in /v1/models responses. It can still be used as normal in API requests." + }, + "variants": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "cmdAdd": { + "type": "string", + "description": "Additional command line arguments to append or override. Arguments with the same flag name will override the base cmd." + }, + "name": { + "type": "string", + "description": "Display name override for this variant." + }, + "description": { + "type": "string", + "description": "Description override for this variant." + }, + "env": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Z_][A-Z0-9_]*=.*$" + }, + "description": "Additional environment variables for this variant." + }, + "aliases": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Additional aliases for this variant." + }, + "unlisted": { + "type": "boolean", + "description": "Override unlisted setting for this variant." + } + }, + "additionalProperties": false + }, + "description": "Template-based configuration that generates multiple model variants. Each key becomes a suffix to the model name (e.g., 'model-variant'). Variant values can override cmd arguments and other settings. See issue #549." } } } diff --git a/config.example.yaml b/config.example.yaml index cc076fa6..d8ad9536 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -277,6 +277,51 @@ models: # - optional, default: undefined (use global setting) sendLoadingState: false + # Variants example (issue #549): + # Use variants to generate multiple model configurations from a single template. + # Each variant key becomes a suffix to the model name (e.g., "Qwen3.5-thinking_normal"). + # Variant's cmdAdd can override or add command line arguments. + "Qwen3.5-35B-A3B": + cmd: | + llama-server + --port ${PORT} + --model /models/Qwen3.5-35B-A3B.gguf + --temp 0.8 + --ctx-size 16384 + name: "Qwen3.5 35B" + description: "Qwen3.5 thinking model with multiple parameter variants" + + # variants: generates multiple model configurations from this template + # - each key becomes a model name suffix (model-variant) + # - cmdAdd: additional/override command line arguments + # - name, description, env, aliases, unlisted: override base settings + variants: + # Generates: Qwen3.5-35B-A3B-thinking_normal + thinking_normal: + cmdAdd: --temp 1.0 + name: "Qwen3.5 35B (Thinking, Normal)" + description: "For general tasks with thinking enabled" + + # Generates: Qwen3.5-35B-A3B-thinking_coding + thinking_coding: + cmdAdd: --temp 0.6 + name: "Qwen3.5 35B (Thinking, Coding)" + description: "For coding tasks with thinking enabled" + + # Generates: Qwen3.5-35B-A3B-nothinking_normal + nothinking_normal: + cmdAdd: "--temp 1.0 --chat-template-kwargs '{\"enable_thinking\": false}'" + name: "Qwen3.5 35B (No Thinking, Normal)" + description: "For general tasks without thinking" + + # Generates: Qwen3.5-35B-A3B-nothinking_coding + nothinking_coding: + cmdAdd: "--temp 0.6 --chat-template-kwargs '{\"enable_thinking\": false}'" + name: "Qwen3.5 35B (No Thinking, Coding)" + description: "For coding tasks without thinking" + aliases: + - "qwen-code" + # Unlisted model example: "qwen-unlisted": # unlisted: boolean, true or false diff --git a/proxy/config/config.go b/proxy/config/config.go index 4d1e6818..8077513b 100644 --- a/proxy/config/config.go +++ b/proxy/config/config.go @@ -171,6 +171,38 @@ func (c *Config) FindConfig(modelName string) (ModelConfig, string, bool) { } } +// substituteTemplateRefsInConfig replaces template model IDs in groups.members and +// hooks.on_startup.preload with the corresponding expanded variant IDs. +func substituteTemplateRefsInConfig(c *Config, templateToVariants map[string][]string) { + if len(templateToVariants) == 0 { + return + } + for groupID := range c.Groups { + group := c.Groups[groupID] + var newMembers []string + for _, member := range group.Members { + if variantIDs, ok := templateToVariants[member]; ok { + newMembers = append(newMembers, variantIDs...) + } else { + newMembers = append(newMembers, member) + } + } + group.Members = newMembers + c.Groups[groupID] = group + } + if len(c.Hooks.OnStartup.Preload) > 0 { + var newPreload []string + for _, modelID := range c.Hooks.OnStartup.Preload { + if variantIDs, ok := templateToVariants[modelID]; ok { + newPreload = append(newPreload, variantIDs...) + } else { + newPreload = append(newPreload, modelID) + } + } + c.Hooks.OnStartup.Preload = newPreload + } +} + func LoadConfig(path string) (Config, error) { file, err := os.Open(path) if err != nil { @@ -208,6 +240,18 @@ func LoadConfigFromReader(r io.Reader) (Config, error) { return Config{}, err } + // Expand model variants before any other processing + // This transforms template models with variants into individual model configs + if config.Models != nil { + expanded, err := ExpandVariants(config.Models) + if err != nil { + return Config{}, err + } + config.Models = expanded.Models + // Substitute template IDs in groups.members and hooks.on_startup.preload with variant IDs + substituteTemplateRefsInConfig(&config, expanded.TemplateToVariants) + } + if config.HealthCheckTimeout < 15 { config.HealthCheckTimeout = 15 } diff --git a/proxy/config/model_config.go b/proxy/config/model_config.go index 9dc37aea..85a0cbc9 100644 --- a/proxy/config/model_config.go +++ b/proxy/config/model_config.go @@ -36,6 +36,33 @@ type ModelConfig struct { // override global setting SendLoadingState *bool `yaml:"sendLoadingState"` + + // Variants: see issue #549 + // Template-based configuration that generates multiple model variants + // Each variant key becomes a suffix to the model name (e.g., "model-variant") + // Variant values can override cmd arguments + Variants map[string]VariantConfig `yaml:"variants"` +} + +// VariantConfig holds the configuration overrides for a model variant +type VariantConfig struct { + // CmdAdd contains additional command line arguments to append or override + CmdAdd string `yaml:"cmdAdd"` + + // Name override for this variant + Name string `yaml:"name"` + + // Description override for this variant + Description string `yaml:"description"` + + // Env additional environment variables for this variant + Env []string `yaml:"env"` + + // Aliases additional aliases for this variant + Aliases []string `yaml:"aliases"` + + // Unlisted override for this variant + Unlisted *bool `yaml:"unlisted"` } func (m *ModelConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { diff --git a/proxy/config/template_fill.go b/proxy/config/template_fill.go new file mode 100644 index 00000000..c42d1063 --- /dev/null +++ b/proxy/config/template_fill.go @@ -0,0 +1,334 @@ +package config + +import ( + "fmt" + "regexp" + "strings" +) + +// argPattern matches command line arguments like --arg, -a, or --arg=value +var argPattern = regexp.MustCompile(`^(-{1,2}[a-zA-Z][a-zA-Z0-9_-]*)(?:=(.*))?$`) + +// ExpandVariantsResult holds the expanded models and a map from template ID to generated variant IDs. +// TemplateToVariants is only set for models that had variants; use it to substitute template IDs in groups/preload. +type ExpandVariantsResult struct { + Models map[string]ModelConfig + TemplateToVariants map[string][]string // template ID -> list of variant model IDs +} + +// ExpandVariants processes all models with variants and expands them into individual model configurations. +// Returns a new models map and a mapping from template ID to variant IDs. Fails fast on duplicate model ID. +func ExpandVariants(models map[string]ModelConfig) (ExpandVariantsResult, error) { + result := make(map[string]ModelConfig) + templateToVariants := make(map[string][]string) + + for modelID, modelConfig := range models { + if len(modelConfig.Variants) == 0 { + if _, exists := result[modelID]; exists { + return ExpandVariantsResult{}, fmt.Errorf("duplicate model ID after expansion: %s", modelID) + } + result[modelID] = modelConfig + continue + } + + var variantIDs []string + for variantSuffix, variantConfig := range modelConfig.Variants { + expandedModel := expandVariant(modelConfig, variantSuffix, variantConfig) + variantModelID := modelID + "-" + variantSuffix + if _, exists := result[variantModelID]; exists { + return ExpandVariantsResult{}, fmt.Errorf( + "variant %q for model %q collides with existing model ID %q", + variantSuffix, modelID, variantModelID, + ) + } + result[variantModelID] = expandedModel + variantIDs = append(variantIDs, variantModelID) + } + templateToVariants[modelID] = variantIDs + } + + return ExpandVariantsResult{Models: result, TemplateToVariants: templateToVariants}, nil +} + +// expandVariant creates a new ModelConfig by applying variant overrides to the base model +func expandVariant(base ModelConfig, suffix string, variant VariantConfig) ModelConfig { + expanded := ModelConfig{ + Cmd: mergeCommands(base.Cmd, variant.CmdAdd), + CmdStop: base.CmdStop, + Proxy: base.Proxy, + Aliases: nil, // variants don't inherit base aliases to avoid duplicates + Env: copyStringSlice(base.Env), + CheckEndpoint: base.CheckEndpoint, + UnloadAfter: base.UnloadAfter, + Unlisted: base.Unlisted, + UseModelName: base.UseModelName, + Name: base.Name, + Description: base.Description, + ConcurrencyLimit: base.ConcurrencyLimit, + Filters: copyFilters(base.Filters), + Macros: copyMacroList(base.Macros), + Metadata: copyMetadata(base.Metadata), + SendLoadingState: base.SendLoadingState, + Variants: nil, // variants should not be copied to expanded models + } + + // Apply variant overrides + if variant.Name != "" { + expanded.Name = variant.Name + } + + if variant.Description != "" { + expanded.Description = variant.Description + } + + if len(variant.Env) > 0 { + expanded.Env = append(expanded.Env, variant.Env...) + } + + // Variants only get their own aliases, not inherited from base + if len(variant.Aliases) > 0 { + expanded.Aliases = copyStringSlice(variant.Aliases) + } + + if variant.Unlisted != nil { + expanded.Unlisted = *variant.Unlisted + } + + return expanded +} + +// mergeCommands merges the base command with additional arguments from the variant. +// Arguments in cmdAdd can override arguments in baseCmd if they have the same flag name. +func mergeCommands(baseCmd, cmdAdd string) string { + if cmdAdd == "" { + return baseCmd + } + + baseCmd = strings.TrimSpace(baseCmd) + cmdAdd = strings.TrimSpace(cmdAdd) + + if baseCmd == "" { + return cmdAdd + } + + // Parse base command into tokens + baseTokens := tokenizeCommand(baseCmd) + addTokens := tokenizeCommand(cmdAdd) + + // Build a map of argument positions in baseTokens for override detection + // Key: normalized flag name (without leading dashes), Value: index in baseTokens + baseArgIndices := make(map[string]int) + for i := 0; i < len(baseTokens); i++ { + token := baseTokens[i] + if flag, _, isArg := parseArgument(token); isArg { + baseArgIndices[normalizeFlag(flag)] = i + } + } + + // Process addTokens and either override existing args or append new ones + var appendTokens []string + i := 0 + for i < len(addTokens) { + token := addTokens[i] + flag, embeddedValue, isArg := parseArgument(token) + + if !isArg { + // Not an argument, just append + appendTokens = append(appendTokens, token) + i++ + continue + } + + normalizedFlag := normalizeFlag(flag) + + // Check if this argument exists in base + if baseIdx, exists := baseArgIndices[normalizedFlag]; exists { + // Override existing argument + if embeddedValue != "" { + // --arg=value format: replace base token; clear base's separate value if present + baseTokens[baseIdx] = token + if baseIdx+1 < len(baseTokens) && !isArgument(baseTokens[baseIdx+1]) { + baseTokens[baseIdx+1] = "" + } + i++ + } else if i+1 < len(addTokens) && !isArgument(addTokens[i+1]) { + // --arg value format (separate value) + baseTokens[baseIdx] = token + // Check if base also had a separate value + if baseIdx+1 < len(baseTokens) && !isArgument(baseTokens[baseIdx+1]) { + baseTokens[baseIdx+1] = addTokens[i+1] + } else { + // Base didn't have separate value, need to insert + // For simplicity, use --flag=value format + baseTokens[baseIdx] = flag + "=" + addTokens[i+1] + } + i += 2 + } else { + // Boolean flag + baseTokens[baseIdx] = token + i++ + } + } else { + // New argument, append + if embeddedValue != "" { + appendTokens = append(appendTokens, token) + i++ + } else if i+1 < len(addTokens) && !isArgument(addTokens[i+1]) { + appendTokens = append(appendTokens, token, addTokens[i+1]) + i += 2 + } else { + appendTokens = append(appendTokens, token) + i++ + } + } + } + + // Reconstruct the command: drop empty slots (stale values from overrides) then join + compact := make([]string, 0, len(baseTokens)) + for _, tok := range baseTokens { + if tok != "" { + compact = append(compact, tok) + } + } + result := strings.Join(compact, " ") + if len(appendTokens) > 0 { + result += " " + strings.Join(appendTokens, " ") + } + + return result +} + +// tokenizeCommand splits a command string into tokens, handling quoted strings. +// Inside single- or double-quoted segments, backslash escape sequences are supported: +// \\ → \, \" → ", \' → ', \n → newline, \t → tab; any other \X is passed through as X. +// This allows values like --chat-template-kwargs "{\"enable_thinking\": false}" to parse correctly. +func tokenizeCommand(cmd string) []string { + runes := []rune(cmd) + var tokens []string + var current strings.Builder + inQuote := false + quoteChar := rune(0) + + for i := 0; i < len(runes); i++ { + r := runes[i] + switch { + case !inQuote && (r == '"' || r == '\''): + inQuote = true + quoteChar = r + current.WriteRune(r) + case inQuote && r == '\\' && i+1 < len(runes): + next := runes[i+1] + i++ + switch next { + case '\\': + current.WriteRune('\\') + case '"': + current.WriteRune('"') + case '\'': + current.WriteRune('\'') + case 'n': + current.WriteRune('\n') + case 't': + current.WriteRune('\t') + default: + current.WriteRune(next) + } + case inQuote && r == quoteChar: + inQuote = false + current.WriteRune(r) + quoteChar = 0 + case !inQuote && (r == ' ' || r == '\t' || r == '\n'): + if current.Len() > 0 { + tokens = append(tokens, current.String()) + current.Reset() + } + default: + current.WriteRune(r) + } + } + + if current.Len() > 0 { + tokens = append(tokens, current.String()) + } + + return tokens +} + +// parseArgument checks if a token is a command line argument and extracts its components +// Returns: flag name (with dashes), embedded value (if --flag=value), isArgument bool +func parseArgument(token string) (flag string, value string, isArg bool) { + matches := argPattern.FindStringSubmatch(token) + if matches == nil { + return "", "", false + } + return matches[1], matches[2], true +} + +// isArgument checks if a token looks like a command line argument +func isArgument(token string) bool { + _, _, isArg := parseArgument(token) + return isArg +} + +// normalizeFlag removes leading dashes and converts to lowercase for comparison +func normalizeFlag(flag string) string { + flag = strings.TrimLeft(flag, "-") + return strings.ToLower(flag) +} + +// copyFilters creates a deep copy of ModelFilters so expanded variants do not share map references. +func copyFilters(f ModelFilters) ModelFilters { + result := ModelFilters{} + result.StripParams = f.StripParams + if f.SetParams != nil { + result.SetParams = make(map[string]any, len(f.SetParams)) + for k, v := range f.SetParams { + result.SetParams[k] = v + } + } + if f.SetParamsByID != nil { + result.SetParamsByID = make(map[string]map[string]any, len(f.SetParamsByID)) + for k, v := range f.SetParamsByID { + if v != nil { + copied := make(map[string]any, len(v)) + for kk, vv := range v { + copied[kk] = vv + } + result.SetParamsByID[k] = copied + } + } + } + return result +} + +// copyMacroList creates a copy of MacroList so expanded variants do not share the slice with the base. +func copyMacroList(ml MacroList) MacroList { + if ml == nil { + return nil + } + result := make(MacroList, len(ml)) + copy(result, ml) + return result +} + +// copyStringSlice creates a copy of a string slice +func copyStringSlice(s []string) []string { + if s == nil { + return nil + } + result := make([]string, len(s)) + copy(result, s) + return result +} + +// copyMetadata creates a shallow copy of metadata map +func copyMetadata(m map[string]any) map[string]any { + if m == nil { + return nil + } + result := make(map[string]any, len(m)) + for k, v := range m { + result[k] = v + } + return result +} diff --git a/proxy/config/template_fill_test.go b/proxy/config/template_fill_test.go new file mode 100644 index 00000000..73a10823 --- /dev/null +++ b/proxy/config/template_fill_test.go @@ -0,0 +1,485 @@ +package config + +import ( + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExpandVariants_BasicExpansion(t *testing.T) { + content := ` +models: + Qwen3.5-35B: + cmd: llama-server --port ${PORT} --model qwen.gguf --temp 0.8 + variants: + thinking_normal: + cmdAdd: --temp 1.0 + thinking_coding: + cmdAdd: --temp 0.6 + nothinking_normal: + cmdAdd: "--temp 1.0 --chat-template-kwargs '{\"enable_thinking\": false}'" + nothinking_coding: + cmdAdd: "--temp 0.6 --chat-template-kwargs '{\"enable_thinking\": false}'" +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + + // Original model with variants should not exist + _, exists := config.Models["Qwen3.5-35B"] + assert.False(t, exists, "Original template model should not exist in expanded config") + + // All 4 variants should exist + expectedVariants := []string{ + "Qwen3.5-35B-thinking_normal", + "Qwen3.5-35B-thinking_coding", + "Qwen3.5-35B-nothinking_normal", + "Qwen3.5-35B-nothinking_coding", + } + + for _, variantName := range expectedVariants { + _, exists := config.Models[variantName] + assert.True(t, exists, "Variant %s should exist", variantName) + } +} + +func TestExpandVariants_CommandOverride(t *testing.T) { + content := ` +models: + test-model: + cmd: server --port ${PORT} --temp 0.8 --ctx 4096 + variants: + high_temp: + cmdAdd: --temp 1.0 + low_ctx: + cmdAdd: --ctx 2048 + combined: + cmdAdd: --temp 0.5 --ctx 8192 +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + + // Check high_temp variant - temp should be overridden + highTemp := config.Models["test-model-high_temp"] + assert.Contains(t, highTemp.Cmd, "--temp 1.0") + assert.NotContains(t, highTemp.Cmd, "--temp 0.8") + + // Check low_ctx variant - ctx should be overridden + lowCtx := config.Models["test-model-low_ctx"] + assert.Contains(t, lowCtx.Cmd, "--ctx 2048") + assert.NotContains(t, lowCtx.Cmd, "--ctx 4096") + + // Check combined variant - both should be overridden + combined := config.Models["test-model-combined"] + assert.Contains(t, combined.Cmd, "--temp 0.5") + assert.Contains(t, combined.Cmd, "--ctx 8192") + assert.NotContains(t, combined.Cmd, "--temp 0.8") + assert.NotContains(t, combined.Cmd, "--ctx 4096") +} + +func TestExpandVariants_NewArguments(t *testing.T) { + content := ` +models: + test-model: + cmd: server --port ${PORT} + variants: + with_extra: + cmdAdd: --new-flag value --another-flag +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + + model := config.Models["test-model-with_extra"] + assert.Contains(t, model.Cmd, "--new-flag value") + assert.Contains(t, model.Cmd, "--another-flag") +} + +func TestExpandVariants_InheritedProperties(t *testing.T) { + content := ` +models: + test-model: + cmd: server --port ${PORT} + name: "Test Model" + description: "Base description" + env: + - "VAR1=value1" + aliases: + - "base-alias" + ttl: 60 + unlisted: true + variants: + v1: + cmdAdd: --variant v1 + v2: + cmdAdd: --variant v2 + name: "Variant 2" + description: "Custom description" + env: + - "VAR2=value2" + aliases: + - "v2-alias" + unlisted: false +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + + // v1 should inherit most properties but not aliases + v1 := config.Models["test-model-v1"] + assert.Equal(t, "Test Model", v1.Name) + assert.Equal(t, "Base description", v1.Description) + assert.Contains(t, v1.Env, "VAR1=value1") + assert.Nil(t, v1.Aliases) // variants don't inherit base aliases + assert.Equal(t, 60, v1.UnloadAfter) + assert.True(t, v1.Unlisted) + + // v2 should have overridden properties + v2 := config.Models["test-model-v2"] + assert.Equal(t, "Variant 2", v2.Name) + assert.Equal(t, "Custom description", v2.Description) + assert.Contains(t, v2.Env, "VAR1=value1") // env is inherited + assert.Contains(t, v2.Env, "VAR2=value2") // additional env + assert.NotContains(t, v2.Aliases, "base-alias") // base aliases not inherited + assert.Contains(t, v2.Aliases, "v2-alias") // variant's own alias + assert.False(t, v2.Unlisted) +} + +func TestExpandVariants_NoVariants(t *testing.T) { + content := ` +models: + model1: + cmd: server1 --port ${PORT} + model2: + cmd: server2 --port ${PORT} +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + + _, exists := config.Models["model1"] + assert.True(t, exists) + _, exists = config.Models["model2"] + assert.True(t, exists) + + assert.Equal(t, 2, len(config.Models)) +} + +func TestExpandVariants_MixedModels(t *testing.T) { + content := ` +models: + regular-model: + cmd: server1 --port ${PORT} + template-model: + cmd: server2 --port ${PORT} + variants: + v1: + cmdAdd: --v1 + v2: + cmdAdd: --v2 +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + + _, exists := config.Models["regular-model"] + assert.True(t, exists) + + _, exists = config.Models["template-model"] + assert.False(t, exists) + + _, exists = config.Models["template-model-v1"] + assert.True(t, exists) + _, exists = config.Models["template-model-v2"] + assert.True(t, exists) + + assert.Equal(t, 3, len(config.Models)) +} + +func TestExpandVariants_MacrosWork(t *testing.T) { + content := ` +startPort: 9000 +macros: + base_server: server +models: + test-model: + cmd: ${base_server} --port ${PORT} --temp 0.8 + variants: + v1: + cmdAdd: --temp 1.0 +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + + v1 := config.Models["test-model-v1"] + assert.Contains(t, v1.Cmd, "server") + assert.Contains(t, v1.Cmd, "--port 9000") + assert.Contains(t, v1.Cmd, "--temp 1.0") + assert.NotContains(t, v1.Cmd, "--temp 0.8") +} + +func TestExpandVariants_AliasesUnique(t *testing.T) { + content := ` +models: + model1: + cmd: server --port ${PORT} + variants: + v1: + aliases: + - "shared-alias" + model2: + cmd: server --port ${PORT} + variants: + v1: + aliases: + - "shared-alias" +` + _, err := LoadConfigFromReader(strings.NewReader(content)) + assert.Error(t, err) + assert.Contains(t, err.Error(), "duplicate alias") +} + +func TestMergeCommands_Basic(t *testing.T) { + tests := []struct { + name string + base string + add string + expected string + }{ + { + name: "override single arg", + base: "server --temp 0.8 --ctx 4096", + add: "--temp 1.0", + expected: "server --temp 1.0 --ctx 4096", + }, + { + name: "add new arg", + base: "server --temp 0.8", + add: "--ctx 4096", + expected: "server --temp 0.8 --ctx 4096", + }, + { + name: "override and add", + base: "server --temp 0.8", + add: "--temp 1.0 --ctx 4096", + expected: "server --temp 1.0 --ctx 4096", + }, + { + name: "empty add", + base: "server --temp 0.8", + add: "", + expected: "server --temp 0.8", + }, + { + name: "empty base", + base: "", + add: "--temp 0.8", + expected: "--temp 0.8", + }, + { + name: "equals format override", + base: "server --temp=0.8 --ctx=4096", + add: "--temp=1.0", + expected: "server --temp=1.0 --ctx=4096", + }, + { + name: "mixed format override", + base: "server --temp 0.8 --ctx 4096", + add: "--temp=1.0", + expected: "server --temp=1.0 --ctx 4096", + }, + { + name: "short flag override", + base: "server -t 0.8 -c 4096", + add: "-t 1.0", + expected: "server -t 1.0 -c 4096", + }, + { + name: "quoted value", + base: "server --arg value", + add: `--extra '{"key": "value"}'`, + expected: `server --arg value --extra '{"key": "value"}'`, + }, + { + name: "override with double-quoted JSON (escaped inner quotes)", + base: "server --temp 0.8 --chat-template-kwargs '{}'", + add: `--chat-template-kwargs "{\"enable_thinking\": false}"`, + expected: "server --temp 0.8 --chat-template-kwargs \"{\"enable_thinking\": false}\"", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := mergeCommands(tt.base, tt.add) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestTokenizeCommand_Parsing(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + { + name: "simple command", + input: "server --port 8080", + expected: []string{"server", "--port", "8080"}, + }, + { + name: "quoted string", + input: `server --arg "hello world"`, + expected: []string{"server", "--arg", `"hello world"`}, + }, + { + name: "single quoted string", + input: `server --arg '{"key": "value"}'`, + expected: []string{"server", "--arg", `'{"key": "value"}'`}, + }, + { + name: "equals format", + input: "server --port=8080 --temp=0.8", + expected: []string{"server", "--port=8080", "--temp=0.8"}, + }, + { + name: "multiline", + input: "server\n--port\n8080", + expected: []string{"server", "--port", "8080"}, + }, + { + name: "double-quoted with escaped inner quotes", + input: `server --chat-template-kwargs "{\"enable_thinking\": false}"`, + expected: []string{"server", "--chat-template-kwargs", `"{"enable_thinking": false}"`}, + }, + { + name: "single-quoted JSON (no escape needed)", + input: `server --arg '{"enable_thinking": false}'`, + expected: []string{"server", "--arg", `'{"enable_thinking": false}'`}, + }, + { + name: "backslash escape sequences in quotes", + input: `server --arg "a\\b\"c"`, + expected: []string{"server", "--arg", `"a\b"c"`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tokenizeCommand(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestExpandVariants_VariantsNotCopied(t *testing.T) { + content := ` +models: + test-model: + cmd: server --port ${PORT} + variants: + v1: + cmdAdd: --v1 +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + + v1 := config.Models["test-model-v1"] + assert.Nil(t, v1.Variants, "Expanded models should not have variants field") +} + +func TestExpandVariants_GroupsWithVariants(t *testing.T) { + content := ` +models: + template-model: + cmd: server --port ${PORT} + variants: + v1: + cmdAdd: --v1 + v2: + cmdAdd: --v2 + +groups: + mygroup: + members: + - template-model-v1 + - template-model-v2 +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + + group := config.Groups["mygroup"] + assert.Contains(t, group.Members, "template-model-v1") + assert.Contains(t, group.Members, "template-model-v2") +} + +// TestLoadTestVariantsConfig_FromFile loads the test config from ~/tools (when env +// TEST_VARIANTS_CONFIG is set) and verifies variant expansion and quoted/escaped +// args (e.g. --chat-template-kwargs) are handled correctly. +func TestLoadTestVariantsConfig_FromFile(t *testing.T) { + path := os.Getenv("TEST_VARIANTS_CONFIG") + if path == "" { + t.Skip("set TEST_VARIANTS_CONFIG to run (e.g. $HOME/tools/test-variants-config.yaml)") + } + cfg, err := LoadConfig(path) + assert.NoError(t, err) + + // Expected expanded variant model IDs + expectedVariants := []string{ + "Qwen3.5-35B-A3B-thinking_normal", + "Qwen3.5-35B-A3B-thinking_coding", + "Qwen3.5-35B-A3B-nothinking_normal", + "Qwen3.5-35B-A3B-nothinking_coding", + } + for _, id := range expectedVariants { + _, ok := cfg.Models[id] + assert.True(t, ok, "expected model %q after expansion", id) + } + assert.NotContains(t, cfg.Models, "Qwen3.5-35B-A3B", "template ID should be expanded away") + + // Variants with --chat-template-kwargs: merged Cmd must contain valid JSON and no stale/broken tokens + for _, id := range []string{"Qwen3.5-35B-A3B-nothinking_normal", "Qwen3.5-35B-A3B-nothinking_coding"} { + m, ok := cfg.Models[id] + assert.True(t, ok) + cmd := m.Cmd + t.Logf("[%s] Cmd snippet (--chat-template-kwargs): %s", id, extractSnippet(cmd, "chat-template-kwargs", 80)) + assert.Contains(t, cmd, "enable_thinking", "cmd for %s should contain enable_thinking", id) + assert.Contains(t, cmd, "false", "cmd for %s should contain false", id) + // No leftover value token (e.g. "0.8" or "1.0" standing alone after --temp=0.6) + assert.NotRegexp(t, `--temp=[\d.]+[\s]+[\d.]+`, cmd, "cmd for %s should not have stale value after --temp", id) + } +} + +func extractSnippet(s, substr string, maxLen int) string { + i := strings.Index(s, substr) + if i < 0 { + return "" + } + start := i + if start+maxLen > len(s) { + return s[start:] + } + return s[start:start+maxLen] + "..." +} + +func TestExpandVariants_TemplateRefSubstitutionInGroups(t *testing.T) { + content := ` +models: + template-model: + cmd: server --port ${PORT} + variants: + v1: + cmdAdd: --v1 + v2: + cmdAdd: --v2 + +groups: + mygroup: + members: + - template-model +` + config, err := LoadConfigFromReader(strings.NewReader(content)) + assert.NoError(t, err) + + group := config.Groups["mygroup"] + assert.Contains(t, group.Members, "template-model-v1") + assert.Contains(t, group.Members, "template-model-v2") + assert.NotContains(t, group.Members, "template-model") +}