75fced579e
* config: support environment variable macros in peer apiKeys
Add ${env.VAR_NAME} substitution for peer apiKey fields, consistent
with existing env macro support for model fields and global apiKeys.
- Add env macro substitution for peers.{name}.apiKey in LoadConfigFromReader
- Add tests for peer apiKey env substitution
- Update config.example.yaml to show env macro usage
* config: support macros in peer apiKey and filters
Extend macro substitution to peer configuration fields:
- peers.{name}.apiKey supports both global macros and env macros
- peers.{name}.filters.stripParams supports both macro types
- peers.{name}.filters.setParams supports both macro types
Also renamed validateMetadataForUnknownMacros to validateNestedForUnknownMacros
for reuse across model metadata and peer filters validation.
828 lines
25 KiB
Go
828 lines
25 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/billziss-gh/golib/shlex"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
const DEFAULT_GROUP_ID = "(default)"
|
|
const (
|
|
LogToStdoutProxy = "proxy"
|
|
LogToStdoutUpstream = "upstream"
|
|
LogToStdoutBoth = "both"
|
|
LogToStdoutNone = "none"
|
|
)
|
|
|
|
type MacroEntry struct {
|
|
Name string
|
|
Value any
|
|
}
|
|
|
|
type MacroList []MacroEntry
|
|
|
|
// UnmarshalYAML implements custom YAML unmarshaling that preserves macro definition order
|
|
func (ml *MacroList) UnmarshalYAML(value *yaml.Node) error {
|
|
if value.Kind != yaml.MappingNode {
|
|
return fmt.Errorf("macros must be a mapping")
|
|
}
|
|
|
|
// yaml.Node.Content for a mapping contains alternating key/value nodes
|
|
entries := make([]MacroEntry, 0, len(value.Content)/2)
|
|
for i := 0; i < len(value.Content); i += 2 {
|
|
keyNode := value.Content[i]
|
|
valueNode := value.Content[i+1]
|
|
|
|
var name string
|
|
if err := keyNode.Decode(&name); err != nil {
|
|
return fmt.Errorf("failed to decode macro name: %w", err)
|
|
}
|
|
|
|
var val any
|
|
if err := valueNode.Decode(&val); err != nil {
|
|
return fmt.Errorf("failed to decode macro value for '%s': %w", name, err)
|
|
}
|
|
|
|
entries = append(entries, MacroEntry{Name: name, Value: val})
|
|
}
|
|
|
|
*ml = entries
|
|
return nil
|
|
}
|
|
|
|
// Get retrieves a macro value by name
|
|
func (ml MacroList) Get(name string) (any, bool) {
|
|
for _, entry := range ml {
|
|
if entry.Name == name {
|
|
return entry.Value, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// ToMap converts MacroList to a map (for backward compatibility if needed)
|
|
func (ml MacroList) ToMap() map[string]any {
|
|
result := make(map[string]any, len(ml))
|
|
for _, entry := range ml {
|
|
result[entry.Name] = entry.Value
|
|
}
|
|
return result
|
|
}
|
|
|
|
type GroupConfig struct {
|
|
Swap bool `yaml:"swap"`
|
|
Exclusive bool `yaml:"exclusive"`
|
|
Persistent bool `yaml:"persistent"`
|
|
Members []string `yaml:"members"`
|
|
}
|
|
|
|
var (
|
|
macroNameRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
|
macroPatternRegex = regexp.MustCompile(`\$\{([a-zA-Z0-9_-]+)\}`)
|
|
envMacroRegex = regexp.MustCompile(`\$\{env\.([a-zA-Z_][a-zA-Z0-9_]*)\}`)
|
|
)
|
|
|
|
// set default values for GroupConfig
|
|
func (c *GroupConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
|
type rawGroupConfig GroupConfig
|
|
defaults := rawGroupConfig{
|
|
Swap: true,
|
|
Exclusive: true,
|
|
Persistent: false,
|
|
Members: []string{},
|
|
}
|
|
|
|
if err := unmarshal(&defaults); err != nil {
|
|
return err
|
|
}
|
|
|
|
*c = GroupConfig(defaults)
|
|
return nil
|
|
}
|
|
|
|
type HooksConfig struct {
|
|
OnStartup HookOnStartup `yaml:"on_startup"`
|
|
}
|
|
|
|
type HookOnStartup struct {
|
|
Preload []string `yaml:"preload"`
|
|
}
|
|
|
|
type Config struct {
|
|
HealthCheckTimeout int `yaml:"healthCheckTimeout"`
|
|
LogRequests bool `yaml:"logRequests"`
|
|
LogLevel string `yaml:"logLevel"`
|
|
LogTimeFormat string `yaml:"logTimeFormat"`
|
|
LogToStdout string `yaml:"logToStdout"`
|
|
MetricsMaxInMemory int `yaml:"metricsMaxInMemory"`
|
|
Models map[string]ModelConfig `yaml:"models"` /* key is model ID */
|
|
Profiles map[string][]string `yaml:"profiles"`
|
|
Groups map[string]GroupConfig `yaml:"groups"` /* key is group ID */
|
|
|
|
// for key/value replacements in model's cmd, cmdStop, proxy, checkEndPoint
|
|
Macros MacroList `yaml:"macros"`
|
|
|
|
// map aliases to actual model IDs
|
|
aliases map[string]string
|
|
|
|
// automatic port assignments
|
|
StartPort int `yaml:"startPort"`
|
|
|
|
// hooks, see: #209
|
|
Hooks HooksConfig `yaml:"hooks"`
|
|
|
|
// send loading state in reasoning
|
|
SendLoadingState bool `yaml:"sendLoadingState"`
|
|
|
|
// present aliases to /v1/models OpenAI API listing
|
|
IncludeAliasesInList bool `yaml:"includeAliasesInList"`
|
|
|
|
// support API keys, see issue #433, #50, #251
|
|
RequiredAPIKeys []string `yaml:"apiKeys"`
|
|
|
|
// support remote peers, see issue #433, #296
|
|
Peers PeerDictionaryConfig `yaml:"peers"`
|
|
}
|
|
|
|
func (c *Config) RealModelName(search string) (string, bool) {
|
|
if _, found := c.Models[search]; found {
|
|
return search, true
|
|
} else if name, found := c.aliases[search]; found {
|
|
return name, found
|
|
} else {
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func (c *Config) FindConfig(modelName string) (ModelConfig, string, bool) {
|
|
if realName, found := c.RealModelName(modelName); !found {
|
|
return ModelConfig{}, "", false
|
|
} else {
|
|
return c.Models[realName], realName, true
|
|
}
|
|
}
|
|
|
|
func LoadConfig(path string) (Config, error) {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
defer file.Close()
|
|
return LoadConfigFromReader(file)
|
|
}
|
|
|
|
func LoadConfigFromReader(r io.Reader) (Config, error) {
|
|
data, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
// default configuration values
|
|
config := Config{
|
|
HealthCheckTimeout: 120,
|
|
StartPort: 5800,
|
|
LogLevel: "info",
|
|
LogTimeFormat: "",
|
|
LogToStdout: LogToStdoutProxy,
|
|
MetricsMaxInMemory: 1000,
|
|
}
|
|
err = yaml.Unmarshal(data, &config)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
if config.HealthCheckTimeout < 15 {
|
|
// set a minimum of 15 seconds
|
|
config.HealthCheckTimeout = 15
|
|
}
|
|
|
|
if config.StartPort < 1 {
|
|
return Config{}, fmt.Errorf("startPort must be greater than 1")
|
|
}
|
|
|
|
switch config.LogToStdout {
|
|
case LogToStdoutProxy, LogToStdoutUpstream, LogToStdoutBoth, LogToStdoutNone:
|
|
default:
|
|
return Config{}, fmt.Errorf("logToStdout must be one of: proxy, upstream, both, none")
|
|
}
|
|
|
|
// Populate the aliases map
|
|
config.aliases = make(map[string]string)
|
|
for modelName, modelConfig := range config.Models {
|
|
for _, alias := range modelConfig.Aliases {
|
|
if _, found := config.aliases[alias]; found {
|
|
return Config{}, fmt.Errorf("duplicate alias %s found in model: %s", alias, modelName)
|
|
}
|
|
config.aliases[alias] = modelName
|
|
}
|
|
}
|
|
|
|
/* check macro constraint rules:
|
|
|
|
- name must fit the regex ^[a-zA-Z0-9_-]+$
|
|
- names must be less than 64 characters (no reason, just cause)
|
|
- name can not be any reserved macros: PORT, MODEL_ID
|
|
- macro values must be less than 1024 characters
|
|
*/
|
|
for _, macro := range config.Macros {
|
|
if err = validateMacro(macro.Name, macro.Value); err != nil {
|
|
return Config{}, err
|
|
}
|
|
}
|
|
|
|
// Process environment variable macros in global macro values first
|
|
for i, macro := range config.Macros {
|
|
if strVal, ok := macro.Value.(string); ok {
|
|
newVal, err := substituteEnvMacros(strVal)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("global macro '%s': %w", macro.Name, err)
|
|
}
|
|
config.Macros[i].Value = newVal
|
|
}
|
|
}
|
|
|
|
// Get and sort all model IDs first, makes testing more consistent
|
|
modelIds := make([]string, 0, len(config.Models))
|
|
for modelId := range config.Models {
|
|
modelIds = append(modelIds, modelId)
|
|
}
|
|
sort.Strings(modelIds) // This guarantees stable iteration order
|
|
|
|
nextPort := config.StartPort
|
|
for _, modelId := range modelIds {
|
|
modelConfig := config.Models[modelId]
|
|
|
|
// Strip comments from command fields before macro expansion
|
|
modelConfig.Cmd = StripComments(modelConfig.Cmd)
|
|
modelConfig.CmdStop = StripComments(modelConfig.CmdStop)
|
|
|
|
// Substitute environment variable macros in model fields
|
|
modelConfig.Cmd, err = substituteEnvMacros(modelConfig.Cmd)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("model %s cmd: %w", modelId, err)
|
|
}
|
|
modelConfig.CmdStop, err = substituteEnvMacros(modelConfig.CmdStop)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("model %s cmdStop: %w", modelId, err)
|
|
}
|
|
modelConfig.Proxy, err = substituteEnvMacros(modelConfig.Proxy)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("model %s proxy: %w", modelId, err)
|
|
}
|
|
modelConfig.CheckEndpoint, err = substituteEnvMacros(modelConfig.CheckEndpoint)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("model %s checkEndpoint: %w", modelId, err)
|
|
}
|
|
modelConfig.Filters.StripParams, err = substituteEnvMacros(modelConfig.Filters.StripParams)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("model %s filters.stripParams: %w", modelId, err)
|
|
}
|
|
|
|
// Substitute env macros in model-level macro values
|
|
for i, macro := range modelConfig.Macros {
|
|
if strVal, ok := macro.Value.(string); ok {
|
|
newVal, err := substituteEnvMacros(strVal)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("model %s macro '%s': %w", modelId, macro.Name, err)
|
|
}
|
|
modelConfig.Macros[i].Value = newVal
|
|
}
|
|
}
|
|
|
|
// Substitute env macros in metadata
|
|
if len(modelConfig.Metadata) > 0 {
|
|
result, err := substituteEnvMacrosInValue(modelConfig.Metadata)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("model %s metadata: %w", modelId, err)
|
|
}
|
|
modelConfig.Metadata = result.(map[string]any)
|
|
}
|
|
|
|
// validate model macros
|
|
for _, macro := range modelConfig.Macros {
|
|
if err = validateMacro(macro.Name, macro.Value); err != nil {
|
|
return Config{}, fmt.Errorf("model %s: %s", modelId, err.Error())
|
|
}
|
|
}
|
|
|
|
// Merge global config and model macros. Model macros take precedence
|
|
mergedMacros := make(MacroList, 0, len(config.Macros)+len(modelConfig.Macros))
|
|
mergedMacros = append(mergedMacros, MacroEntry{Name: "MODEL_ID", Value: modelId})
|
|
|
|
// Add global macros first
|
|
mergedMacros = append(mergedMacros, config.Macros...)
|
|
|
|
// Add model macros (can override global)
|
|
for _, entry := range modelConfig.Macros {
|
|
// Remove any existing global macro with same name
|
|
found := false
|
|
for i, existing := range mergedMacros {
|
|
if existing.Name == entry.Name {
|
|
mergedMacros[i] = entry // Override
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
mergedMacros = append(mergedMacros, entry)
|
|
}
|
|
}
|
|
|
|
// First pass: Substitute user-defined macros in reverse order (LIFO - last defined first)
|
|
// This allows later macros to reference earlier ones
|
|
for i := len(mergedMacros) - 1; i >= 0; i-- {
|
|
entry := mergedMacros[i]
|
|
macroSlug := fmt.Sprintf("${%s}", entry.Name)
|
|
macroStr := fmt.Sprintf("%v", entry.Value)
|
|
|
|
// Substitute in command fields
|
|
modelConfig.Cmd = strings.ReplaceAll(modelConfig.Cmd, macroSlug, macroStr)
|
|
modelConfig.CmdStop = strings.ReplaceAll(modelConfig.CmdStop, macroSlug, macroStr)
|
|
modelConfig.Proxy = strings.ReplaceAll(modelConfig.Proxy, macroSlug, macroStr)
|
|
modelConfig.CheckEndpoint = strings.ReplaceAll(modelConfig.CheckEndpoint, macroSlug, macroStr)
|
|
modelConfig.Filters.StripParams = strings.ReplaceAll(modelConfig.Filters.StripParams, macroSlug, macroStr)
|
|
|
|
// Substitute in metadata (recursive)
|
|
if len(modelConfig.Metadata) > 0 {
|
|
var err error
|
|
result, err := substituteMacroInValue(modelConfig.Metadata, entry.Name, entry.Value)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("model %s metadata: %s", modelId, err.Error())
|
|
}
|
|
modelConfig.Metadata = result.(map[string]any)
|
|
}
|
|
}
|
|
|
|
// Final pass: check if PORT macro is needed after macro expansion
|
|
// ${PORT} is a resource on the local machine so a new port is only allocated
|
|
// if it is required in either cmd or proxy keys
|
|
cmdHasPort := strings.Contains(modelConfig.Cmd, "${PORT}")
|
|
proxyHasPort := strings.Contains(modelConfig.Proxy, "${PORT}")
|
|
if cmdHasPort || proxyHasPort { // either has it
|
|
if !cmdHasPort && proxyHasPort { // but both don't have it
|
|
return Config{}, fmt.Errorf("model %s: proxy uses ${PORT} but cmd does not - ${PORT} is only available when used in cmd", modelId)
|
|
}
|
|
|
|
// Add PORT macro and substitute it
|
|
portEntry := MacroEntry{Name: "PORT", Value: nextPort}
|
|
macroSlug := "${PORT}"
|
|
macroStr := fmt.Sprintf("%v", nextPort)
|
|
|
|
modelConfig.Cmd = strings.ReplaceAll(modelConfig.Cmd, macroSlug, macroStr)
|
|
modelConfig.CmdStop = strings.ReplaceAll(modelConfig.CmdStop, macroSlug, macroStr)
|
|
modelConfig.Proxy = strings.ReplaceAll(modelConfig.Proxy, macroSlug, macroStr)
|
|
|
|
// Substitute PORT in metadata
|
|
if len(modelConfig.Metadata) > 0 {
|
|
var err error
|
|
result, err := substituteMacroInValue(modelConfig.Metadata, portEntry.Name, portEntry.Value)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("model %s metadata: %s", modelId, err.Error())
|
|
}
|
|
modelConfig.Metadata = result.(map[string]any)
|
|
}
|
|
|
|
nextPort++
|
|
}
|
|
|
|
// make sure there are no unknown macros that have not been replaced
|
|
fieldMap := map[string]string{
|
|
"cmd": modelConfig.Cmd,
|
|
"cmdStop": modelConfig.CmdStop,
|
|
"proxy": modelConfig.Proxy,
|
|
"checkEndpoint": modelConfig.CheckEndpoint,
|
|
"filters.stripParams": modelConfig.Filters.StripParams,
|
|
}
|
|
|
|
for fieldName, fieldValue := range fieldMap {
|
|
matches := macroPatternRegex.FindAllStringSubmatch(fieldValue, -1)
|
|
for _, match := range matches {
|
|
macroName := match[1]
|
|
if macroName == "PID" && fieldName == "cmdStop" {
|
|
continue // this is ok, has to be replaced by process later
|
|
}
|
|
// Reserved macros are always valid (they should have been substituted already)
|
|
if macroName == "PORT" || macroName == "MODEL_ID" {
|
|
return Config{}, fmt.Errorf("macro '${%s}' should have been substituted in %s.%s", macroName, modelId, fieldName)
|
|
}
|
|
// Any other macro is unknown
|
|
return Config{}, fmt.Errorf("unknown macro '${%s}' found in %s.%s", macroName, modelId, fieldName)
|
|
}
|
|
|
|
// Check for unsubstituted env macros
|
|
envMatches := envMacroRegex.FindAllStringSubmatch(fieldValue, -1)
|
|
for _, match := range envMatches {
|
|
varName := match[1]
|
|
return Config{}, fmt.Errorf("environment variable '%s' not set (found in %s.%s)", varName, modelId, fieldName)
|
|
}
|
|
}
|
|
|
|
// Check for unknown macros in metadata
|
|
if len(modelConfig.Metadata) > 0 {
|
|
if err := validateNestedForUnknownMacros(modelConfig.Metadata, fmt.Sprintf("model %s metadata", modelId)); err != nil {
|
|
return Config{}, err
|
|
}
|
|
}
|
|
|
|
// Validate the proxy URL.
|
|
if _, err := url.Parse(modelConfig.Proxy); err != nil {
|
|
return Config{}, fmt.Errorf(
|
|
"model %s: invalid proxy URL: %w", modelId, err,
|
|
)
|
|
}
|
|
|
|
// if sendLoadingState is nil, set it to the global config value
|
|
// see #366
|
|
if modelConfig.SendLoadingState == nil {
|
|
v := config.SendLoadingState // copy it
|
|
modelConfig.SendLoadingState = &v
|
|
}
|
|
|
|
config.Models[modelId] = modelConfig
|
|
}
|
|
|
|
config = AddDefaultGroupToConfig(config)
|
|
// check that members are all unique in the groups
|
|
memberUsage := make(map[string]string) // maps member to group it appears in
|
|
for groupID, groupConfig := range config.Groups {
|
|
prevSet := make(map[string]bool)
|
|
for _, member := range groupConfig.Members {
|
|
// Check for duplicates within this group
|
|
if _, found := prevSet[member]; found {
|
|
return Config{}, fmt.Errorf("duplicate model member %s found in group: %s", member, groupID)
|
|
}
|
|
prevSet[member] = true
|
|
|
|
// Check if member is used in another group
|
|
if existingGroup, exists := memberUsage[member]; exists {
|
|
return Config{}, fmt.Errorf("model member %s is used in multiple groups: %s and %s", member, existingGroup, groupID)
|
|
}
|
|
memberUsage[member] = groupID
|
|
}
|
|
}
|
|
|
|
// clean up hooks preload
|
|
if len(config.Hooks.OnStartup.Preload) > 0 {
|
|
var toPreload []string
|
|
for _, modelID := range config.Hooks.OnStartup.Preload {
|
|
modelID = strings.TrimSpace(modelID)
|
|
if modelID == "" {
|
|
continue
|
|
}
|
|
if real, found := config.RealModelName(modelID); found {
|
|
toPreload = append(toPreload, real)
|
|
}
|
|
}
|
|
|
|
config.Hooks.OnStartup.Preload = toPreload
|
|
}
|
|
|
|
// check api keys validity and substitute env macros
|
|
for i, apikey := range config.RequiredAPIKeys {
|
|
apikey, err = substituteEnvMacros(apikey)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("apiKeys[%d]: %w", i, err)
|
|
}
|
|
config.RequiredAPIKeys[i] = apikey
|
|
|
|
if apikey == "" {
|
|
return Config{}, fmt.Errorf("empty api key found in apiKeys")
|
|
}
|
|
|
|
if strings.Contains(apikey, " ") {
|
|
return Config{}, fmt.Errorf("api key cannot contain spaces: `%s`", apikey)
|
|
}
|
|
}
|
|
|
|
// substitute macros and env macros in peer fields
|
|
for peerName, peerConfig := range config.Peers {
|
|
// Substitute global macros first (LIFO order like models)
|
|
for i := len(config.Macros) - 1; i >= 0; i-- {
|
|
entry := config.Macros[i]
|
|
macroSlug := fmt.Sprintf("${%s}", entry.Name)
|
|
macroStr := fmt.Sprintf("%v", entry.Value)
|
|
|
|
peerConfig.ApiKey = strings.ReplaceAll(peerConfig.ApiKey, macroSlug, macroStr)
|
|
peerConfig.Filters.StripParams = strings.ReplaceAll(peerConfig.Filters.StripParams, macroSlug, macroStr)
|
|
|
|
// Substitute in setParams
|
|
if len(peerConfig.Filters.SetParams) > 0 {
|
|
result, err := substituteMacroInValue(peerConfig.Filters.SetParams, entry.Name, entry.Value)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("peers.%s.filters.setParams: %w", peerName, err)
|
|
}
|
|
peerConfig.Filters.SetParams = result.(map[string]any)
|
|
}
|
|
}
|
|
|
|
// Substitute env macros
|
|
peerConfig.ApiKey, err = substituteEnvMacros(peerConfig.ApiKey)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("peers.%s.apiKey: %w", peerName, err)
|
|
}
|
|
|
|
peerConfig.Filters.StripParams, err = substituteEnvMacros(peerConfig.Filters.StripParams)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("peers.%s.filters.stripParams: %w", peerName, err)
|
|
}
|
|
|
|
if len(peerConfig.Filters.SetParams) > 0 {
|
|
result, err := substituteEnvMacrosInValue(peerConfig.Filters.SetParams)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("peers.%s.filters.setParams: %w", peerName, err)
|
|
}
|
|
peerConfig.Filters.SetParams = result.(map[string]any)
|
|
}
|
|
|
|
// Validate no unknown macros remain
|
|
if matches := macroPatternRegex.FindAllStringSubmatch(peerConfig.ApiKey, -1); len(matches) > 0 {
|
|
return Config{}, fmt.Errorf("peers.%s.apiKey: unknown macro '${%s}'", peerName, matches[0][1])
|
|
}
|
|
if matches := macroPatternRegex.FindAllStringSubmatch(peerConfig.Filters.StripParams, -1); len(matches) > 0 {
|
|
return Config{}, fmt.Errorf("peers.%s.filters.stripParams: unknown macro '${%s}'", peerName, matches[0][1])
|
|
}
|
|
if len(peerConfig.Filters.SetParams) > 0 {
|
|
if err := validateNestedForUnknownMacros(peerConfig.Filters.SetParams, fmt.Sprintf("peers.%s.filters.setParams", peerName)); err != nil {
|
|
return Config{}, err
|
|
}
|
|
}
|
|
|
|
config.Peers[peerName] = peerConfig
|
|
}
|
|
|
|
return config, nil
|
|
}
|
|
|
|
// rewrites the yaml to include a default group with any orphaned models
|
|
func AddDefaultGroupToConfig(config Config) Config {
|
|
|
|
if config.Groups == nil {
|
|
config.Groups = make(map[string]GroupConfig)
|
|
}
|
|
|
|
defaultGroup := GroupConfig{
|
|
Swap: true,
|
|
Exclusive: true,
|
|
Members: []string{},
|
|
}
|
|
// if groups is empty, create a default group and put
|
|
// all models into it
|
|
if len(config.Groups) == 0 {
|
|
for modelName := range config.Models {
|
|
defaultGroup.Members = append(defaultGroup.Members, modelName)
|
|
}
|
|
} else {
|
|
// iterate over existing group members and add non-grouped models into the default group
|
|
for modelName := range config.Models {
|
|
foundModel := false
|
|
found:
|
|
// search for the model in existing groups
|
|
for _, groupConfig := range config.Groups {
|
|
for _, member := range groupConfig.Members {
|
|
if member == modelName {
|
|
foundModel = true
|
|
break found
|
|
}
|
|
}
|
|
}
|
|
|
|
if !foundModel {
|
|
defaultGroup.Members = append(defaultGroup.Members, modelName)
|
|
}
|
|
}
|
|
}
|
|
|
|
sort.Strings(defaultGroup.Members) // make consistent ordering for testing
|
|
config.Groups[DEFAULT_GROUP_ID] = defaultGroup
|
|
|
|
return config
|
|
}
|
|
|
|
func SanitizeCommand(cmdStr string) ([]string, error) {
|
|
var cleanedLines []string
|
|
for _, line := range strings.Split(cmdStr, "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
// Skip comment lines
|
|
if strings.HasPrefix(trimmed, "#") {
|
|
continue
|
|
}
|
|
// Handle trailing backslashes by replacing with space
|
|
if strings.HasSuffix(trimmed, "\\") {
|
|
cleanedLines = append(cleanedLines, strings.TrimSuffix(trimmed, "\\")+" ")
|
|
} else {
|
|
cleanedLines = append(cleanedLines, line)
|
|
}
|
|
}
|
|
|
|
// put it back together
|
|
cmdStr = strings.Join(cleanedLines, "\n")
|
|
|
|
// Split the command into arguments
|
|
var args []string
|
|
if runtime.GOOS == "windows" {
|
|
args = shlex.Windows.Split(cmdStr)
|
|
} else {
|
|
args = shlex.Posix.Split(cmdStr)
|
|
}
|
|
|
|
// Ensure the command is not empty
|
|
if len(args) == 0 {
|
|
return nil, fmt.Errorf("empty command")
|
|
}
|
|
|
|
return args, nil
|
|
}
|
|
|
|
func StripComments(cmdStr string) string {
|
|
var cleanedLines []string
|
|
for _, line := range strings.Split(cmdStr, "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
// Skip comment lines
|
|
if strings.HasPrefix(trimmed, "#") {
|
|
continue
|
|
}
|
|
cleanedLines = append(cleanedLines, line)
|
|
}
|
|
return strings.Join(cleanedLines, "\n")
|
|
}
|
|
|
|
// validateMacro validates macro name and value constraints
|
|
func validateMacro(name string, value any) error {
|
|
if len(name) >= 64 {
|
|
return fmt.Errorf("macro name '%s' exceeds maximum length of 63 characters", name)
|
|
}
|
|
if !macroNameRegex.MatchString(name) {
|
|
return fmt.Errorf("macro name '%s' contains invalid characters, must match pattern ^[a-zA-Z0-9_-]+$", name)
|
|
}
|
|
|
|
// Validate that value is a scalar type
|
|
switch v := value.(type) {
|
|
case string:
|
|
if len(v) >= 1024 {
|
|
return fmt.Errorf("macro value for '%s' exceeds maximum length of 1024 characters", name)
|
|
}
|
|
// Check for self-reference
|
|
macroSlug := fmt.Sprintf("${%s}", name)
|
|
if strings.Contains(v, macroSlug) {
|
|
return fmt.Errorf("macro '%s' contains self-reference", name)
|
|
}
|
|
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, bool:
|
|
// These types are allowed
|
|
default:
|
|
return fmt.Errorf("macro '%s' has invalid type %T, must be a scalar type (string, int, float, or bool)", name, value)
|
|
}
|
|
|
|
switch name {
|
|
case "PORT", "MODEL_ID":
|
|
return fmt.Errorf("macro name '%s' is reserved", name)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateNestedForUnknownMacros recursively checks for any remaining macro references in nested structures
|
|
func validateNestedForUnknownMacros(value any, context string) error {
|
|
switch v := value.(type) {
|
|
case string:
|
|
matches := macroPatternRegex.FindAllStringSubmatch(v, -1)
|
|
for _, match := range matches {
|
|
macroName := match[1]
|
|
return fmt.Errorf("%s: unknown macro '${%s}'", context, macroName)
|
|
}
|
|
// Check for unsubstituted env macros
|
|
envMatches := envMacroRegex.FindAllStringSubmatch(v, -1)
|
|
for _, match := range envMatches {
|
|
varName := match[1]
|
|
return fmt.Errorf("%s: environment variable '%s' not set", context, varName)
|
|
}
|
|
return nil
|
|
|
|
case map[string]any:
|
|
for _, val := range v {
|
|
if err := validateNestedForUnknownMacros(val, context); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
|
|
case []any:
|
|
for _, val := range v {
|
|
if err := validateNestedForUnknownMacros(val, context); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
|
|
default:
|
|
// Scalar types don't contain macros
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// substituteMacroInValue recursively substitutes a single macro in a value structure
|
|
// This is called once per macro, allowing LIFO substitution order
|
|
func substituteMacroInValue(value any, macroName string, macroValue any) (any, error) {
|
|
macroSlug := fmt.Sprintf("${%s}", macroName)
|
|
macroStr := fmt.Sprintf("%v", macroValue)
|
|
|
|
switch v := value.(type) {
|
|
case string:
|
|
// Check if this is a direct macro substitution
|
|
if v == macroSlug {
|
|
return macroValue, nil
|
|
}
|
|
// Handle string interpolation
|
|
if strings.Contains(v, macroSlug) {
|
|
return strings.ReplaceAll(v, macroSlug, macroStr), nil
|
|
}
|
|
return v, nil
|
|
|
|
case map[string]any:
|
|
// Recursively process map values
|
|
newMap := make(map[string]any)
|
|
for key, val := range v {
|
|
newVal, err := substituteMacroInValue(val, macroName, macroValue)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
newMap[key] = newVal
|
|
}
|
|
return newMap, nil
|
|
|
|
case []any:
|
|
// Recursively process slice elements
|
|
newSlice := make([]any, len(v))
|
|
for i, val := range v {
|
|
newVal, err := substituteMacroInValue(val, macroName, macroValue)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
newSlice[i] = newVal
|
|
}
|
|
return newSlice, nil
|
|
|
|
default:
|
|
// Return scalar types as-is
|
|
return value, nil
|
|
}
|
|
}
|
|
|
|
// substituteEnvMacros replaces ${env.VAR_NAME} with environment variable values
|
|
// Returns error if any env var is not set
|
|
func substituteEnvMacros(s string) (string, error) {
|
|
result := s
|
|
matches := envMacroRegex.FindAllStringSubmatch(s, -1)
|
|
for _, match := range matches {
|
|
fullMatch := match[0] // ${env.VAR_NAME}
|
|
varName := match[1] // VAR_NAME
|
|
|
|
value, exists := os.LookupEnv(varName)
|
|
if !exists {
|
|
return "", fmt.Errorf("environment variable '%s' is not set", varName)
|
|
}
|
|
result = strings.ReplaceAll(result, fullMatch, value)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// substituteEnvMacrosInValue recursively substitutes env macros in nested structures
|
|
func substituteEnvMacrosInValue(value any) (any, error) {
|
|
switch v := value.(type) {
|
|
case string:
|
|
return substituteEnvMacros(v)
|
|
|
|
case map[string]any:
|
|
newMap := make(map[string]any)
|
|
for key, val := range v {
|
|
newVal, err := substituteEnvMacrosInValue(val)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
newMap[key] = newVal
|
|
}
|
|
return newMap, nil
|
|
|
|
case []any:
|
|
newSlice := make([]any, len(v))
|
|
for i, val := range v {
|
|
newVal, err := substituteEnvMacrosInValue(val)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
newSlice[i] = newVal
|
|
}
|
|
return newSlice, nil
|
|
|
|
default:
|
|
return value, nil
|
|
}
|
|
}
|