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) hasProviders := len(n.Providers) > 0 if !hasOllama && !n.Adapters.CLI.Enabled && !hasVllm && !hasOpenAICompat && !hasProviders && !n.Adapters.Mock.Enabled { 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 } // COMPAT: provider-first / legacy adapter field conflicts if err := config.CheckProviderLegacyConflict(i, name, &n); err != nil { return err } } if err := validateMockSurfaceReferences(cfg); err != nil { return err } return nil } func validateProviderAdapterReferences(nodeName string, n config.NodeDefinition) error { idx := buildAdapterIndex(n.Adapters) for i, p := range n.Providers { // Disabled providers are validated for structural fields (type, category, // models, numeric bounds) but adapter reference checks are skipped since // the provider won't be dispatched and its adapter may be offline. if !config.ProviderEnabled(p) { if err := p.Validate(); err != nil { return fmt.Errorf("node %q: providers[%d]: %w", nodeName, i, err) } normType := config.NormalizeProviderType(p.Type) if normType != "openai_compat" && normType != "ollama" && normType != "cli" { return fmt.Errorf("node %q: providers[%d] %q: unknown provider type %q", nodeName, i, p.ID, p.Type) } continue } trimmedType := strings.TrimSpace(p.Type) if trimmedType == "" { return fmt.Errorf("node %q: providers[%d] %q: type must not be empty", nodeName, i, p.ID) } normType := config.NormalizeProviderType(p.Type) if normType != "openai_compat" && normType != "ollama" && normType != "cli" { return fmt.Errorf("node %q: providers[%d] %q: unknown provider type %q", nodeName, i, p.ID, p.Type) } adapter := strings.TrimSpace(p.Adapter) if adapter == "" { if p.Endpoint == "" && p.BaseURL == "" && p.Command == "" { if len(p.Models) == 0 { return fmt.Errorf("node %q: providers[%d] %q: adapter must not be empty", nodeName, i, p.ID) } count := 0 switch normType { case "openai_compat": count = idx.typeCount["openai_compat"] + idx.typeCount["vllm"] case "ollama": count = idx.typeCount["ollama"] case "cli": count = idx.typeCount["cli"] } if count == 1 { continue } else if count > 1 { return fmt.Errorf("node %q: providers[%d] %q: adapter %q is ambiguous; use an enabled adapter instance key", nodeName, i, p.ID, p.Type) } return fmt.Errorf("node %q: providers[%d] %q: adapter must not be empty", nodeName, i, p.ID) } // provider-first execution fields present switch normType { case "openai_compat": if p.Endpoint == "" { return fmt.Errorf("node %q: providers[%d] %q: openai_compat provider endpoint must not be empty", nodeName, i, p.ID) } if p.BaseURL != "" || p.Command != "" { return fmt.Errorf("node %q: providers[%d] %q: openai_compat provider must not set base_url or command", nodeName, i, p.ID) } case "ollama": if p.BaseURL == "" { return fmt.Errorf("node %q: providers[%d] %q: ollama provider base_url must not be empty", nodeName, i, p.ID) } if p.Endpoint != "" || p.Command != "" { return fmt.Errorf("node %q: providers[%d] %q: ollama provider must not set endpoint or command", nodeName, i, p.ID) } case "cli": if p.Command == "" { return fmt.Errorf("node %q: providers[%d] %q: cli provider command must not be empty", nodeName, i, p.ID) } if p.Endpoint != "" || p.BaseURL != "" { return fmt.Errorf("node %q: providers[%d] %q: cli provider must not set endpoint or base_url", nodeName, i, p.ID) } } continue } 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") } if a.Mock.Enabled { idx.add("mock", "mock") } 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 } // validateMockSurfaceReferences ensures that mock adapter references in // edge-wide surfaces (console, openai, a2a) are backed by explicit mock // activation on the relevant node(s). func validateMockSurfaceReferences(cfg *config.EdgeConfig) error { // Build maps: node id/alias -> has mock enabled nodeIDtoMock := make(map[string]bool) nodeAliasToMock := make(map[string]bool) for _, n := range cfg.Nodes { nodeIDtoMock[n.ID] = n.Adapters.Mock.Enabled if n.Alias != "" { nodeAliasToMock[n.Alias] = n.Adapters.Mock.Enabled } } // resolveMockNode checks whether a node reference resolves to a node // with mock enabled. Returns (hasMock, found). resolveMockNode := func(ref string) (bool, bool) { ref = strings.TrimSpace(ref) if ref == "" { return false, false } if v, ok := nodeIDtoMock[ref]; ok { return v, true } if v, ok := nodeAliasToMock[ref]; ok { return v, true } return false, false } // hasAnyMock reports whether at least one node has explicit mock enabled. hasAnyMock := false for _, v := range nodeIDtoMock { if v { hasAnyMock = true break } } // console.adapter: mock - console has no node ref; any node with mock suffices. if cfg.Console.Adapter == "mock" && !hasAnyMock { return fmt.Errorf("console.adapter references mock but no node has explicit mock enabled") } // openai.adapter and openai.model_routes[].adapter. if cfg.OpenAI.Enabled { if cfg.OpenAI.Adapter == "mock" { if cfg.OpenAI.NodeRef != "" { hasMock, found := resolveMockNode(cfg.OpenAI.NodeRef) if !found { return fmt.Errorf("openai adapter node reference %q does not resolve to any node", cfg.OpenAI.NodeRef) } if !hasMock { return fmt.Errorf("openai adapter references mock but node %q does not have explicit mock enabled", cfg.OpenAI.NodeRef) } } else if !hasAnyMock { return fmt.Errorf("openai.adapter references mock but no node has explicit mock enabled") } } for i, route := range cfg.OpenAI.ModelRoutes { if route.Adapter == "mock" { if route.NodeRef != "" { hasMock, found := resolveMockNode(route.NodeRef) if !found { return fmt.Errorf("openai.model_routes[%d] adapter references mock but node reference %q does not resolve to any node", i, route.NodeRef) } if !hasMock { return fmt.Errorf("openai.model_routes[%d] adapter references mock but node %q does not have explicit mock enabled", i, route.NodeRef) } } else if !hasAnyMock { return fmt.Errorf("openai.model_routes[%d] adapter references mock but no node has explicit mock enabled", i) } } } } // a2a.adapter: mock. if cfg.A2A.Enabled { if cfg.A2A.Adapter == "mock" { if cfg.A2A.NodeRef != "" { hasMock, found := resolveMockNode(cfg.A2A.NodeRef) if !found { return fmt.Errorf("a2a adapter node reference %q does not resolve to any node", cfg.A2A.NodeRef) } if !hasMock { return fmt.Errorf("a2a adapter references mock but node %q does not have explicit mock enabled", cfg.A2A.NodeRef) } } else if !hasAnyMock { return fmt.Errorf("a2a.adapter references mock but no node has explicit mock enabled") } } } return nil }