package edgevalidate import ( "fmt" "strings" edgenode "iop/apps/edge/internal/node" "iop/packages/go/config" ) // ValidateEdgeConfig validates Edge config fields that config.LoadEdge does // not check: adapter endpoint presence, per-node adapter-enabled requirements, // and node store consistency (token uniqueness). It is the shared validation // path used by both `config check` and the refresh candidate loader so that // the same ruleset applies regardless of the entry point. func ValidateEdgeConfig(cfg *config.EdgeConfig) error { _, err := edgenode.LoadFromConfig(cfg.Nodes) if err != nil { return err } for i, n := range cfg.Nodes { name := n.ID if name == "" { name = n.Alias } if name == "" { name = fmt.Sprintf("index %d", i) } hasOllama := anyOllamaEnabled(n.Adapters) hasVllm := anyVllmEnabled(n.Adapters) hasOpenAICompat := anyOpenAICompatEnabled(n.Adapters) if !hasOllama && !n.Adapters.CLI.Enabled && !hasVllm && !hasOpenAICompat { return fmt.Errorf("node %q: at least one adapter must be enabled", name) } if n.Adapters.Ollama.Enabled && n.Adapters.Ollama.BaseURL == "" { return fmt.Errorf("node %q: ollama adapter base_url must not be empty", name) } for j, inst := range n.Adapters.OllamaInstances { if inst.Enabled && inst.BaseURL == "" { return fmt.Errorf("node %q: ollama_instances[%d] %q: base_url must not be empty", name, j, inst.Name) } } if n.Adapters.Vllm.Enabled && n.Adapters.Vllm.Endpoint == "" { return fmt.Errorf("node %q: vllm adapter endpoint must not be empty", name) } for j, inst := range n.Adapters.VllmInstances { if inst.Enabled && inst.Endpoint == "" { return fmt.Errorf("node %q: vllm_instances[%d] %q: endpoint must not be empty", name, j, inst.Name) } } if n.Adapters.OpenAICompat.Enabled && n.Adapters.OpenAICompat.Endpoint == "" { return fmt.Errorf("node %q: openai_compat adapter endpoint must not be empty", name) } for j, inst := range n.Adapters.OpenAICompatInstances { if inst.Enabled && inst.Endpoint == "" { return fmt.Errorf("node %q: openai_compat_instances[%d] %q: endpoint must not be empty", name, j, inst.Name) } } if n.Runtime.Concurrency < 0 { return fmt.Errorf("node %q: runtime concurrency must be non-negative", name) } if err := validateProviderAdapterReferences(name, n); err != nil { return err } } return nil } func validateProviderAdapterReferences(nodeName string, n config.NodeDefinition) error { idx := buildAdapterIndex(n.Adapters) for i, p := range n.Providers { adapter := strings.TrimSpace(p.Adapter) if adapter == "" { return fmt.Errorf("node %q: providers[%d] %q: adapter must not be empty", nodeName, i, p.ID) } if p.Category == config.CategoryCLI && !n.Adapters.CLI.Enabled { return fmt.Errorf("node %q: providers[%d] %q: category cli requires enabled cli adapter", nodeName, i, p.ID) } if idx.exact[adapter] { continue } if count, knownType := idx.typeCount[adapter]; knownType { switch count { case 0: return fmt.Errorf("node %q: providers[%d] %q: adapter %q does not resolve to an enabled adapter instance", nodeName, i, p.ID, adapter) case 1: continue default: return fmt.Errorf("node %q: providers[%d] %q: adapter %q is ambiguous; use an enabled adapter instance key", nodeName, i, p.ID, adapter) } } return fmt.Errorf("node %q: providers[%d] %q: adapter %q does not resolve to an enabled adapter instance", nodeName, i, p.ID, adapter) } return nil } type adapterIndex struct { exact map[string]bool typeKeys map[string]map[string]struct{} typeCount map[string]int } func buildAdapterIndex(a config.AdaptersConf) adapterIndex { idx := adapterIndex{ exact: make(map[string]bool), typeKeys: make(map[string]map[string]struct{}), typeCount: map[string]int{"ollama": 0, "vllm": 0, "openai_compat": 0, "cli": 0}, } if a.Ollama.Enabled { idx.add("ollama", "ollama") } for _, inst := range a.OllamaInstances { if inst.Enabled { idx.add("ollama", defaultAdapterKey(inst.Name, "ollama")) } } if a.Vllm.Enabled { idx.add("vllm", "vllm") } for _, inst := range a.VllmInstances { if inst.Enabled { idx.add("vllm", defaultAdapterKey(inst.Name, "vllm")) } } if a.OpenAICompat.Enabled { idx.add("openai_compat", "openai_compat") } for _, inst := range a.OpenAICompatInstances { if inst.Enabled { idx.add("openai_compat", defaultAdapterKey(inst.Name, "openai_compat")) } } if a.CLI.Enabled { idx.add("cli", "cli") } for typeName, keys := range idx.typeKeys { idx.typeCount[typeName] = len(keys) } return idx } func (idx adapterIndex) add(typeName, key string) { key = strings.TrimSpace(key) if key == "" { return } idx.exact[key] = true if idx.typeKeys[typeName] == nil { idx.typeKeys[typeName] = make(map[string]struct{}) } idx.typeKeys[typeName][key] = struct{}{} } func defaultAdapterKey(name, typeName string) string { if strings.TrimSpace(name) == "" { return typeName } return name } func anyOllamaEnabled(a config.AdaptersConf) bool { if a.Ollama.Enabled { return true } for _, inst := range a.OllamaInstances { if inst.Enabled { return true } } return false } func anyVllmEnabled(a config.AdaptersConf) bool { if a.Vllm.Enabled { return true } for _, inst := range a.VllmInstances { if inst.Enabled { return true } } return false } func anyOpenAICompatEnabled(a config.AdaptersConf) bool { if a.OpenAICompat.Enabled { return true } for _, inst := range a.OpenAICompatInstances { if inst.Enabled { return true } } return false }