Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
Expand Down
45 changes: 45 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions proxy/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,12 @@ 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 {
config.Models = ExpandVariants(config.Models)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if config.HealthCheckTimeout < 15 {
config.HealthCheckTimeout = 15
}
Expand Down
27 changes: 27 additions & 0 deletions proxy/config/model_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
247 changes: 247 additions & 0 deletions proxy/config/template_fill.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
package config

import (
"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_-]*)(?:=(.*))?$`)

// ExpandVariants processes all models with variants and expands them into individual model configurations.
// Returns a new models map with expanded variants and removes the original template models.
func ExpandVariants(models map[string]ModelConfig) map[string]ModelConfig {
result := make(map[string]ModelConfig)

for modelID, modelConfig := range models {
if len(modelConfig.Variants) == 0 {
result[modelID] = modelConfig
continue
}

for variantSuffix, variantConfig := range modelConfig.Variants {
expandedModel := expandVariant(modelConfig, variantSuffix, variantConfig)
variantModelID := modelID + "-" + variantSuffix
result[variantModelID] = expandedModel
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

return result
}

// 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: base.Filters,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Macros: 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
baseTokens[baseIdx] = token
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} 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
result := strings.Join(baseTokens, " ")
if len(appendTokens) > 0 {
result += " " + strings.Join(appendTokens, " ")
}

return result
}

// tokenizeCommand splits a command string into tokens, handling quoted strings
func tokenizeCommand(cmd string) []string {
var tokens []string
var current strings.Builder
inQuote := false
quoteChar := rune(0)

for _, r := range cmd {
switch {
case !inQuote && (r == '"' || r == '\''):
inQuote = true
quoteChar = r
current.WriteRune(r)
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)
}

// 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
}
Loading
Loading