package config import ( "fmt" "strings" "github.com/spf13/viper" ) const AgentKindGenericNode = "generic-node" // NormalizeAgentKind returns the canonical agent kind for a node definition. // An empty value defaults to generic-node; any other unsupported value is // rejected so misconfigured kinds fail at config load time. func NormalizeAgentKind(kind string) (string, error) { switch kind { case "": return AgentKindGenericNode, nil case AgentKindGenericNode: return kind, nil default: return "", fmt.Errorf("invalid agent_kind %q (allowed: %q)", kind, AgentKindGenericNode) } } type NodeConfig struct { Transport TransportConf `mapstructure:"transport" yaml:"transport"` Logging LoggingConf `mapstructure:"logging" yaml:"logging"` Metrics MetricsConf `mapstructure:"metrics" yaml:"metrics"` } type EdgeConfig struct { Edge EdgeInfo `mapstructure:"edge" yaml:"edge"` Server EdgeServerConf `mapstructure:"server" yaml:"server"` Bootstrap EdgeBootstrapConf `mapstructure:"bootstrap" yaml:"bootstrap"` OpenAI EdgeOpenAIConf `mapstructure:"openai" yaml:"openai"` A2A EdgeA2AConf `mapstructure:"a2a" yaml:"a2a"` TLS TLSConf `mapstructure:"tls" yaml:"tls"` Logging LoggingConf `mapstructure:"logging" yaml:"logging"` Metrics MetricsConf `mapstructure:"metrics" yaml:"metrics"` Console EdgeConsoleConf `mapstructure:"console" yaml:"console"` ControlPlane EdgeControlPlaneConf `mapstructure:"control_plane" yaml:"control_plane"` Nodes []NodeDefinition `mapstructure:"nodes" yaml:"nodes"` } // EdgeControlPlaneConf holds the outbound connector settings for the // Control Plane-Edge wire. When Enabled is false or WireAddr is empty the // connector is a no-op and existing Edge behaviour is unaffected. type EdgeControlPlaneConf struct { Enabled bool `mapstructure:"enabled" yaml:"enabled"` WireAddr string `mapstructure:"wire_addr" yaml:"wire_addr"` ReconnectIntervalSec int `mapstructure:"reconnect_interval_sec" yaml:"reconnect_interval_sec"` } // EdgeInfo carries this edge instance's stable identity for loading and logging. // It is not used for Control Plane registration. type EdgeInfo struct { ID string `mapstructure:"id" yaml:"id"` Name string `mapstructure:"name" yaml:"name"` } // NodeDefinition is the edge-side record for a pre-registered node. type NodeDefinition struct { ID string `mapstructure:"id" yaml:"id"` // stable node identity; if empty, a UUID v4 is auto-assigned (dev fallback only) Alias string `mapstructure:"alias" yaml:"alias"` Token string `mapstructure:"token" yaml:"token"` AgentKind string `mapstructure:"agent_kind" yaml:"agent_kind"` // generic-node (default) Adapters AdaptersConf `mapstructure:"adapters" yaml:"adapters"` Runtime RuntimeConf `mapstructure:"runtime" yaml:"runtime"` } type NodeInfo struct { ID string `mapstructure:"id" yaml:"id"` Name string `mapstructure:"name" yaml:"name"` } type EdgeServerConf struct { Listen string `mapstructure:"listen" yaml:"listen"` AdvertiseHost string `mapstructure:"advertise_host" yaml:"advertise_host"` } type EdgeBootstrapConf struct { ArtifactBaseURL string `mapstructure:"artifact_base_url" yaml:"artifact_base_url"` Listen string `mapstructure:"listen" yaml:"listen"` ArtifactDir string `mapstructure:"artifact_dir" yaml:"artifact_dir"` } // OpenAIRouteEntry maps an external model id to an internal adapter/target routing. // Fields not set here fall back to the top-level EdgeOpenAIConf defaults. type OpenAIRouteEntry struct { Model string `mapstructure:"model" yaml:"model"` NodeRef string `mapstructure:"node" yaml:"node,omitempty"` Adapter string `mapstructure:"adapter" yaml:"adapter,omitempty"` Target string `mapstructure:"target" yaml:"target"` SessionID string `mapstructure:"session_id" yaml:"session_id,omitempty"` TimeoutSec int `mapstructure:"timeout_sec" yaml:"timeout_sec,omitempty"` WorkspaceRequired bool `mapstructure:"workspace_required" yaml:"workspace_required,omitempty"` } type EdgeOpenAIConf struct { Enabled bool `mapstructure:"enabled" yaml:"enabled"` Listen string `mapstructure:"listen" yaml:"listen"` NodeRef string `mapstructure:"node" yaml:"node"` Adapter string `mapstructure:"adapter" yaml:"adapter"` Target string `mapstructure:"target" yaml:"target"` Models []string `mapstructure:"models" yaml:"models"` ModelRoutes []OpenAIRouteEntry `mapstructure:"model_routes" yaml:"model_routes,omitempty"` SessionID string `mapstructure:"session_id" yaml:"session_id"` TimeoutSec int `mapstructure:"timeout_sec" yaml:"timeout_sec"` StrictOutput bool `mapstructure:"strict_output" yaml:"strict_output"` StrictStreamBuffer bool `mapstructure:"strict_stream_buffer" yaml:"strict_stream_buffer"` } type EdgeA2AConf struct { Enabled bool `mapstructure:"enabled" yaml:"enabled"` Listen string `mapstructure:"listen" yaml:"listen"` Path string `mapstructure:"path" yaml:"path"` NodeRef string `mapstructure:"node" yaml:"node"` Adapter string `mapstructure:"adapter" yaml:"adapter"` Target string `mapstructure:"target" yaml:"target"` SessionID string `mapstructure:"session_id" yaml:"session_id"` TimeoutSec int `mapstructure:"timeout_sec" yaml:"timeout_sec"` BearerToken string `mapstructure:"bearer_token" yaml:"bearer_token"` } type EdgeConsoleConf struct { Adapter string `mapstructure:"adapter" yaml:"adapter"` Target string `mapstructure:"target" yaml:"target"` Agent string `mapstructure:"agent" yaml:"agent"` // legacy alias for target Model string `mapstructure:"model" yaml:"model"` // legacy alias for target SessionID string `mapstructure:"session_id" yaml:"session_id"` Background bool `mapstructure:"background" yaml:"background"` TimeoutSec int `mapstructure:"timeout_sec" yaml:"timeout_sec"` } func (c EdgeConsoleConf) ResolveTarget() string { if c.Target != "" { return c.Target } if c.Agent != "" { return c.Agent } return c.Model } // ResolveAgent is kept for callers still using the legacy console.agent name. func (c EdgeConsoleConf) ResolveAgent() string { return c.ResolveTarget() } type TransportConf struct { EdgeAddr string `mapstructure:"edge_addr" yaml:"edge_addr"` Token string `mapstructure:"token" yaml:"token"` } type TLSConf struct { Enabled bool `mapstructure:"enabled" yaml:"enabled"` Cert string `mapstructure:"cert" yaml:"cert"` Key string `mapstructure:"key" yaml:"key"` CA string `mapstructure:"ca" yaml:"ca"` } type RuntimeConf struct { Concurrency int `mapstructure:"concurrency" yaml:"concurrency"` WorkspaceRoot string `mapstructure:"workspace_root" yaml:"workspace_root"` } type SQLiteConf struct { DSN string `mapstructure:"dsn" yaml:"dsn"` } type LoggingConf struct { Level string `mapstructure:"level" yaml:"level"` Pretty bool `mapstructure:"pretty" yaml:"pretty"` Path string `mapstructure:"path" yaml:"path"` } type MetricsConf struct { Port int `mapstructure:"port" yaml:"port"` } type AdaptersConf struct { // Legacy single-instance fields. When non-empty they are normalised into // OllamaInstances / VllmInstances / OpenAICompatInstances during config load // so that all downstream code only needs to inspect the slice fields. Ollama OllamaConf `mapstructure:"ollama" yaml:"ollama"` Vllm VllmConf `mapstructure:"vllm" yaml:"vllm"` OpenAICompat OpenAICompatConf `mapstructure:"openai_compat" yaml:"openai_compat"` CLI CLIConf `mapstructure:"cli" yaml:"cli"` // Multi-instance collections. Each entry carries a unique Name that acts as // the stable adapter instance identity within the node. Names must be unique // within each type collection; duplicate names are rejected at load time. OllamaInstances []OllamaInstanceConf `mapstructure:"ollama_instances" yaml:"ollama_instances,omitempty"` VllmInstances []VllmInstanceConf `mapstructure:"vllm_instances" yaml:"vllm_instances,omitempty"` OpenAICompatInstances []OpenAICompatInstanceConf `mapstructure:"openai_compat_instances" yaml:"openai_compat_instances,omitempty"` } // OllamaInstanceConf is one named Ollama engine instance within a node. type OllamaInstanceConf struct { Name string `mapstructure:"name" yaml:"name"` Enabled bool `mapstructure:"enabled" yaml:"enabled"` BaseURL string `mapstructure:"base_url" yaml:"base_url"` ContextSize int `mapstructure:"context_size" yaml:"context_size"` Capacity int `mapstructure:"capacity" yaml:"capacity"` MaxQueue int `mapstructure:"max_queue" yaml:"max_queue"` QueueTimeoutMS int `mapstructure:"queue_timeout_ms" yaml:"queue_timeout_ms"` RequestTimeoutMS int `mapstructure:"request_timeout_ms" yaml:"request_timeout_ms"` } // VllmInstanceConf is one named vLLM engine instance within a node. type VllmInstanceConf struct { Name string `mapstructure:"name" yaml:"name"` Enabled bool `mapstructure:"enabled" yaml:"enabled"` Endpoint string `mapstructure:"endpoint" yaml:"endpoint"` Capacity int `mapstructure:"capacity" yaml:"capacity"` MaxQueue int `mapstructure:"max_queue" yaml:"max_queue"` QueueTimeoutMS int `mapstructure:"queue_timeout_ms" yaml:"queue_timeout_ms"` RequestTimeoutMS int `mapstructure:"request_timeout_ms" yaml:"request_timeout_ms"` } type OpenAICompatConf struct { Enabled bool `mapstructure:"enabled" yaml:"enabled"` Provider string `mapstructure:"provider" yaml:"provider"` Endpoint string `mapstructure:"endpoint" yaml:"endpoint"` Headers map[string]string `mapstructure:"headers" yaml:"headers"` Capacity int `mapstructure:"capacity" yaml:"capacity"` MaxQueue int `mapstructure:"max_queue" yaml:"max_queue"` QueueTimeoutMS int `mapstructure:"queue_timeout_ms" yaml:"queue_timeout_ms"` RequestTimeoutMS int `mapstructure:"request_timeout_ms" yaml:"request_timeout_ms"` } type OpenAICompatInstanceConf struct { Name string `mapstructure:"name" yaml:"name"` Enabled bool `mapstructure:"enabled" yaml:"enabled"` Provider string `mapstructure:"provider" yaml:"provider"` Endpoint string `mapstructure:"endpoint" yaml:"endpoint"` Headers map[string]string `mapstructure:"headers" yaml:"headers"` Capacity int `mapstructure:"capacity" yaml:"capacity"` MaxQueue int `mapstructure:"max_queue" yaml:"max_queue"` QueueTimeoutMS int `mapstructure:"queue_timeout_ms" yaml:"queue_timeout_ms"` RequestTimeoutMS int `mapstructure:"request_timeout_ms" yaml:"request_timeout_ms"` } type OllamaConf struct { Enabled bool `mapstructure:"enabled" yaml:"enabled"` BaseURL string `mapstructure:"base_url" yaml:"base_url"` ContextSize int `mapstructure:"context_size" yaml:"context_size"` Capacity int `mapstructure:"capacity" yaml:"capacity"` MaxQueue int `mapstructure:"max_queue" yaml:"max_queue"` QueueTimeoutMS int `mapstructure:"queue_timeout_ms" yaml:"queue_timeout_ms"` RequestTimeoutMS int `mapstructure:"request_timeout_ms" yaml:"request_timeout_ms"` } type VllmConf struct { Enabled bool `mapstructure:"enabled" yaml:"enabled"` Endpoint string `mapstructure:"endpoint" yaml:"endpoint"` Capacity int `mapstructure:"capacity" yaml:"capacity"` MaxQueue int `mapstructure:"max_queue" yaml:"max_queue"` QueueTimeoutMS int `mapstructure:"queue_timeout_ms" yaml:"queue_timeout_ms"` RequestTimeoutMS int `mapstructure:"request_timeout_ms" yaml:"request_timeout_ms"` } type CLIConf struct { Enabled bool `mapstructure:"enabled" yaml:"enabled"` Profiles map[string]CLIProfileConf `mapstructure:"profiles" yaml:"profiles"` } type CLIProfileConf struct { Command string `mapstructure:"command" yaml:"command"` Args []string `mapstructure:"args" yaml:"args"` Env []string `mapstructure:"env" yaml:"env"` Persistent bool `mapstructure:"persistent" yaml:"persistent"` Terminal bool `mapstructure:"terminal" yaml:"terminal"` ResponseIdleTimeoutMS int `mapstructure:"response_idle_timeout_ms" yaml:"response_idle_timeout_ms"` StartupIdleTimeoutMS int `mapstructure:"startup_idle_timeout_ms" yaml:"startup_idle_timeout_ms"` OutputFormat string `mapstructure:"output_format" yaml:"output_format"` CompletionMarker CompletionMarkerConf `mapstructure:"completion_marker" yaml:"completion_marker"` Mode string `mapstructure:"mode" yaml:"mode"` ResumeArgs []string `mapstructure:"resume_args" yaml:"resume_args"` } type CompletionMarkerConf struct { Line string `mapstructure:"line" yaml:"line"` Regex string `mapstructure:"regex" yaml:"regex"` } func (m CompletionMarkerConf) Empty() bool { return m.Line == "" && m.Regex == "" } func Load(cfgFile string) (*NodeConfig, error) { v := viper.New() v.SetConfigFile(cfgFile) setDefaults(v) if err := v.ReadInConfig(); err != nil { return nil, err } var cfg NodeConfig if err := v.Unmarshal(&cfg); err != nil { return nil, err } return &cfg, nil } func LoadEdge(cfgFile string) (*EdgeConfig, error) { v := viper.New() v.SetConfigFile(cfgFile) setEdgeDefaults(v) if err := v.ReadInConfig(); err != nil { return nil, err } var cfg EdgeConfig if err := v.Unmarshal(&cfg); err != nil { return nil, err } if !v.InConfig("console.target") { if v.InConfig("console.agent") { cfg.Console.Target = cfg.Console.Agent } else if v.InConfig("console.model") { cfg.Console.Target = cfg.Console.Model } } if err := validateOpenAIRoutes(cfg.OpenAI.ModelRoutes); err != nil { return nil, err } for i := range cfg.Nodes { kind, err := NormalizeAgentKind(cfg.Nodes[i].AgentKind) if err != nil { return nil, fmt.Errorf("nodes[%d] alias=%q: %w", i, cfg.Nodes[i].Alias, err) } cfg.Nodes[i].AgentKind = kind if err := normalizeAdapters(&cfg.Nodes[i].Adapters); err != nil { name := cfg.Nodes[i].ID if name == "" { name = cfg.Nodes[i].Alias } return nil, fmt.Errorf("nodes[%d] %q adapters: %w", i, name, err) } } return &cfg, nil } // NormalizeAdapters promotes legacy single-instance Ollama/Vllm fields into the // typed instance slices and validates that all instance names are unique. func NormalizeAdapters(a *AdaptersConf) error { return normalizeAdapters(a) } func normalizeAdapters(a *AdaptersConf) error { if a.Ollama.Enabled { existing := findOllamaInstance(a.OllamaInstances, "ollama") if existing == nil { a.OllamaInstances = append([]OllamaInstanceConf{{ Name: "ollama", Enabled: a.Ollama.Enabled, BaseURL: a.Ollama.BaseURL, ContextSize: a.Ollama.ContextSize, Capacity: a.Ollama.Capacity, MaxQueue: a.Ollama.MaxQueue, QueueTimeoutMS: a.Ollama.QueueTimeoutMS, RequestTimeoutMS: a.Ollama.RequestTimeoutMS, }}, a.OllamaInstances...) } else if !sameOllamaInstance(*existing, a.Ollama) { return fmt.Errorf("ollama: legacy field conflicts with explicit instance %q: enabled/base_url/context_size/capacity/max_queue/queue_timeout_ms/request_timeout_ms mismatch", "ollama") } } if a.Vllm.Enabled { existing := findVllmInstance(a.VllmInstances, "vllm") if existing == nil { a.VllmInstances = append([]VllmInstanceConf{{ Name: "vllm", Enabled: a.Vllm.Enabled, Endpoint: a.Vllm.Endpoint, Capacity: a.Vllm.Capacity, MaxQueue: a.Vllm.MaxQueue, QueueTimeoutMS: a.Vllm.QueueTimeoutMS, RequestTimeoutMS: a.Vllm.RequestTimeoutMS, }}, a.VllmInstances...) } else if !sameVllmInstance(*existing, a.Vllm) { return fmt.Errorf("vllm: legacy field conflicts with explicit instance %q: enabled/endpoint/capacity/max_queue/queue_timeout_ms/request_timeout_ms mismatch", "vllm") } } if a.OpenAICompat.Enabled { existing := findOpenAICompatInstance(a.OpenAICompatInstances, "openai_compat") if existing == nil { a.OpenAICompatInstances = append([]OpenAICompatInstanceConf{{ Name: "openai_compat", Enabled: a.OpenAICompat.Enabled, Provider: a.OpenAICompat.Provider, Endpoint: a.OpenAICompat.Endpoint, Headers: a.OpenAICompat.Headers, Capacity: a.OpenAICompat.Capacity, MaxQueue: a.OpenAICompat.MaxQueue, QueueTimeoutMS: a.OpenAICompat.QueueTimeoutMS, RequestTimeoutMS: a.OpenAICompat.RequestTimeoutMS, }}, a.OpenAICompatInstances...) } else if !sameOpenAICompatInstance(*existing, a.OpenAICompat) { return fmt.Errorf("openai_compat: legacy field conflicts with explicit instance %q: enabled/provider/endpoint/headers/capacity/max_queue/queue_timeout_ms/request_timeout_ms mismatch", "openai_compat") } } if err := checkUniqueNames("ollama_instances", func(i int) string { return a.OllamaInstances[i].Name }, len(a.OllamaInstances)); err != nil { return err } if err := checkUniqueNames("vllm_instances", func(i int) string { return a.VllmInstances[i].Name }, len(a.VllmInstances)); err != nil { return err } if err := checkUniqueNames("openai_compat_instances", func(i int) string { return a.OpenAICompatInstances[i].Name }, len(a.OpenAICompatInstances)); err != nil { return err } for i, inst := range a.OllamaInstances { field := fmt.Sprintf("ollama_instances[%d]", i) if err := validateProviderQueueConfig(field, inst.Capacity, inst.MaxQueue, inst.QueueTimeoutMS, inst.RequestTimeoutMS); err != nil { return err } } for i, inst := range a.VllmInstances { field := fmt.Sprintf("vllm_instances[%d]", i) if err := validateProviderQueueConfig(field, inst.Capacity, inst.MaxQueue, inst.QueueTimeoutMS, inst.RequestTimeoutMS); err != nil { return err } } for i, inst := range a.OpenAICompatInstances { field := fmt.Sprintf("openai_compat_instances[%d]", i) if err := validateProviderQueueConfig(field, inst.Capacity, inst.MaxQueue, inst.QueueTimeoutMS, inst.RequestTimeoutMS); err != nil { return err } } return nil } func validateProviderQueueConfig(field string, capacity, maxQueue, queueTimeoutMS, requestTimeoutMS int) error { if capacity < 0 { return fmt.Errorf("%s.capacity must be non-negative", field) } if maxQueue < 0 { return fmt.Errorf("%s.max_queue must be non-negative", field) } if queueTimeoutMS < 0 { return fmt.Errorf("%s.queue_timeout_ms must be non-negative", field) } if requestTimeoutMS < 0 { return fmt.Errorf("%s.request_timeout_ms must be non-negative", field) } return nil } func sameOllamaInstance(inst OllamaInstanceConf, legacy OllamaConf) bool { return inst.Enabled == legacy.Enabled && inst.BaseURL == legacy.BaseURL && inst.ContextSize == legacy.ContextSize && inst.Capacity == legacy.Capacity && inst.MaxQueue == legacy.MaxQueue && inst.QueueTimeoutMS == legacy.QueueTimeoutMS && inst.RequestTimeoutMS == legacy.RequestTimeoutMS } func sameVllmInstance(inst VllmInstanceConf, legacy VllmConf) bool { return inst.Enabled == legacy.Enabled && inst.Endpoint == legacy.Endpoint && inst.Capacity == legacy.Capacity && inst.MaxQueue == legacy.MaxQueue && inst.QueueTimeoutMS == legacy.QueueTimeoutMS && inst.RequestTimeoutMS == legacy.RequestTimeoutMS } func findOllamaInstance(instances []OllamaInstanceConf, name string) *OllamaInstanceConf { for i := range instances { if instances[i].Name == name { return &instances[i] } } return nil } func findVllmInstance(instances []VllmInstanceConf, name string) *VllmInstanceConf { for i := range instances { if instances[i].Name == name { return &instances[i] } } return nil } func sameOpenAICompatInstance(inst OpenAICompatInstanceConf, legacy OpenAICompatConf) bool { if inst.Enabled != legacy.Enabled || inst.Provider != legacy.Provider || inst.Endpoint != legacy.Endpoint || inst.Capacity != legacy.Capacity || inst.MaxQueue != legacy.MaxQueue || inst.QueueTimeoutMS != legacy.QueueTimeoutMS || inst.RequestTimeoutMS != legacy.RequestTimeoutMS { return false } if len(inst.Headers) != len(legacy.Headers) { return false } for k, v := range inst.Headers { if lv, ok := legacy.Headers[k]; !ok || lv != v { return false } } return true } func findOpenAICompatInstance(instances []OpenAICompatInstanceConf, name string) *OpenAICompatInstanceConf { for i := range instances { if instances[i].Name == name { return &instances[i] } } return nil } // validateOpenAIRoutes rejects duplicate and empty model ids in the route catalog. func validateOpenAIRoutes(routes []OpenAIRouteEntry) error { seen := make(map[string]struct{}, len(routes)) for i, r := range routes { model := strings.TrimSpace(r.Model) if model == "" { return fmt.Errorf("openai.model_routes[%d]: model must not be empty", i) } if _, dup := seen[model]; dup { return fmt.Errorf("openai.model_routes: duplicate model %q", model) } seen[model] = struct{}{} } return nil } func checkUniqueNames(field string, name func(int) string, n int) error { seen := make(map[string]struct{}, n) for i := 0; i < n; i++ { k := name(i) if k == "" { return fmt.Errorf("%s[%d]: name must not be empty", field, i) } if _, dup := seen[k]; dup { return fmt.Errorf("%s: duplicate name %q", field, k) } seen[k] = struct{}{} } return nil } func setDefaults(v *viper.Viper) { v.SetDefault("transport.edge_addr", "localhost:9090") v.SetDefault("logging.level", "info") v.SetDefault("metrics.port", 9091) } func setEdgeDefaults(v *viper.Viper) { v.SetDefault("server.listen", "0.0.0.0:9090") v.SetDefault("bootstrap.listen", "0.0.0.0:18080") v.SetDefault("bootstrap.artifact_dir", "artifacts") v.SetDefault("openai.enabled", false) v.SetDefault("openai.listen", "0.0.0.0:18081") v.SetDefault("openai.adapter", "ollama") v.SetDefault("openai.session_id", "openai") v.SetDefault("openai.timeout_sec", 120) v.SetDefault("openai.strict_output", true) v.SetDefault("openai.strict_stream_buffer", false) v.SetDefault("a2a.enabled", false) v.SetDefault("a2a.listen", "0.0.0.0:8081") v.SetDefault("a2a.path", "/a2a") v.SetDefault("a2a.adapter", "cli") v.SetDefault("a2a.session_id", "a2a") v.SetDefault("a2a.timeout_sec", 120) v.SetDefault("logging.level", "info") v.SetDefault("metrics.port", 19092) v.SetDefault("tls.enabled", false) v.SetDefault("console.adapter", "cli") v.SetDefault("console.target", "claude") v.SetDefault("console.session_id", "default") v.SetDefault("console.background", false) v.SetDefault("console.timeout_sec", 120) v.SetDefault("control_plane.enabled", false) v.SetDefault("control_plane.wire_addr", "") v.SetDefault("control_plane.reconnect_interval_sec", 5) }